diff --git a/.env.example b/.env.example
index a5153d1d07a..515c0016079 100644
--- a/.env.example
+++ b/.env.example
@@ -45,14 +45,44 @@ MINIMAX_API_KEY=
MINIMAX_CN_API_KEY=
# MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1 # Override default base URL
+# =============================================================================
+# LLM PROVIDER (OpenCode Zen)
+# =============================================================================
+# OpenCode Zen provides curated, tested models (GPT, Claude, Gemini, MiniMax, GLM, Kimi)
+# Pay-as-you-go pricing. Get your key at: https://opencode.ai/auth
+OPENCODE_ZEN_API_KEY=
+# OPENCODE_ZEN_BASE_URL=https://opencode.ai/zen/v1 # Override default base URL
+
+# =============================================================================
+# LLM PROVIDER (OpenCode Go)
+# =============================================================================
+# OpenCode Go provides access to open models (GLM-5, Kimi K2.5, MiniMax M2.5)
+# $10/month subscription. Get your key at: https://opencode.ai/auth
+OPENCODE_GO_API_KEY=
+
+# =============================================================================
+# LLM PROVIDER (Hugging Face Inference Providers)
+# =============================================================================
+# Hugging Face routes to 20+ open models via unified OpenAI-compatible endpoint.
+# Free tier included ($0.10/month), no markup on provider rates.
+# Get your token at: https://huggingface.co/settings/tokens
+# Required permission: "Make calls to Inference Providers"
+HF_TOKEN=
+# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
+
# =============================================================================
# TOOL API KEYS
# =============================================================================
+# Parallel API Key - AI-native web search and extract
+# Get at: https://parallel.ai
+PARALLEL_API_KEY=
+
# Firecrawl API Key - Web search, extract, and crawl
# Get at: https://firecrawl.dev/
FIRECRAWL_API_KEY=
+
# FAL.ai API Key - Image generation
# Get at: https://fal.ai/
FAL_KEY=
@@ -275,3 +305,27 @@ WANDB_API_KEY=
# GITHUB_APP_ID=
# GITHUB_APP_PRIVATE_KEY_PATH=
# GITHUB_APP_INSTALLATION_ID=
+
+# Groq API key (free tier โ used for Whisper STT in voice mode)
+# GROQ_API_KEY=
+
+# =============================================================================
+# STT PROVIDER SELECTION
+# =============================================================================
+# Default STT provider is "local" (faster-whisper) โ runs on your machine, no API key needed.
+# Install with: pip install faster-whisper
+# Model downloads automatically on first use (~150 MB for "base").
+# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
+# Provider priority: local > groq > openai
+# Configure in config.yaml: stt.provider: local | groq | openai
+
+# =============================================================================
+# STT ADVANCED OVERRIDES (optional)
+# =============================================================================
+# Override default STT models per provider (normally set via stt.model in config.yaml)
+# STT_GROQ_MODEL=whisper-large-v3-turbo
+# STT_OPENAI_MODEL=whisper-1
+
+# Override STT provider endpoints (for proxies or self-hosted instances)
+# GROQ_BASE_URL=https://api.groq.com/openai/v1
+# STT_OPENAI_BASE_URL=https://api.openai.com/v1
diff --git a/.envrc b/.envrc
new file mode 100644
index 00000000000..3550a30f2de
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml
new file mode 100644
index 00000000000..6e4b966b26f
--- /dev/null
+++ b/.github/workflows/docs-site-checks.yml
@@ -0,0 +1,39 @@
+name: Docs Site Checks
+
+on:
+ pull_request:
+ paths:
+ - 'website/**'
+ - '.github/workflows/docs-site-checks.yml'
+ workflow_dispatch:
+
+jobs:
+ docs-site-checks:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Install website dependencies
+ run: npm ci
+ working-directory: website
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install ascii-guard
+ run: python -m pip install ascii-guard
+
+ - name: Lint docs diagrams
+ run: npm run lint:diagrams
+ working-directory: website
+
+ - name: Build Docusaurus
+ run: npm run build
+ working-directory: website
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 00000000000..004f8236a2b
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,40 @@
+name: Nix
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ paths:
+ - 'flake.nix'
+ - 'flake.lock'
+ - 'nix/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+ - 'hermes_cli/**'
+ - 'run_agent.py'
+ - 'acp_adapter/**'
+
+concurrency:
+ group: nix-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ nix:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: DeterminateSystems/nix-installer-action@main
+ - uses: DeterminateSystems/magic-nix-cache-action@main
+ - name: Check flake
+ if: runner.os == 'Linux'
+ run: nix flake check --print-build-logs
+ - name: Build package
+ if: runner.os == 'Linux'
+ run: nix build --print-build-logs
+ - name: Evaluate flake (macOS)
+ if: runner.os == 'macOS'
+ run: nix flake show --json > /dev/null
diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml
new file mode 100644
index 00000000000..b94e1dda433
--- /dev/null
+++ b/.github/workflows/supply-chain-audit.yml
@@ -0,0 +1,192 @@
+name: Supply Chain Audit
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ pull-requests: write
+ contents: read
+
+jobs:
+ scan:
+ name: Scan PR for supply chain risks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Scan diff for suspicious patterns
+ id: scan
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ BASE="${{ github.event.pull_request.base.sha }}"
+ HEAD="${{ github.event.pull_request.head.sha }}"
+
+ # Get the full diff (added lines only)
+ DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
+
+ FINDINGS=""
+ CRITICAL=false
+
+ # --- .pth files (auto-execute on Python startup) ---
+ PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
+ if [ -n "$PTH_FILES" ]; then
+ CRITICAL=true
+ FINDINGS="${FINDINGS}
+ ### ๐จ CRITICAL: .pth file added or modified
+ Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts โ no import required. This is the exact mechanism used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512).
+
+ **Files:**
+ \`\`\`
+ ${PTH_FILES}
+ \`\`\`
+ "
+ fi
+
+ # --- base64 + exec/eval combo (the litellm attack pattern) ---
+ B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
+ if [ -n "$B64_EXEC_HITS" ]; then
+ CRITICAL=true
+ FINDINGS="${FINDINGS}
+ ### ๐จ CRITICAL: base64 decode + exec/eval combo
+ This is the exact pattern used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512) โ base64-decoded strings passed to exec/eval to hide credential-stealing payloads.
+
+ **Matches:**
+ \`\`\`
+ ${B64_EXEC_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- base64 decode/encode (alone โ legitimate uses exist) ---
+ B64_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|b64encode|decodebytes|encodebytes|urlsafe_b64decode)|atob\(|btoa\(|Buffer\.from\(.*base64' | head -20 || true)
+ if [ -n "$B64_HITS" ]; then
+ FINDINGS="${FINDINGS}
+ ### โ ๏ธ WARNING: base64 encoding/decoding detected
+ Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.
+
+ **Matches (first 20):**
+ \`\`\`
+ ${B64_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- exec/eval with string arguments ---
+ EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E '(exec|eval)\s*\(' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert\|# ' | head -20 || true)
+ if [ -n "$EXEC_HITS" ]; then
+ FINDINGS="${FINDINGS}
+ ### โ ๏ธ WARNING: exec() or eval() usage
+ Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.
+
+ **Matches (first 20):**
+ \`\`\`
+ ${EXEC_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- subprocess with encoded/obfuscated commands ---
+ PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|decode|encode|\\x|chr\(' | head -10 || true)
+ if [ -n "$PROC_HITS" ]; then
+ CRITICAL=true
+ FINDINGS="${FINDINGS}
+ ### ๐จ CRITICAL: subprocess with encoded/obfuscated command
+ Subprocess calls with encoded arguments are a strong indicator of payload execution.
+
+ **Matches:**
+ \`\`\`
+ ${PROC_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- Network calls to non-standard domains ---
+ EXFIL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'requests\.(post|put)\(|httpx\.(post|put)\(|urllib\.request\.urlopen' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert' | head -10 || true)
+ if [ -n "$EXFIL_HITS" ]; then
+ FINDINGS="${FINDINGS}
+ ### โ ๏ธ WARNING: Outbound network calls (POST/PUT)
+ Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.
+
+ **Matches (first 10):**
+ \`\`\`
+ ${EXFIL_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- setup.py / setup.cfg install hooks ---
+ SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(setup\.py|setup\.cfg|__init__\.pth|sitecustomize\.py|usercustomize\.py)$' || true)
+ if [ -n "$SETUP_HITS" ]; then
+ FINDINGS="${FINDINGS}
+ ### โ ๏ธ WARNING: Install hook files modified
+ These files can execute code during package installation or interpreter startup.
+
+ **Files:**
+ \`\`\`
+ ${SETUP_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- Compile/marshal/pickle (code object injection) ---
+ MARSHAL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'marshal\.loads|pickle\.loads|compile\(' | grep -v '^\+\s*#' | grep -v 'test_\|re\.compile\|ast\.compile' | head -10 || true)
+ if [ -n "$MARSHAL_HITS" ]; then
+ FINDINGS="${FINDINGS}
+ ### โ ๏ธ WARNING: marshal/pickle/compile usage
+ These can deserialize or construct executable code objects.
+
+ **Matches:**
+ \`\`\`
+ ${MARSHAL_HITS}
+ \`\`\`
+ "
+ fi
+
+ # --- Output results ---
+ if [ -n "$FINDINGS" ]; then
+ echo "found=true" >> "$GITHUB_OUTPUT"
+ if [ "$CRITICAL" = true ]; then
+ echo "critical=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "critical=false" >> "$GITHUB_OUTPUT"
+ fi
+ # Write findings to a file (multiline env vars are fragile)
+ echo "$FINDINGS" > /tmp/findings.md
+ else
+ echo "found=false" >> "$GITHUB_OUTPUT"
+ echo "critical=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Post warning comment
+ if: steps.scan.outputs.found == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ SEVERITY="โ ๏ธ Supply Chain Risk Detected"
+ if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
+ SEVERITY="๐จ CRITICAL Supply Chain Risk Detected"
+ fi
+
+ BODY="## ${SEVERITY}
+
+ This PR contains patterns commonly associated with supply chain attacks. This does **not** mean the PR is malicious โ but these patterns require careful human review before merging.
+
+ $(cat /tmp/findings.md)
+
+ ---
+ *Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
+
+ gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
+
+ - name: Fail on critical findings
+ if: steps.scan.outputs.critical == 'true'
+ run: |
+ echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
+ exit 1
diff --git a/.gitignore b/.gitignore
index cc30cd9d4f3..baa31a543c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,8 @@ environments/benchmarks/evals/
# Release script temp files
.release_notes.md
+mini-swe-agent/
+
+# Nix
+.direnv/
+result
diff --git a/.gitmodules b/.gitmodules
index 6a494f4bc21..76580d6e8e5 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,3 @@
-[submodule "mini-swe-agent"]
- path = mini-swe-agent
- url = https://github.com/SWE-agent/mini-swe-agent
[submodule "tinker-atropos"]
path = tinker-atropos
url = https://github.com/nousresearch/tinker-atropos
diff --git a/AGENTS.md b/AGENTS.md
index e52a4f8cbb0..19c6f279779 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@ Instructions for AI coding assistants and developers working on the hermes-agent
## Development Environment
```bash
-source .venv/bin/activate # ALWAYS activate before running Python
+source venv/bin/activate # ALWAYS activate before running Python
```
## Project Structure
@@ -23,6 +23,7 @@ hermes-agent/
โ โโโ prompt_caching.py # Anthropic prompt caching
โ โโโ auxiliary_client.py # Auxiliary LLM client (vision, summarization)
โ โโโ model_metadata.py # Model context lengths, token estimation
+โ โโโ models_dev.py # models.dev registry integration (provider-aware context)
โ โโโ display.py # KawaiiSpinner, tool preview formatting
โ โโโ skill_commands.py # Skill slash commands (shared CLI/gateway)
โ โโโ trajectory.py # Trajectory saving helpers
@@ -37,6 +38,7 @@ hermes-agent/
โ โโโ tools_config.py # `hermes tools` โ enable/disable tools per platform
โ โโโ skills_hub.py # `/skills` slash command (search, browse, install)
โ โโโ models.py # Model catalog, provider model lists
+โ โโโ model_switch.py # Shared /model switch pipeline (CLI + gateway)
โ โโโ auth.py # Provider credential resolution
โโโ tools/ # Tool implementations (one file per tool)
โ โโโ registry.py # Central tool registry (schemas, handlers, dispatch)
@@ -44,7 +46,7 @@ hermes-agent/
โ โโโ terminal_tool.py # Terminal orchestration
โ โโโ process_registry.py # Background process management
โ โโโ file_tools.py # File read/write/search/patch
-โ โโโ web_tools.py # Firecrawl search/extract
+โ โโโ web_tools.py # Web search/extract (Parallel + Firecrawl)
โ โโโ browser_tool.py # Browserbase browser automation
โ โโโ code_execution_tool.py # execute_code sandbox
โ โโโ delegate_tool.py # Subagent delegation
@@ -129,14 +131,51 @@ Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`. Re
- **KawaiiSpinner** (`agent/display.py`) โ animated faces during API calls, `โ` activity feed for tool results
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
- **Skin engine** (`hermes_cli/skin_engine.py`) โ data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
-- `process_command()` is a method on `HermesCLI` (not in commands.py)
+- `process_command()` is a method on `HermesCLI` โ dispatches on canonical command name resolved via `resolve_command()` from the central registry
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
-### Adding CLI Commands
+### Slash Command Registry (`hermes_cli/commands.py`)
-1. Add to `COMMANDS` dict in `hermes_cli/commands.py`
-2. Add handler in `HermesCLI.process_command()` in `cli.py`
-3. For persistent settings, use `save_config_value()` in `cli.py`
+All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
+
+- **CLI** โ `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
+- **Gateway** โ `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
+- **Gateway help** โ `gateway_help_lines()` generates `/help` output
+- **Telegram** โ `telegram_bot_commands()` generates the BotCommand menu
+- **Slack** โ `slack_subcommand_map()` generates `/hermes` subcommand routing
+- **Autocomplete** โ `COMMANDS` flat dict feeds `SlashCommandCompleter`
+- **CLI help** โ `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
+
+### Adding a Slash Command
+
+1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
+```python
+CommandDef("mycommand", "Description of what it does", "Session",
+ aliases=("mc",), args_hint="[arg]"),
+```
+2. Add handler in `HermesCLI.process_command()` in `cli.py`:
+```python
+elif canonical == "mycommand":
+ self._handle_mycommand(cmd_original)
+```
+3. If the command is available in the gateway, add a handler in `gateway/run.py`:
+```python
+if canonical == "mycommand":
+ return await self._handle_mycommand(event)
+```
+4. For persistent settings, use `save_config_value()` in `cli.py`
+
+**CommandDef fields:**
+- `name` โ canonical name without slash (e.g. `"background"`)
+- `description` โ human-readable description
+- `category` โ one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
+- `aliases` โ tuple of alternative names (e.g. `("bg",)`)
+- `args_hint` โ argument placeholder shown in help (e.g. `""`, `"[name]"`)
+- `cli_only` โ only available in the interactive CLI
+- `gateway_only` โ only available in messaging platforms
+- `gateway_config_gate` โ config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
+
+**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed โ dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
---
@@ -235,6 +274,7 @@ hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` |
+| Per-tool emojis | `tool_emojis` | `display.py` โ `get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` |
@@ -292,7 +332,6 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
---
## Important Policies
-
### Prompt Caching Must Not Break
Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
@@ -328,7 +367,10 @@ Rendering bugs in tmux/iTerm2 โ ghosting on scroll. Use `curses` (stdlib) inst
Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
### `_last_resolved_tool_names` is a process-global in `model_tools.py`
-When subagents overwrite this global, `execute_code` calls after delegation may fail with missing tool imports. Known bug.
+`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
+
+### DO NOT hardcode cross-tool references in schema descriptions
+Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` โ see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
### Tests must not write to `~/.hermes/`
The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
@@ -338,7 +380,7 @@ The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HER
## Testing
```bash
-source .venv/bin/activate
+source venv/bin/activate
python -m pytest tests/ -q # Full suite (~3000 tests, ~3 min)
python -m pytest tests/test_model_tools.py -q # Toolset resolution
python -m pytest tests/test_cli_init.py -q # CLI config loading
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 60e8706bb68..4577454e441 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -72,8 +72,9 @@ export VIRTUAL_ENV="$(pwd)/venv"
# Install with all extras (messaging, cron, CLI menus, dev tools)
uv pip install -e ".[all,dev]"
-uv pip install -e "./mini-swe-agent"
-uv pip install -e "./tinker-atropos"
+
+# Optional: RL training submodule
+# git submodule update --init tinker-atropos && uv pip install -e "./tinker-atropos"
# Optional: browser tools
npm install
@@ -136,7 +137,7 @@ hermes-agent/
โ โโโ auth.py # Provider resolution, OAuth, Nous Portal
โ โโโ models.py # OpenRouter model selection lists
โ โโโ banner.py # Welcome banner, ASCII art
-โ โโโ commands.py # Slash command definitions + autocomplete
+โ โโโ commands.py # Central slash command registry (CommandDef), autocomplete, gateway helpers
โ โโโ callbacks.py # Interactive callbacks (clarify, sudo, approval)
โ โโโ doctor.py # Diagnostics
โ โโโ skills_hub.py # Skills Hub CLI + /skills slash command
@@ -147,7 +148,7 @@ hermes-agent/
โ โโโ approval.py # Dangerous command detection + per-session approval
โ โโโ terminal_tool.py # Terminal orchestration (sudo, env lifecycle, backends)
โ โโโ file_operations.py # read_file, write_file, search, patch, etc.
-โ โโโ web_tools.py # web_search, web_extract (Firecrawl + Gemini summarization)
+โ โโโ web_tools.py # web_search, web_extract (Parallel/Firecrawl + Gemini summarization)
โ โโโ vision_tools.py # Image analysis via multimodal models
โ โโโ delegate_tool.py # Subagent spawning and parallel task execution
โ โโโ code_execution_tool.py # Sandboxed Python with RPC tool access
@@ -329,6 +330,14 @@ license: MIT
platforms: [macos, linux] # Optional โ restrict to specific OS platforms
# Valid: macos, linux, windows
# Omit to load on all platforms (default)
+required_environment_variables: # Optional โ secure setup-on-load metadata
+ - name: MY_API_KEY
+ prompt: API key
+ help: Where to get it
+ required_for: full functionality
+prerequisites: # Optional legacy runtime requirements
+ env_vars: [MY_API_KEY] # Backward-compatible alias for required env vars
+ commands: [curl, jq] # Advisory only; does not hide the skill
metadata:
hermes:
tags: [Category, Subcategory, Keywords]
@@ -411,6 +420,40 @@ metadata:
The filtering happens at prompt build time in `agent/prompt_builder.py`. The `build_skills_system_prompt()` function receives the set of available tools and toolsets from the agent and uses `_skill_should_show()` to evaluate each skill's conditions.
+### Skill setup metadata
+
+Skills can declare secure setup-on-load metadata via the `required_environment_variables` frontmatter field. Missing values do not hide the skill from discovery; they trigger a CLI-only secure prompt when the skill is actually loaded.
+
+```yaml
+required_environment_variables:
+ - name: TENOR_API_KEY
+ prompt: Tenor API key
+ help: Get a key from https://developers.google.com/tenor
+ required_for: full functionality
+```
+
+The user may skip setup and keep loading the skill. Hermes only exposes metadata (`stored_as`, `skipped`, `validated`) to the model โ never the secret value.
+
+Legacy `prerequisites.env_vars` remains supported and is normalized into the new representation.
+
+```yaml
+prerequisites:
+ env_vars: [TENOR_API_KEY] # Legacy alias for required_environment_variables
+ commands: [curl, jq] # Advisory CLI checks
+```
+
+Gateway and messaging sessions never collect secrets in-band; they instruct the user to run `hermes setup` or update `~/.hermes/.env` locally.
+
+**When to declare required environment variables:**
+- The skill uses an API key or token that should be collected securely at load time
+- The skill can still be useful if the user skips setup, but may degrade gracefully
+
+**When to declare command prerequisites:**
+- The skill relies on a CLI tool that may not be installed (e.g., `himalaya`, `openhue`, `ddgs`)
+- Treat command checks as guidance, not discovery-time hiding
+
+See `skills/gifs/gif-search/` and `skills/email/himalaya/` for examples.
+
### Skill guidelines
- **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`).
diff --git a/README.md b/README.md
index ca042613d87..fde4cae334a 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-# Hermes Agent โ
+# Hermes Agent โค
@@ -62,6 +62,24 @@ hermes doctor # Diagnose any issues
๐ **[Full documentation โ](https://hermes-agent.nousresearch.com/docs/)**
+## CLI vs Messaging Quick Reference
+
+Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
+
+| Action | CLI | Messaging platforms |
+|---------|-----|---------------------|
+| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
+| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` |
+| Change model | `/model [provider:model]` | `/model [provider:model]` |
+| Set a personality | `/personality [name]` | `/personality [name]` |
+| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` |
+| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
+| Browse skills | `/skills` or `/` | `/skills` or `/` |
+| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message |
+| Platform-specific status | `/platforms` | `/status`, `/sethome` |
+
+For the full command lists, see the [CLI guide](https://hermes-agent.nousresearch.com/docs/user-guide/cli) and the [Messaging Gateway guide](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
+
---
## Documentation
@@ -126,16 +144,14 @@ Quick start for contributors:
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
-git submodule update --init mini-swe-agent # required terminal backend
curl -LsSf https://astral.sh/uv/install.sh | sh
-uv venv .venv --python 3.11
-source .venv/bin/activate
+uv venv venv --python 3.11
+source venv/bin/activate
uv pip install -e ".[all,dev]"
-uv pip install -e "./mini-swe-agent"
python -m pytest tests/ -q
```
-> **RL Training (optional):** To work on the RL/Tinker-Atropos integration, also run:
+> **RL Training (optional):** To work on the RL/Tinker-Atropos integration:
> ```bash
> git submodule update --init tinker-atropos
> uv pip install -e "./tinker-atropos"
diff --git a/RELEASE_v0.3.0.md b/RELEASE_v0.3.0.md
new file mode 100644
index 00000000000..92f9276bcc6
--- /dev/null
+++ b/RELEASE_v0.3.0.md
@@ -0,0 +1,377 @@
+# Hermes Agent v0.3.0 (v2026.3.17)
+
+**Release Date:** March 17, 2026
+
+> The streaming, plugins, and provider release โ unified real-time token delivery, first-class plugin architecture, rebuilt provider system with Vercel AI Gateway, native Anthropic provider, smart approvals, live Chrome CDP browser connect, ACP IDE integration, Honcho memory, voice mode, persistent shell, and 50+ bug fixes across every platform.
+
+---
+
+## โจ Highlights
+
+- **Unified Streaming Infrastructure** โ Real-time token-by-token delivery in CLI and all gateway platforms. Responses stream as they're generated instead of arriving as a block. ([#1538](https://github.com/NousResearch/hermes-agent/pull/1538))
+
+- **First-Class Plugin Architecture** โ Drop Python files into `~/.hermes/plugins/` to extend Hermes with custom tools, commands, and hooks. No forking required. ([#1544](https://github.com/NousResearch/hermes-agent/pull/1544), [#1555](https://github.com/NousResearch/hermes-agent/pull/1555))
+
+- **Native Anthropic Provider** โ Direct Anthropic API calls with Claude Code credential auto-discovery, OAuth PKCE flows, and native prompt caching. No OpenRouter middleman needed. ([#1097](https://github.com/NousResearch/hermes-agent/pull/1097))
+
+- **Smart Approvals + /stop Command** โ Codex-inspired approval system that learns which commands are safe and remembers your preferences. `/stop` kills the current agent run immediately. ([#1543](https://github.com/NousResearch/hermes-agent/pull/1543))
+
+- **Honcho Memory Integration** โ Async memory writes, configurable recall modes, session title integration, and multi-user isolation in gateway mode. By @erosika. ([#736](https://github.com/NousResearch/hermes-agent/pull/736))
+
+- **Voice Mode** โ Push-to-talk in CLI, voice notes in Telegram/Discord, Discord voice channel support, and local Whisper transcription via faster-whisper. ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299), [#1185](https://github.com/NousResearch/hermes-agent/pull/1185), [#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
+
+- **Concurrent Tool Execution** โ Multiple independent tool calls now run in parallel via ThreadPoolExecutor, significantly reducing latency for multi-tool turns. ([#1152](https://github.com/NousResearch/hermes-agent/pull/1152))
+
+- **PII Redaction** โ When `privacy.redact_pii` is enabled, personally identifiable information is automatically scrubbed before sending context to LLM providers. ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
+
+- **`/browser connect` via CDP** โ Attach browser tools to a live Chrome instance through Chrome DevTools Protocol. Debug, inspect, and interact with pages you already have open. ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
+
+- **Vercel AI Gateway Provider** โ Route Hermes through Vercel's AI Gateway for access to their model catalog and infrastructure. ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
+
+- **Centralized Provider Router** โ Rebuilt provider system with `call_llm` API, unified `/model` command, auto-detect provider on model switch, and direct endpoint overrides for auxiliary/delegation clients. ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003), [#1506](https://github.com/NousResearch/hermes-agent/pull/1506), [#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
+
+- **ACP Server (IDE Integration)** โ VS Code, Zed, and JetBrains can now connect to Hermes as an agent backend, with full slash command support. ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254), [#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
+
+- **Persistent Shell Mode** โ Local and SSH terminal backends can maintain shell state across tool calls โ cd, env vars, and aliases persist. By @alt-glitch. ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
+
+- **Agentic On-Policy Distillation (OPD)** โ New RL training environment for distilling agent policies, expanding the Atropos training ecosystem. ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
+
+---
+
+## ๐๏ธ Core Agent & Architecture
+
+### Provider & Model Support
+- **Centralized provider router** with `call_llm` API and unified `/model` command โ switch models and providers seamlessly ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003))
+- **Vercel AI Gateway** provider support ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
+- **Auto-detect provider** when switching models via `/model` ([#1506](https://github.com/NousResearch/hermes-agent/pull/1506))
+- **Direct endpoint overrides** for auxiliary and delegation clients โ point vision/subagent calls at specific endpoints ([#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
+- **Native Anthropic auxiliary vision** โ use Claude's native vision API instead of routing through OpenAI-compatible endpoints ([#1377](https://github.com/NousResearch/hermes-agent/pull/1377))
+- Anthropic OAuth flow improvements โ auto-run `claude setup-token`, reauthentication, PKCE state persistence, identity fingerprinting ([#1132](https://github.com/NousResearch/hermes-agent/pull/1132), [#1360](https://github.com/NousResearch/hermes-agent/pull/1360), [#1396](https://github.com/NousResearch/hermes-agent/pull/1396), [#1597](https://github.com/NousResearch/hermes-agent/pull/1597))
+- Fix adaptive thinking without `budget_tokens` for Claude 4.6 models โ by @ASRagab ([#1128](https://github.com/NousResearch/hermes-agent/pull/1128))
+- Fix Anthropic cache markers through adapter โ by @brandtcormorant ([#1216](https://github.com/NousResearch/hermes-agent/pull/1216))
+- Retry Anthropic 429/529 errors and surface details to users โ by @0xbyt4 ([#1585](https://github.com/NousResearch/hermes-agent/pull/1585))
+- Fix Anthropic adapter max_tokens, fallback crash, proxy base_url โ by @0xbyt4 ([#1121](https://github.com/NousResearch/hermes-agent/pull/1121))
+- Fix DeepSeek V3 parser dropping multiple parallel tool calls โ by @mr-emmett-one ([#1365](https://github.com/NousResearch/hermes-agent/pull/1365), [#1300](https://github.com/NousResearch/hermes-agent/pull/1300))
+- Accept unlisted models with warning instead of rejecting ([#1047](https://github.com/NousResearch/hermes-agent/pull/1047), [#1102](https://github.com/NousResearch/hermes-agent/pull/1102))
+- Skip reasoning params for unsupported OpenRouter models ([#1485](https://github.com/NousResearch/hermes-agent/pull/1485))
+- MiniMax Anthropic API compatibility fix ([#1623](https://github.com/NousResearch/hermes-agent/pull/1623))
+- Custom endpoint `/models` verification and `/v1` base URL suggestion ([#1480](https://github.com/NousResearch/hermes-agent/pull/1480))
+- Resolve delegation providers from `custom_providers` config ([#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
+- Kimi model additions and User-Agent fix ([#1039](https://github.com/NousResearch/hermes-agent/pull/1039))
+- Strip `call_id`/`response_item_id` for Mistral compatibility ([#1058](https://github.com/NousResearch/hermes-agent/pull/1058))
+
+### Agent Loop & Conversation
+- **Anthropic Context Editing API** support ([#1147](https://github.com/NousResearch/hermes-agent/pull/1147))
+- Improved context compaction handoff summaries โ compressor now preserves more actionable state ([#1273](https://github.com/NousResearch/hermes-agent/pull/1273))
+- Sync session_id after mid-run context compression ([#1160](https://github.com/NousResearch/hermes-agent/pull/1160))
+- Session hygiene threshold tuned to 50% for more proactive compression ([#1096](https://github.com/NousResearch/hermes-agent/pull/1096), [#1161](https://github.com/NousResearch/hermes-agent/pull/1161))
+- Include session ID in system prompt via `--pass-session-id` flag ([#1040](https://github.com/NousResearch/hermes-agent/pull/1040))
+- Prevent closed OpenAI client reuse across retries ([#1391](https://github.com/NousResearch/hermes-agent/pull/1391))
+- Sanitize chat payloads and provider precedence ([#1253](https://github.com/NousResearch/hermes-agent/pull/1253))
+- Handle dict tool call arguments from Codex and local backends ([#1393](https://github.com/NousResearch/hermes-agent/pull/1393), [#1440](https://github.com/NousResearch/hermes-agent/pull/1440))
+
+### Memory & Sessions
+- **Improve memory prioritization** โ user preferences and corrections weighted above procedural knowledge ([#1548](https://github.com/NousResearch/hermes-agent/pull/1548))
+- Tighter memory and session recall guidance in system prompts ([#1329](https://github.com/NousResearch/hermes-agent/pull/1329))
+- Persist CLI token counts to session DB for `/insights` ([#1498](https://github.com/NousResearch/hermes-agent/pull/1498))
+- Keep Honcho recall out of the cached system prefix ([#1201](https://github.com/NousResearch/hermes-agent/pull/1201))
+- Correct `seed_ai_identity` to use `session.add_messages()` ([#1475](https://github.com/NousResearch/hermes-agent/pull/1475))
+- Isolate Honcho session routing for multi-user gateway ([#1500](https://github.com/NousResearch/hermes-agent/pull/1500))
+
+---
+
+## ๐ฑ Messaging Platforms (Gateway)
+
+### Gateway Core
+- **System gateway service mode** โ run as a system-level systemd service, not just user-level ([#1371](https://github.com/NousResearch/hermes-agent/pull/1371))
+- **Gateway install scope prompts** โ choose user vs system scope during setup ([#1374](https://github.com/NousResearch/hermes-agent/pull/1374))
+- **Reasoning hot reload** โ change reasoning settings without restarting the gateway ([#1275](https://github.com/NousResearch/hermes-agent/pull/1275))
+- Default group sessions to per-user isolation โ no more shared state across users in group chats ([#1495](https://github.com/NousResearch/hermes-agent/pull/1495), [#1417](https://github.com/NousResearch/hermes-agent/pull/1417))
+- Harden gateway restart recovery ([#1310](https://github.com/NousResearch/hermes-agent/pull/1310))
+- Cancel active runs during shutdown ([#1427](https://github.com/NousResearch/hermes-agent/pull/1427))
+- SSL certificate auto-detection for NixOS and non-standard systems ([#1494](https://github.com/NousResearch/hermes-agent/pull/1494))
+- Auto-detect D-Bus session bus for `systemctl --user` on headless servers ([#1601](https://github.com/NousResearch/hermes-agent/pull/1601))
+- Auto-enable systemd linger during gateway install on headless servers ([#1334](https://github.com/NousResearch/hermes-agent/pull/1334))
+- Fall back to module entrypoint when `hermes` is not on PATH ([#1355](https://github.com/NousResearch/hermes-agent/pull/1355))
+- Fix dual gateways on macOS launchd after `hermes update` ([#1567](https://github.com/NousResearch/hermes-agent/pull/1567))
+- Remove recursive ExecStop from systemd units ([#1530](https://github.com/NousResearch/hermes-agent/pull/1530))
+- Prevent logging handler accumulation in gateway mode ([#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
+- Restart on retryable startup failures โ by @jplew ([#1517](https://github.com/NousResearch/hermes-agent/pull/1517))
+- Backfill model on gateway sessions after agent runs ([#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
+- PID-based gateway kill and deferred config write ([#1499](https://github.com/NousResearch/hermes-agent/pull/1499))
+
+### Telegram
+- Buffer media groups to prevent self-interruption from photo bursts ([#1341](https://github.com/NousResearch/hermes-agent/pull/1341), [#1422](https://github.com/NousResearch/hermes-agent/pull/1422))
+- Retry on transient TLS failures during connect and send ([#1535](https://github.com/NousResearch/hermes-agent/pull/1535))
+- Harden polling conflict handling ([#1339](https://github.com/NousResearch/hermes-agent/pull/1339))
+- Escape chunk indicators and inline code in MarkdownV2 ([#1478](https://github.com/NousResearch/hermes-agent/pull/1478), [#1626](https://github.com/NousResearch/hermes-agent/pull/1626))
+- Check updater/app state before disconnect ([#1389](https://github.com/NousResearch/hermes-agent/pull/1389))
+
+### Discord
+- `/thread` command with `auto_thread` config and media metadata fixes ([#1178](https://github.com/NousResearch/hermes-agent/pull/1178))
+- Auto-thread on @mention, skip mention text in bot threads ([#1438](https://github.com/NousResearch/hermes-agent/pull/1438))
+- Retry without reply reference for system messages ([#1385](https://github.com/NousResearch/hermes-agent/pull/1385))
+- Preserve native document and video attachment support ([#1392](https://github.com/NousResearch/hermes-agent/pull/1392))
+- Defer discord adapter annotations to avoid optional import crashes ([#1314](https://github.com/NousResearch/hermes-agent/pull/1314))
+
+### Slack
+- Thread handling overhaul โ progress messages, responses, and session isolation all respect threads ([#1103](https://github.com/NousResearch/hermes-agent/pull/1103))
+- Formatting, reactions, user resolution, and command improvements ([#1106](https://github.com/NousResearch/hermes-agent/pull/1106))
+- Fix MAX_MESSAGE_LENGTH 3900 โ 39000 ([#1117](https://github.com/NousResearch/hermes-agent/pull/1117))
+- File upload fallback preserves thread context โ by @0xbyt4 ([#1122](https://github.com/NousResearch/hermes-agent/pull/1122))
+- Improve setup guidance ([#1387](https://github.com/NousResearch/hermes-agent/pull/1387))
+
+### Email
+- Fix IMAP UID tracking and SMTP TLS verification ([#1305](https://github.com/NousResearch/hermes-agent/pull/1305))
+- Add `skip_attachments` option via config.yaml ([#1536](https://github.com/NousResearch/hermes-agent/pull/1536))
+
+### Home Assistant
+- Event filtering closed by default ([#1169](https://github.com/NousResearch/hermes-agent/pull/1169))
+
+---
+
+## ๐ฅ๏ธ CLI & User Experience
+
+### Interactive CLI
+- **Persistent CLI status bar** โ always-visible model, provider, and token counts ([#1522](https://github.com/NousResearch/hermes-agent/pull/1522))
+- **File path autocomplete** in the input prompt ([#1545](https://github.com/NousResearch/hermes-agent/pull/1545))
+- **`/plan` command** โ generate implementation plans from specs ([#1372](https://github.com/NousResearch/hermes-agent/pull/1372), [#1381](https://github.com/NousResearch/hermes-agent/pull/1381))
+- **Major `/rollback` improvements** โ richer checkpoint history, clearer UX ([#1505](https://github.com/NousResearch/hermes-agent/pull/1505))
+- **Preload CLI skills on launch** โ skills are ready before the first prompt ([#1359](https://github.com/NousResearch/hermes-agent/pull/1359))
+- **Centralized slash command registry** โ all commands defined once, consumed everywhere ([#1603](https://github.com/NousResearch/hermes-agent/pull/1603))
+- `/bg` alias for `/background` ([#1590](https://github.com/NousResearch/hermes-agent/pull/1590))
+- Prefix matching for slash commands โ `/mod` resolves to `/model` ([#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
+- `/new`, `/reset`, `/clear` now start genuinely fresh sessions ([#1237](https://github.com/NousResearch/hermes-agent/pull/1237))
+- Accept session ID prefixes for session actions ([#1425](https://github.com/NousResearch/hermes-agent/pull/1425))
+- TUI prompt and accent output now respect active skin ([#1282](https://github.com/NousResearch/hermes-agent/pull/1282))
+- Centralize tool emoji metadata in registry + skin integration ([#1484](https://github.com/NousResearch/hermes-agent/pull/1484))
+- "View full command" option added to dangerous command approval โ by @teknium1 based on design by community ([#887](https://github.com/NousResearch/hermes-agent/pull/887))
+- Non-blocking startup update check and banner deduplication ([#1386](https://github.com/NousResearch/hermes-agent/pull/1386))
+- `/reasoning` command output ordering and inline think extraction fixes ([#1031](https://github.com/NousResearch/hermes-agent/pull/1031))
+- Verbose mode shows full untruncated output ([#1472](https://github.com/NousResearch/hermes-agent/pull/1472))
+- Fix `/status` to report live state and tokens ([#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
+- Seed a default global SOUL.md ([#1311](https://github.com/NousResearch/hermes-agent/pull/1311))
+
+### Setup & Configuration
+- **OpenClaw migration** during first-time setup โ by @kshitijk4poor ([#981](https://github.com/NousResearch/hermes-agent/pull/981))
+- `hermes claw migrate` command + migration docs ([#1059](https://github.com/NousResearch/hermes-agent/pull/1059))
+- Smart vision setup that respects the user's chosen provider ([#1323](https://github.com/NousResearch/hermes-agent/pull/1323))
+- Handle headless setup flows end-to-end ([#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
+- Prefer curses over `simple_term_menu` in setup.py ([#1487](https://github.com/NousResearch/hermes-agent/pull/1487))
+- Show effective model and provider in `/status` ([#1284](https://github.com/NousResearch/hermes-agent/pull/1284))
+- Config set examples use placeholder syntax ([#1322](https://github.com/NousResearch/hermes-agent/pull/1322))
+- Reload .env over stale shell overrides ([#1434](https://github.com/NousResearch/hermes-agent/pull/1434))
+- Fix is_coding_plan NameError crash โ by @0xbyt4 ([#1123](https://github.com/NousResearch/hermes-agent/pull/1123))
+- Add missing packages to setuptools config โ by @alt-glitch ([#912](https://github.com/NousResearch/hermes-agent/pull/912))
+- Installer: clarify why sudo is needed at every prompt ([#1602](https://github.com/NousResearch/hermes-agent/pull/1602))
+
+---
+
+## ๐ง Tool System
+
+### Terminal & Execution
+- **Persistent shell mode** for local and SSH backends โ maintain shell state across tool calls โ by @alt-glitch ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
+- **Tirith pre-exec command scanning** โ security layer that analyzes commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
+- Strip Hermes provider env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419)) โ initial fix by @eren-karakus0
+- SSH preflight check ([#1486](https://github.com/NousResearch/hermes-agent/pull/1486))
+- Docker backend: make cwd workspace mount explicit opt-in ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
+- Add project root to PYTHONPATH in execute_code sandbox ([#1383](https://github.com/NousResearch/hermes-agent/pull/1383))
+- Eliminate execute_code progress spam on gateway platforms ([#1098](https://github.com/NousResearch/hermes-agent/pull/1098))
+- Clearer docker backend preflight errors ([#1276](https://github.com/NousResearch/hermes-agent/pull/1276))
+
+### Browser
+- **`/browser connect`** โ attach browser tools to a live Chrome instance via CDP ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
+- Improve browser cleanup, local browser PATH setup, and screenshot recovery ([#1333](https://github.com/NousResearch/hermes-agent/pull/1333))
+
+### MCP
+- **Selective tool loading** with utility policies โ filter which MCP tools are available ([#1302](https://github.com/NousResearch/hermes-agent/pull/1302))
+- Auto-reload MCP tools when `mcp_servers` config changes without restart ([#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
+- Resolve npx stdio connection failures ([#1291](https://github.com/NousResearch/hermes-agent/pull/1291))
+- Preserve MCP toolsets when saving platform tool config ([#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
+
+### Vision
+- Unify vision backend gating ([#1367](https://github.com/NousResearch/hermes-agent/pull/1367))
+- Surface actual error reason instead of generic message ([#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
+- Make Claude image handling work end-to-end ([#1408](https://github.com/NousResearch/hermes-agent/pull/1408))
+
+### Cron
+- **Compress cron management into one tool** โ single `cronjob` tool replaces multiple commands ([#1343](https://github.com/NousResearch/hermes-agent/pull/1343))
+- Suppress duplicate cron sends to auto-delivery targets ([#1357](https://github.com/NousResearch/hermes-agent/pull/1357))
+- Persist cron sessions to SQLite ([#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
+- Per-job runtime overrides (provider, model, base_url) ([#1398](https://github.com/NousResearch/hermes-agent/pull/1398))
+- Atomic write in `save_job_output` to prevent data loss on crash ([#1173](https://github.com/NousResearch/hermes-agent/pull/1173))
+- Preserve thread context for `deliver=origin` ([#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
+
+### Patch Tool
+- Avoid corrupting pipe chars in V4A patch apply ([#1286](https://github.com/NousResearch/hermes-agent/pull/1286))
+- Permissive `block_anchor` thresholds and unicode normalization ([#1539](https://github.com/NousResearch/hermes-agent/pull/1539))
+
+### Delegation
+- Add observability metadata to subagent results (model, tokens, duration, tool trace) ([#1175](https://github.com/NousResearch/hermes-agent/pull/1175))
+
+---
+
+## ๐งฉ Skills Ecosystem
+
+### Skills System
+- **Integrate skills.sh** as a hub source alongside ClawHub ([#1303](https://github.com/NousResearch/hermes-agent/pull/1303))
+- Secure skill env setup on load ([#1153](https://github.com/NousResearch/hermes-agent/pull/1153))
+- Honor policy table for dangerous verdicts ([#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
+- Harden ClawHub skill search exact matches ([#1400](https://github.com/NousResearch/hermes-agent/pull/1400))
+- Fix ClawHub skill install โ use `/download` ZIP endpoint ([#1060](https://github.com/NousResearch/hermes-agent/pull/1060))
+- Avoid mislabeling local skills as builtin โ by @arceus77-7 ([#862](https://github.com/NousResearch/hermes-agent/pull/862))
+
+### New Skills
+- **Linear** project management ([#1230](https://github.com/NousResearch/hermes-agent/pull/1230))
+- **X/Twitter** via x-cli ([#1285](https://github.com/NousResearch/hermes-agent/pull/1285))
+- **Telephony** โ Twilio, SMS, and AI calls ([#1289](https://github.com/NousResearch/hermes-agent/pull/1289))
+- **1Password** โ by @arceus77-7 ([#883](https://github.com/NousResearch/hermes-agent/pull/883), [#1179](https://github.com/NousResearch/hermes-agent/pull/1179))
+- **NeuroSkill BCI** integration ([#1135](https://github.com/NousResearch/hermes-agent/pull/1135))
+- **Blender MCP** for 3D modeling ([#1531](https://github.com/NousResearch/hermes-agent/pull/1531))
+- **OSS Security Forensics** ([#1482](https://github.com/NousResearch/hermes-agent/pull/1482))
+- **Parallel CLI** research skill ([#1301](https://github.com/NousResearch/hermes-agent/pull/1301))
+- **OpenCode** CLI skill ([#1174](https://github.com/NousResearch/hermes-agent/pull/1174))
+- **ASCII Video** skill refactored โ by @SHL0MS ([#1213](https://github.com/NousResearch/hermes-agent/pull/1213), [#1598](https://github.com/NousResearch/hermes-agent/pull/1598))
+
+---
+
+## ๐๏ธ Voice Mode
+
+- Voice mode foundation โ push-to-talk CLI, Telegram/Discord voice notes ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299))
+- Free local Whisper transcription via faster-whisper ([#1185](https://github.com/NousResearch/hermes-agent/pull/1185))
+- Discord voice channel reliability fixes ([#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
+- Restore local STT fallback for gateway voice notes ([#1490](https://github.com/NousResearch/hermes-agent/pull/1490))
+- Honor `stt.enabled: false` across gateway transcription ([#1394](https://github.com/NousResearch/hermes-agent/pull/1394))
+- Fix bogus incapability message on Telegram voice notes (Issue [#1033](https://github.com/NousResearch/hermes-agent/issues/1033))
+
+---
+
+## ๐ ACP (IDE Integration)
+
+- Restore ACP server implementation ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254))
+- Support slash commands in ACP adapter ([#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
+
+---
+
+## ๐งช RL Training
+
+- **Agentic On-Policy Distillation (OPD)** environment โ new RL training environment for agent policy distillation ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
+- Make tinker-atropos RL training fully optional ([#1062](https://github.com/NousResearch/hermes-agent/pull/1062))
+
+---
+
+## ๐ Security & Reliability
+
+### Security Hardening
+- **Tirith pre-exec command scanning** โ static analysis of terminal commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
+- **PII redaction** when `privacy.redact_pii` is enabled ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
+- Strip Hermes provider/gateway/tool env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419))
+- Docker cwd workspace mount now explicit opt-in โ never auto-mount host directories ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
+- Escape parens and braces in fork bomb regex pattern ([#1397](https://github.com/NousResearch/hermes-agent/pull/1397))
+- Harden `.worktreeinclude` path containment ([#1388](https://github.com/NousResearch/hermes-agent/pull/1388))
+- Use description as `pattern_key` to prevent approval collisions ([#1395](https://github.com/NousResearch/hermes-agent/pull/1395))
+
+### Reliability
+- Guard init-time stdio writes ([#1271](https://github.com/NousResearch/hermes-agent/pull/1271))
+- Session log writes reuse shared atomic JSON helper ([#1280](https://github.com/NousResearch/hermes-agent/pull/1280))
+- Atomic temp cleanup protected on interrupts ([#1401](https://github.com/NousResearch/hermes-agent/pull/1401))
+
+---
+
+## ๐ Notable Bug Fixes
+
+- **`/status` always showing 0 tokens** โ now reports live state (Issue [#1465](https://github.com/NousResearch/hermes-agent/issues/1465), [#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
+- **Custom model endpoints not working** โ restored config-saved endpoint resolution (Issue [#1460](https://github.com/NousResearch/hermes-agent/issues/1460), [#1373](https://github.com/NousResearch/hermes-agent/pull/1373))
+- **MCP tools not visible until restart** โ auto-reload on config change (Issue [#1036](https://github.com/NousResearch/hermes-agent/issues/1036), [#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
+- **`hermes tools` removing MCP tools** โ preserve MCP toolsets when saving (Issue [#1247](https://github.com/NousResearch/hermes-agent/issues/1247), [#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
+- **Terminal subprocesses inheriting `OPENAI_BASE_URL`** breaking external tools (Issue [#1002](https://github.com/NousResearch/hermes-agent/issues/1002), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399))
+- **Background process lost on gateway restart** โ improved recovery (Issue [#1144](https://github.com/NousResearch/hermes-agent/issues/1144))
+- **Cron jobs not persisting state** โ now stored in SQLite (Issue [#1416](https://github.com/NousResearch/hermes-agent/issues/1416), [#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
+- **Cronjob `deliver: origin` not preserving thread context** (Issue [#1219](https://github.com/NousResearch/hermes-agent/issues/1219), [#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
+- **Gateway systemd service failing to auto-restart** when browser processes orphaned (Issue [#1617](https://github.com/NousResearch/hermes-agent/issues/1617))
+- **`/background` completion report cut off in Telegram** (Issue [#1443](https://github.com/NousResearch/hermes-agent/issues/1443))
+- **Model switching not taking effect** (Issue [#1244](https://github.com/NousResearch/hermes-agent/issues/1244), [#1183](https://github.com/NousResearch/hermes-agent/pull/1183))
+- **`hermes doctor` reporting cronjob as unavailable** (Issue [#878](https://github.com/NousResearch/hermes-agent/issues/878), [#1180](https://github.com/NousResearch/hermes-agent/pull/1180))
+- **WhatsApp bridge messages not received** from mobile (Issue [#1142](https://github.com/NousResearch/hermes-agent/issues/1142))
+- **Setup wizard hanging on headless SSH** (Issue [#905](https://github.com/NousResearch/hermes-agent/issues/905), [#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
+- **Log handler accumulation** degrading gateway performance (Issue [#990](https://github.com/NousResearch/hermes-agent/issues/990), [#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
+- **Gateway NULL model in DB** (Issue [#987](https://github.com/NousResearch/hermes-agent/issues/987), [#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
+- **Strict endpoints rejecting replayed tool_calls** (Issue [#893](https://github.com/NousResearch/hermes-agent/issues/893))
+- **Remaining hardcoded `~/.hermes` paths** โ all now respect `HERMES_HOME` (Issue [#892](https://github.com/NousResearch/hermes-agent/issues/892), [#1233](https://github.com/NousResearch/hermes-agent/pull/1233))
+- **Delegate tool not working with custom inference providers** (Issue [#1011](https://github.com/NousResearch/hermes-agent/issues/1011), [#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
+- **Skills Guard blocking official skills** (Issue [#1006](https://github.com/NousResearch/hermes-agent/issues/1006), [#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
+- **Setup writing provider before model selection** (Issue [#1182](https://github.com/NousResearch/hermes-agent/issues/1182))
+- **`GatewayConfig.get()` AttributeError** crashing all message handling (Issue [#1158](https://github.com/NousResearch/hermes-agent/issues/1158), [#1287](https://github.com/NousResearch/hermes-agent/pull/1287))
+- **`/update` hard-failing with "command not found"** (Issue [#1049](https://github.com/NousResearch/hermes-agent/issues/1049))
+- **Image analysis failing silently** (Issue [#1034](https://github.com/NousResearch/hermes-agent/issues/1034), [#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
+- **API `BadRequestError` from `'dict'` object has no attribute `'strip'`** (Issue [#1071](https://github.com/NousResearch/hermes-agent/issues/1071))
+- **Slash commands requiring exact full name** โ now uses prefix matching (Issue [#928](https://github.com/NousResearch/hermes-agent/issues/928), [#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
+- **Gateway stops responding when terminal is closed on headless** (Issue [#1005](https://github.com/NousResearch/hermes-agent/issues/1005))
+
+---
+
+## ๐งช Testing
+
+- Cover empty cached Anthropic tool-call turns ([#1222](https://github.com/NousResearch/hermes-agent/pull/1222))
+- Fix stale CI assumptions in parser and quick-command coverage ([#1236](https://github.com/NousResearch/hermes-agent/pull/1236))
+- Fix gateway async tests without implicit event loop ([#1278](https://github.com/NousResearch/hermes-agent/pull/1278))
+- Make gateway async tests xdist-safe ([#1281](https://github.com/NousResearch/hermes-agent/pull/1281))
+- Cross-timezone naive timestamp regression for cron ([#1319](https://github.com/NousResearch/hermes-agent/pull/1319))
+- Isolate codex provider tests from local env ([#1335](https://github.com/NousResearch/hermes-agent/pull/1335))
+- Lock retry replacement semantics ([#1379](https://github.com/NousResearch/hermes-agent/pull/1379))
+- Improve error logging in session search tool โ by @aydnOktay ([#1533](https://github.com/NousResearch/hermes-agent/pull/1533))
+
+---
+
+## ๐ Documentation
+
+- Comprehensive SOUL.md guide ([#1315](https://github.com/NousResearch/hermes-agent/pull/1315))
+- Voice mode documentation ([#1316](https://github.com/NousResearch/hermes-agent/pull/1316), [#1362](https://github.com/NousResearch/hermes-agent/pull/1362))
+- Provider contribution guide ([#1361](https://github.com/NousResearch/hermes-agent/pull/1361))
+- ACP and internal systems implementation guides ([#1259](https://github.com/NousResearch/hermes-agent/pull/1259))
+- Expand Docusaurus coverage across CLI, tools, skills, and skins ([#1232](https://github.com/NousResearch/hermes-agent/pull/1232))
+- Terminal backend and Windows troubleshooting ([#1297](https://github.com/NousResearch/hermes-agent/pull/1297))
+- Skills hub reference section ([#1317](https://github.com/NousResearch/hermes-agent/pull/1317))
+- Checkpoint, /rollback, and git worktrees guide ([#1493](https://github.com/NousResearch/hermes-agent/pull/1493), [#1524](https://github.com/NousResearch/hermes-agent/pull/1524))
+- CLI status bar and /usage reference ([#1523](https://github.com/NousResearch/hermes-agent/pull/1523))
+- Fallback providers + /background command docs ([#1430](https://github.com/NousResearch/hermes-agent/pull/1430))
+- Gateway service scopes docs ([#1378](https://github.com/NousResearch/hermes-agent/pull/1378))
+- Slack thread reply behavior docs ([#1407](https://github.com/NousResearch/hermes-agent/pull/1407))
+- Redesigned landing page with Nous blue palette โ by @austinpickett ([#974](https://github.com/NousResearch/hermes-agent/pull/974))
+- Fix several documentation typos โ by @JackTheGit ([#953](https://github.com/NousResearch/hermes-agent/pull/953))
+- Stabilize website diagrams ([#1405](https://github.com/NousResearch/hermes-agent/pull/1405))
+- CLI vs messaging quick reference in README ([#1491](https://github.com/NousResearch/hermes-agent/pull/1491))
+- Add search to Docusaurus ([#1053](https://github.com/NousResearch/hermes-agent/pull/1053))
+- Home Assistant integration docs ([#1170](https://github.com/NousResearch/hermes-agent/pull/1170))
+
+---
+
+## ๐ฅ Contributors
+
+### Core
+- **@teknium1** โ 220+ PRs spanning every area of the codebase
+
+### Top Community Contributors
+
+- **@0xbyt4** (4 PRs) โ Anthropic adapter fixes (max_tokens, fallback crash, 429/529 retry), Slack file upload thread context, setup NameError fix
+- **@erosika** (1 PR) โ Honcho memory integration: async writes, memory modes, session title integration
+- **@SHL0MS** (2 PRs) โ ASCII video skill design patterns and refactoring
+- **@alt-glitch** (2 PRs) โ Persistent shell mode for local/SSH backends, setuptools packaging fix
+- **@arceus77-7** (2 PRs) โ 1Password skill, fix skills list mislabeling
+- **@kshitijk4poor** (1 PR) โ OpenClaw migration during setup wizard
+- **@ASRagab** (1 PR) โ Fix adaptive thinking for Claude 4.6 models
+- **@eren-karakus0** (1 PR) โ Strip Hermes provider env vars from subprocess environment
+- **@mr-emmett-one** (1 PR) โ Fix DeepSeek V3 parser multi-tool call support
+- **@jplew** (1 PR) โ Gateway restart on retryable startup failures
+- **@brandtcormorant** (1 PR) โ Fix Anthropic cache control for empty text blocks
+- **@aydnOktay** (1 PR) โ Improve error logging in session search tool
+- **@austinpickett** (1 PR) โ Landing page redesign with Nous blue palette
+- **@JackTheGit** (1 PR) โ Documentation typo fixes
+
+### All Contributors
+
+@0xbyt4, @alt-glitch, @arceus77-7, @ASRagab, @austinpickett, @aydnOktay, @brandtcormorant, @eren-karakus0, @erosika, @JackTheGit, @jplew, @kshitijk4poor, @mr-emmett-one, @SHL0MS, @teknium1
+
+---
+
+**Full Changelog**: [v2026.3.12...v2026.3.17](https://github.com/NousResearch/hermes-agent/compare/v2026.3.12...v2026.3.17)
diff --git a/RELEASE_v0.4.0.md b/RELEASE_v0.4.0.md
new file mode 100644
index 00000000000..e2ddf21d6d6
--- /dev/null
+++ b/RELEASE_v0.4.0.md
@@ -0,0 +1,400 @@
+# Hermes Agent v0.4.0 (v2026.3.23)
+
+**Release Date:** March 23, 2026
+
+> The platform expansion release โ OpenAI-compatible API server, 6 new messaging adapters, 4 new inference providers, MCP server management with OAuth 2.1, @ context references, gateway prompt caching, streaming enabled by default, and a sweeping reliability pass with 200+ bug fixes.
+
+---
+
+## โจ Highlights
+
+- **OpenAI-compatible API server** โ Expose Hermes as an `/v1/chat/completions` endpoint with a new `/api/jobs` REST API for cron job management, hardened with input limits, field whitelists, SQLite-backed response persistence, and CORS origin protection ([#1756](https://github.com/NousResearch/hermes-agent/pull/1756), [#2450](https://github.com/NousResearch/hermes-agent/pull/2450), [#2456](https://github.com/NousResearch/hermes-agent/pull/2456), [#2451](https://github.com/NousResearch/hermes-agent/pull/2451), [#2472](https://github.com/NousResearch/hermes-agent/pull/2472))
+
+- **6 new messaging platform adapters** โ Signal, DingTalk, SMS (Twilio), Mattermost, Matrix, and Webhook adapters join Telegram, Discord, and WhatsApp. Gateway auto-reconnects failed platforms with exponential backoff ([#2206](https://github.com/NousResearch/hermes-agent/pull/2206), [#1685](https://github.com/NousResearch/hermes-agent/pull/1685), [#1688](https://github.com/NousResearch/hermes-agent/pull/1688), [#1683](https://github.com/NousResearch/hermes-agent/pull/1683), [#2166](https://github.com/NousResearch/hermes-agent/pull/2166), [#2584](https://github.com/NousResearch/hermes-agent/pull/2584))
+
+- **@ context references** โ Claude Code-style `@file` and `@url` context injection with tab completions in the CLI ([#2343](https://github.com/NousResearch/hermes-agent/pull/2343), [#2482](https://github.com/NousResearch/hermes-agent/pull/2482))
+
+- **4 new inference providers** โ GitHub Copilot (OAuth + token validation), Alibaba Cloud / DashScope, Kilo Code, and OpenCode Zen/Go ([#1924](https://github.com/NousResearch/hermes-agent/pull/1924), [#1879](https://github.com/NousResearch/hermes-agent/pull/1879) by @mchzimm, [#1673](https://github.com/NousResearch/hermes-agent/pull/1673), [#1666](https://github.com/NousResearch/hermes-agent/pull/1666), [#1650](https://github.com/NousResearch/hermes-agent/pull/1650))
+
+- **MCP server management CLI** โ `hermes mcp` commands for installing, configuring, and authenticating MCP servers with full OAuth 2.1 PKCE flow ([#2465](https://github.com/NousResearch/hermes-agent/pull/2465))
+
+- **Gateway prompt caching** โ Cache AIAgent instances per session, preserving Anthropic prompt cache across turns for dramatic cost reduction on long conversations ([#2282](https://github.com/NousResearch/hermes-agent/pull/2282), [#2284](https://github.com/NousResearch/hermes-agent/pull/2284), [#2361](https://github.com/NousResearch/hermes-agent/pull/2361))
+
+- **Context compression overhaul** โ Structured summaries with iterative updates, token-budget tail protection, configurable summary endpoint, and fallback model support ([#2323](https://github.com/NousResearch/hermes-agent/pull/2323), [#1727](https://github.com/NousResearch/hermes-agent/pull/1727), [#2224](https://github.com/NousResearch/hermes-agent/pull/2224))
+
+- **Streaming enabled by default** โ CLI streaming on by default with proper spinner/tool progress display during streaming mode, plus extensive linebreak and concatenation fixes ([#2340](https://github.com/NousResearch/hermes-agent/pull/2340), [#2161](https://github.com/NousResearch/hermes-agent/pull/2161), [#2258](https://github.com/NousResearch/hermes-agent/pull/2258))
+
+---
+
+## ๐ฅ๏ธ CLI & User Experience
+
+### New Commands & Interactions
+- **@ context completions** โ Tab-completable `@file`/`@url` references that inject file content or web pages into the conversation ([#2482](https://github.com/NousResearch/hermes-agent/pull/2482), [#2343](https://github.com/NousResearch/hermes-agent/pull/2343))
+- **`/statusbar`** โ Toggle a persistent config bar showing model + provider info in the prompt ([#2240](https://github.com/NousResearch/hermes-agent/pull/2240), [#1917](https://github.com/NousResearch/hermes-agent/pull/1917))
+- **`/queue`** โ Queue prompts for the agent without interrupting the current run ([#2191](https://github.com/NousResearch/hermes-agent/pull/2191), [#2469](https://github.com/NousResearch/hermes-agent/pull/2469))
+- **`/permission`** โ Switch approval mode dynamically during a session ([#2207](https://github.com/NousResearch/hermes-agent/pull/2207))
+- **`/browser`** โ Interactive browser sessions from the CLI ([#2273](https://github.com/NousResearch/hermes-agent/pull/2273), [#1814](https://github.com/NousResearch/hermes-agent/pull/1814))
+- **`/cost`** โ Live pricing and usage tracking in gateway mode ([#2180](https://github.com/NousResearch/hermes-agent/pull/2180))
+- **`/approve` and `/deny`** โ Replaced bare text approval in gateway with explicit commands ([#2002](https://github.com/NousResearch/hermes-agent/pull/2002))
+
+### Streaming & Display
+- Streaming enabled by default in CLI ([#2340](https://github.com/NousResearch/hermes-agent/pull/2340))
+- Show spinners and tool progress during streaming mode ([#2161](https://github.com/NousResearch/hermes-agent/pull/2161))
+- Show reasoning/thinking blocks when `show_reasoning` enabled ([#2118](https://github.com/NousResearch/hermes-agent/pull/2118))
+- Context pressure warnings for CLI and gateway ([#2159](https://github.com/NousResearch/hermes-agent/pull/2159))
+- Fix: streaming chunks concatenated without whitespace ([#2258](https://github.com/NousResearch/hermes-agent/pull/2258))
+- Fix: iteration boundary linebreak prevents stream concatenation ([#2413](https://github.com/NousResearch/hermes-agent/pull/2413))
+- Fix: defer streaming linebreak to prevent blank line stacking ([#2473](https://github.com/NousResearch/hermes-agent/pull/2473))
+- Fix: suppress spinner animation in non-TTY environments ([#2216](https://github.com/NousResearch/hermes-agent/pull/2216))
+- Fix: display provider and endpoint in API error messages ([#2266](https://github.com/NousResearch/hermes-agent/pull/2266))
+- Fix: resolve garbled ANSI escape codes in status printouts ([#2448](https://github.com/NousResearch/hermes-agent/pull/2448))
+- Fix: update gold ANSI color to true-color format ([#2246](https://github.com/NousResearch/hermes-agent/pull/2246))
+- Fix: normalize toolset labels and use skin colors in banner ([#1912](https://github.com/NousResearch/hermes-agent/pull/1912))
+
+### CLI Polish
+- Fix: prevent 'Press ENTER to continue...' on exit ([#2555](https://github.com/NousResearch/hermes-agent/pull/2555))
+- Fix: flush stdout during agent loop to prevent macOS display freeze ([#1654](https://github.com/NousResearch/hermes-agent/pull/1654))
+- Fix: show human-readable error when `hermes setup` hits permissions error ([#2196](https://github.com/NousResearch/hermes-agent/pull/2196))
+- Fix: `/stop` command crash + UnboundLocalError in streaming media delivery ([#2463](https://github.com/NousResearch/hermes-agent/pull/2463))
+- Fix: allow custom/local endpoints without API key ([#2556](https://github.com/NousResearch/hermes-agent/pull/2556))
+- Fix: Kitty keyboard protocol Shift+Enter for Ghostty/WezTerm (attempted + reverted due to prompt_toolkit crash) ([#2345](https://github.com/NousResearch/hermes-agent/pull/2345), [#2349](https://github.com/NousResearch/hermes-agent/pull/2349))
+
+### Configuration
+- **`${ENV_VAR}` substitution** in config.yaml ([#2684](https://github.com/NousResearch/hermes-agent/pull/2684))
+- **Real-time config reload** โ config.yaml changes apply without restart ([#2210](https://github.com/NousResearch/hermes-agent/pull/2210))
+- **`custom_models.yaml`** for user-managed model additions ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214))
+- **Priority-based context file selection** + CLAUDE.md support ([#2301](https://github.com/NousResearch/hermes-agent/pull/2301))
+- **Merge nested YAML sections** instead of replacing on config update ([#2213](https://github.com/NousResearch/hermes-agent/pull/2213))
+- Fix: config.yaml provider key overrides env var silently ([#2272](https://github.com/NousResearch/hermes-agent/pull/2272))
+- Fix: log warning instead of silently swallowing config.yaml errors ([#2683](https://github.com/NousResearch/hermes-agent/pull/2683))
+- Fix: disabled toolsets re-enable themselves after `hermes tools` ([#2268](https://github.com/NousResearch/hermes-agent/pull/2268))
+- Fix: platform default toolsets silently override tool deselection ([#2624](https://github.com/NousResearch/hermes-agent/pull/2624))
+- Fix: honor bare YAML `approvals.mode: off` ([#2620](https://github.com/NousResearch/hermes-agent/pull/2620))
+- Fix: `hermes update` use `.[all]` extras with fallback ([#1728](https://github.com/NousResearch/hermes-agent/pull/1728))
+- Fix: `hermes update` prompt before resetting working tree on stash conflicts ([#2390](https://github.com/NousResearch/hermes-agent/pull/2390))
+- Fix: use git pull --rebase in update/install to avoid divergent branch error ([#2274](https://github.com/NousResearch/hermes-agent/pull/2274))
+- Fix: add zprofile fallback and create zshrc on fresh macOS installs ([#2320](https://github.com/NousResearch/hermes-agent/pull/2320))
+- Fix: remove `ANTHROPIC_BASE_URL` env var to avoid collisions ([#1675](https://github.com/NousResearch/hermes-agent/pull/1675))
+- Fix: don't ask IMAP password if already in keyring or env ([#2212](https://github.com/NousResearch/hermes-agent/pull/2212))
+- Fix: OpenCode Zen/Go show OpenRouter models instead of their own ([#2277](https://github.com/NousResearch/hermes-agent/pull/2277))
+
+---
+
+## ๐๏ธ Core Agent & Architecture
+
+### New Providers
+- **GitHub Copilot** โ Full OAuth auth, API routing, token validation, and 400k context. ([#1924](https://github.com/NousResearch/hermes-agent/pull/1924), [#1896](https://github.com/NousResearch/hermes-agent/pull/1896), [#1879](https://github.com/NousResearch/hermes-agent/pull/1879) by @mchzimm, [#2507](https://github.com/NousResearch/hermes-agent/pull/2507))
+- **Alibaba Cloud / DashScope** โ Full integration with DashScope v1 runtime, model dot preservation, and 401 auth fixes ([#1673](https://github.com/NousResearch/hermes-agent/pull/1673), [#2332](https://github.com/NousResearch/hermes-agent/pull/2332), [#2459](https://github.com/NousResearch/hermes-agent/pull/2459))
+- **Kilo Code** โ First-class inference provider ([#1666](https://github.com/NousResearch/hermes-agent/pull/1666))
+- **OpenCode Zen and OpenCode Go** โ New provider backends ([#1650](https://github.com/NousResearch/hermes-agent/pull/1650), [#2393](https://github.com/NousResearch/hermes-agent/pull/2393) by @0xbyt4)
+- **NeuTTS** โ Local TTS provider backend with built-in setup flow, replacing the old optional skill ([#1657](https://github.com/NousResearch/hermes-agent/pull/1657), [#1664](https://github.com/NousResearch/hermes-agent/pull/1664))
+
+### Provider Improvements
+- **Eager fallback** to backup model on rate-limit errors ([#1730](https://github.com/NousResearch/hermes-agent/pull/1730))
+- **Endpoint metadata** for custom model context and pricing; query local servers for actual context window size ([#1906](https://github.com/NousResearch/hermes-agent/pull/1906), [#2091](https://github.com/NousResearch/hermes-agent/pull/2091) by @dusterbloom)
+- **Context length detection overhaul** โ models.dev integration, provider-aware resolution, fuzzy matching for custom endpoints, `/v1/props` for llama.cpp ([#2158](https://github.com/NousResearch/hermes-agent/pull/2158), [#2051](https://github.com/NousResearch/hermes-agent/pull/2051), [#2403](https://github.com/NousResearch/hermes-agent/pull/2403))
+- **Model catalog updates** โ gpt-5.4-mini, gpt-5.4-nano, healer-alpha, haiku-4.5, minimax-m2.7, claude 4.6 at 1M context ([#1913](https://github.com/NousResearch/hermes-agent/pull/1913), [#1915](https://github.com/NousResearch/hermes-agent/pull/1915), [#1900](https://github.com/NousResearch/hermes-agent/pull/1900), [#2155](https://github.com/NousResearch/hermes-agent/pull/2155), [#2474](https://github.com/NousResearch/hermes-agent/pull/2474))
+- **Custom endpoint improvements** โ `model.base_url` in config.yaml, `api_mode` override for responses API, allow endpoints without API key, fail fast on missing keys ([#2330](https://github.com/NousResearch/hermes-agent/pull/2330), [#1651](https://github.com/NousResearch/hermes-agent/pull/1651), [#2556](https://github.com/NousResearch/hermes-agent/pull/2556), [#2445](https://github.com/NousResearch/hermes-agent/pull/2445), [#1994](https://github.com/NousResearch/hermes-agent/pull/1994), [#1998](https://github.com/NousResearch/hermes-agent/pull/1998))
+- Inject model and provider into system prompt ([#1929](https://github.com/NousResearch/hermes-agent/pull/1929))
+- Tie `api_mode` to provider config instead of env var ([#1656](https://github.com/NousResearch/hermes-agent/pull/1656))
+- Fix: prevent Anthropic token leaking to third-party `anthropic_messages` providers ([#2389](https://github.com/NousResearch/hermes-agent/pull/2389))
+- Fix: prevent Anthropic fallback from inheriting non-Anthropic `base_url` ([#2388](https://github.com/NousResearch/hermes-agent/pull/2388))
+- Fix: `auxiliary_is_nous` flag never resets โ leaked Nous tags to other providers ([#1713](https://github.com/NousResearch/hermes-agent/pull/1713))
+- Fix: Anthropic `tool_choice 'none'` still allowed tool calls ([#1714](https://github.com/NousResearch/hermes-agent/pull/1714))
+- Fix: Mistral parser nested JSON fallback extraction ([#2335](https://github.com/NousResearch/hermes-agent/pull/2335))
+- Fix: MiniMax 401 auth resolved by defaulting to `anthropic_messages` ([#2103](https://github.com/NousResearch/hermes-agent/pull/2103))
+- Fix: case-insensitive model family matching ([#2350](https://github.com/NousResearch/hermes-agent/pull/2350))
+- Fix: ignore placeholder provider keys in activation checks ([#2358](https://github.com/NousResearch/hermes-agent/pull/2358))
+- Fix: Preserve Ollama model:tag colons in context length detection ([#2149](https://github.com/NousResearch/hermes-agent/pull/2149))
+- Fix: recognize Claude Code OAuth credentials in startup gate ([#1663](https://github.com/NousResearch/hermes-agent/pull/1663))
+- Fix: detect Claude Code version dynamically for OAuth user-agent ([#1670](https://github.com/NousResearch/hermes-agent/pull/1670))
+- Fix: OAuth flag stale after refresh/fallback ([#1890](https://github.com/NousResearch/hermes-agent/pull/1890))
+- Fix: auxiliary client skips expired Codex JWT ([#2397](https://github.com/NousResearch/hermes-agent/pull/2397))
+
+### Agent Loop
+- **Gateway prompt caching** โ Cache AIAgent per session, keep assistant turns, fix session restore ([#2282](https://github.com/NousResearch/hermes-agent/pull/2282), [#2284](https://github.com/NousResearch/hermes-agent/pull/2284), [#2361](https://github.com/NousResearch/hermes-agent/pull/2361))
+- **Context compression overhaul** โ Structured summaries, iterative updates, token-budget tail protection, configurable `summary_base_url` ([#2323](https://github.com/NousResearch/hermes-agent/pull/2323), [#1727](https://github.com/NousResearch/hermes-agent/pull/1727), [#2224](https://github.com/NousResearch/hermes-agent/pull/2224))
+- **Pre-call sanitization and post-call tool guardrails** ([#1732](https://github.com/NousResearch/hermes-agent/pull/1732))
+- **Auto-recover** from provider-rejected `tool_choice` by retrying without ([#2174](https://github.com/NousResearch/hermes-agent/pull/2174))
+- **Background memory/skill review** replaces inline nudges ([#2235](https://github.com/NousResearch/hermes-agent/pull/2235))
+- **SOUL.md as primary agent identity** instead of hardcoded default ([#1922](https://github.com/NousResearch/hermes-agent/pull/1922))
+- Fix: prevent silent tool result loss during context compression ([#1993](https://github.com/NousResearch/hermes-agent/pull/1993))
+- Fix: handle empty/null function arguments in tool call recovery ([#2163](https://github.com/NousResearch/hermes-agent/pull/2163))
+- Fix: handle API refusal responses gracefully instead of crashing ([#2156](https://github.com/NousResearch/hermes-agent/pull/2156))
+- Fix: prevent stuck agent loop on malformed tool calls ([#2114](https://github.com/NousResearch/hermes-agent/pull/2114))
+- Fix: return JSON parse error to model instead of dispatching with empty args ([#2342](https://github.com/NousResearch/hermes-agent/pull/2342))
+- Fix: consecutive assistant message merge drops content on mixed types ([#1703](https://github.com/NousResearch/hermes-agent/pull/1703))
+- Fix: message role alternation violations in JSON recovery and error handler ([#1722](https://github.com/NousResearch/hermes-agent/pull/1722))
+- Fix: `compression_attempts` resets each iteration โ allowed unlimited compressions ([#1723](https://github.com/NousResearch/hermes-agent/pull/1723))
+- Fix: `length_continue_retries` never resets โ later truncations got fewer retries ([#1717](https://github.com/NousResearch/hermes-agent/pull/1717))
+- Fix: compressor summary role violated consecutive-role constraint ([#1720](https://github.com/NousResearch/hermes-agent/pull/1720), [#1743](https://github.com/NousResearch/hermes-agent/pull/1743))
+- Fix: remove hardcoded `gemini-3-flash-preview` as default summary model ([#2464](https://github.com/NousResearch/hermes-agent/pull/2464))
+- Fix: correctly handle empty tool results ([#2201](https://github.com/NousResearch/hermes-agent/pull/2201))
+- Fix: crash on None entry in `tool_calls` list ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209) by @0xbyt4, [#2316](https://github.com/NousResearch/hermes-agent/pull/2316))
+- Fix: per-thread persistent event loops in worker threads ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214) by @jquesnelle)
+- Fix: prevent 'event loop already running' when async tools run in parallel ([#2207](https://github.com/NousResearch/hermes-agent/pull/2207))
+- Fix: strip ANSI at the source โ clean terminal output before it reaches the model ([#2115](https://github.com/NousResearch/hermes-agent/pull/2115))
+- Fix: skip top-level `cache_control` on role:tool for OpenRouter ([#2391](https://github.com/NousResearch/hermes-agent/pull/2391))
+- Fix: delegate tool โ save parent tool names before child construction mutates global ([#2083](https://github.com/NousResearch/hermes-agent/pull/2083) by @ygd58, [#1894](https://github.com/NousResearch/hermes-agent/pull/1894))
+- Fix: only strip last assistant message if empty string ([#2326](https://github.com/NousResearch/hermes-agent/pull/2326))
+
+### Session & Memory
+- **Session search** and management slash commands ([#2198](https://github.com/NousResearch/hermes-agent/pull/2198))
+- **Auto session titles** and `.hermes.md` project config ([#1712](https://github.com/NousResearch/hermes-agent/pull/1712))
+- Fix: concurrent memory writes silently drop entries โ added file locking ([#1726](https://github.com/NousResearch/hermes-agent/pull/1726))
+- Fix: search all sources by default in `session_search` ([#1892](https://github.com/NousResearch/hermes-agent/pull/1892))
+- Fix: handle hyphenated FTS5 queries and preserve quoted literals ([#1776](https://github.com/NousResearch/hermes-agent/pull/1776))
+- Fix: skip corrupt lines in `load_transcript` instead of crashing ([#1744](https://github.com/NousResearch/hermes-agent/pull/1744))
+- Fix: normalize session keys to prevent case-sensitive duplicates ([#2157](https://github.com/NousResearch/hermes-agent/pull/2157))
+- Fix: prevent `session_search` crash when no sessions exist ([#2194](https://github.com/NousResearch/hermes-agent/pull/2194))
+- Fix: reset token counters on new session for accurate usage display ([#2101](https://github.com/NousResearch/hermes-agent/pull/2101) by @InB4DevOps)
+- Fix: prevent stale memory overwrites by flush agent ([#2687](https://github.com/NousResearch/hermes-agent/pull/2687))
+- Fix: remove synthetic error message injection, fix session resume after repeated failures ([#2303](https://github.com/NousResearch/hermes-agent/pull/2303))
+- Fix: quiet mode with `--resume` now passes conversation_history ([#2357](https://github.com/NousResearch/hermes-agent/pull/2357))
+- Fix: unify resume logic in batch mode ([#2331](https://github.com/NousResearch/hermes-agent/pull/2331))
+
+### Honcho Memory
+- Honcho config fixes and @ context reference integration ([#2343](https://github.com/NousResearch/hermes-agent/pull/2343))
+- Self-hosted / Docker configuration documentation ([#2475](https://github.com/NousResearch/hermes-agent/pull/2475))
+
+---
+
+## ๐ฑ Messaging Platforms (Gateway)
+
+### New Platform Adapters
+- **Signal Messenger** โ Full adapter with attachment handling, group message filtering, and Note to Self echo-back protection ([#2206](https://github.com/NousResearch/hermes-agent/pull/2206), [#2400](https://github.com/NousResearch/hermes-agent/pull/2400), [#2297](https://github.com/NousResearch/hermes-agent/pull/2297), [#2156](https://github.com/NousResearch/hermes-agent/pull/2156))
+- **DingTalk** โ Adapter with gateway wiring and setup docs ([#1685](https://github.com/NousResearch/hermes-agent/pull/1685), [#1690](https://github.com/NousResearch/hermes-agent/pull/1690), [#1692](https://github.com/NousResearch/hermes-agent/pull/1692))
+- **SMS (Twilio)** ([#1688](https://github.com/NousResearch/hermes-agent/pull/1688))
+- **Mattermost** โ With @-mention-only channel filter ([#1683](https://github.com/NousResearch/hermes-agent/pull/1683), [#2443](https://github.com/NousResearch/hermes-agent/pull/2443))
+- **Matrix** โ With vision support and image caching ([#1683](https://github.com/NousResearch/hermes-agent/pull/1683), [#2520](https://github.com/NousResearch/hermes-agent/pull/2520))
+- **Webhook** โ Platform adapter for external event triggers ([#2166](https://github.com/NousResearch/hermes-agent/pull/2166))
+- **OpenAI-compatible API server** โ `/v1/chat/completions` endpoint with `/api/jobs` cron management ([#1756](https://github.com/NousResearch/hermes-agent/pull/1756), [#2450](https://github.com/NousResearch/hermes-agent/pull/2450), [#2456](https://github.com/NousResearch/hermes-agent/pull/2456))
+
+### Telegram Improvements
+- MarkdownV2 support โ strikethrough, spoiler, blockquotes, escape parentheses/braces/backslashes/backticks ([#2199](https://github.com/NousResearch/hermes-agent/pull/2199), [#2200](https://github.com/NousResearch/hermes-agent/pull/2200) by @llbn, [#2386](https://github.com/NousResearch/hermes-agent/pull/2386))
+- Auto-detect HTML tags and use `parse_mode=HTML` ([#1709](https://github.com/NousResearch/hermes-agent/pull/1709))
+- Telegram group vision support + thread-based sessions ([#2153](https://github.com/NousResearch/hermes-agent/pull/2153))
+- Auto-reconnect polling after network interruption ([#2517](https://github.com/NousResearch/hermes-agent/pull/2517))
+- Aggregate split text messages before dispatching ([#1674](https://github.com/NousResearch/hermes-agent/pull/1674))
+- Fix: streaming config bridge, not-modified, flood control ([#1782](https://github.com/NousResearch/hermes-agent/pull/1782), [#1783](https://github.com/NousResearch/hermes-agent/pull/1783))
+- Fix: edited_message event crashes ([#2074](https://github.com/NousResearch/hermes-agent/pull/2074))
+- Fix: retry 409 polling conflicts before giving up ([#2312](https://github.com/NousResearch/hermes-agent/pull/2312))
+- Fix: topic delivery via `platform:chat_id:thread_id` format ([#2455](https://github.com/NousResearch/hermes-agent/pull/2455))
+
+### Discord Improvements
+- Document caching and text-file injection ([#2503](https://github.com/NousResearch/hermes-agent/pull/2503))
+- Persistent typing indicator for DMs ([#2468](https://github.com/NousResearch/hermes-agent/pull/2468))
+- Discord DM vision โ inline images + attachment analysis ([#2186](https://github.com/NousResearch/hermes-agent/pull/2186))
+- Persist thread participation across gateway restarts ([#1661](https://github.com/NousResearch/hermes-agent/pull/1661))
+- Fix: gateway crash on non-ASCII guild names ([#2302](https://github.com/NousResearch/hermes-agent/pull/2302))
+- Fix: thread permission errors ([#2073](https://github.com/NousResearch/hermes-agent/pull/2073))
+- Fix: slash event routing in threads ([#2460](https://github.com/NousResearch/hermes-agent/pull/2460))
+- Fix: remove bugged followup messages + `/ask` command ([#1836](https://github.com/NousResearch/hermes-agent/pull/1836))
+- Fix: graceful WebSocket reconnection ([#2127](https://github.com/NousResearch/hermes-agent/pull/2127))
+- Fix: voice channel TTS when streaming enabled ([#2322](https://github.com/NousResearch/hermes-agent/pull/2322))
+
+### WhatsApp & Other Adapters
+- WhatsApp: outbound `send_message` routing ([#1769](https://github.com/NousResearch/hermes-agent/pull/1769) by @sai-samarth), LID format self-chat ([#1667](https://github.com/NousResearch/hermes-agent/pull/1667)), `reply_prefix` config fix ([#1923](https://github.com/NousResearch/hermes-agent/pull/1923)), restart on bridge child exit ([#2334](https://github.com/NousResearch/hermes-agent/pull/2334)), image/bridge improvements ([#2181](https://github.com/NousResearch/hermes-agent/pull/2181))
+- Matrix: correct `reply_to_message_id` parameter ([#1895](https://github.com/NousResearch/hermes-agent/pull/1895)), bare media types fix ([#1736](https://github.com/NousResearch/hermes-agent/pull/1736))
+- Mattermost: MIME types for media attachments ([#2329](https://github.com/NousResearch/hermes-agent/pull/2329))
+
+### Gateway Core
+- **Auto-reconnect** failed platforms with exponential backoff ([#2584](https://github.com/NousResearch/hermes-agent/pull/2584))
+- **Notify users when session auto-resets** ([#2519](https://github.com/NousResearch/hermes-agent/pull/2519))
+- **Reply-to message context** for out-of-session replies ([#1662](https://github.com/NousResearch/hermes-agent/pull/1662))
+- **Ignore unauthorized DMs** config option ([#1919](https://github.com/NousResearch/hermes-agent/pull/1919))
+- Fix: `/reset` in thread-mode resets global session instead of thread ([#2254](https://github.com/NousResearch/hermes-agent/pull/2254))
+- Fix: deliver MEDIA: files after streaming responses ([#2382](https://github.com/NousResearch/hermes-agent/pull/2382))
+- Fix: cap interrupt recursion depth to prevent resource exhaustion ([#1659](https://github.com/NousResearch/hermes-agent/pull/1659))
+- Fix: detect stopped processes and release stale locks on `--replace` ([#2406](https://github.com/NousResearch/hermes-agent/pull/2406), [#1908](https://github.com/NousResearch/hermes-agent/pull/1908))
+- Fix: PID-based wait with force-kill for gateway restart ([#1902](https://github.com/NousResearch/hermes-agent/pull/1902))
+- Fix: prevent `--replace` mode from killing the caller process ([#2185](https://github.com/NousResearch/hermes-agent/pull/2185))
+- Fix: `/model` shows active fallback model instead of config default ([#1660](https://github.com/NousResearch/hermes-agent/pull/1660))
+- Fix: `/title` command fails when session doesn't exist in SQLite yet ([#2379](https://github.com/NousResearch/hermes-agent/pull/2379) by @ten-jampa)
+- Fix: process `/queue`'d messages after agent completion ([#2469](https://github.com/NousResearch/hermes-agent/pull/2469))
+- Fix: strip orphaned `tool_results` + let `/reset` bypass running agent ([#2180](https://github.com/NousResearch/hermes-agent/pull/2180))
+- Fix: prevent agents from starting gateway outside systemd management ([#2617](https://github.com/NousResearch/hermes-agent/pull/2617))
+- Fix: prevent systemd restart storm on gateway connection failure ([#2327](https://github.com/NousResearch/hermes-agent/pull/2327))
+- Fix: include resolved node path in systemd unit ([#1767](https://github.com/NousResearch/hermes-agent/pull/1767) by @sai-samarth)
+- Fix: send error details to user in gateway outer exception handler ([#1966](https://github.com/NousResearch/hermes-agent/pull/1966))
+- Fix: improve error handling for 429 usage limits and 500 context overflow ([#1839](https://github.com/NousResearch/hermes-agent/pull/1839))
+- Fix: add all missing platform allowlist env vars to startup warning check ([#2628](https://github.com/NousResearch/hermes-agent/pull/2628))
+- Fix: media delivery fails for file paths containing spaces ([#2621](https://github.com/NousResearch/hermes-agent/pull/2621))
+- Fix: duplicate session-key collision in multi-platform gateway ([#2171](https://github.com/NousResearch/hermes-agent/pull/2171))
+- Fix: Matrix and Mattermost never report as connected ([#1711](https://github.com/NousResearch/hermes-agent/pull/1711))
+- Fix: PII redaction config never read โ missing yaml import ([#1701](https://github.com/NousResearch/hermes-agent/pull/1701))
+- Fix: NameError on skill slash commands ([#1697](https://github.com/NousResearch/hermes-agent/pull/1697))
+- Fix: persist watcher metadata in checkpoint for crash recovery ([#1706](https://github.com/NousResearch/hermes-agent/pull/1706))
+- Fix: pass `message_thread_id` in send_image_file, send_document, send_video ([#2339](https://github.com/NousResearch/hermes-agent/pull/2339))
+- Fix: media-group aggregation on rapid successive photo messages ([#2160](https://github.com/NousResearch/hermes-agent/pull/2160))
+
+---
+
+## ๐ง Tool System
+
+### MCP Enhancements
+- **MCP server management CLI** + OAuth 2.1 PKCE auth ([#2465](https://github.com/NousResearch/hermes-agent/pull/2465))
+- **Expose MCP servers as standalone toolsets** ([#1907](https://github.com/NousResearch/hermes-agent/pull/1907))
+- **Interactive MCP tool configuration** in `hermes tools` ([#1694](https://github.com/NousResearch/hermes-agent/pull/1694))
+- Fix: MCP-OAuth port mismatch, path traversal, and shared handler state ([#2552](https://github.com/NousResearch/hermes-agent/pull/2552))
+- Fix: preserve MCP tool registrations across session resets ([#2124](https://github.com/NousResearch/hermes-agent/pull/2124))
+- Fix: concurrent file access crash + duplicate MCP registration ([#2154](https://github.com/NousResearch/hermes-agent/pull/2154))
+- Fix: normalise MCP schemas + expand session list columns ([#2102](https://github.com/NousResearch/hermes-agent/pull/2102))
+- Fix: `tool_choice` `mcp_` prefix handling ([#1775](https://github.com/NousResearch/hermes-agent/pull/1775))
+
+### Web Tool Backends
+- **Tavily** as web search/extract/crawl backend ([#1731](https://github.com/NousResearch/hermes-agent/pull/1731))
+- **Parallel** as alternative web search/extract backend ([#1696](https://github.com/NousResearch/hermes-agent/pull/1696))
+- **Configurable web backend** โ Firecrawl/BeautifulSoup/Playwright selection ([#2256](https://github.com/NousResearch/hermes-agent/pull/2256))
+- Fix: whitespace-only env vars bypass web backend detection ([#2341](https://github.com/NousResearch/hermes-agent/pull/2341))
+
+### New Tools
+- **IMAP email** reading and sending ([#2173](https://github.com/NousResearch/hermes-agent/pull/2173))
+- **STT (speech-to-text)** tool using Whisper API ([#2072](https://github.com/NousResearch/hermes-agent/pull/2072))
+- **Route-aware pricing estimates** ([#1695](https://github.com/NousResearch/hermes-agent/pull/1695))
+
+### Tool Improvements
+- TTS: `base_url` support for OpenAI TTS provider ([#2064](https://github.com/NousResearch/hermes-agent/pull/2064) by @hanai)
+- Vision: configurable timeout, tilde expansion in file paths, DM vision with multi-image and base64 fallback ([#2480](https://github.com/NousResearch/hermes-agent/pull/2480), [#2585](https://github.com/NousResearch/hermes-agent/pull/2585), [#2211](https://github.com/NousResearch/hermes-agent/pull/2211))
+- Browser: race condition fix in session creation ([#1721](https://github.com/NousResearch/hermes-agent/pull/1721)), TypeError on unexpected LLM params ([#1735](https://github.com/NousResearch/hermes-agent/pull/1735))
+- File tools: strip ANSI escape codes from write_file and patch content ([#2532](https://github.com/NousResearch/hermes-agent/pull/2532)), include pagination args in repeated search key ([#1824](https://github.com/NousResearch/hermes-agent/pull/1824) by @cutepawss), improve fuzzy matching accuracy + position calculation refactor ([#2096](https://github.com/NousResearch/hermes-agent/pull/2096), [#1681](https://github.com/NousResearch/hermes-agent/pull/1681))
+- Code execution: resource leak and double socket close fix ([#2381](https://github.com/NousResearch/hermes-agent/pull/2381))
+- Delegate: thread safety for concurrent subagent delegation ([#1672](https://github.com/NousResearch/hermes-agent/pull/1672)), preserve parent agent's tool list after delegation ([#1778](https://github.com/NousResearch/hermes-agent/pull/1778))
+- Fix: make concurrent tool batching path-aware for file mutations ([#1914](https://github.com/NousResearch/hermes-agent/pull/1914))
+- Fix: chunk long messages in `send_message_tool` before platform dispatch ([#1646](https://github.com/NousResearch/hermes-agent/pull/1646))
+- Fix: add missing 'messaging' toolset ([#1718](https://github.com/NousResearch/hermes-agent/pull/1718))
+- Fix: prevent unavailable tool names from leaking into model schemas ([#2072](https://github.com/NousResearch/hermes-agent/pull/2072))
+- Fix: pass visited set by reference to prevent diamond dependency duplication ([#2311](https://github.com/NousResearch/hermes-agent/pull/2311))
+- Fix: Daytona sandbox lookup migrated from `find_one` to `get/list` ([#2063](https://github.com/NousResearch/hermes-agent/pull/2063) by @rovle)
+
+---
+
+## ๐งฉ Skills Ecosystem
+
+### Skills System Improvements
+- **Agent-created skills** โ Caution-level findings allowed, dangerous skills ask instead of block ([#1840](https://github.com/NousResearch/hermes-agent/pull/1840), [#2446](https://github.com/NousResearch/hermes-agent/pull/2446))
+- **`--yes` flag** to bypass confirmation in `/skills install` and uninstall ([#1647](https://github.com/NousResearch/hermes-agent/pull/1647))
+- **Disabled skills respected** across banner, system prompt, and slash commands ([#1897](https://github.com/NousResearch/hermes-agent/pull/1897))
+- Fix: skills custom_tools import crash + sandbox file_tools integration ([#2239](https://github.com/NousResearch/hermes-agent/pull/2239))
+- Fix: agent-created skills with pip requirements crash on install ([#2145](https://github.com/NousResearch/hermes-agent/pull/2145))
+- Fix: race condition in `Skills.__init__` when `hub.yaml` missing ([#2242](https://github.com/NousResearch/hermes-agent/pull/2242))
+- Fix: validate skill metadata before install and block duplicates ([#2241](https://github.com/NousResearch/hermes-agent/pull/2241))
+- Fix: skills hub inspect/resolve โ 4 bugs in inspect, redirects, discovery, tap list ([#2447](https://github.com/NousResearch/hermes-agent/pull/2447))
+- Fix: agent-created skills keep working after session reset ([#2121](https://github.com/NousResearch/hermes-agent/pull/2121))
+
+### New Skills
+- **OCR-and-documents** โ PDF/DOCX/XLS/PPTX/image OCR with optional GPU ([#2236](https://github.com/NousResearch/hermes-agent/pull/2236), [#2461](https://github.com/NousResearch/hermes-agent/pull/2461))
+- **Huggingface-hub** bundled skill ([#1921](https://github.com/NousResearch/hermes-agent/pull/1921))
+- **Sherlock OSINT** username search ([#1671](https://github.com/NousResearch/hermes-agent/pull/1671))
+- **Meme-generation** โ Image generator with Pillow ([#2344](https://github.com/NousResearch/hermes-agent/pull/2344))
+- **Bioinformatics** gateway skill โ index to 400+ bio skills ([#2387](https://github.com/NousResearch/hermes-agent/pull/2387))
+- **Inference.sh** skill (terminal-based) ([#1686](https://github.com/NousResearch/hermes-agent/pull/1686))
+- **Base blockchain** optional skill ([#1643](https://github.com/NousResearch/hermes-agent/pull/1643))
+- **3D-model-viewer** optional skill ([#2226](https://github.com/NousResearch/hermes-agent/pull/2226))
+- **FastMCP** optional skill ([#2113](https://github.com/NousResearch/hermes-agent/pull/2113))
+- **Hermes-agent-setup** skill ([#1905](https://github.com/NousResearch/hermes-agent/pull/1905))
+
+---
+
+## ๐ Plugin System Enhancements
+
+- **TUI extension hooks** โ Build custom CLIs on top of Hermes ([#2333](https://github.com/NousResearch/hermes-agent/pull/2333))
+- **`hermes plugins install/remove/list`** commands ([#2337](https://github.com/NousResearch/hermes-agent/pull/2337))
+- **Slash command registration** for plugins ([#2359](https://github.com/NousResearch/hermes-agent/pull/2359))
+- **`session:end` lifecycle event** hook ([#1725](https://github.com/NousResearch/hermes-agent/pull/1725))
+- Fix: require opt-in for project plugin discovery ([#2215](https://github.com/NousResearch/hermes-agent/pull/2215))
+
+---
+
+## ๐ Security & Reliability
+
+### Security
+- **SSRF protection** for vision_tools and web_tools ([#2679](https://github.com/NousResearch/hermes-agent/pull/2679))
+- **Shell injection prevention** in `_expand_path` via `~user` path suffix ([#2685](https://github.com/NousResearch/hermes-agent/pull/2685))
+- **Block untrusted browser-origin** API server access ([#2451](https://github.com/NousResearch/hermes-agent/pull/2451))
+- **Block sandbox backend creds** from subprocess env ([#1658](https://github.com/NousResearch/hermes-agent/pull/1658))
+- **Block @ references** from reading secrets outside workspace ([#2601](https://github.com/NousResearch/hermes-agent/pull/2601) by @Gutslabs)
+- **Malicious code pattern pre-exec scanner** for terminal_tool ([#2245](https://github.com/NousResearch/hermes-agent/pull/2245))
+- **Harden terminal safety** and sandbox file writes ([#1653](https://github.com/NousResearch/hermes-agent/pull/1653))
+- **PKCE verifier leak** fix + OAuth refresh Content-Type ([#1775](https://github.com/NousResearch/hermes-agent/pull/1775))
+- **Eliminate SQL string formatting** in `execute()` calls ([#2061](https://github.com/NousResearch/hermes-agent/pull/2061) by @dusterbloom)
+- **Harden jobs API** โ input limits, field whitelist, startup check ([#2456](https://github.com/NousResearch/hermes-agent/pull/2456))
+
+### Reliability
+- Thread locks on 4 SessionDB methods ([#1704](https://github.com/NousResearch/hermes-agent/pull/1704))
+- File locking for concurrent memory writes ([#1726](https://github.com/NousResearch/hermes-agent/pull/1726))
+- Handle OpenRouter errors gracefully ([#2112](https://github.com/NousResearch/hermes-agent/pull/2112))
+- Guard print() calls against OSError ([#1668](https://github.com/NousResearch/hermes-agent/pull/1668))
+- Safely handle non-string inputs in redacting formatter ([#2392](https://github.com/NousResearch/hermes-agent/pull/2392), [#1700](https://github.com/NousResearch/hermes-agent/pull/1700))
+- ACP: preserve session provider on model switch, persist sessions to disk ([#2380](https://github.com/NousResearch/hermes-agent/pull/2380), [#2071](https://github.com/NousResearch/hermes-agent/pull/2071))
+- API server: persist ResponseStore to SQLite across restarts ([#2472](https://github.com/NousResearch/hermes-agent/pull/2472))
+- Fix: `fetch_nous_models` always TypeError from positional args ([#1699](https://github.com/NousResearch/hermes-agent/pull/1699))
+- Fix: resolve merge conflict markers in cli.py breaking startup ([#2347](https://github.com/NousResearch/hermes-agent/pull/2347))
+- Fix: `minisweagent_path.py` missing from wheel ([#2098](https://github.com/NousResearch/hermes-agent/pull/2098) by @JiwaniZakir)
+
+### Cron System
+- **`[SILENT]` response** โ cron agents can suppress delivery ([#1833](https://github.com/NousResearch/hermes-agent/pull/1833))
+- **Scale missed-job grace window** with schedule frequency ([#2449](https://github.com/NousResearch/hermes-agent/pull/2449))
+- **Recover recent one-shot jobs** ([#1918](https://github.com/NousResearch/hermes-agent/pull/1918))
+- Fix: normalize `repeat<=0` to None โ jobs deleted after first run when LLM passes -1 ([#2612](https://github.com/NousResearch/hermes-agent/pull/2612) by @Mibayy)
+- Fix: Matrix added to scheduler delivery platform_map ([#2167](https://github.com/NousResearch/hermes-agent/pull/2167) by @buntingszn)
+- Fix: naive ISO timestamps without timezone โ jobs fire at wrong time ([#1729](https://github.com/NousResearch/hermes-agent/pull/1729))
+- Fix: `get_due_jobs` reads `jobs.json` twice โ race condition ([#1716](https://github.com/NousResearch/hermes-agent/pull/1716))
+- Fix: silent jobs return empty response for delivery skip ([#2442](https://github.com/NousResearch/hermes-agent/pull/2442))
+- Fix: stop injecting cron outputs into gateway session history ([#2313](https://github.com/NousResearch/hermes-agent/pull/2313))
+- Fix: close abandoned coroutine when `asyncio.run()` raises RuntimeError ([#2317](https://github.com/NousResearch/hermes-agent/pull/2317))
+
+---
+
+## ๐งช Testing
+
+- Resolve all consistently failing tests ([#2488](https://github.com/NousResearch/hermes-agent/pull/2488))
+- Replace `FakePath` with `monkeypatch` for Python 3.12 compat ([#2444](https://github.com/NousResearch/hermes-agent/pull/2444))
+- Align Hermes setup and full-suite expectations ([#1710](https://github.com/NousResearch/hermes-agent/pull/1710))
+
+---
+
+## ๐ Documentation
+
+- Comprehensive docs update for recent features ([#1693](https://github.com/NousResearch/hermes-agent/pull/1693), [#2183](https://github.com/NousResearch/hermes-agent/pull/2183))
+- Alibaba Cloud and DingTalk setup guides ([#1687](https://github.com/NousResearch/hermes-agent/pull/1687), [#1692](https://github.com/NousResearch/hermes-agent/pull/1692))
+- Detailed skills documentation ([#2244](https://github.com/NousResearch/hermes-agent/pull/2244))
+- Honcho self-hosted / Docker configuration ([#2475](https://github.com/NousResearch/hermes-agent/pull/2475))
+- Context length detection FAQ and quickstart references ([#2179](https://github.com/NousResearch/hermes-agent/pull/2179))
+- Fix docs inconsistencies across reference and user guides ([#1995](https://github.com/NousResearch/hermes-agent/pull/1995))
+- Fix MCP install commands โ use uv, not bare pip ([#1909](https://github.com/NousResearch/hermes-agent/pull/1909))
+- Replace ASCII diagrams with Mermaid/lists ([#2402](https://github.com/NousResearch/hermes-agent/pull/2402))
+- Gemini OAuth provider implementation plan ([#2467](https://github.com/NousResearch/hermes-agent/pull/2467))
+- Discord Server Members Intent marked as required ([#2330](https://github.com/NousResearch/hermes-agent/pull/2330))
+- Fix MDX build error in api-server.md ([#1787](https://github.com/NousResearch/hermes-agent/pull/1787))
+- Align venv path to match installer ([#2114](https://github.com/NousResearch/hermes-agent/pull/2114))
+- New skills added to hub index ([#2281](https://github.com/NousResearch/hermes-agent/pull/2281))
+
+---
+
+## ๐ฅ Contributors
+
+### Core
+- **@teknium1** (Teknium) โ 280 PRs
+
+### Community Contributors
+- **@mchzimm** (to_the_max) โ GitHub Copilot provider integration ([#1879](https://github.com/NousResearch/hermes-agent/pull/1879))
+- **@jquesnelle** (Jeffrey Quesnelle) โ Per-thread persistent event loops fix ([#2214](https://github.com/NousResearch/hermes-agent/pull/2214))
+- **@llbn** (lbn) โ Telegram MarkdownV2 strikethrough, spoiler, blockquotes, and escape fixes ([#2199](https://github.com/NousResearch/hermes-agent/pull/2199), [#2200](https://github.com/NousResearch/hermes-agent/pull/2200))
+- **@dusterbloom** โ SQL injection prevention + local server context window querying ([#2061](https://github.com/NousResearch/hermes-agent/pull/2061), [#2091](https://github.com/NousResearch/hermes-agent/pull/2091))
+- **@0xbyt4** โ Anthropic tool_calls None guard + OpenCode-Go provider config fix ([#2209](https://github.com/NousResearch/hermes-agent/pull/2209), [#2393](https://github.com/NousResearch/hermes-agent/pull/2393))
+- **@sai-samarth** (Saisamarth) โ WhatsApp send_message routing + systemd node path ([#1769](https://github.com/NousResearch/hermes-agent/pull/1769), [#1767](https://github.com/NousResearch/hermes-agent/pull/1767))
+- **@Gutslabs** (Guts) โ Block @ references from reading secrets ([#2601](https://github.com/NousResearch/hermes-agent/pull/2601))
+- **@Mibayy** (Mibay) โ Cron job repeat normalization ([#2612](https://github.com/NousResearch/hermes-agent/pull/2612))
+- **@ten-jampa** (Tenzin Jampa) โ Gateway /title command fix ([#2379](https://github.com/NousResearch/hermes-agent/pull/2379))
+- **@cutepawss** (lila) โ File tools search pagination fix ([#1824](https://github.com/NousResearch/hermes-agent/pull/1824))
+- **@hanai** (Hanai) โ OpenAI TTS base_url support ([#2064](https://github.com/NousResearch/hermes-agent/pull/2064))
+- **@rovle** (Lovre Peลกut) โ Daytona sandbox API migration ([#2063](https://github.com/NousResearch/hermes-agent/pull/2063))
+- **@buntingszn** (bunting szn) โ Matrix cron delivery support ([#2167](https://github.com/NousResearch/hermes-agent/pull/2167))
+- **@InB4DevOps** โ Token counter reset on new session ([#2101](https://github.com/NousResearch/hermes-agent/pull/2101))
+- **@JiwaniZakir** (Zakir Jiwani) โ Missing file in wheel fix ([#2098](https://github.com/NousResearch/hermes-agent/pull/2098))
+- **@ygd58** (buray) โ Delegate tool parent tool names fix ([#2083](https://github.com/NousResearch/hermes-agent/pull/2083))
+
+---
+
+**Full Changelog**: [v2026.3.17...v2026.3.23](https://github.com/NousResearch/hermes-agent/compare/v2026.3.17...v2026.3.23)
diff --git a/acp_adapter/__init__.py b/acp_adapter/__init__.py
new file mode 100644
index 00000000000..b58a27b6018
--- /dev/null
+++ b/acp_adapter/__init__.py
@@ -0,0 +1 @@
+"""ACP (Agent Communication Protocol) adapter for hermes-agent."""
diff --git a/acp_adapter/__main__.py b/acp_adapter/__main__.py
new file mode 100644
index 00000000000..a6ccd099735
--- /dev/null
+++ b/acp_adapter/__main__.py
@@ -0,0 +1,5 @@
+"""Allow running the ACP adapter as ``python -m acp_adapter``."""
+
+from .entry import main
+
+main()
diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py
new file mode 100644
index 00000000000..a33b5a93938
--- /dev/null
+++ b/acp_adapter/auth.py
@@ -0,0 +1,24 @@
+"""ACP auth helpers โ detect the currently configured Hermes provider."""
+
+from __future__ import annotations
+
+from typing import Optional
+
+
+def detect_provider() -> Optional[str]:
+ """Resolve the active Hermes runtime provider, or None if unavailable."""
+ try:
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ runtime = resolve_runtime_provider()
+ api_key = runtime.get("api_key")
+ provider = runtime.get("provider")
+ if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip():
+ return provider.strip().lower()
+ except Exception:
+ return None
+ return None
+
+
+def has_provider() -> bool:
+ """Return True if Hermes can resolve any runtime provider credentials."""
+ return detect_provider() is not None
diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py
new file mode 100644
index 00000000000..fe13ce703af
--- /dev/null
+++ b/acp_adapter/entry.py
@@ -0,0 +1,86 @@
+"""CLI entry point for the hermes-agent ACP adapter.
+
+Loads environment variables from ``~/.hermes/.env``, configures logging
+to write to stderr (so stdout is reserved for ACP JSON-RPC transport),
+and starts the ACP agent server.
+
+Usage::
+
+ python -m acp_adapter.entry
+ # or
+ hermes acp
+ # or
+ hermes-acp
+"""
+
+import asyncio
+import logging
+import os
+import sys
+from pathlib import Path
+from hermes_constants import get_hermes_home
+
+
+def _setup_logging() -> None:
+ """Route all logging to stderr so stdout stays clean for ACP stdio."""
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(
+ logging.Formatter(
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ )
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(logging.INFO)
+
+ # Quiet down noisy libraries
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
+ logging.getLogger("openai").setLevel(logging.WARNING)
+
+
+def _load_env() -> None:
+ """Load .env from HERMES_HOME (default ``~/.hermes``)."""
+ from hermes_cli.env_loader import load_hermes_dotenv
+
+ hermes_home = get_hermes_home()
+ loaded = load_hermes_dotenv(hermes_home=hermes_home)
+ if loaded:
+ for env_file in loaded:
+ logging.getLogger(__name__).info("Loaded env from %s", env_file)
+ else:
+ logging.getLogger(__name__).info(
+ "No .env found at %s, using system env", hermes_home / ".env"
+ )
+
+
+def main() -> None:
+ """Entry point: load env, configure logging, run the ACP agent."""
+ _setup_logging()
+ _load_env()
+
+ logger = logging.getLogger(__name__)
+ logger.info("Starting hermes-agent ACP adapter")
+
+ # Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
+ project_root = str(Path(__file__).resolve().parent.parent)
+ if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+ import acp
+ from .server import HermesACPAgent
+
+ agent = HermesACPAgent()
+ try:
+ asyncio.run(acp.run_agent(agent))
+ except KeyboardInterrupt:
+ logger.info("Shutting down (KeyboardInterrupt)")
+ except Exception:
+ logger.exception("ACP agent crashed")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/acp_adapter/events.py b/acp_adapter/events.py
new file mode 100644
index 00000000000..5d10309d56a
--- /dev/null
+++ b/acp_adapter/events.py
@@ -0,0 +1,171 @@
+"""Callback factories for bridging AIAgent events to ACP notifications.
+
+Each factory returns a callable with the signature that AIAgent expects
+for its callbacks. Internally, the callbacks push ACP session updates
+to the client via ``conn.session_update()`` using
+``asyncio.run_coroutine_threadsafe()`` (since AIAgent runs in a worker
+thread while the event loop lives on the main thread).
+"""
+
+import asyncio
+import json
+import logging
+from collections import deque
+from typing import Any, Callable, Deque, Dict
+
+import acp
+
+from .tools import (
+ build_tool_complete,
+ build_tool_start,
+ make_tool_call_id,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _send_update(
+ conn: acp.Client,
+ session_id: str,
+ loop: asyncio.AbstractEventLoop,
+ update: Any,
+) -> None:
+ """Fire-and-forget an ACP session update from a worker thread."""
+ try:
+ future = asyncio.run_coroutine_threadsafe(
+ conn.session_update(session_id, update), loop
+ )
+ future.result(timeout=5)
+ except Exception:
+ logger.debug("Failed to send ACP update", exc_info=True)
+
+
+# ------------------------------------------------------------------
+# Tool progress callback
+# ------------------------------------------------------------------
+
+def make_tool_progress_cb(
+ conn: acp.Client,
+ session_id: str,
+ loop: asyncio.AbstractEventLoop,
+ tool_call_ids: Dict[str, Deque[str]],
+) -> Callable:
+ """Create a ``tool_progress_callback`` for AIAgent.
+
+ Signature expected by AIAgent::
+
+ tool_progress_callback(name: str, preview: str, args: dict)
+
+ Emits ``ToolCallStart`` for each tool invocation and tracks IDs in a FIFO
+ queue per tool name so duplicate/parallel same-name calls still complete
+ against the correct ACP tool call.
+ """
+
+ def _tool_progress(name: str, preview: str, args: Any = None) -> None:
+ if isinstance(args, str):
+ try:
+ args = json.loads(args)
+ except (json.JSONDecodeError, TypeError):
+ args = {"raw": args}
+ if not isinstance(args, dict):
+ args = {}
+
+ tc_id = make_tool_call_id()
+ queue = tool_call_ids.get(name)
+ if queue is None:
+ queue = deque()
+ tool_call_ids[name] = queue
+ elif isinstance(queue, str):
+ queue = deque([queue])
+ tool_call_ids[name] = queue
+ queue.append(tc_id)
+
+ update = build_tool_start(tc_id, name, args)
+ _send_update(conn, session_id, loop, update)
+
+ return _tool_progress
+
+
+# ------------------------------------------------------------------
+# Thinking callback
+# ------------------------------------------------------------------
+
+def make_thinking_cb(
+ conn: acp.Client,
+ session_id: str,
+ loop: asyncio.AbstractEventLoop,
+) -> Callable:
+ """Create a ``thinking_callback`` for AIAgent."""
+
+ def _thinking(text: str) -> None:
+ if not text:
+ return
+ update = acp.update_agent_thought_text(text)
+ _send_update(conn, session_id, loop, update)
+
+ return _thinking
+
+
+# ------------------------------------------------------------------
+# Step callback
+# ------------------------------------------------------------------
+
+def make_step_cb(
+ conn: acp.Client,
+ session_id: str,
+ loop: asyncio.AbstractEventLoop,
+ tool_call_ids: Dict[str, Deque[str]],
+) -> Callable:
+ """Create a ``step_callback`` for AIAgent.
+
+ Signature expected by AIAgent::
+
+ step_callback(api_call_count: int, prev_tools: list)
+ """
+
+ def _step(api_call_count: int, prev_tools: Any = None) -> None:
+ if prev_tools and isinstance(prev_tools, list):
+ for tool_info in prev_tools:
+ tool_name = None
+ result = None
+
+ if isinstance(tool_info, dict):
+ tool_name = tool_info.get("name") or tool_info.get("function_name")
+ result = tool_info.get("result") or tool_info.get("output")
+ elif isinstance(tool_info, str):
+ tool_name = tool_info
+
+ queue = tool_call_ids.get(tool_name or "")
+ if isinstance(queue, str):
+ queue = deque([queue])
+ tool_call_ids[tool_name] = queue
+ if tool_name and queue:
+ tc_id = queue.popleft()
+ update = build_tool_complete(
+ tc_id, tool_name, result=str(result) if result is not None else None
+ )
+ _send_update(conn, session_id, loop, update)
+ if not queue:
+ tool_call_ids.pop(tool_name, None)
+
+ return _step
+
+
+# ------------------------------------------------------------------
+# Agent message callback
+# ------------------------------------------------------------------
+
+def make_message_cb(
+ conn: acp.Client,
+ session_id: str,
+ loop: asyncio.AbstractEventLoop,
+) -> Callable:
+ """Create a callback that streams agent response text to the editor."""
+
+ def _message(text: str) -> None:
+ if not text:
+ return
+ update = acp.update_agent_message_text(text)
+ _send_update(conn, session_id, loop, update)
+
+ return _message
diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py
new file mode 100644
index 00000000000..68f61e340ab
--- /dev/null
+++ b/acp_adapter/permissions.py
@@ -0,0 +1,77 @@
+"""ACP permission bridging โ maps ACP approval requests to hermes approval callbacks."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from concurrent.futures import TimeoutError as FutureTimeout
+from typing import Callable
+
+from acp.schema import (
+ AllowedOutcome,
+ PermissionOption,
+)
+
+logger = logging.getLogger(__name__)
+
+# Maps ACP PermissionOptionKind -> hermes approval result strings
+_KIND_TO_HERMES = {
+ "allow_once": "once",
+ "allow_always": "always",
+ "reject_once": "deny",
+ "reject_always": "deny",
+}
+
+
+def make_approval_callback(
+ request_permission_fn: Callable,
+ loop: asyncio.AbstractEventLoop,
+ session_id: str,
+ timeout: float = 60.0,
+) -> Callable[[str, str], str]:
+ """
+ Return a hermes-compatible ``approval_callback(command, description) -> str``
+ that bridges to the ACP client's ``request_permission`` call.
+
+ Args:
+ request_permission_fn: The ACP connection's ``request_permission`` coroutine.
+ loop: The event loop on which the ACP connection lives.
+ session_id: Current ACP session id.
+ timeout: Seconds to wait for a response before auto-denying.
+ """
+
+ def _callback(command: str, description: str) -> str:
+ options = [
+ PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
+ PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"),
+ PermissionOption(option_id="deny", kind="reject_once", name="Deny"),
+ ]
+ import acp as _acp
+
+ tool_call = _acp.start_tool_call("perm-check", command, kind="execute")
+
+ coro = request_permission_fn(
+ session_id=session_id,
+ tool_call=tool_call,
+ options=options,
+ )
+
+ try:
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
+ response = future.result(timeout=timeout)
+ except (FutureTimeout, Exception) as exc:
+ logger.warning("Permission request timed out or failed: %s", exc)
+ return "deny"
+
+ outcome = response.outcome
+ if isinstance(outcome, AllowedOutcome):
+ option_id = outcome.option_id
+ # Look up the kind from our options list
+ for opt in options:
+ if opt.option_id == option_id:
+ return _KIND_TO_HERMES.get(opt.kind, "deny")
+ return "once" # fallback for unknown option_id
+ else:
+ return "deny"
+
+ return _callback
diff --git a/acp_adapter/server.py b/acp_adapter/server.py
new file mode 100644
index 00000000000..64c1e5185ac
--- /dev/null
+++ b/acp_adapter/server.py
@@ -0,0 +1,492 @@
+"""ACP agent server โ exposes Hermes Agent via the Agent Client Protocol."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections import defaultdict, deque
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any, Deque, Optional
+
+import acp
+from acp.schema import (
+ AgentCapabilities,
+ AuthenticateResponse,
+ AuthMethod,
+ ClientCapabilities,
+ EmbeddedResourceContentBlock,
+ ForkSessionResponse,
+ ImageContentBlock,
+ AudioContentBlock,
+ Implementation,
+ InitializeResponse,
+ ListSessionsResponse,
+ LoadSessionResponse,
+ NewSessionResponse,
+ PromptResponse,
+ ResumeSessionResponse,
+ ResourceContentBlock,
+ SessionCapabilities,
+ SessionForkCapabilities,
+ SessionListCapabilities,
+ SessionInfo,
+ TextContentBlock,
+ Usage,
+)
+
+from acp_adapter.auth import detect_provider, has_provider
+from acp_adapter.events import (
+ make_message_cb,
+ make_step_cb,
+ make_thinking_cb,
+ make_tool_progress_cb,
+)
+from acp_adapter.permissions import make_approval_callback
+from acp_adapter.session import SessionManager, SessionState
+
+logger = logging.getLogger(__name__)
+
+try:
+ from hermes_cli import __version__ as HERMES_VERSION
+except Exception:
+ HERMES_VERSION = "0.0.0"
+
+# Thread pool for running AIAgent (synchronous) in parallel.
+_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
+
+
+def _extract_text(
+ prompt: list[
+ TextContentBlock
+ | ImageContentBlock
+ | AudioContentBlock
+ | ResourceContentBlock
+ | EmbeddedResourceContentBlock
+ ],
+) -> str:
+ """Extract plain text from ACP content blocks."""
+ parts: list[str] = []
+ for block in prompt:
+ if isinstance(block, TextContentBlock):
+ parts.append(block.text)
+ elif hasattr(block, "text"):
+ parts.append(str(block.text))
+ # Non-text blocks are ignored for now.
+ return "\n".join(parts)
+
+
+class HermesACPAgent(acp.Agent):
+ """ACP Agent implementation wrapping Hermes AIAgent."""
+
+ def __init__(self, session_manager: SessionManager | None = None):
+ super().__init__()
+ self.session_manager = session_manager or SessionManager()
+ self._conn: Optional[acp.Client] = None
+
+ # ---- Connection lifecycle -----------------------------------------------
+
+ def on_connect(self, conn: acp.Client) -> None:
+ """Store the client connection for sending session updates."""
+ self._conn = conn
+ logger.info("ACP client connected")
+
+ # ---- ACP lifecycle ------------------------------------------------------
+
+ async def initialize(
+ self,
+ protocol_version: int,
+ client_capabilities: ClientCapabilities | None = None,
+ client_info: Implementation | None = None,
+ **kwargs: Any,
+ ) -> InitializeResponse:
+ provider = detect_provider()
+ auth_methods = None
+ if provider:
+ auth_methods = [
+ AuthMethod(
+ id=provider,
+ name=f"{provider} runtime credentials",
+ description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.",
+ )
+ ]
+
+ client_name = client_info.name if client_info else "unknown"
+ logger.info("Initialize from %s (protocol v%s)", client_name, protocol_version)
+
+ return InitializeResponse(
+ protocol_version=acp.PROTOCOL_VERSION,
+ agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
+ agent_capabilities=AgentCapabilities(
+ session_capabilities=SessionCapabilities(
+ fork=SessionForkCapabilities(),
+ list=SessionListCapabilities(),
+ ),
+ ),
+ auth_methods=auth_methods,
+ )
+
+ async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
+ if has_provider():
+ return AuthenticateResponse()
+ return None
+
+ # ---- Session management -------------------------------------------------
+
+ async def new_session(
+ self,
+ cwd: str,
+ mcp_servers: list | None = None,
+ **kwargs: Any,
+ ) -> NewSessionResponse:
+ state = self.session_manager.create_session(cwd=cwd)
+ logger.info("New session %s (cwd=%s)", state.session_id, cwd)
+ return NewSessionResponse(session_id=state.session_id)
+
+ async def load_session(
+ self,
+ cwd: str,
+ session_id: str,
+ mcp_servers: list | None = None,
+ **kwargs: Any,
+ ) -> LoadSessionResponse | None:
+ state = self.session_manager.update_cwd(session_id, cwd)
+ if state is None:
+ logger.warning("load_session: session %s not found", session_id)
+ return None
+ logger.info("Loaded session %s", session_id)
+ return LoadSessionResponse()
+
+ async def resume_session(
+ self,
+ cwd: str,
+ session_id: str,
+ mcp_servers: list | None = None,
+ **kwargs: Any,
+ ) -> ResumeSessionResponse:
+ state = self.session_manager.update_cwd(session_id, cwd)
+ if state is None:
+ logger.warning("resume_session: session %s not found, creating new", session_id)
+ state = self.session_manager.create_session(cwd=cwd)
+ logger.info("Resumed session %s", state.session_id)
+ return ResumeSessionResponse()
+
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
+ state = self.session_manager.get_session(session_id)
+ if state and state.cancel_event:
+ state.cancel_event.set()
+ try:
+ if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
+ state.agent.interrupt()
+ except Exception:
+ logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
+ logger.info("Cancelled session %s", session_id)
+
+ async def fork_session(
+ self,
+ cwd: str,
+ session_id: str,
+ mcp_servers: list | None = None,
+ **kwargs: Any,
+ ) -> ForkSessionResponse:
+ state = self.session_manager.fork_session(session_id, cwd=cwd)
+ new_id = state.session_id if state else ""
+ logger.info("Forked session %s -> %s", session_id, new_id)
+ return ForkSessionResponse(session_id=new_id)
+
+ async def list_sessions(
+ self,
+ cursor: str | None = None,
+ cwd: str | None = None,
+ **kwargs: Any,
+ ) -> ListSessionsResponse:
+ infos = self.session_manager.list_sessions()
+ sessions = [
+ SessionInfo(session_id=s["session_id"], cwd=s["cwd"])
+ for s in infos
+ ]
+ return ListSessionsResponse(sessions=sessions)
+
+ # ---- Prompt (core) ------------------------------------------------------
+
+ async def prompt(
+ self,
+ prompt: list[
+ TextContentBlock
+ | ImageContentBlock
+ | AudioContentBlock
+ | ResourceContentBlock
+ | EmbeddedResourceContentBlock
+ ],
+ session_id: str,
+ **kwargs: Any,
+ ) -> PromptResponse:
+ """Run Hermes on the user's prompt and stream events back to the editor."""
+ state = self.session_manager.get_session(session_id)
+ if state is None:
+ logger.error("prompt: session %s not found", session_id)
+ return PromptResponse(stop_reason="refusal")
+
+ user_text = _extract_text(prompt).strip()
+ if not user_text:
+ return PromptResponse(stop_reason="end_turn")
+
+ # Intercept slash commands โ handle locally without calling the LLM
+ if user_text.startswith("/"):
+ response_text = self._handle_slash_command(user_text, state)
+ if response_text is not None:
+ if self._conn:
+ update = acp.update_agent_message_text(response_text)
+ await self._conn.session_update(session_id, update)
+ return PromptResponse(stop_reason="end_turn")
+
+ logger.info("Prompt on session %s: %s", session_id, user_text[:100])
+
+ conn = self._conn
+ loop = asyncio.get_running_loop()
+
+ if state.cancel_event:
+ state.cancel_event.clear()
+
+ tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
+ previous_approval_cb = None
+
+ if conn:
+ tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids)
+ thinking_cb = make_thinking_cb(conn, session_id, loop)
+ step_cb = make_step_cb(conn, session_id, loop, tool_call_ids)
+ message_cb = make_message_cb(conn, session_id, loop)
+ approval_cb = make_approval_callback(conn.request_permission, loop, session_id)
+ else:
+ tool_progress_cb = None
+ thinking_cb = None
+ step_cb = None
+ message_cb = None
+ approval_cb = None
+
+ agent = state.agent
+ agent.tool_progress_callback = tool_progress_cb
+ agent.thinking_callback = thinking_cb
+ agent.step_callback = step_cb
+ agent.message_callback = message_cb
+
+ if approval_cb:
+ try:
+ from tools import terminal_tool as _terminal_tool
+ previous_approval_cb = getattr(_terminal_tool, "_approval_callback", None)
+ _terminal_tool.set_approval_callback(approval_cb)
+ except Exception:
+ logger.debug("Could not set ACP approval callback", exc_info=True)
+
+ def _run_agent() -> dict:
+ try:
+ result = agent.run_conversation(
+ user_message=user_text,
+ conversation_history=state.history,
+ task_id=session_id,
+ )
+ return result
+ except Exception as e:
+ logger.exception("Agent error in session %s", session_id)
+ return {"final_response": f"Error: {e}", "messages": state.history}
+ finally:
+ if approval_cb:
+ try:
+ from tools import terminal_tool as _terminal_tool
+ _terminal_tool.set_approval_callback(previous_approval_cb)
+ except Exception:
+ logger.debug("Could not restore approval callback", exc_info=True)
+
+ try:
+ result = await loop.run_in_executor(_executor, _run_agent)
+ except Exception:
+ logger.exception("Executor error for session %s", session_id)
+ return PromptResponse(stop_reason="end_turn")
+
+ if result.get("messages"):
+ state.history = result["messages"]
+ # Persist updated history so sessions survive process restarts.
+ self.session_manager.save_session(session_id)
+
+ final_response = result.get("final_response", "")
+ if final_response and conn:
+ update = acp.update_agent_message_text(final_response)
+ await conn.session_update(session_id, update)
+
+ usage = None
+ usage_data = result.get("usage")
+ if usage_data and isinstance(usage_data, dict):
+ usage = Usage(
+ input_tokens=usage_data.get("prompt_tokens", 0),
+ output_tokens=usage_data.get("completion_tokens", 0),
+ total_tokens=usage_data.get("total_tokens", 0),
+ thought_tokens=usage_data.get("reasoning_tokens"),
+ cached_read_tokens=usage_data.get("cached_tokens"),
+ )
+
+ stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
+ return PromptResponse(stop_reason=stop_reason, usage=usage)
+
+ # ---- Slash commands (headless) -------------------------------------------
+
+ _SLASH_COMMANDS = {
+ "help": "Show available commands",
+ "model": "Show or change current model",
+ "tools": "List available tools",
+ "context": "Show conversation context info",
+ "reset": "Clear conversation history",
+ "compact": "Compress conversation context",
+ "version": "Show Hermes version",
+ }
+
+ def _handle_slash_command(self, text: str, state: SessionState) -> str | None:
+ """Dispatch a slash command and return the response text.
+
+ Returns ``None`` for unrecognized commands so they fall through
+ to the LLM (the user may have typed ``/something`` as prose).
+ """
+ parts = text.split(maxsplit=1)
+ cmd = parts[0].lstrip("/").lower()
+ args = parts[1].strip() if len(parts) > 1 else ""
+
+ handler = {
+ "help": self._cmd_help,
+ "model": self._cmd_model,
+ "tools": self._cmd_tools,
+ "context": self._cmd_context,
+ "reset": self._cmd_reset,
+ "compact": self._cmd_compact,
+ "version": self._cmd_version,
+ }.get(cmd)
+
+ if handler is None:
+ return None # not a known command โ let the LLM handle it
+
+ try:
+ return handler(args, state)
+ except Exception as e:
+ logger.error("Slash command /%s error: %s", cmd, e, exc_info=True)
+ return f"Error executing /{cmd}: {e}"
+
+ def _cmd_help(self, args: str, state: SessionState) -> str:
+ lines = ["Available commands:", ""]
+ for cmd, desc in self._SLASH_COMMANDS.items():
+ lines.append(f" /{cmd:10s} {desc}")
+ lines.append("")
+ lines.append("Unrecognized /commands are sent to the model as normal messages.")
+ return "\n".join(lines)
+
+ def _cmd_model(self, args: str, state: SessionState) -> str:
+ if not args:
+ model = state.model or getattr(state.agent, "model", "unknown")
+ provider = getattr(state.agent, "provider", None) or "auto"
+ return f"Current model: {model}\nProvider: {provider}"
+
+ new_model = args.strip()
+ target_provider = None
+ current_provider = getattr(state.agent, "provider", None) or "openrouter"
+
+ # Auto-detect provider for the requested model
+ try:
+ from hermes_cli.models import parse_model_input, detect_provider_for_model
+ target_provider, new_model = parse_model_input(new_model, current_provider)
+ if target_provider == current_provider:
+ detected = detect_provider_for_model(new_model, current_provider)
+ if detected:
+ target_provider, new_model = detected
+ except Exception:
+ logger.debug("Provider detection failed, using model as-is", exc_info=True)
+
+ state.model = new_model
+ state.agent = self.session_manager._make_agent(
+ session_id=state.session_id,
+ cwd=state.cwd,
+ model=new_model,
+ requested_provider=target_provider or current_provider,
+ )
+ self.session_manager.save_session(state.session_id)
+ provider_label = getattr(state.agent, "provider", None) or target_provider or current_provider
+ logger.info("Session %s: model switched to %s", state.session_id, new_model)
+ return f"Model switched to: {new_model}\nProvider: {provider_label}"
+
+ def _cmd_tools(self, args: str, state: SessionState) -> str:
+ try:
+ from model_tools import get_tool_definitions
+ toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
+ tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
+ if not tools:
+ return "No tools available."
+ lines = [f"Available tools ({len(tools)}):"]
+ for t in tools:
+ name = t.get("function", {}).get("name", "?")
+ desc = t.get("function", {}).get("description", "")
+ # Truncate long descriptions
+ if len(desc) > 80:
+ desc = desc[:77] + "..."
+ lines.append(f" {name}: {desc}")
+ return "\n".join(lines)
+ except Exception as e:
+ return f"Could not list tools: {e}"
+
+ def _cmd_context(self, args: str, state: SessionState) -> str:
+ n_messages = len(state.history)
+ if n_messages == 0:
+ return "Conversation is empty (no messages yet)."
+ # Count by role
+ roles: dict[str, int] = {}
+ for msg in state.history:
+ role = msg.get("role", "unknown")
+ roles[role] = roles.get(role, 0) + 1
+ lines = [
+ f"Conversation: {n_messages} messages",
+ f" user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, "
+ f"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}",
+ ]
+ model = state.model or getattr(state.agent, "model", "")
+ if model:
+ lines.append(f"Model: {model}")
+ return "\n".join(lines)
+
+ def _cmd_reset(self, args: str, state: SessionState) -> str:
+ state.history.clear()
+ self.session_manager.save_session(state.session_id)
+ return "Conversation history cleared."
+
+ def _cmd_compact(self, args: str, state: SessionState) -> str:
+ if not state.history:
+ return "Nothing to compress โ conversation is empty."
+ try:
+ agent = state.agent
+ if hasattr(agent, "compress_context"):
+ agent.compress_context(state.history)
+ self.session_manager.save_session(state.session_id)
+ return f"Context compressed. Messages: {len(state.history)}"
+ return "Context compression not available for this agent."
+ except Exception as e:
+ return f"Compression failed: {e}"
+
+ def _cmd_version(self, args: str, state: SessionState) -> str:
+ return f"Hermes Agent v{HERMES_VERSION}"
+
+ # ---- Model switching (ACP protocol method) -------------------------------
+
+ async def set_session_model(
+ self, model_id: str, session_id: str, **kwargs: Any
+ ):
+ """Switch the model for a session (called by ACP protocol)."""
+ state = self.session_manager.get_session(session_id)
+ if state:
+ state.model = model_id
+ current_provider = getattr(state.agent, "provider", None)
+ current_base_url = getattr(state.agent, "base_url", None)
+ current_api_mode = getattr(state.agent, "api_mode", None)
+ state.agent = self.session_manager._make_agent(
+ session_id=session_id,
+ cwd=state.cwd,
+ model=model_id,
+ requested_provider=current_provider,
+ base_url=current_base_url,
+ api_mode=current_api_mode,
+ )
+ self.session_manager.save_session(session_id)
+ logger.info("Session %s: model switched to %s", session_id, model_id)
+ return None
diff --git a/acp_adapter/session.py b/acp_adapter/session.py
new file mode 100644
index 00000000000..c9069d1e2a7
--- /dev/null
+++ b/acp_adapter/session.py
@@ -0,0 +1,461 @@
+"""ACP session manager โ maps ACP sessions to Hermes AIAgent instances.
+
+Sessions are persisted to the shared SessionDB (``~/.hermes/state.db``) so they
+survive process restarts and appear in ``session_search``. When the editor
+reconnects after idle/restart, the ``load_session`` / ``resume_session`` calls
+find the persisted session in the database and restore the full conversation
+history.
+"""
+from __future__ import annotations
+
+from hermes_constants import get_hermes_home
+
+import copy
+import json
+import logging
+import uuid
+from dataclasses import dataclass, field
+from threading import Lock
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+def _register_task_cwd(task_id: str, cwd: str) -> None:
+ """Bind a task/session id to the editor's working directory for tools."""
+ if not task_id:
+ return
+ try:
+ from tools.terminal_tool import register_task_env_overrides
+ register_task_env_overrides(task_id, {"cwd": cwd})
+ except Exception:
+ logger.debug("Failed to register ACP task cwd override", exc_info=True)
+
+
+def _clear_task_cwd(task_id: str) -> None:
+ """Remove task-specific cwd overrides for an ACP session."""
+ if not task_id:
+ return
+ try:
+ from tools.terminal_tool import clear_task_env_overrides
+ clear_task_env_overrides(task_id)
+ except Exception:
+ logger.debug("Failed to clear ACP task cwd override", exc_info=True)
+
+
+@dataclass
+class SessionState:
+ """Tracks per-session state for an ACP-managed Hermes agent."""
+
+ session_id: str
+ agent: Any # AIAgent instance
+ cwd: str = "."
+ model: str = ""
+ history: List[Dict[str, Any]] = field(default_factory=list)
+ cancel_event: Any = None # threading.Event
+
+
+class SessionManager:
+ """Thread-safe manager for ACP sessions backed by Hermes AIAgent instances.
+
+ Sessions are held in-memory for fast access **and** persisted to the
+ shared SessionDB so they survive process restarts and are searchable
+ via ``session_search``.
+ """
+
+ def __init__(self, agent_factory=None, db=None):
+ """
+ Args:
+ agent_factory: Optional callable that creates an AIAgent-like object.
+ Used by tests. When omitted, a real AIAgent is created
+ using the current Hermes runtime provider configuration.
+ db: Optional SessionDB instance. When omitted, the default
+ SessionDB (``~/.hermes/state.db``) is lazily created.
+ """
+ self._sessions: Dict[str, SessionState] = {}
+ self._lock = Lock()
+ self._agent_factory = agent_factory
+ self._db_instance = db # None โ lazy-init on first use
+
+ # ---- public API ---------------------------------------------------------
+
+ def create_session(self, cwd: str = ".") -> SessionState:
+ """Create a new session with a unique ID and a fresh AIAgent."""
+ import threading
+
+ session_id = str(uuid.uuid4())
+ agent = self._make_agent(session_id=session_id, cwd=cwd)
+ state = SessionState(
+ session_id=session_id,
+ agent=agent,
+ cwd=cwd,
+ model=getattr(agent, "model", "") or "",
+ cancel_event=threading.Event(),
+ )
+ with self._lock:
+ self._sessions[session_id] = state
+ _register_task_cwd(session_id, cwd)
+ self._persist(state)
+ logger.info("Created ACP session %s (cwd=%s)", session_id, cwd)
+ return state
+
+ def get_session(self, session_id: str) -> Optional[SessionState]:
+ """Return the session for *session_id*, or ``None``.
+
+ If the session is not in memory but exists in the database (e.g. after
+ a process restart), it is transparently restored.
+ """
+ with self._lock:
+ state = self._sessions.get(session_id)
+ if state is not None:
+ return state
+ # Attempt to restore from database.
+ return self._restore(session_id)
+
+ def remove_session(self, session_id: str) -> bool:
+ """Remove a session from memory and database. Returns True if it existed."""
+ with self._lock:
+ existed = self._sessions.pop(session_id, None) is not None
+ db_existed = self._delete_persisted(session_id)
+ if existed or db_existed:
+ _clear_task_cwd(session_id)
+ return existed or db_existed
+
+ def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
+ """Deep-copy a session's history into a new session."""
+ import threading
+
+ original = self.get_session(session_id) # checks DB too
+ if original is None:
+ return None
+
+ new_id = str(uuid.uuid4())
+ agent = self._make_agent(
+ session_id=new_id,
+ cwd=cwd,
+ model=original.model or None,
+ )
+ state = SessionState(
+ session_id=new_id,
+ agent=agent,
+ cwd=cwd,
+ model=getattr(agent, "model", original.model) or original.model,
+ history=copy.deepcopy(original.history),
+ cancel_event=threading.Event(),
+ )
+ with self._lock:
+ self._sessions[new_id] = state
+ _register_task_cwd(new_id, cwd)
+ self._persist(state)
+ logger.info("Forked ACP session %s -> %s", session_id, new_id)
+ return state
+
+ def list_sessions(self) -> List[Dict[str, Any]]:
+ """Return lightweight info dicts for all sessions (memory + database)."""
+ # Collect in-memory sessions first.
+ with self._lock:
+ seen_ids = set(self._sessions.keys())
+ results = [
+ {
+ "session_id": s.session_id,
+ "cwd": s.cwd,
+ "model": s.model,
+ "history_len": len(s.history),
+ }
+ for s in self._sessions.values()
+ ]
+
+ # Merge any persisted sessions not currently in memory.
+ db = self._get_db()
+ if db is not None:
+ try:
+ rows = db.search_sessions(source="acp", limit=1000)
+ for row in rows:
+ sid = row["id"]
+ if sid in seen_ids:
+ continue
+ # Extract cwd from model_config JSON.
+ cwd = "."
+ mc = row.get("model_config")
+ if mc:
+ try:
+ cwd = json.loads(mc).get("cwd", ".")
+ except (json.JSONDecodeError, TypeError):
+ pass
+ results.append({
+ "session_id": sid,
+ "cwd": cwd,
+ "model": row.get("model") or "",
+ "history_len": row.get("message_count") or 0,
+ })
+ except Exception:
+ logger.debug("Failed to list ACP sessions from DB", exc_info=True)
+
+ return results
+
+ def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
+ """Update the working directory for a session and its tool overrides."""
+ state = self.get_session(session_id) # checks DB too
+ if state is None:
+ return None
+ state.cwd = cwd
+ _register_task_cwd(session_id, cwd)
+ self._persist(state)
+ return state
+
+ def cleanup(self) -> None:
+ """Remove all sessions (memory and database) and clear task-specific cwd overrides."""
+ with self._lock:
+ session_ids = list(self._sessions.keys())
+ self._sessions.clear()
+ for session_id in session_ids:
+ _clear_task_cwd(session_id)
+ self._delete_persisted(session_id)
+ # Also remove any DB-only ACP sessions not currently in memory.
+ db = self._get_db()
+ if db is not None:
+ try:
+ rows = db.search_sessions(source="acp", limit=10000)
+ for row in rows:
+ sid = row["id"]
+ _clear_task_cwd(sid)
+ db.delete_session(sid)
+ except Exception:
+ logger.debug("Failed to cleanup ACP sessions from DB", exc_info=True)
+
+ def save_session(self, session_id: str) -> None:
+ """Persist the current state of a session to the database.
+
+ Called by the server after prompt completion, slash commands that
+ mutate history, and model switches.
+ """
+ with self._lock:
+ state = self._sessions.get(session_id)
+ if state is not None:
+ self._persist(state)
+
+ # ---- persistence via SessionDB ------------------------------------------
+
+ def _get_db(self):
+ """Lazily initialise and return the SessionDB instance.
+
+ Returns ``None`` if the DB is unavailable (e.g. import error in a
+ minimal test environment).
+
+ Note: we resolve ``HERMES_HOME`` dynamically rather than relying on
+ the module-level ``DEFAULT_DB_PATH`` constant, because that constant
+ is evaluated at import time and won't reflect env-var changes made
+ later (e.g. by the test fixture ``_isolate_hermes_home``).
+ """
+ if self._db_instance is not None:
+ return self._db_instance
+ try:
+ import os
+ from pathlib import Path
+ from hermes_state import SessionDB
+ hermes_home = get_hermes_home()
+ self._db_instance = SessionDB(db_path=hermes_home / "state.db")
+ return self._db_instance
+ except Exception:
+ logger.debug("SessionDB unavailable for ACP persistence", exc_info=True)
+ return None
+
+ def _persist(self, state: SessionState) -> None:
+ """Write session state to the database.
+
+ Creates the session record if it doesn't exist, then replaces all
+ stored messages with the current in-memory history.
+ """
+ db = self._get_db()
+ if db is None:
+ return
+
+ # Ensure model is a plain string (not a MagicMock or other proxy).
+ model_str = str(state.model) if state.model else None
+ session_meta = {"cwd": state.cwd}
+ provider = getattr(state.agent, "provider", None)
+ base_url = getattr(state.agent, "base_url", None)
+ api_mode = getattr(state.agent, "api_mode", None)
+ if isinstance(provider, str) and provider.strip():
+ session_meta["provider"] = provider.strip()
+ if isinstance(base_url, str) and base_url.strip():
+ session_meta["base_url"] = base_url.strip()
+ if isinstance(api_mode, str) and api_mode.strip():
+ session_meta["api_mode"] = api_mode.strip()
+ cwd_json = json.dumps(session_meta)
+
+ try:
+ # Ensure the session record exists.
+ existing = db.get_session(state.session_id)
+ if existing is None:
+ db.create_session(
+ session_id=state.session_id,
+ source="acp",
+ model=model_str,
+ model_config={"cwd": state.cwd},
+ )
+ else:
+ # Update model_config (contains cwd) if changed.
+ try:
+ with db._lock:
+ db._conn.execute(
+ "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
+ (cwd_json, model_str, state.session_id),
+ )
+ db._conn.commit()
+ except Exception:
+ logger.debug("Failed to update ACP session metadata", exc_info=True)
+
+ # Replace stored messages with current history.
+ db.clear_messages(state.session_id)
+ for msg in state.history:
+ db.append_message(
+ session_id=state.session_id,
+ role=msg.get("role", "user"),
+ content=msg.get("content"),
+ tool_name=msg.get("tool_name") or msg.get("name"),
+ tool_calls=msg.get("tool_calls"),
+ tool_call_id=msg.get("tool_call_id"),
+ )
+ except Exception:
+ logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
+
+ def _restore(self, session_id: str) -> Optional[SessionState]:
+ """Load a session from the database into memory, recreating the AIAgent."""
+ import threading
+
+ db = self._get_db()
+ if db is None:
+ return None
+
+ try:
+ row = db.get_session(session_id)
+ except Exception:
+ logger.debug("Failed to query DB for ACP session %s", session_id, exc_info=True)
+ return None
+
+ if row is None:
+ return None
+
+ # Only restore ACP sessions.
+ if row.get("source") != "acp":
+ return None
+
+ # Extract cwd from model_config.
+ cwd = "."
+ requested_provider = row.get("billing_provider")
+ restored_base_url = row.get("billing_base_url")
+ restored_api_mode = None
+ mc = row.get("model_config")
+ if mc:
+ try:
+ meta = json.loads(mc)
+ if isinstance(meta, dict):
+ cwd = meta.get("cwd", ".")
+ requested_provider = meta.get("provider") or requested_provider
+ restored_base_url = meta.get("base_url") or restored_base_url
+ restored_api_mode = meta.get("api_mode") or restored_api_mode
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ model = row.get("model") or None
+
+ # Load conversation history.
+ try:
+ history = db.get_messages_as_conversation(session_id)
+ except Exception:
+ logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
+ history = []
+
+ try:
+ agent = self._make_agent(
+ session_id=session_id,
+ cwd=cwd,
+ model=model,
+ requested_provider=requested_provider,
+ base_url=restored_base_url,
+ api_mode=restored_api_mode,
+ )
+ except Exception:
+ logger.warning("Failed to recreate agent for ACP session %s", session_id, exc_info=True)
+ return None
+
+ state = SessionState(
+ session_id=session_id,
+ agent=agent,
+ cwd=cwd,
+ model=model or getattr(agent, "model", "") or "",
+ history=history,
+ cancel_event=threading.Event(),
+ )
+ with self._lock:
+ self._sessions[session_id] = state
+ _register_task_cwd(session_id, cwd)
+ logger.info("Restored ACP session %s from DB (%d messages)", session_id, len(history))
+ return state
+
+ def _delete_persisted(self, session_id: str) -> bool:
+ """Delete a session from the database. Returns True if it existed."""
+ db = self._get_db()
+ if db is None:
+ return False
+ try:
+ return db.delete_session(session_id)
+ except Exception:
+ logger.debug("Failed to delete ACP session %s from DB", session_id, exc_info=True)
+ return False
+
+ # ---- internal -----------------------------------------------------------
+
+ def _make_agent(
+ self,
+ *,
+ session_id: str,
+ cwd: str,
+ model: str | None = None,
+ requested_provider: str | None = None,
+ base_url: str | None = None,
+ api_mode: str | None = None,
+ ):
+ if self._agent_factory is not None:
+ return self._agent_factory()
+
+ from run_agent import AIAgent
+ from hermes_cli.config import load_config
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ config = load_config()
+ model_cfg = config.get("model")
+ default_model = "anthropic/claude-opus-4.6"
+ config_provider = None
+ if isinstance(model_cfg, dict):
+ default_model = str(model_cfg.get("default") or default_model)
+ config_provider = model_cfg.get("provider")
+ elif isinstance(model_cfg, str) and model_cfg.strip():
+ default_model = model_cfg.strip()
+
+ kwargs = {
+ "platform": "acp",
+ "enabled_toolsets": ["hermes-acp"],
+ "quiet_mode": True,
+ "session_id": session_id,
+ "model": model or default_model,
+ }
+
+ try:
+ runtime = resolve_runtime_provider(requested=requested_provider or config_provider)
+ kwargs.update(
+ {
+ "provider": runtime.get("provider"),
+ "api_mode": api_mode or runtime.get("api_mode"),
+ "base_url": base_url or runtime.get("base_url"),
+ "api_key": runtime.get("api_key"),
+ "command": runtime.get("command"),
+ "args": list(runtime.get("args") or []),
+ }
+ )
+ except Exception:
+ logger.debug("ACP session falling back to default provider resolution", exc_info=True)
+
+ _register_task_cwd(session_id, cwd)
+ return AIAgent(**kwargs)
diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py
new file mode 100644
index 00000000000..8756aa92967
--- /dev/null
+++ b/acp_adapter/tools.py
@@ -0,0 +1,215 @@
+"""ACP tool-call helpers for mapping hermes tools to ACP ToolKind and building content."""
+
+from __future__ import annotations
+
+import uuid
+from typing import Any, Dict, List, Optional
+
+import acp
+from acp.schema import (
+ ToolCallLocation,
+ ToolCallStart,
+ ToolCallProgress,
+ ToolKind,
+)
+
+# ---------------------------------------------------------------------------
+# Map hermes tool names -> ACP ToolKind
+# ---------------------------------------------------------------------------
+
+TOOL_KIND_MAP: Dict[str, ToolKind] = {
+ # File operations
+ "read_file": "read",
+ "write_file": "edit",
+ "patch": "edit",
+ "search_files": "search",
+ # Terminal / execution
+ "terminal": "execute",
+ "process": "execute",
+ "execute_code": "execute",
+ # Web / fetch
+ "web_search": "fetch",
+ "web_extract": "fetch",
+ # Browser
+ "browser_navigate": "fetch",
+ "browser_click": "execute",
+ "browser_type": "execute",
+ "browser_snapshot": "read",
+ "browser_vision": "read",
+ "browser_scroll": "execute",
+ "browser_press": "execute",
+ "browser_back": "execute",
+ "browser_close": "execute",
+ "browser_get_images": "read",
+ # Agent internals
+ "delegate_task": "execute",
+ "vision_analyze": "read",
+ "image_generate": "execute",
+ "text_to_speech": "execute",
+ # Thinking / meta
+ "_thinking": "think",
+}
+
+
+def get_tool_kind(tool_name: str) -> ToolKind:
+ """Return the ACP ToolKind for a hermes tool, defaulting to 'other'."""
+ return TOOL_KIND_MAP.get(tool_name, "other")
+
+
+def make_tool_call_id() -> str:
+ """Generate a unique tool call ID."""
+ return f"tc-{uuid.uuid4().hex[:12]}"
+
+
+def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
+ """Build a human-readable title for a tool call."""
+ if tool_name == "terminal":
+ cmd = args.get("command", "")
+ if len(cmd) > 80:
+ cmd = cmd[:77] + "..."
+ return f"terminal: {cmd}"
+ if tool_name == "read_file":
+ return f"read: {args.get('path', '?')}"
+ if tool_name == "write_file":
+ return f"write: {args.get('path', '?')}"
+ if tool_name == "patch":
+ mode = args.get("mode", "replace")
+ path = args.get("path", "?")
+ return f"patch ({mode}): {path}"
+ if tool_name == "search_files":
+ return f"search: {args.get('pattern', '?')}"
+ if tool_name == "web_search":
+ return f"web search: {args.get('query', '?')}"
+ if tool_name == "web_extract":
+ urls = args.get("urls", [])
+ if urls:
+ return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
+ return "web extract"
+ if tool_name == "delegate_task":
+ goal = args.get("goal", "")
+ if goal and len(goal) > 60:
+ goal = goal[:57] + "..."
+ return f"delegate: {goal}" if goal else "delegate task"
+ if tool_name == "execute_code":
+ return "execute code"
+ if tool_name == "vision_analyze":
+ return f"analyze image: {args.get('question', '?')[:50]}"
+ return tool_name
+
+
+# ---------------------------------------------------------------------------
+# Build ACP content objects for tool-call events
+# ---------------------------------------------------------------------------
+
+
+def build_tool_start(
+ tool_call_id: str,
+ tool_name: str,
+ arguments: Dict[str, Any],
+) -> ToolCallStart:
+ """Create a ToolCallStart event for the given hermes tool invocation."""
+ kind = get_tool_kind(tool_name)
+ title = build_tool_title(tool_name, arguments)
+ locations = extract_locations(arguments)
+
+ if tool_name == "patch":
+ mode = arguments.get("mode", "replace")
+ if mode == "replace":
+ path = arguments.get("path", "")
+ old = arguments.get("old_string", "")
+ new = arguments.get("new_string", "")
+ content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)]
+ else:
+ # Patch mode โ show the patch content as text
+ patch_text = arguments.get("patch", "")
+ content = [acp.tool_content(acp.text_block(patch_text))]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+ if tool_name == "write_file":
+ path = arguments.get("path", "")
+ file_content = arguments.get("content", "")
+ content = [acp.tool_diff_content(path=path, new_text=file_content)]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+ if tool_name == "terminal":
+ command = arguments.get("command", "")
+ content = [acp.tool_content(acp.text_block(f"$ {command}"))]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+ if tool_name == "read_file":
+ path = arguments.get("path", "")
+ content = [acp.tool_content(acp.text_block(f"Reading {path}"))]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+ if tool_name == "search_files":
+ pattern = arguments.get("pattern", "")
+ target = arguments.get("target", "content")
+ content = [acp.tool_content(acp.text_block(f"Searching for '{pattern}' ({target})"))]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+ # Generic fallback
+ import json
+ try:
+ args_text = json.dumps(arguments, indent=2, default=str)
+ except (TypeError, ValueError):
+ args_text = str(arguments)
+ content = [acp.tool_content(acp.text_block(args_text))]
+ return acp.start_tool_call(
+ tool_call_id, title, kind=kind, content=content, locations=locations,
+ raw_input=arguments,
+ )
+
+
+def build_tool_complete(
+ tool_call_id: str,
+ tool_name: str,
+ result: Optional[str] = None,
+) -> ToolCallProgress:
+ """Create a ToolCallUpdate (progress) event for a completed tool call."""
+ kind = get_tool_kind(tool_name)
+
+ # Truncate very large results for the UI
+ display_result = result or ""
+ if len(display_result) > 5000:
+ display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)"
+
+ content = [acp.tool_content(acp.text_block(display_result))]
+ return acp.update_tool_call(
+ tool_call_id,
+ kind=kind,
+ status="completed",
+ content=content,
+ raw_output=result,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Location extraction
+# ---------------------------------------------------------------------------
+
+
+def extract_locations(
+ arguments: Dict[str, Any],
+) -> List[ToolCallLocation]:
+ """Extract file-system locations from tool arguments."""
+ locations: List[ToolCallLocation] = []
+ path = arguments.get("path")
+ if path:
+ line = arguments.get("offset") or arguments.get("line")
+ locations.append(ToolCallLocation(path=path, line=line))
+ return locations
diff --git a/acp_registry/agent.json b/acp_registry/agent.json
new file mode 100644
index 00000000000..492a84445d4
--- /dev/null
+++ b/acp_registry/agent.json
@@ -0,0 +1,12 @@
+{
+ "schema_version": 1,
+ "name": "hermes-agent",
+ "display_name": "Hermes Agent",
+ "description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support",
+ "icon": "icon.svg",
+ "distribution": {
+ "type": "command",
+ "command": "hermes",
+ "args": ["acp"]
+ }
+}
diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg
new file mode 100644
index 00000000000..fc08ec05190
--- /dev/null
+++ b/acp_registry/icon.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py
index f00eb1c7aeb..a2a052d0a8b 100644
--- a/agent/anthropic_adapter.py
+++ b/agent/anthropic_adapter.py
@@ -14,6 +14,8 @@
import logging
import os
from pathlib import Path
+
+from hermes_constants import get_hermes_home
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
@@ -25,6 +27,67 @@
logger = logging.getLogger(__name__)
THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
+ADAPTIVE_EFFORT_MAP = {
+ "xhigh": "max",
+ "high": "high",
+ "medium": "medium",
+ "low": "low",
+ "minimal": "low",
+}
+
+# โโ Max output token limits per Anthropic model โโโโโโโโโโโโโโโโโโโโโโโ
+# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
+# max_tokens as a mandatory field. Previously we hardcoded 16384, which
+# starves thinking-enabled models (thinking tokens count toward the limit).
+_ANTHROPIC_OUTPUT_LIMITS = {
+ # Claude 4.6
+ "claude-opus-4-6": 128_000,
+ "claude-sonnet-4-6": 64_000,
+ # Claude 4.5
+ "claude-opus-4-5": 64_000,
+ "claude-sonnet-4-5": 64_000,
+ "claude-haiku-4-5": 64_000,
+ # Claude 4
+ "claude-opus-4": 32_000,
+ "claude-sonnet-4": 64_000,
+ # Claude 3.7
+ "claude-3-7-sonnet": 128_000,
+ # Claude 3.5
+ "claude-3-5-sonnet": 8_192,
+ "claude-3-5-haiku": 8_192,
+ # Claude 3
+ "claude-3-opus": 4_096,
+ "claude-3-sonnet": 4_096,
+ "claude-3-haiku": 4_096,
+}
+
+# For any model not in the table, assume the highest current limit.
+# Future Anthropic models are unlikely to have *less* output capacity.
+_ANTHROPIC_DEFAULT_OUTPUT_LIMIT = 128_000
+
+
+def _get_anthropic_max_output(model: str) -> int:
+ """Look up the max output token limit for an Anthropic model.
+
+ Uses substring matching against _ANTHROPIC_OUTPUT_LIMITS so date-stamped
+ model IDs (claude-sonnet-4-5-20250929) and variant suffixes (:1m, :fast)
+ resolve correctly. Longest-prefix match wins to avoid e.g. "claude-3-5"
+ matching before "claude-3-5-sonnet".
+ """
+ m = model.lower()
+ best_key = ""
+ best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
+ for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
+ if key in m and len(key) > len(best_key):
+ best_key = key
+ best_val = val
+ return best_val
+
+
+def _supports_adaptive_thinking(model: str) -> bool:
+ """Return True for Claude 4.6 models that support adaptive thinking."""
+ return any(v in model for v in ("4-6", "4.6"))
+
# Beta headers for enhanced features (sent with ALL auth types)
_COMMON_BETAS = [
@@ -32,11 +95,57 @@
"fine-grained-tool-streaming-2025-05-14",
]
-# Additional beta headers required for OAuth/subscription auth
+# Additional beta headers required for OAuth/subscription auth.
+# Matches what Claude Code (and pi-ai / OpenCode) send.
_OAUTH_ONLY_BETAS = [
+ "claude-code-20250219",
"oauth-2025-04-20",
]
+# Claude Code identity โ required for OAuth requests to be routed correctly.
+# Without these, Anthropic's infrastructure intermittently 500s OAuth traffic.
+# The version must stay reasonably current โ Anthropic rejects OAuth requests
+# when the spoofed user-agent version is too far behind the actual release.
+_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
+_claude_code_version_cache: Optional[str] = None
+
+
+def _detect_claude_code_version() -> str:
+ """Detect the installed Claude Code version, fall back to a static constant.
+
+ Anthropic's OAuth infrastructure validates the user-agent version and may
+ reject requests with a version that's too old. Detecting dynamically means
+ users who keep Claude Code updated never hit stale-version 400s.
+ """
+ import subprocess as _sp
+
+ for cmd in ("claude", "claude-code"):
+ try:
+ result = _sp.run(
+ [cmd, "--version"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ # Output is like "2.1.74 (Claude Code)" or just "2.1.74"
+ version = result.stdout.strip().split()[0]
+ if version and version[0].isdigit():
+ return version
+ except Exception:
+ pass
+ return _CLAUDE_CODE_VERSION_FALLBACK
+
+
+_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
+_MCP_TOOL_PREFIX = "mcp_"
+
+
+def _get_claude_code_version() -> str:
+ """Lazily detect the installed Claude Code version when OAuth headers need it."""
+ global _claude_code_version_cache
+ if _claude_code_version_cache is None:
+ _claude_code_version_cache = _detect_claude_code_version()
+ return _claude_code_version_cache
+
def _is_oauth_token(key: str) -> bool:
"""Check if the key is an OAuth/setup token (not a regular Console API key).
@@ -72,10 +181,16 @@ def build_anthropic_client(api_key: str, base_url: str = None):
kwargs["base_url"] = base_url
if _is_oauth_token(api_key):
- # OAuth access token / setup-token โ Bearer auth + beta headers
+ # 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.
all_betas = _COMMON_BETAS + _OAUTH_ONLY_BETAS
kwargs["auth_token"] = api_key
- kwargs["default_headers"] = {"anthropic-beta": ",".join(all_betas)}
+ kwargs["default_headers"] = {
+ "anthropic-beta": ",".join(all_betas),
+ "user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
+ "x-app": "cli",
+ }
else:
# Regular API key โ x-api-key header + common betas
kwargs["api_key"] = api_key
@@ -86,30 +201,15 @@ def build_anthropic_client(api_key: str, base_url: str = None):
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
- """Read credentials from Claude Code's config files.
+ """Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json.
- Checks two locations (in order):
- 1. ~/.claude.json โ top-level primaryApiKey (native binary, v2.x)
- 2. ~/.claude/.credentials.json โ claudeAiOauth block (npm/legacy installs)
+ This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
+ subscription flow is OAuth/setup-token based with refreshable credentials,
+ and native direct Anthropic provider usage should follow that path rather
+ than auto-detecting Claude's first-party managed key.
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
"""
- # 1. Native binary (v2.x): ~/.claude.json with top-level primaryApiKey
- claude_json = Path.home() / ".claude.json"
- if claude_json.exists():
- try:
- data = json.loads(claude_json.read_text(encoding="utf-8"))
- primary_key = data.get("primaryApiKey", "")
- if primary_key:
- return {
- "accessToken": primary_key,
- "refreshToken": "",
- "expiresAt": 0, # Managed keys don't have a user-visible expiry
- }
- except (json.JSONDecodeError, OSError, IOError) as e:
- logger.debug("Failed to read ~/.claude.json: %s", e)
-
- # 2. Legacy/npm installs: ~/.claude/.credentials.json
cred_path = Path.home() / ".claude" / ".credentials.json"
if cred_path.exists():
try:
@@ -122,6 +222,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
"accessToken": access_token,
"refreshToken": oauth_data.get("refreshToken", ""),
"expiresAt": oauth_data.get("expiresAt", 0),
+ "source": "claude_code_credentials_file",
}
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
@@ -129,6 +230,20 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
return None
+def read_claude_managed_key() -> Optional[str]:
+ """Read Claude's native managed key from ~/.claude.json for diagnostics only."""
+ claude_json = Path.home() / ".claude.json"
+ if claude_json.exists():
+ try:
+ data = json.loads(claude_json.read_text(encoding="utf-8"))
+ primary_key = data.get("primaryApiKey", "")
+ if isinstance(primary_key, str) and primary_key.strip():
+ return primary_key.strip()
+ except (json.JSONDecodeError, OSError, IOError) as e:
+ logger.debug("Failed to read ~/.claude.json: %s", e)
+ return None
+
+
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
"""Check if Claude Code credentials have a non-expired access token."""
import time
@@ -144,55 +259,268 @@ def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
return now_ms < (expires_at - 60_000)
+def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
+ """Attempt to refresh an expired Claude Code OAuth token.
+
+ Uses the same token endpoint and client_id as Claude Code / OpenCode.
+ Only works for credentials that have a refresh token (from claude /login
+ or claude setup-token with OAuth flow).
+
+ Tries the new platform.claude.com endpoint first (Claude Code >=2.1.81),
+ then falls back to console.anthropic.com for older tokens.
+
+ Returns the new access token, or None if refresh fails.
+ """
+ import time
+ import urllib.request
+
+ refresh_token = creds.get("refreshToken", "")
+ if not refresh_token:
+ logger.debug("No refresh token available โ cannot refresh")
+ return None
+
+ # Client ID used by Claude Code's OAuth flow
+ CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+
+ # Anthropic migrated OAuth from console.anthropic.com to platform.claude.com
+ # (Claude Code v2.1.81+). Try new endpoint first, fall back to old.
+ token_endpoints = [
+ "https://platform.claude.com/v1/oauth/token",
+ "https://console.anthropic.com/v1/oauth/token",
+ ]
+
+ payload = json.dumps({
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": CLIENT_ID,
+ }).encode()
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
+ }
+
+ for endpoint in token_endpoints:
+ req = urllib.request.Request(
+ endpoint, data=payload, headers=headers, method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ result = json.loads(resp.read().decode())
+ new_access = result.get("access_token", "")
+ new_refresh = result.get("refresh_token", refresh_token)
+ expires_in = result.get("expires_in", 3600)
+
+ if new_access:
+ new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)
+ _write_claude_code_credentials(new_access, new_refresh, new_expires_ms)
+ logger.debug("Refreshed Claude Code OAuth token via %s", endpoint)
+ return new_access
+ except Exception as e:
+ logger.debug("Token refresh failed at %s: %s", endpoint, e)
+
+ return None
+
+
+def _write_claude_code_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
+ """Write refreshed credentials back to ~/.claude/.credentials.json."""
+ cred_path = Path.home() / ".claude" / ".credentials.json"
+ try:
+ # Read existing file to preserve other fields
+ existing = {}
+ if cred_path.exists():
+ existing = json.loads(cred_path.read_text(encoding="utf-8"))
+
+ existing["claudeAiOauth"] = {
+ "accessToken": access_token,
+ "refreshToken": refresh_token,
+ "expiresAt": expires_at_ms,
+ }
+
+ cred_path.parent.mkdir(parents=True, exist_ok=True)
+ cred_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
+ # Restrict permissions (credentials file)
+ cred_path.chmod(0o600)
+ except (OSError, IOError) as e:
+ logger.debug("Failed to write refreshed credentials: %s", e)
+
+
+def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ """Resolve a token from Claude Code credential files, refreshing if needed."""
+ creds = creds or read_claude_code_credentials()
+ if creds and is_claude_code_token_valid(creds):
+ logger.debug("Using Claude Code credentials (auto-detected)")
+ return creds["accessToken"]
+ if creds:
+ logger.debug("Claude Code credentials expired โ attempting refresh")
+ refreshed = _refresh_oauth_token(creds)
+ if refreshed:
+ return refreshed
+ logger.debug("Token refresh failed โ re-run 'claude setup-token' to reauthenticate")
+ return None
+
+
+def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
+ """Prefer Claude Code creds when a persisted env OAuth token would shadow refresh.
+
+ Hermes historically persisted setup tokens into ANTHROPIC_TOKEN. That makes
+ later refresh impossible because the static env token wins before we ever
+ inspect Claude Code's refreshable credential file. If we have a refreshable
+ Claude Code credential record, prefer it over the static env OAuth token.
+ """
+ if not env_token or not _is_oauth_token(env_token) or not isinstance(creds, dict):
+ return None
+ if not creds.get("refreshToken"):
+ return None
+
+ resolved = _resolve_claude_code_token_from_credentials(creds)
+ if resolved and resolved != env_token:
+ logger.debug(
+ "Preferring Claude Code credential file over static env OAuth token so refresh can proceed"
+ )
+ return resolved
+ return None
+
+
+def get_anthropic_token_source(token: Optional[str] = None) -> str:
+ """Best-effort source classification for an Anthropic credential token."""
+ token = (token or "").strip()
+ if not token:
+ return "none"
+
+ env_token = os.getenv("ANTHROPIC_TOKEN", "").strip()
+ if env_token and env_token == token:
+ return "anthropic_token_env"
+
+ cc_env_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
+ if cc_env_token and cc_env_token == token:
+ return "claude_code_oauth_token_env"
+
+ creds = read_claude_code_credentials()
+ if creds and creds.get("accessToken") == token:
+ return str(creds.get("source") or "claude_code_credentials")
+
+ managed_key = read_claude_managed_key()
+ if managed_key and managed_key == token:
+ return "claude_json_primary_api_key"
+
+ api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
+ if api_key and api_key == token:
+ return "anthropic_api_key_env"
+
+ return "unknown"
+
+
def resolve_anthropic_token() -> Optional[str]:
"""Resolve an Anthropic token from all available sources.
Priority:
- 1. ANTHROPIC_API_KEY env var (regular API key)
- 2. ANTHROPIC_TOKEN env var (OAuth/setup token)
+ 1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
+ 2. CLAUDE_CODE_OAUTH_TOKEN env var
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
+ โ with automatic refresh if expired and a refresh token is available
+ 4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
Returns the token string or None.
"""
- # 1. Regular API key
- api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
- if api_key:
- return api_key
+ creds = read_claude_code_credentials()
- # 2. OAuth/setup token env var
+ # 1. Hermes-managed OAuth/setup token env var
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
if token:
+ preferred = _prefer_refreshable_claude_code_token(token, creds)
+ if preferred:
+ return preferred
return token
- # Also check CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
+ # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if cc_token:
+ preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
+ if preferred:
+ return preferred
return cc_token
# 3. Claude Code credential file
+ resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
+ if resolved_claude_token:
+ return resolved_claude_token
+
+ # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
+ # This remains as a compatibility fallback for pre-migration Hermes configs.
+ api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
+ if api_key:
+ return api_key
+
+ return None
+
+
+def run_oauth_setup_token() -> Optional[str]:
+ """Run 'claude setup-token' interactively and return the resulting token.
+
+ Checks multiple sources after the subprocess completes:
+ 1. Claude Code credential files (may be written by the subprocess)
+ 2. CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_TOKEN env vars
+
+ Returns the token string, or None if no credentials were obtained.
+ Raises FileNotFoundError if the 'claude' CLI is not installed.
+ """
+ import shutil
+ import subprocess
+
+ claude_path = shutil.which("claude")
+ if not claude_path:
+ raise FileNotFoundError(
+ "The 'claude' CLI is not installed. "
+ "Install it with: npm install -g @anthropic-ai/claude-code"
+ )
+
+ # Run interactively โ stdin/stdout/stderr inherited so user can interact
+ try:
+ subprocess.run([claude_path, "setup-token"])
+ except (KeyboardInterrupt, EOFError):
+ return None
+
+ # Check if credentials were saved to Claude Code's config files
creds = read_claude_code_credentials()
if creds and is_claude_code_token_valid(creds):
- logger.debug("Using Claude Code credentials (auto-detected)")
return creds["accessToken"]
- elif creds:
- logger.debug("Claude Code credentials expired โ run 'claude' to refresh")
+
+ # Check env vars that may have been set
+ for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"):
+ val = os.getenv(env_var, "").strip()
+ if val:
+ return val
return None
+
+
+
+
+
+
# ---------------------------------------------------------------------------
# Message / tool / response format conversion
# ---------------------------------------------------------------------------
-def normalize_model_name(model: str) -> str:
+def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
"""Normalize a model name for the Anthropic API.
- Strips 'anthropic/' prefix (OpenRouter format, case-insensitive)
+ - Converts dots to hyphens in version numbers (OpenRouter uses dots,
+ Anthropic uses hyphens: claude-opus-4.6 โ claude-opus-4-6), unless
+ preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus).
"""
lower = model.lower()
if lower.startswith("anthropic/"):
model = model[len("anthropic/"):]
+ if not preserve_dots:
+ # OpenRouter uses dots for version separators (claude-opus-4.6),
+ # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens.
+ model = model.replace(".", "-")
return model
@@ -209,6 +537,68 @@ def _sanitize_tool_id(tool_id: str) -> str:
return sanitized or "tool_0"
+def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Convert an OpenAI-style image block to Anthropic's image source format."""
+ image_data = part.get("image_url", {})
+ url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
+ if not isinstance(url, str) or not url.strip():
+ return None
+ url = url.strip()
+
+ if url.startswith("data:"):
+ header, sep, data = url.partition(",")
+ if sep and ";base64" in header:
+ media_type = header[5:].split(";", 1)[0] or "image/png"
+ return {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": media_type,
+ "data": data,
+ },
+ }
+
+ if url.startswith("http://") or url.startswith("https://"):
+ return {
+ "type": "image",
+ "source": {
+ "type": "url",
+ "url": url,
+ },
+ }
+
+ return None
+
+
+def _convert_user_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
+ if isinstance(part, dict):
+ ptype = part.get("type")
+ if ptype == "text":
+ block = {"type": "text", "text": part.get("text", "")}
+ if isinstance(part.get("cache_control"), dict):
+ block["cache_control"] = dict(part["cache_control"])
+ return block
+ if ptype == "image_url":
+ return _convert_openai_image_part_to_anthropic(part)
+ if ptype == "image" and part.get("source"):
+ return dict(part)
+ if ptype == "image" and part.get("data"):
+ media_type = part.get("mimeType") or part.get("media_type") or "image/png"
+ return {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": media_type,
+ "data": part.get("data", ""),
+ },
+ }
+ if ptype == "tool_result":
+ return dict(part)
+ elif part is not None:
+ return {"type": "text", "text": str(part)}
+ return None
+
+
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
"""Convert OpenAI tool definitions to Anthropic format."""
if not tools:
@@ -224,6 +614,66 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
return result
+def _image_source_from_openai_url(url: str) -> Dict[str, str]:
+ """Convert an OpenAI-style image URL/data URL into Anthropic image source."""
+ url = str(url or "").strip()
+ if not url:
+ return {"type": "url", "url": ""}
+
+ if url.startswith("data:"):
+ header, _, data = url.partition(",")
+ media_type = "image/jpeg"
+ if header.startswith("data:"):
+ mime_part = header[len("data:"):].split(";", 1)[0].strip()
+ if mime_part.startswith("image/"):
+ media_type = mime_part
+ return {
+ "type": "base64",
+ "media_type": media_type,
+ "data": data,
+ }
+
+ return {"type": "url", "url": url}
+
+
+def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
+ """Convert a single OpenAI-style content part to Anthropic format."""
+ if part is None:
+ return None
+ if isinstance(part, str):
+ return {"type": "text", "text": part}
+ if not isinstance(part, dict):
+ return {"type": "text", "text": str(part)}
+
+ ptype = part.get("type")
+
+ if ptype == "input_text":
+ block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
+ elif ptype in {"image_url", "input_image"}:
+ image_value = part.get("image_url", {})
+ url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
+ block = {"type": "image", "source": _image_source_from_openai_url(url)}
+ else:
+ block = dict(part)
+
+ if isinstance(part.get("cache_control"), dict) and "cache_control" not in block:
+ block["cache_control"] = dict(part["cache_control"])
+ return block
+
+
+def _convert_content_to_anthropic(content: Any) -> Any:
+ """Convert OpenAI-style multimodal content arrays to Anthropic blocks."""
+ if not isinstance(content, list):
+ return content
+
+ converted = []
+ for part in content:
+ block = _convert_content_part_to_anthropic(part)
+ if block is not None:
+ converted.append(block)
+ return converted
+
+
def convert_messages_to_anthropic(
messages: List[Dict],
) -> Tuple[Optional[Any], List[Dict]]:
@@ -259,9 +709,15 @@ def convert_messages_to_anthropic(
if role == "assistant":
blocks = []
if content:
- text = content if isinstance(content, str) else json.dumps(content)
- blocks.append({"type": "text", "text": text})
+ if isinstance(content, list):
+ converted_content = _convert_content_to_anthropic(content)
+ if isinstance(converted_content, list):
+ blocks.extend(converted_content)
+ else:
+ blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
+ if not tc or not isinstance(tc, dict):
+ continue
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
try:
@@ -291,6 +747,8 @@ def convert_messages_to_anthropic(
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": result_content,
}
+ if isinstance(m.get("cache_control"), dict):
+ tool_result["cache_control"] = dict(m["cache_control"])
# Merge consecutive tool results into one user message
if (
result
@@ -304,8 +762,22 @@ def convert_messages_to_anthropic(
result.append({"role": "user", "content": [tool_result]})
continue
- # Regular user message
- result.append({"role": "user", "content": content})
+ # Regular user message โ validate non-empty content (Anthropic rejects empty)
+ if isinstance(content, list):
+ converted_blocks = _convert_content_to_anthropic(content)
+ # Check if all text blocks are empty
+ if not converted_blocks or all(
+ b.get("text", "").strip() == ""
+ for b in converted_blocks
+ if isinstance(b, dict) and b.get("type") == "text"
+ ):
+ converted_blocks = [{"type": "text", "text": "(empty message)"}]
+ result.append({"role": "user", "content": converted_blocks})
+ else:
+ # Validate string content is non-empty
+ if not content or (isinstance(content, str) and not content.strip()):
+ content = "(empty message)"
+ result.append({"role": "user", "content": content})
# Strip orphaned tool_use blocks (no matching tool_result follows)
tool_result_ids = set()
@@ -324,6 +796,26 @@ def convert_messages_to_anthropic(
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
+ # Strip orphaned tool_result blocks (no matching tool_use precedes them).
+ # This is the mirror of the above: context compression or session truncation
+ # can remove an assistant message containing a tool_use while leaving the
+ # subsequent tool_result intact. Anthropic rejects these with a 400.
+ tool_use_ids = set()
+ for m in result:
+ if m["role"] == "assistant" and isinstance(m["content"], list):
+ for block in m["content"]:
+ if block.get("type") == "tool_use":
+ tool_use_ids.add(block.get("id"))
+ for m in result:
+ if m["role"] == "user" and isinstance(m["content"], list):
+ m["content"] = [
+ b
+ for b in m["content"]
+ if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
+ ]
+ if not m["content"]:
+ m["content"] = [{"type": "text", "text": "(tool result removed)"}]
+
# Enforce strict role alternation (Anthropic rejects consecutive same-role messages)
fixed = []
for m in result:
@@ -352,8 +844,12 @@ def convert_messages_to_anthropic(
elif isinstance(prev_blocks, str) and isinstance(curr_blocks, str):
fixed[-1]["content"] = prev_blocks + "\n" + curr_blocks
else:
- # Keep the later message
- fixed[-1] = m
+ # Mixed types โ normalize both to list and merge
+ if isinstance(prev_blocks, str):
+ prev_blocks = [{"type": "text", "text": prev_blocks}]
+ if isinstance(curr_blocks, str):
+ curr_blocks = [{"type": "text", "text": curr_blocks}]
+ fixed[-1]["content"] = prev_blocks + curr_blocks
else:
fixed.append(m)
result = fixed
@@ -368,13 +864,73 @@ def build_anthropic_kwargs(
max_tokens: Optional[int],
reasoning_config: Optional[Dict[str, Any]],
tool_choice: Optional[str] = None,
+ is_oauth: bool = False,
+ preserve_dots: bool = False,
+ context_length: Optional[int] = None,
) -> Dict[str, Any]:
- """Build kwargs for anthropic.messages.create()."""
+ """Build kwargs for anthropic.messages.create().
+
+ When *max_tokens* is None, the model's native output limit is used
+ (e.g. 128K for Opus 4.6, 64K for Sonnet 4.6). If *context_length*
+ is provided, the effective limit is clamped so it doesn't exceed
+ the context window.
+
+ When *is_oauth* is True, applies Claude Code compatibility transforms:
+ system prompt prefix, tool name prefixing, and prompt sanitization.
+
+ When *preserve_dots* is True, model name dots are not converted to hyphens
+ (for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
+ """
system, anthropic_messages = convert_messages_to_anthropic(messages)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
- model = normalize_model_name(model)
- effective_max_tokens = max_tokens or 16384
+ model = normalize_model_name(model, preserve_dots=preserve_dots)
+ effective_max_tokens = max_tokens or _get_anthropic_max_output(model)
+
+ # Clamp to context window if the user set a lower context_length
+ # (e.g. custom endpoint with limited capacity).
+ if context_length and effective_max_tokens > context_length:
+ effective_max_tokens = max(context_length - 1, 1)
+
+ # โโ OAuth: Claude Code identity โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ if is_oauth:
+ # 1. Prepend Claude Code system prompt identity
+ cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
+ if isinstance(system, list):
+ system = [cc_block] + system
+ elif isinstance(system, str) and system:
+ system = [cc_block, {"type": "text", "text": system}]
+ else:
+ system = [cc_block]
+
+ # 2. Sanitize system prompt โ replace product name references
+ # to avoid Anthropic's server-side content filters.
+ for block in system:
+ if isinstance(block, dict) and block.get("type") == "text":
+ text = block.get("text", "")
+ text = text.replace("Hermes Agent", "Claude Code")
+ text = text.replace("Hermes agent", "Claude Code")
+ text = text.replace("hermes-agent", "claude-code")
+ text = text.replace("Nous Research", "Anthropic")
+ block["text"] = text
+
+ # 3. Prefix tool names with mcp_ (Claude Code convention)
+ if anthropic_tools:
+ for tool in anthropic_tools:
+ if "name" in tool:
+ tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
+
+ # 4. Prefix tool names in message history (tool_use and tool_result blocks)
+ for msg in anthropic_messages:
+ content = msg.get("content")
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict):
+ if block.get("type") == "tool_use" and "name" in block:
+ if not block["name"].startswith(_MCP_TOOL_PREFIX):
+ block["name"] = _MCP_TOOL_PREFIX + block["name"]
+ elif block.get("type") == "tool_result" and "tool_use_id" in block:
+ pass # tool_result uses ID, not name
kwargs: Dict[str, Any] = {
"model": model,
@@ -393,36 +949,45 @@ def build_anthropic_kwargs(
elif tool_choice == "required":
kwargs["tool_choice"] = {"type": "any"}
elif tool_choice == "none":
- pass # Don't send tool_choice โ Anthropic will use tools if needed
+ # Anthropic has no tool_choice "none" โ omit tools entirely to prevent use
+ kwargs.pop("tools", None)
elif isinstance(tool_choice, str):
# Specific tool name
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
- # Map reasoning_config to Anthropic's thinking parameter
- # Newer models (4.6+) prefer "adaptive" thinking; older models use "enabled"
+ # Map reasoning_config to Anthropic's thinking parameter.
+ # Claude 4.6 models use adaptive thinking + output_config.effort.
+ # Older models use manual thinking with budget_tokens.
+ # Haiku models do NOT support extended thinking at all โ skip entirely.
if reasoning_config and isinstance(reasoning_config, dict):
- if reasoning_config.get("enabled") is not False:
- effort = reasoning_config.get("effort", "medium")
+ if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
+ effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)
- # Use adaptive thinking for 4.5+ models (they deprecate type=enabled)
- if any(v in model for v in ("4-6", "4-5", "4.6", "4.5")):
- kwargs["thinking"] = {"type": "adaptive", "budget_tokens": budget}
+ if _supports_adaptive_thinking(model):
+ kwargs["thinking"] = {"type": "adaptive"}
+ kwargs["output_config"] = {
+ "effort": ADAPTIVE_EFFORT_MAP.get(effort, "medium")
+ }
else:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
# Anthropic requires temperature=1 when thinking is enabled on older models
kwargs["temperature"] = 1
- kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
+ kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
return kwargs
def normalize_anthropic_response(
response,
+ strip_tool_prefix: bool = False,
) -> Tuple[SimpleNamespace, str]:
"""Normalize Anthropic response to match the shape expected by AIAgent.
Returns (assistant_message, finish_reason) where assistant_message has
.content, .tool_calls, and .reasoning attributes.
+
+ When *strip_tool_prefix* is True, removes the ``mcp_`` prefix that was
+ added to tool names for OAuth Claude Code compatibility.
"""
text_parts = []
reasoning_parts = []
@@ -434,12 +999,15 @@ def normalize_anthropic_response(
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
elif block.type == "tool_use":
+ name = block.name
+ if strip_tool_prefix and name.startswith(_MCP_TOOL_PREFIX):
+ name = name[len(_MCP_TOOL_PREFIX):]
tool_calls.append(
SimpleNamespace(
id=block.id,
type="function",
function=SimpleNamespace(
- name=block.name,
+ name=name,
arguments=json.dumps(block.input),
),
)
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index f9c12e7fb8e..2a0c346a50b 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -1,4 +1,4 @@
-"""Shared auxiliary OpenAI client for cheap/fast side tasks.
+"""Shared auxiliary client router for side tasks.
Provides a single resolution chain so every consumer (context compression,
session search, web extraction, vision analysis, browser vision) picks up
@@ -10,37 +10,44 @@
3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY)
4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
wrapped to look like a chat.completions client)
- 5. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN)
- โ checked via PROVIDER_REGISTRY entries with auth_type='api_key'
- 6. None
+ 5. Native Anthropic
+ 6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN)
+ 7. None
Resolution order for vision/multimodal tasks (auto mode):
- 1. OpenRouter
- 2. Nous Portal
- 3. Codex OAuth (gpt-5.3-codex supports vision via Responses API)
- 4. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
- 5. None (API-key providers like z.ai/Kimi/MiniMax are skipped โ
- they may not support multimodal)
+ 1. Selected main provider, if it is one of the supported vision backends below
+ 2. OpenRouter
+ 3. Nous Portal
+ 4. Codex OAuth (gpt-5.3-codex supports vision via Responses API)
+ 5. Native Anthropic
+ 6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
+ 7. None
Per-task provider overrides (e.g. AUXILIARY_VISION_PROVIDER,
-CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task:
-"openrouter", "nous", "codex", or "main" (= steps 3-5).
+CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task.
Default "auto" follows the chains above.
Per-task model overrides (e.g. AUXILIARY_VISION_MODEL,
AUXILIARY_WEB_EXTRACT_MODEL) let callers use a different model slug
than the provider's default.
+
+Per-task direct endpoint overrides (e.g. AUXILIARY_VISION_BASE_URL,
+AUXILIARY_VISION_API_KEY) let callers route a specific auxiliary task to a
+custom OpenAI-compatible endpoint without touching the main model settings.
"""
import json
import logging
import os
-from pathlib import Path
+import threading
+import time
+from pathlib import Path # noqa: F401 โ used by test mocks
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from openai import OpenAI
+from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
@@ -49,9 +56,13 @@
_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
- "minimax": "MiniMax-M2.5-highspeed",
- "minimax-cn": "MiniMax-M2.5-highspeed",
+ "minimax": "MiniMax-M2.7-highspeed",
+ "minimax-cn": "MiniMax-M2.7-highspeed",
"anthropic": "claude-haiku-4-5-20251001",
+ "ai-gateway": "google/gemini-3-flash",
+ "opencode-zen": "gemini-3-flash",
+ "opencode-go": "glm-5",
+ "kilocode": "google/gemini-3-flash-preview",
}
# OpenRouter app attribution headers
@@ -71,13 +82,17 @@
# Default auxiliary models per provider
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
-_NOUS_MODEL = "gemini-3-flash"
+_NOUS_MODEL = "google/gemini-3-flash-preview"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
-_AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
+_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
+_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
# Codex fallback: uses the Responses API (the only endpoint the Codex
# OAuth token can access) with a fast model for auxiliary tasks.
-_CODEX_AUX_MODEL = "gpt-5.3-codex"
+# ChatGPT-backed Codex accounts currently reject gpt-5.3-codex for these
+# auxiliary flows, while gpt-5.2-codex remains broadly available and supports
+# vision via Responses.
+_CODEX_AUX_MODEL = "gpt-5.2-codex"
_CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
@@ -308,6 +323,116 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"):
self.base_url = sync_wrapper.base_url
+class _AnthropicCompletionsAdapter:
+ """OpenAI-client-compatible adapter for Anthropic Messages API."""
+
+ def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
+ self._client = real_client
+ self._model = model
+ self._is_oauth = is_oauth
+
+ def create(self, **kwargs) -> Any:
+ from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response
+
+ messages = kwargs.get("messages", [])
+ model = kwargs.get("model", self._model)
+ tools = kwargs.get("tools")
+ tool_choice = kwargs.get("tool_choice")
+ max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
+ temperature = kwargs.get("temperature")
+
+ normalized_tool_choice = None
+ if isinstance(tool_choice, str):
+ normalized_tool_choice = tool_choice
+ elif isinstance(tool_choice, dict):
+ choice_type = str(tool_choice.get("type", "")).lower()
+ if choice_type == "function":
+ normalized_tool_choice = tool_choice.get("function", {}).get("name")
+ elif choice_type in {"auto", "required", "none"}:
+ normalized_tool_choice = choice_type
+
+ anthropic_kwargs = build_anthropic_kwargs(
+ model=model,
+ messages=messages,
+ tools=tools,
+ max_tokens=max_tokens,
+ reasoning_config=None,
+ tool_choice=normalized_tool_choice,
+ is_oauth=self._is_oauth,
+ )
+ if temperature is not None:
+ anthropic_kwargs["temperature"] = temperature
+
+ response = self._client.messages.create(**anthropic_kwargs)
+ assistant_message, finish_reason = normalize_anthropic_response(response)
+
+ usage = None
+ if hasattr(response, "usage") and response.usage:
+ prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
+ completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
+ total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
+ usage = SimpleNamespace(
+ prompt_tokens=prompt_tokens,
+ completion_tokens=completion_tokens,
+ total_tokens=total_tokens,
+ )
+
+ choice = SimpleNamespace(
+ index=0,
+ message=assistant_message,
+ finish_reason=finish_reason,
+ )
+ return SimpleNamespace(
+ choices=[choice],
+ model=model,
+ usage=usage,
+ )
+
+
+class _AnthropicChatShim:
+ def __init__(self, adapter: _AnthropicCompletionsAdapter):
+ self.completions = adapter
+
+
+class AnthropicAuxiliaryClient:
+ """OpenAI-client-compatible wrapper over a native Anthropic client."""
+
+ def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
+ self._real_client = real_client
+ adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
+ self.chat = _AnthropicChatShim(adapter)
+ self.api_key = api_key
+ self.base_url = base_url
+
+ def close(self):
+ close_fn = getattr(self._real_client, "close", None)
+ if callable(close_fn):
+ close_fn()
+
+
+class _AsyncAnthropicCompletionsAdapter:
+ def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
+ self._sync = sync_adapter
+
+ async def create(self, **kwargs) -> Any:
+ import asyncio
+ return await asyncio.to_thread(self._sync.create, **kwargs)
+
+
+class _AsyncAnthropicChatShim:
+ def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
+ self.completions = adapter
+
+
+class AsyncAnthropicAuxiliaryClient:
+ def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
+ sync_adapter = sync_wrapper.chat.completions
+ async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
+ self.chat = _AsyncAnthropicChatShim(async_adapter)
+ self.api_key = sync_wrapper.api_key
+ self.base_url = sync_wrapper.base_url
+
+
def _read_nous_auth() -> Optional[dict]:
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
@@ -341,15 +466,30 @@ def _nous_base_url() -> str:
def _read_codex_access_token() -> Optional[str]:
- """Read a valid Codex OAuth access token from Hermes auth store (~/.hermes/auth.json)."""
+ """Read a valid, non-expired Codex OAuth access token from Hermes auth store."""
try:
from hermes_cli.auth import _read_codex_tokens
data = _read_codex_tokens()
tokens = data.get("tokens", {})
access_token = tokens.get("access_token")
- if isinstance(access_token, str) and access_token.strip():
- return access_token.strip()
- return None
+ if not isinstance(access_token, str) or not access_token.strip():
+ return None
+
+ # Check JWT expiry โ expired tokens block the auto chain and
+ # prevent fallback to working providers (e.g. Anthropic).
+ try:
+ import base64
+ payload = access_token.split(".")[1]
+ payload += "=" * (-len(payload) % 4)
+ claims = json.loads(base64.urlsafe_b64decode(payload))
+ exp = claims.get("exp", 0)
+ if exp and time.time() > exp:
+ logger.debug("Codex access token expired (exp=%s), skipping", exp)
+ return None
+ except Exception:
+ pass # Non-JWT token or decode error โ use as-is
+
+ return access_token.strip()
except Exception as exc:
logger.debug("Could not read Codex auth for auxiliary client: %s", exc)
return None
@@ -358,11 +498,11 @@ def _read_codex_access_token() -> Optional[str]:
def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
"""Try each API-key provider in PROVIDER_REGISTRY order.
- Returns (client, model) for the first provider whose env var is set,
- or (None, None) if none are configured.
+ Returns (client, model) for the first provider with usable runtime
+ credentials, or (None, None) if none are configured.
"""
try:
- from hermes_cli.auth import PROVIDER_REGISTRY
+ from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
except ImportError:
logger.debug("Could not import PROVIDER_REGISTRY for API-key fallback")
return None, None
@@ -370,31 +510,24 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
for provider_id, pconfig in PROVIDER_REGISTRY.items():
if pconfig.auth_type != "api_key":
continue
- # Check if any of the provider's env vars are set
- api_key = ""
- for env_var in pconfig.api_key_env_vars:
- val = os.getenv(env_var, "").strip()
- if val:
- api_key = val
- break
+ if provider_id == "anthropic":
+ return _try_anthropic()
+
+ creds = resolve_api_key_provider_credentials(provider_id)
+ api_key = str(creds.get("api_key", "")).strip()
if not api_key:
continue
- # Resolve base URL (with optional env-var override)
- # Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1
- env_url = ""
- if pconfig.base_url_env_var:
- env_url = os.getenv(pconfig.base_url_env_var, "").strip()
- if env_url:
- base_url = env_url.rstrip("/")
- elif provider_id == "kimi-coding" and api_key.startswith("sk-kimi-"):
- base_url = "https://api.kimi.com/coding/v1"
- else:
- base_url = pconfig.inference_base_url
+
+ base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
extra = {}
if "api.kimi.com" in base_url.lower():
extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
+ elif "api.githubcopilot.com" in base_url.lower():
+ from hermes_cli.models import copilot_default_headers
+
+ extra["default_headers"] = copilot_default_headers()
return OpenAI(api_key=api_key, base_url=base_url, **extra), model
return None, None
@@ -417,6 +550,17 @@ def _get_auxiliary_provider(task: str = "") -> str:
return "auto"
+def _get_auxiliary_env_override(task: str, suffix: str) -> Optional[str]:
+ """Read an auxiliary env override from AUXILIARY_* or CONTEXT_* prefixes."""
+ if not task:
+ return None
+ for prefix in ("AUXILIARY_", "CONTEXT_"):
+ val = os.getenv(f"{prefix}{task.upper()}_{suffix}", "").strip()
+ if val:
+ return val
+ return None
+
+
def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]:
or_key = os.getenv("OPENROUTER_API_KEY")
if not or_key:
@@ -439,12 +583,72 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
)
+def _read_main_model() -> str:
+ """Read the user's configured main model from config/env.
+
+ Falls back through HERMES_MODEL โ LLM_MODEL โ config.yaml model.default
+ so the auxiliary client can use the same model as the main agent when no
+ dedicated auxiliary model is available.
+ """
+ from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
+ if from_env:
+ return from_env.strip()
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ model_cfg = cfg.get("model", {})
+ if isinstance(model_cfg, str) and model_cfg.strip():
+ return model_cfg.strip()
+ if isinstance(model_cfg, dict):
+ default = model_cfg.get("default", "")
+ if isinstance(default, str) and default.strip():
+ return default.strip()
+ except Exception:
+ pass
+ return ""
+
+
+def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
+ """Resolve the active custom/main endpoint the same way the main CLI does.
+
+ This covers both env-driven OPENAI_BASE_URL setups and config-saved custom
+ endpoints where the base URL lives in config.yaml instead of the live
+ environment.
+ """
+ try:
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ runtime = resolve_runtime_provider(requested="custom")
+ except Exception as exc:
+ logger.debug("Auxiliary client: custom runtime resolution failed: %s", exc)
+ return None, None
+
+ custom_base = runtime.get("base_url")
+ 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():
+ # requested='custom' falls back to OpenRouter when no custom endpoint is
+ # configured. Treat that as "no custom endpoint" for auxiliary routing.
+ return None, None
+
+ return custom_base, custom_key.strip()
+
+
+def _current_custom_base_url() -> str:
+ custom_base, _ = _resolve_custom_runtime()
+ return custom_base or ""
+
+
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
- custom_base = os.getenv("OPENAI_BASE_URL")
- custom_key = os.getenv("OPENAI_API_KEY")
+ custom_base, custom_key = _resolve_custom_runtime()
if not custom_base or not custom_key:
return None, None
- model = os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
+ model = _read_main_model() or "gpt-4o-mini"
logger.debug("Auxiliary client: custom endpoint (%s)", model)
return OpenAI(api_key=custom_key, base_url=custom_base), model
@@ -458,6 +662,47 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL
+def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
+ try:
+ from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
+ except ImportError:
+ return None, None
+
+ token = resolve_anthropic_token()
+ if not token:
+ return None, None
+
+ # Allow base URL override from config.yaml model.base_url, but only
+ # when the configured provider is anthropic โ otherwise a non-Anthropic
+ # base_url (e.g. Codex endpoint) would leak into Anthropic requests.
+ base_url = _ANTHROPIC_DEFAULT_BASE_URL
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ model_cfg = cfg.get("model")
+ if isinstance(model_cfg, dict):
+ cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
+ if cfg_provider == "anthropic":
+ cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
+ if cfg_base_url:
+ base_url = cfg_base_url
+ except Exception:
+ pass
+
+ from agent.anthropic_adapter import _is_oauth_token
+ is_oauth = _is_oauth_token(token)
+ model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001")
+ logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
+ try:
+ real_client = build_anthropic_client(token, base_url)
+ except ImportError:
+ # The anthropic_adapter module imports fine but the SDK itself is
+ # missing โ build_anthropic_client raises ImportError at call time
+ # when _anthropic_sdk is None. Treat as unavailable.
+ return None, None
+ return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model
+
+
def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Resolve a specific forced provider. Returns (None, None) if creds missing."""
if forced == "openrouter":
@@ -494,6 +739,8 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
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
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
_try_codex, _resolve_api_key_provider):
client, model = try_fn()
@@ -520,6 +767,8 @@ def _to_async_client(sync_client, model: str):
if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
+ if isinstance(sync_client, AnthropicAuxiliaryClient):
+ return AsyncAnthropicAuxiliaryClient(sync_client), model
async_kwargs = {
"api_key": sync_client.api_key,
@@ -528,6 +777,10 @@ def _to_async_client(sync_client, model: str):
base_lower = str(sync_client.base_url).lower()
if "openrouter" in base_lower:
async_kwargs["default_headers"] = dict(_OR_HEADERS)
+ elif "api.githubcopilot.com" in base_lower:
+ from hermes_cli.models import copilot_default_headers
+
+ async_kwargs["default_headers"] = copilot_default_headers()
elif "api.kimi.com" in base_lower:
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"}
return AsyncOpenAI(**async_kwargs), model
@@ -538,6 +791,8 @@ def resolve_provider_client(
model: str = None,
async_mode: bool = False,
raw_codex: bool = False,
+ explicit_base_url: str = None,
+ explicit_api_key: str = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -559,6 +814,8 @@ def resolve_provider_client(
instead of wrapping in CodexAuxiliaryClient. Use this when
the caller needs direct access to responses.stream() (e.g.,
the main agent loop).
+ explicit_base_url: Optional direct OpenAI-compatible endpoint.
+ explicit_api_key: Optional API key paired with explicit_base_url.
Returns:
(client, resolved_model) or (None, None) if auth is unavailable.
@@ -575,6 +832,15 @@ def resolve_provider_client(
client, resolved = _resolve_auto()
if client is None:
return None, None
+ # When auto-detection lands on a non-OpenRouter provider (e.g. a
+ # local server), an OpenRouter-formatted model override like
+ # "google/gemini-3-flash-preview" won't work. Drop it and use
+ # the provider's own default model instead.
+ if model and "/" in model and resolved and "/" not in resolved:
+ logger.debug(
+ "Dropping OpenRouter-format model %r for non-OpenRouter "
+ "auxiliary provider (using %r instead)", model, resolved)
+ model = None
final_model = model or resolved
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
@@ -626,6 +892,22 @@ def resolve_provider_client(
# โโ Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) โโโโโโโโโโโ
if provider == "custom":
+ if explicit_base_url:
+ custom_base = explicit_base_url.strip()
+ custom_key = (
+ (explicit_api_key or "").strip()
+ or os.getenv("OPENAI_API_KEY", "").strip()
+ )
+ if not custom_base or not custom_key:
+ logger.warning(
+ "resolve_provider_client: explicit custom endpoint requested "
+ "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"
+ client = OpenAI(api_key=custom_key, base_url=custom_base)
+ return (_to_async_client(client, final_model) if async_mode
+ else (client, final_model))
# Try custom first, then codex, then API-key providers
for try_fn in (_try_custom_endpoint, _try_codex,
_resolve_api_key_provider):
@@ -640,7 +922,7 @@ def resolve_provider_client(
# โโ API-key providers from PROVIDER_REGISTRY โโโโโโโโโโโโโโโโโโโโโ
try:
- from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_kimi_base_url
+ from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
except ImportError:
logger.debug("hermes_cli.auth not available for provider %s", provider)
return None, None
@@ -651,26 +933,26 @@ def resolve_provider_client(
return None, None
if pconfig.auth_type == "api_key":
- # Find the first configured API key
- api_key = ""
- for env_var in pconfig.api_key_env_vars:
- api_key = os.getenv(env_var, "").strip()
- if api_key:
- break
+ if provider == "anthropic":
+ client, default_model = _try_anthropic()
+ if client is None:
+ logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
+ return None, None
+ final_model = model or default_model
+ return (_to_async_client(client, final_model) if async_mode else (client, final_model))
+
+ creds = resolve_api_key_provider_credentials(provider)
+ api_key = str(creds.get("api_key", "")).strip()
if not api_key:
+ tried_sources = list(pconfig.api_key_env_vars)
+ if provider == "copilot":
+ tried_sources.append("gh auth token")
logger.warning("resolve_provider_client: provider %s has no API "
"key configured (tried: %s)",
- provider, ", ".join(pconfig.api_key_env_vars))
+ provider, ", ".join(tried_sources))
return None, None
- # Resolve base URL (env override โ provider-specific logic โ default)
- base_url_override = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
- if provider == "kimi-coding":
- base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, base_url_override)
- elif base_url_override:
- base_url = base_url_override
- else:
- base_url = pconfig.inference_base_url
+ base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
final_model = model or default_model
@@ -679,6 +961,10 @@ def resolve_provider_client(
headers = {}
if "api.kimi.com" in base_url.lower():
headers["User-Agent"] = "KimiCLI/1.0"
+ elif "api.githubcopilot.com" in base_url.lower():
+ from hermes_cli.models import copilot_default_headers
+
+ headers.update(copilot_default_headers())
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
@@ -714,10 +1000,13 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
Callers may override the returned model with a per-task env var
(e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).
"""
- forced = _get_auxiliary_provider(task)
- if forced != "auto":
- return resolve_provider_client(forced)
- return resolve_provider_client("auto")
+ provider, model, base_url, api_key = _resolve_task_provider_model(task or None)
+ return resolve_provider_client(
+ provider,
+ model=model,
+ explicit_base_url=base_url,
+ explicit_api_key=api_key,
+ )
def get_async_text_auxiliary_client(task: str = ""):
@@ -727,54 +1016,160 @@ def get_async_text_auxiliary_client(task: str = ""):
(AsyncCodexAuxiliaryClient, model) which wraps the Responses API.
Returns (None, None) when no provider is available.
"""
- forced = _get_auxiliary_provider(task)
- if forced != "auto":
- return resolve_provider_client(forced, async_mode=True)
- return resolve_provider_client("auto", async_mode=True)
+ provider, model, base_url, api_key = _resolve_task_provider_model(task or None)
+ return resolve_provider_client(
+ provider,
+ model=model,
+ async_mode=True,
+ explicit_base_url=base_url,
+ explicit_api_key=api_key,
+ )
-def get_vision_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:
- """Return (client, default_model_slug) for vision/multimodal auxiliary tasks.
+_VISION_AUTO_PROVIDER_ORDER = (
+ "openrouter",
+ "nous",
+ "openai-codex",
+ "anthropic",
+ "custom",
+)
- Checks AUXILIARY_VISION_PROVIDER for a forced provider, otherwise
- auto-detects. Callers may override the returned model with
- AUXILIARY_VISION_MODEL.
- In auto mode, only providers known to support multimodal are tried:
- OpenRouter, Nous Portal, and Codex OAuth (gpt-5.3-codex supports
- vision via the Responses API). Custom endpoints and API-key
- providers are skipped โ they may not handle vision input. To use
- them, set AUXILIARY_VISION_PROVIDER explicitly.
- """
- forced = _get_auxiliary_provider("vision")
- if forced != "auto":
- return resolve_provider_client(forced)
- # Auto: try providers known to support multimodal first, then fall
- # back to the user's custom endpoint. Many local models (Qwen-VL,
- # LLaVA, Pixtral, etc.) support vision โ skipping them entirely
- # caused silent failures for local-only users.
- for try_fn in (_try_openrouter, _try_nous, _try_codex,
- _try_custom_endpoint):
- client, model = try_fn()
- if client is not None:
- return client, model
- logger.debug("Auxiliary vision client: none available")
+def _normalize_vision_provider(provider: Optional[str]) -> str:
+ provider = (provider or "auto").strip().lower()
+ if provider == "codex":
+ return "openai-codex"
+ if provider == "main":
+ return "custom"
+ return provider
+
+
+def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:
+ provider = _normalize_vision_provider(provider)
+ if provider == "openrouter":
+ return _try_openrouter()
+ if provider == "nous":
+ return _try_nous()
+ if provider == "openai-codex":
+ return _try_codex()
+ if provider == "anthropic":
+ return _try_anthropic()
+ if provider == "custom":
+ return _try_custom_endpoint()
return None, None
-def get_async_vision_auxiliary_client():
- """Return (async_client, model_slug) for async vision consumers.
+def _strict_vision_backend_available(provider: str) -> bool:
+ return _resolve_strict_vision_backend(provider)[0] is not None
- Properly handles Codex routing โ unlike manually constructing
- AsyncOpenAI from a sync client, this preserves the Responses API
- adapter for Codex providers.
- Returns (None, None) when no provider is available.
+def _preferred_main_vision_provider() -> Optional[str]:
+ """Return the selected main provider when it is also a supported vision backend."""
+ try:
+ from hermes_cli.config import load_config
+
+ config = load_config()
+ model_cfg = config.get("model", {})
+ if isinstance(model_cfg, dict):
+ provider = _normalize_vision_provider(model_cfg.get("provider", ""))
+ if provider in _VISION_AUTO_PROVIDER_ORDER:
+ return provider
+ except Exception:
+ pass
+ return None
+
+
+def get_available_vision_backends() -> List[str]:
+ """Return the currently available vision backends in auto-selection order.
+
+ This is the single source of truth for setup, tool gating, and runtime
+ auto-routing of vision tasks. The selected main provider is preferred when
+ it is also a known-good vision backend; otherwise Hermes falls back through
+ the standard conservative order.
"""
- sync_client, model = get_vision_auxiliary_client()
- if sync_client is None:
- return None, None
- return _to_async_client(sync_client, model)
+ ordered = list(_VISION_AUTO_PROVIDER_ORDER)
+ preferred = _preferred_main_vision_provider()
+ if preferred in ordered:
+ ordered.remove(preferred)
+ ordered.insert(0, preferred)
+ return [provider for provider in ordered if _strict_vision_backend_available(provider)]
+
+
+def resolve_vision_provider_client(
+ provider: Optional[str] = None,
+ model: Optional[str] = None,
+ *,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+ async_mode: bool = False,
+) -> Tuple[Optional[str], Optional[Any], Optional[str]]:
+ """Resolve the client actually used for vision tasks.
+
+ Direct endpoint overrides take precedence over provider selection. Explicit
+ provider overrides still use the generic provider router for non-standard
+ backends, so users can intentionally force experimental providers. Auto mode
+ stays conservative and only tries vision backends known to work today.
+ """
+ requested, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
+ "vision", provider, model, base_url, api_key
+ )
+ requested = _normalize_vision_provider(requested)
+
+ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[str]):
+ if sync_client is None:
+ return resolved_provider, None, None
+ final_model = resolved_model or default_model
+ if async_mode:
+ async_client, async_model = _to_async_client(sync_client, final_model)
+ return resolved_provider, async_client, async_model
+ return resolved_provider, sync_client, final_model
+
+ if resolved_base_url:
+ client, final_model = resolve_provider_client(
+ "custom",
+ model=resolved_model,
+ async_mode=async_mode,
+ explicit_base_url=resolved_base_url,
+ explicit_api_key=resolved_api_key,
+ )
+ if client is None:
+ return "custom", None, None
+ return "custom", client, final_model
+
+ if requested == "auto":
+ ordered = list(_VISION_AUTO_PROVIDER_ORDER)
+ preferred = _preferred_main_vision_provider()
+ if preferred in ordered:
+ ordered.remove(preferred)
+ ordered.insert(0, preferred)
+
+ for candidate in ordered:
+ sync_client, default_model = _resolve_strict_vision_backend(candidate)
+ if sync_client is not None:
+ return _finalize(candidate, sync_client, default_model)
+ logger.debug("Auxiliary vision client: none available")
+ return None, None, None
+
+ if requested in _VISION_AUTO_PROVIDER_ORDER:
+ sync_client, default_model = _resolve_strict_vision_backend(requested)
+ return _finalize(requested, sync_client, default_model)
+
+ client, final_model = _get_cached_client(requested, resolved_model, async_mode)
+ if client is None:
+ return requested, None, None
+ return requested, client, final_model
+
+
+def get_vision_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:
+ """Return (client, default_model_slug) for vision/multimodal auxiliary tasks."""
+ _, client, final_model = resolve_vision_provider_client(async_mode=False)
+ return client, final_model
+
+
+def get_async_vision_auxiliary_client():
+ """Return (async_client, model_slug) for async vision consumers."""
+ _, client, final_model = resolve_vision_provider_client(async_mode=True)
+ return client, final_model
def get_auxiliary_extra_body() -> dict:
@@ -794,7 +1189,7 @@ def auxiliary_max_tokens_param(value: int) -> dict:
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well.
"""
- custom_base = os.getenv("OPENAI_BASE_URL", "")
+ custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
# Only use max_completion_tokens for direct OpenAI custom endpoints
if (not or_key
@@ -816,21 +1211,171 @@ def auxiliary_max_tokens_param(value: int) -> dict:
# Every auxiliary LLM consumer should use these instead of manually
# constructing clients and calling .chat.completions.create().
-# Client cache: (provider, async_mode) -> (client, default_model)
+# Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model)
_client_cache: Dict[tuple, tuple] = {}
+_client_cache_lock = threading.Lock()
+
+
+def neuter_async_httpx_del() -> None:
+ """Monkey-patch ``AsyncHttpxClientWrapper.__del__`` to be a no-op.
+
+ The OpenAI SDK's ``AsyncHttpxClientWrapper.__del__`` schedules
+ ``self.aclose()`` via ``asyncio.get_running_loop().create_task()``.
+ When an ``AsyncOpenAI`` client is garbage-collected while
+ prompt_toolkit's event loop is running (the common CLI idle state),
+ the ``aclose()`` task runs on prompt_toolkit's loop but the
+ underlying TCP transport is bound to a *different* loop (the worker
+ thread's loop that the client was originally created on). If that
+ loop is closed or its thread is dead, the transport's
+ ``self._loop.call_soon()`` raises ``RuntimeError("Event loop is
+ closed")``, which prompt_toolkit surfaces as "Unhandled exception
+ in event loop ... Press ENTER to continue...".
+
+ Neutering ``__del__`` is safe because:
+ - Cached clients are explicitly cleaned via ``_force_close_async_httpx``
+ on stale-loop detection and ``shutdown_cached_clients`` on exit.
+ - Uncached clients' TCP connections are cleaned up by the OS when the
+ process exits.
+ - The OpenAI SDK itself marks this as a TODO (``# TODO(someday):
+ support non asyncio runtimes here``).
+
+ Call this once at CLI startup, before any ``AsyncOpenAI`` clients are
+ created.
+ """
+ try:
+ from openai._base_client import AsyncHttpxClientWrapper
+ AsyncHttpxClientWrapper.__del__ = lambda self: None # type: ignore[assignment]
+ except (ImportError, AttributeError):
+ pass # Graceful degradation if the SDK changes its internals
+
+
+def _force_close_async_httpx(client: Any) -> None:
+ """Mark the httpx AsyncClient inside an AsyncOpenAI client as closed.
+
+ This prevents ``AsyncHttpxClientWrapper.__del__`` from scheduling
+ ``aclose()`` on a (potentially closed) event loop, which causes
+ ``RuntimeError: Event loop is closed`` โ prompt_toolkit's
+ "Press ENTER to continue..." handler.
+
+ We intentionally do NOT run the full async close path โ the
+ connections will be dropped by the OS when the process exits.
+ """
+ try:
+ from httpx._client import ClientState
+ inner = getattr(client, "_client", None)
+ if inner is not None and not getattr(inner, "is_closed", True):
+ inner._state = ClientState.CLOSED
+ except Exception:
+ pass
+
+
+def shutdown_cached_clients() -> None:
+ """Close all cached clients (sync and async) to prevent event-loop errors.
+
+ Call this during CLI shutdown, *before* the event loop is closed, to
+ avoid ``AsyncHttpxClientWrapper.__del__`` raising on a dead loop.
+ """
+ import inspect
+
+ with _client_cache_lock:
+ for key, entry in list(_client_cache.items()):
+ client = entry[0]
+ if client is None:
+ continue
+ # Mark any async httpx transport as closed first (prevents __del__
+ # from scheduling aclose() on a dead event loop).
+ _force_close_async_httpx(client)
+ # Sync clients: close the httpx connection pool cleanly.
+ # Async clients: skip โ we already neutered __del__ above.
+ try:
+ close_fn = getattr(client, "close", None)
+ if close_fn and not inspect.iscoroutinefunction(close_fn):
+ close_fn()
+ except Exception:
+ pass
+ _client_cache.clear()
+
+
+def cleanup_stale_async_clients() -> None:
+ """Force-close cached async clients whose event loop is closed.
+
+ Call this after each agent turn to proactively clean up stale clients
+ before GC can trigger ``AsyncHttpxClientWrapper.__del__`` on them.
+ This is defense-in-depth โ the primary fix is ``neuter_async_httpx_del``
+ which disables ``__del__`` entirely.
+ """
+ with _client_cache_lock:
+ stale_keys = []
+ for key, entry in _client_cache.items():
+ client, _default, cached_loop = entry
+ if cached_loop is not None and cached_loop.is_closed():
+ _force_close_async_httpx(client)
+ stale_keys.append(key)
+ for key in stale_keys:
+ del _client_cache[key]
def _get_cached_client(
- provider: str, model: str = None, async_mode: bool = False,
+ provider: str,
+ model: str = None,
+ async_mode: bool = False,
+ base_url: str = None,
+ api_key: str = None,
) -> Tuple[Optional[Any], Optional[str]]:
- """Get or create a cached client for the given provider."""
- cache_key = (provider, async_mode)
- if cache_key in _client_cache:
- cached_client, cached_default = _client_cache[cache_key]
- return cached_client, model or cached_default
- client, default_model = resolve_provider_client(provider, model, async_mode)
+ """Get or create a cached client for the given provider.
+
+ Async clients (AsyncOpenAI) use httpx.AsyncClient internally, which
+ binds to the event loop that was current when the client was created.
+ Using such a client on a *different* loop causes deadlocks or
+ RuntimeError. To prevent cross-loop issues (especially in gateway
+ mode where _run_async() may spawn fresh loops in worker threads), the
+ cache key for async clients includes the current event loop's identity
+ so each loop gets its own client instance.
+ """
+ # Include loop identity for async clients to prevent cross-loop reuse.
+ # httpx.AsyncClient (inside AsyncOpenAI) is bound to the loop where it
+ # was created โ reusing it on a different loop causes deadlocks (#2681).
+ loop_id = 0
+ current_loop = None
+ if async_mode:
+ try:
+ import asyncio as _aio
+ current_loop = _aio.get_event_loop()
+ loop_id = id(current_loop)
+ except RuntimeError:
+ pass
+ cache_key = (provider, async_mode, base_url or "", api_key or "", loop_id)
+ with _client_cache_lock:
+ if cache_key in _client_cache:
+ cached_client, cached_default, cached_loop = _client_cache[cache_key]
+ if async_mode:
+ # A cached async client whose loop has been closed will raise
+ # "Event loop is closed" when httpx tries to clean up its
+ # transport. Discard the stale client and create a fresh one.
+ if cached_loop is not None and cached_loop.is_closed():
+ _force_close_async_httpx(cached_client)
+ del _client_cache[cache_key]
+ else:
+ return cached_client, model or cached_default
+ else:
+ return cached_client, model or cached_default
+ # Build outside the lock
+ client, default_model = resolve_provider_client(
+ provider,
+ model,
+ async_mode,
+ explicit_base_url=base_url,
+ explicit_api_key=api_key,
+ )
if client is not None:
- _client_cache[cache_key] = (client, default_model)
+ # For async clients, remember which loop they were created on so we
+ # can detect stale entries later.
+ bound_loop = current_loop
+ with _client_cache_lock:
+ if cache_key not in _client_cache:
+ _client_cache[cache_key] = (client, default_model, bound_loop)
+ else:
+ client, default_model, _ = _client_cache[cache_key]
return client, model or default_model
@@ -838,57 +1383,79 @@ def _resolve_task_provider_model(
task: str = None,
provider: str = None,
model: str = None,
-) -> Tuple[str, Optional[str]]:
+ base_url: str = None,
+ api_key: str = None,
+) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
Priority:
- 1. Explicit provider/model args (always win)
- 2. Env var overrides (AUXILIARY_{TASK}_PROVIDER, etc.)
- 3. Config file (auxiliary.{task}.provider/model or compression.*)
+ 1. Explicit provider/model/base_url/api_key args (always win)
+ 2. Env var overrides (AUXILIARY_{TASK}_*, CONTEXT_{TASK}_*)
+ 3. Config file (auxiliary.{task}.* or compression.*)
4. "auto" (full auto-detection chain)
- Returns (provider, model) where model may be None (use provider default).
+ Returns (provider, model, base_url, api_key) where model may be None
+ (use provider default). When base_url is set, provider is forced to
+ "custom" and the task uses that direct endpoint.
"""
- if provider:
- return provider, model
+ config = {}
+ cfg_provider = None
+ cfg_model = None
+ cfg_base_url = None
+ cfg_api_key = None
if task:
- # Check env var overrides first
- env_provider = _get_auxiliary_provider(task)
- if env_provider != "auto":
- # Check for env var model override too
- env_model = None
- for prefix in ("AUXILIARY_", "CONTEXT_"):
- val = os.getenv(f"{prefix}{task.upper()}_MODEL", "").strip()
- if val:
- env_model = val
- break
- return env_provider, model or env_model
-
- # Read from config file
try:
from hermes_cli.config import load_config
config = load_config()
except ImportError:
- return "auto", model
+ config = {}
+
+ aux = config.get("auxiliary", {}) if isinstance(config, dict) else {}
+ task_config = aux.get(task, {}) if isinstance(aux, dict) else {}
+ if not isinstance(task_config, dict):
+ task_config = {}
+ cfg_provider = str(task_config.get("provider", "")).strip() or None
+ cfg_model = str(task_config.get("model", "")).strip() or None
+ cfg_base_url = str(task_config.get("base_url", "")).strip() or None
+ cfg_api_key = str(task_config.get("api_key", "")).strip() or None
+
+ # Backwards compat: compression section has its own keys.
+ # The auxiliary.compression defaults to provider="auto", so treat
+ # both None and "auto" as "not explicitly configured".
+ if task == "compression" and (not cfg_provider or cfg_provider == "auto"):
+ comp = config.get("compression", {}) if isinstance(config, dict) else {}
+ if isinstance(comp, dict):
+ cfg_provider = comp.get("summary_provider", "").strip() or None
+ cfg_model = cfg_model or comp.get("summary_model", "").strip() or None
+ _sbu = comp.get("summary_base_url") or ""
+ cfg_base_url = cfg_base_url or _sbu.strip() or None
+
+ env_model = _get_auxiliary_env_override(task, "MODEL") if task else None
+ resolved_model = model or env_model or cfg_model
+
+ if base_url:
+ return "custom", resolved_model, base_url, api_key
+ if provider:
+ return provider, resolved_model, base_url, api_key
- # Check auxiliary.{task} section
- aux = config.get("auxiliary", {})
- task_config = aux.get(task, {})
- cfg_provider = task_config.get("provider", "").strip() or None
- cfg_model = task_config.get("model", "").strip() or None
+ if task:
+ env_base_url = _get_auxiliary_env_override(task, "BASE_URL")
+ env_api_key = _get_auxiliary_env_override(task, "API_KEY")
+ if env_base_url:
+ return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key
- # Backwards compat: compression section has its own keys
- if task == "compression" and not cfg_provider:
- comp = config.get("compression", {})
- cfg_provider = comp.get("summary_provider", "").strip() or None
- cfg_model = cfg_model or comp.get("summary_model", "").strip() or None
+ env_provider = _get_auxiliary_provider(task)
+ if env_provider != "auto":
+ return env_provider, resolved_model, None, None
+ if cfg_base_url:
+ return "custom", resolved_model, cfg_base_url, cfg_api_key
if cfg_provider and cfg_provider != "auto":
- return cfg_provider, model or cfg_model
- return "auto", model or cfg_model
+ return cfg_provider, resolved_model, None, None
+ return "auto", resolved_model, None, None
- return "auto", model
+ return "auto", resolved_model, None, None
def _build_call_kwargs(
@@ -900,6 +1467,7 @@ def _build_call_kwargs(
tools: Optional[list] = None,
timeout: float = 30.0,
extra_body: Optional[dict] = None,
+ base_url: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
kwargs: Dict[str, Any] = {
@@ -915,7 +1483,7 @@ def _build_call_kwargs(
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
if provider == "custom":
- custom_base = os.getenv("OPENAI_BASE_URL", "")
+ custom_base = base_url or _current_custom_base_url()
if "api.openai.com" in custom_base.lower():
kwargs["max_completion_tokens"] = max_tokens
else:
@@ -941,6 +1509,8 @@ def call_llm(
*,
provider: str = None,
model: str = None,
+ base_url: str = None,
+ api_key: str = None,
messages: list,
temperature: float = None,
max_tokens: int = None,
@@ -972,26 +1542,67 @@ def call_llm(
Raises:
RuntimeError: If no provider is configured.
"""
- resolved_provider, resolved_model = _resolve_task_provider_model(
- task, provider, model)
+ resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
+ task, provider, model, base_url, api_key)
- client, final_model = _get_cached_client(resolved_provider, resolved_model)
- if client is None:
- # Fallback: try openrouter
- if resolved_provider != "openrouter":
- 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:
- raise RuntimeError(
- f"No LLM provider configured for task={task} provider={resolved_provider}. "
- f"Run: hermes setup")
+ if task == "vision":
+ effective_provider, client, final_model = resolve_vision_provider_client(
+ provider=provider,
+ model=model,
+ base_url=base_url,
+ api_key=api_key,
+ async_mode=False,
+ )
+ if client is None and resolved_provider != "auto" and not resolved_base_url:
+ logger.warning(
+ "Vision provider %s unavailable, falling back to auto vision backends",
+ resolved_provider,
+ )
+ effective_provider, client, final_model = resolve_vision_provider_client(
+ provider="auto",
+ model=resolved_model,
+ async_mode=False,
+ )
+ if client is None:
+ raise RuntimeError(
+ f"No LLM provider configured for task={task} provider={resolved_provider}. "
+ f"Run: hermes setup"
+ )
+ resolved_provider = effective_provider or resolved_provider
+ else:
+ client, final_model = _get_cached_client(
+ resolved_provider,
+ resolved_model,
+ base_url=resolved_base_url,
+ api_key=resolved_api_key,
+ )
+ if client is None:
+ # When the user explicitly chose a non-OpenRouter provider but no
+ # credentials were found, fail fast instead of silently routing
+ # through OpenRouter (which causes confusing 404s).
+ _explicit = (resolved_provider or "").strip().lower()
+ if _explicit and _explicit not in ("auto", "openrouter", "custom"):
+ raise RuntimeError(
+ f"Provider '{_explicit}' is set in config.yaml but no API key "
+ f"was found. Set the {_explicit.upper()}_API_KEY environment "
+ f"variable, or switch to a different provider with `hermes model`."
+ )
+ # For auto/custom, fall back to OpenRouter
+ if not resolved_base_url:
+ 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:
+ raise RuntimeError(
+ f"No LLM provider configured for task={task} provider={resolved_provider}. "
+ f"Run: hermes setup")
kwargs = _build_call_kwargs(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
- tools=tools, timeout=timeout, extra_body=extra_body)
+ tools=tools, timeout=timeout, extra_body=extra_body,
+ base_url=resolved_base_url)
# Handle max_tokens vs max_completion_tokens retry
try:
@@ -1005,11 +1616,69 @@ def call_llm(
raise
+def extract_content_or_reasoning(response) -> str:
+ """Extract content from an LLM response, falling back to reasoning fields.
+
+ Mirrors the main agent loop's behavior when a reasoning model (DeepSeek-R1,
+ Qwen-QwQ, etc.) returns ``content=None`` with reasoning in structured fields.
+
+ Resolution order:
+ 1. ``message.content`` โ strip inline think/reasoning blocks, check for
+ remaining non-whitespace text.
+ 2. ``message.reasoning`` / ``message.reasoning_content`` โ direct
+ structured reasoning fields (DeepSeek, Moonshot, Novita, etc.).
+ 3. ``message.reasoning_details`` โ OpenRouter unified array format.
+
+ Returns the best available text, or ``""`` if nothing found.
+ """
+ import re
+
+ msg = response.choices[0].message
+ content = (msg.content or "").strip()
+
+ if content:
+ # Strip inline think/reasoning blocks (mirrors _strip_think_blocks)
+ cleaned = re.sub(
+ r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>"
+ r".*?"
+ r"(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>",
+ "", content, flags=re.DOTALL | re.IGNORECASE,
+ ).strip()
+ if cleaned:
+ return cleaned
+
+ # Content is empty or reasoning-only โ try structured reasoning fields
+ reasoning_parts: list[str] = []
+ for field in ("reasoning", "reasoning_content"):
+ val = getattr(msg, field, None)
+ if val and isinstance(val, str) and val.strip() and val not in reasoning_parts:
+ reasoning_parts.append(val.strip())
+
+ details = getattr(msg, "reasoning_details", None)
+ if details and isinstance(details, list):
+ for detail in details:
+ if isinstance(detail, dict):
+ summary = (
+ detail.get("summary")
+ or detail.get("content")
+ or detail.get("text")
+ )
+ if summary and summary not in reasoning_parts:
+ reasoning_parts.append(summary.strip() if isinstance(summary, str) else str(summary))
+
+ if reasoning_parts:
+ return "\n\n".join(reasoning_parts)
+
+ return ""
+
+
async def async_call_llm(
task: str = None,
*,
provider: str = None,
model: str = None,
+ base_url: str = None,
+ api_key: str = None,
messages: list,
temperature: float = None,
max_tokens: int = None,
@@ -1021,27 +1690,65 @@ async def async_call_llm(
Same as call_llm() but async. See call_llm() for full documentation.
"""
- resolved_provider, resolved_model = _resolve_task_provider_model(
- task, provider, model)
+ resolved_provider, resolved_model, resolved_base_url, resolved_api_key = _resolve_task_provider_model(
+ task, provider, model, base_url, api_key)
- client, final_model = _get_cached_client(
- resolved_provider, resolved_model, async_mode=True)
- if client is None:
- if resolved_provider != "openrouter":
- logger.warning("Provider %s unavailable, falling back to openrouter",
- resolved_provider)
- client, final_model = _get_cached_client(
- "openrouter", resolved_model or _OPENROUTER_MODEL,
- async_mode=True)
- if client is None:
- raise RuntimeError(
- f"No LLM provider configured for task={task} provider={resolved_provider}. "
- f"Run: hermes setup")
+ if task == "vision":
+ effective_provider, client, final_model = resolve_vision_provider_client(
+ provider=provider,
+ model=model,
+ base_url=base_url,
+ api_key=api_key,
+ async_mode=True,
+ )
+ if client is None and resolved_provider != "auto" and not resolved_base_url:
+ logger.warning(
+ "Vision provider %s unavailable, falling back to auto vision backends",
+ resolved_provider,
+ )
+ effective_provider, client, final_model = resolve_vision_provider_client(
+ provider="auto",
+ model=resolved_model,
+ async_mode=True,
+ )
+ if client is None:
+ raise RuntimeError(
+ f"No LLM provider configured for task={task} provider={resolved_provider}. "
+ f"Run: hermes setup"
+ )
+ resolved_provider = effective_provider or resolved_provider
+ else:
+ client, final_model = _get_cached_client(
+ resolved_provider,
+ resolved_model,
+ async_mode=True,
+ base_url=resolved_base_url,
+ api_key=resolved_api_key,
+ )
+ if client is None:
+ _explicit = (resolved_provider or "").strip().lower()
+ if _explicit and _explicit not in ("auto", "openrouter", "custom"):
+ raise RuntimeError(
+ f"Provider '{_explicit}' is set in config.yaml but no API key "
+ f"was found. Set the {_explicit.upper()}_API_KEY environment "
+ f"variable, or switch to a different provider with `hermes model`."
+ )
+ if not resolved_base_url:
+ logger.warning("Provider %s unavailable, falling back to openrouter",
+ resolved_provider)
+ client, final_model = _get_cached_client(
+ "openrouter", resolved_model or _OPENROUTER_MODEL,
+ async_mode=True)
+ if client is None:
+ raise RuntimeError(
+ f"No LLM provider configured for task={task} provider={resolved_provider}. "
+ f"Run: hermes setup")
kwargs = _build_call_kwargs(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
- tools=tools, timeout=timeout, extra_body=extra_body)
+ tools=tools, timeout=timeout, extra_body=extra_body,
+ base_url=resolved_base_url)
try:
return await client.chat.completions.create(**kwargs)
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index b2dff9c85a7..a39b19359b8 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -1,12 +1,19 @@
"""Automatic context window compression for long conversations.
Self-contained class with its own OpenAI client for summarization.
-Uses Gemini Flash (cheap/fast) to summarize middle turns while
+Uses auxiliary model (cheap/fast) to summarize middle turns while
protecting head and tail context.
+
+Improvements over v1:
+ - Structured summary template (Goal, Progress, Decisions, Files, Next Steps)
+ - Iterative summary updates (preserves info across multiple compactions)
+ - Token-budget tail protection instead of fixed message count
+ - Tool output pruning before LLM summarization (cheap pre-pass)
+ - Scaled summary budget (proportional to compressed content)
+ - Richer tool call/result detail in summarizer input
"""
import logging
-import os
from typing import Any, Dict, List, Optional
from agent.auxiliary_client import call_llm
@@ -17,12 +24,39 @@
logger = logging.getLogger(__name__)
+SUMMARY_PREFIX = (
+ "[CONTEXT COMPACTION] Earlier turns in this conversation were compacted "
+ "to save context space. The summary below describes work that was "
+ "already completed, and the current session state may still reflect "
+ "that work (for example, files may already be changed). Use the summary "
+ "and the current state to continue from where things left off, and "
+ "avoid repeating work:"
+)
+LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
+
+# Minimum tokens for the summary output
+_MIN_SUMMARY_TOKENS = 2000
+# Proportion of compressed content to allocate for summary
+_SUMMARY_RATIO = 0.20
+# Absolute ceiling for summary tokens (even on very large context windows)
+_SUMMARY_TOKENS_CEILING = 12_000
+
+# Placeholder used when pruning old tool results
+_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
+
+# Chars per token rough estimate
+_CHARS_PER_TOKEN = 4
+
class ContextCompressor:
"""Compresses conversation context when approaching the model's context limit.
- Algorithm: protect first N + last N turns, summarize everything in between.
- Token tracking uses actual counts from API responses for accuracy.
+ Algorithm:
+ 1. Prune old tool results (cheap, no LLM call)
+ 2. Protect head messages (system prompt + first exchange)
+ 3. Protect tail messages by token budget (most recent ~20K tokens)
+ 4. Summarize middle turns with structured LLM prompt
+ 5. On subsequent compactions, iteratively update the previous summary
"""
def __init__(
@@ -30,23 +64,50 @@ def __init__(
model: str,
threshold_percent: float = 0.50,
protect_first_n: int = 3,
- protect_last_n: int = 4,
- summary_target_tokens: int = 2500,
+ protect_last_n: int = 20,
+ summary_target_ratio: float = 0.20,
quiet_mode: bool = False,
summary_model_override: str = None,
base_url: str = "",
+ api_key: str = "",
+ config_context_length: int | None = None,
+ provider: str = "",
):
self.model = model
self.base_url = base_url
+ self.api_key = api_key
+ self.provider = provider
self.threshold_percent = threshold_percent
self.protect_first_n = protect_first_n
self.protect_last_n = protect_last_n
- self.summary_target_tokens = summary_target_tokens
+ self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80))
self.quiet_mode = quiet_mode
- self.context_length = get_model_context_length(model, base_url=base_url)
+ self.context_length = get_model_context_length(
+ model, base_url=base_url, api_key=api_key,
+ config_context_length=config_context_length,
+ provider=provider,
+ )
self.threshold_tokens = int(self.context_length * threshold_percent)
self.compression_count = 0
+
+ # Derive token budgets: ratio is relative to the threshold, not total context
+ target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
+ self.tail_token_budget = target_tokens
+ self.max_summary_tokens = min(
+ int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
+ )
+
+ if not quiet_mode:
+ logger.info(
+ "Context compressor initialized: model=%s context_length=%d "
+ "threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
+ "provider=%s base_url=%s",
+ model, self.context_length, self.threshold_tokens,
+ threshold_percent * 100, self.summary_target_ratio * 100,
+ self.tail_token_budget,
+ provider or "none", base_url or "none",
+ )
self._context_probed = False # True after a step-down from context error
self.last_prompt_tokens = 0
@@ -55,6 +116,9 @@ def __init__(
self.summary_model = summary_model_override or ""
+ # Stores the previous compaction summary for iterative updates
+ self._previous_summary: Optional[str] = None
+
def update_from_response(self, usage: Dict[str, Any]):
"""Update tracked token usage from API response."""
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
@@ -81,61 +145,221 @@ def get_status(self) -> Dict[str, Any]:
"compression_count": self.compression_count,
}
- def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
- """Generate a concise summary of conversation turns.
+ # ------------------------------------------------------------------
+ # Tool output pruning (cheap pre-pass, no LLM call)
+ # ------------------------------------------------------------------
- Tries the auxiliary model first, then falls back to the user's main
- model. Returns None if all attempts fail โ the caller should drop
- the middle turns without a summary rather than inject a useless
- placeholder.
+ def _prune_old_tool_results(
+ self, messages: List[Dict[str, Any]], protect_tail_count: int,
+ ) -> tuple[List[Dict[str, Any]], int]:
+ """Replace old tool result contents with a short placeholder.
+
+ Walks backward from the end, protecting the most recent
+ ``protect_tail_count`` messages. Older tool results get their
+ content replaced with a placeholder string.
+
+ Returns (pruned_messages, pruned_count).
+ """
+ if not messages:
+ return messages, 0
+
+ result = [m.copy() for m in messages]
+ pruned = 0
+ prune_boundary = len(result) - protect_tail_count
+
+ for i in range(prune_boundary):
+ msg = result[i]
+ if msg.get("role") != "tool":
+ continue
+ content = msg.get("content", "")
+ if not content or content == _PRUNED_TOOL_PLACEHOLDER:
+ continue
+ # Only prune if the content is substantial (>200 chars)
+ if len(content) > 200:
+ result[i] = {**msg, "content": _PRUNED_TOOL_PLACEHOLDER}
+ pruned += 1
+
+ return result, pruned
+
+ # ------------------------------------------------------------------
+ # Summarization
+ # ------------------------------------------------------------------
+
+ def _compute_summary_budget(self, turns_to_summarize: List[Dict[str, Any]]) -> int:
+ """Scale summary token budget with the amount of content being compressed.
+
+ The maximum scales with the model's context window (5% of context,
+ capped at ``_SUMMARY_TOKENS_CEILING``) so large-context models get
+ richer summaries instead of being hard-capped at 8K tokens.
+ """
+ content_tokens = estimate_messages_tokens_rough(turns_to_summarize)
+ budget = int(content_tokens * _SUMMARY_RATIO)
+ return max(_MIN_SUMMARY_TOKENS, min(budget, self.max_summary_tokens))
+
+ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str:
+ """Serialize conversation turns into labeled text for the summarizer.
+
+ Includes tool call arguments and result content (up to 3000 chars
+ per message) so the summarizer can preserve specific details like
+ file paths, commands, and outputs.
"""
parts = []
- for msg in turns_to_summarize:
+ for msg in turns:
role = msg.get("role", "unknown")
content = msg.get("content") or ""
- if len(content) > 2000:
- content = content[:1000] + "\n...[truncated]...\n" + content[-500:]
- tool_calls = msg.get("tool_calls", [])
- if tool_calls:
- tool_names = [tc.get("function", {}).get("name", "?") for tc in tool_calls if isinstance(tc, dict)]
- content += f"\n[Tool calls: {', '.join(tool_names)}]"
+
+ # Tool results: keep more content than before (3000 chars)
+ if role == "tool":
+ tool_id = msg.get("tool_call_id", "")
+ if len(content) > 3000:
+ content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
+ parts.append(f"[TOOL RESULT {tool_id}]: {content}")
+ continue
+
+ # Assistant messages: include tool call names AND arguments
+ if role == "assistant":
+ if len(content) > 3000:
+ content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
+ tool_calls = msg.get("tool_calls", [])
+ if tool_calls:
+ tc_parts = []
+ for tc in tool_calls:
+ if isinstance(tc, dict):
+ fn = tc.get("function", {})
+ name = fn.get("name", "?")
+ args = fn.get("arguments", "")
+ # Truncate long arguments but keep enough for context
+ if len(args) > 500:
+ args = args[:400] + "..."
+ tc_parts.append(f" {name}({args})")
+ else:
+ fn = getattr(tc, "function", None)
+ name = getattr(fn, "name", "?") if fn else "?"
+ tc_parts.append(f" {name}(...)")
+ content += "\n[Tool calls:\n" + "\n".join(tc_parts) + "\n]"
+ parts.append(f"[ASSISTANT]: {content}")
+ continue
+
+ # User and other roles
+ if len(content) > 3000:
+ content = content[:2000] + "\n...[truncated]...\n" + content[-800:]
parts.append(f"[{role.upper()}]: {content}")
- content_to_summarize = "\n\n".join(parts)
- prompt = f"""Summarize these conversation turns concisely. This summary will replace these turns in the conversation history.
+ return "\n\n".join(parts)
+
+ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
+ """Generate a structured summary of conversation turns.
+
+ Uses a structured template (Goal, Progress, Decisions, Files, Next Steps)
+ inspired by Pi-mono and OpenCode. When a previous summary exists,
+ generates an iterative update instead of summarizing from scratch.
+
+ Returns None if all attempts fail โ the caller should drop
+ the middle turns without a summary rather than inject a useless
+ placeholder.
+ """
+ summary_budget = self._compute_summary_budget(turns_to_summarize)
+ content_to_summarize = self._serialize_for_summary(turns_to_summarize)
+
+ if self._previous_summary:
+ # Iterative update: preserve existing info, add new progress
+ prompt = f"""You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated.
+
+PREVIOUS SUMMARY:
+{self._previous_summary}
+
+NEW TURNS TO INCORPORATE:
+{content_to_summarize}
+
+Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new progress. Move items from "In Progress" to "Done" when completed. Remove information only if it is clearly obsolete.
+
+## Goal
+[What the user is trying to accomplish โ preserve from previous summary, update if goal evolved]
+
+## Constraints & Preferences
+[User preferences, coding style, constraints, important decisions โ accumulate across compactions]
+
+## Progress
+### Done
+[Completed work โ include specific file paths, commands run, results obtained]
+### In Progress
+[Work currently underway]
+### Blocked
+[Any blockers or issues encountered]
+
+## Key Decisions
+[Important technical decisions and why they were made]
-Write from a neutral perspective describing:
-1. What actions were taken (tool calls, searches, file operations)
-2. Key information or results obtained
-3. Important decisions or findings
-4. Relevant data, file names, or outputs
+## Relevant Files
+[Files read, modified, or created โ with brief note on each. Accumulate across compactions.]
-Keep factual and informative. Target ~{self.summary_target_tokens} tokens.
+## Next Steps
+[What needs to happen next to continue the work]
+
+## Critical Context
+[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
+
+Target ~{summary_budget} tokens. Be specific โ include file paths, command outputs, error messages, and concrete values rather than vague descriptions.
+
+Write only the summary body. Do not include any preamble or prefix."""
+ else:
+ # First compaction: summarize from scratch
+ prompt = f"""Create a structured handoff summary for a later assistant that will continue this conversation after earlier turns are compacted.
----
TURNS TO SUMMARIZE:
{content_to_summarize}
----
-Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
+Use this exact structure:
+
+## Goal
+[What the user is trying to accomplish]
+
+## Constraints & Preferences
+[User preferences, coding style, constraints, important decisions]
+
+## Progress
+### Done
+[Completed work โ include specific file paths, commands run, results obtained]
+### In Progress
+[Work currently underway]
+### Blocked
+[Any blockers or issues encountered]
+
+## Key Decisions
+[Important technical decisions and why they were made]
+
+## Relevant Files
+[Files read, modified, or created โ with brief note on each]
+
+## Next Steps
+[What needs to happen next to continue the work]
+
+## Critical Context
+[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]
+
+Target ~{summary_budget} tokens. Be specific โ include file paths, command outputs, error messages, and concrete values rather than vague descriptions. The goal is to prevent the next assistant from repeating work or losing important details.
+
+Write only the summary body. Do not include any preamble or prefix."""
- # Use the centralized LLM router โ handles provider resolution,
- # auth, and fallback internally.
try:
call_kwargs = {
"task": "compression",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
- "max_tokens": self.summary_target_tokens * 2,
- "timeout": 30.0,
+ "max_tokens": summary_budget * 2,
+ "timeout": 45.0,
}
if self.summary_model:
call_kwargs["model"] = self.summary_model
response = call_llm(**call_kwargs)
- summary = response.choices[0].message.content.strip()
- if not summary.startswith("[CONTEXT SUMMARY]:"):
- summary = "[CONTEXT SUMMARY]: " + summary
- return summary
+ content = response.choices[0].message.content
+ # Handle cases where content is not a string (e.g., dict from llama.cpp)
+ if not isinstance(content, str):
+ content = str(content) if content else ""
+ summary = content.strip()
+ # Store for iterative updates on next compaction
+ self._previous_summary = summary
+ return self._with_summary_prefix(summary)
except RuntimeError:
logging.warning("Context compression: no provider available for "
"summary. Middle turns will be dropped without summary.")
@@ -144,6 +368,16 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optiona
logging.warning("Failed to generate context summary: %s", e)
return None
+ @staticmethod
+ def _with_summary_prefix(summary: str) -> str:
+ """Normalize summary text to the current compaction handoff format."""
+ text = (summary or "").strip()
+ for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
+ if text.startswith(prefix):
+ text = text[len(prefix):].lstrip()
+ break
+ return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
+
# ------------------------------------------------------------------
# Tool-call / tool-result pair integrity helpers
# ------------------------------------------------------------------
@@ -229,73 +463,200 @@ def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) ->
"""Pull a compress-end boundary backward to avoid splitting a
tool_call / result group.
- If the message just before ``idx`` is an assistant message with
- tool_calls, those tool results will start at ``idx`` and would be
- separated from their parent. Move backwards to include the whole
- group in the summarised region.
+ If the boundary falls in the middle of a tool-result group (i.e.
+ there are consecutive tool messages before ``idx``), walk backward
+ past all of them to find the parent assistant message. If found,
+ move the boundary before the assistant so the entire
+ assistant + tool_results group is included in the summarised region
+ rather than being split (which causes silent data loss when
+ ``_sanitize_tool_pairs`` removes the orphaned tail results).
"""
if idx <= 0 or idx >= len(messages):
return idx
- prev = messages[idx - 1]
- if prev.get("role") == "assistant" and prev.get("tool_calls"):
- # The results for this assistant turn sit at idx..idx+k.
- # Include the assistant message in the summarised region too.
- idx -= 1
+ # Walk backward past consecutive tool results
+ check = idx - 1
+ while check >= 0 and messages[check].get("role") == "tool":
+ check -= 1
+ # If we landed on the parent assistant with tool_calls, pull the
+ # boundary before it so the whole group gets summarised together.
+ if check >= 0 and messages[check].get("role") == "assistant" and messages[check].get("tool_calls"):
+ idx = check
return idx
+ # ------------------------------------------------------------------
+ # Tail protection by token budget
+ # ------------------------------------------------------------------
+
+ def _find_tail_cut_by_tokens(
+ self, messages: List[Dict[str, Any]], head_end: int,
+ token_budget: int | None = None,
+ ) -> int:
+ """Walk backward from the end of messages, accumulating tokens until
+ the budget is reached. Returns the index where the tail starts.
+
+ ``token_budget`` defaults to ``self.tail_token_budget`` which is
+ derived from ``summary_target_ratio * context_length``, so it
+ scales automatically with the model's context window.
+
+ Never cuts inside a tool_call/result group. Falls back to the old
+ ``protect_last_n`` if the budget would protect fewer messages.
+ """
+ if token_budget is None:
+ token_budget = self.tail_token_budget
+ n = len(messages)
+ min_tail = self.protect_last_n
+ accumulated = 0
+ cut_idx = n # start from beyond the end
+
+ for i in range(n - 1, head_end - 1, -1):
+ msg = messages[i]
+ content = msg.get("content") or ""
+ msg_tokens = len(content) // _CHARS_PER_TOKEN + 10 # +10 for role/metadata
+ # Include tool call arguments in estimate
+ for tc in msg.get("tool_calls") or []:
+ if isinstance(tc, dict):
+ args = tc.get("function", {}).get("arguments", "")
+ msg_tokens += len(args) // _CHARS_PER_TOKEN
+ if accumulated + msg_tokens > token_budget and (n - i) >= min_tail:
+ break
+ accumulated += msg_tokens
+ cut_idx = i
+
+ # Ensure we protect at least protect_last_n messages
+ fallback_cut = n - min_tail
+ if cut_idx > fallback_cut:
+ cut_idx = fallback_cut
+
+ # If the token budget would protect everything (small conversations),
+ # fall back to the fixed protect_last_n approach so compression can
+ # still remove middle turns.
+ if cut_idx <= head_end:
+ cut_idx = fallback_cut
+
+ # Align to avoid splitting tool groups
+ cut_idx = self._align_boundary_backward(messages, cut_idx)
+
+ return max(cut_idx, head_end + 1)
+
+ # ------------------------------------------------------------------
+ # Main compression entry point
+ # ------------------------------------------------------------------
+
def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -> List[Dict[str, Any]]:
"""Compress conversation messages by summarizing middle turns.
- Keeps first N + last N turns, summarizes everything in between.
+ Algorithm:
+ 1. Prune old tool results (cheap pre-pass, no LLM call)
+ 2. Protect head messages (system prompt + first exchange)
+ 3. Find tail boundary by token budget (~20K tokens of recent context)
+ 4. Summarize middle turns with structured LLM prompt
+ 5. On re-compression, iteratively update the previous summary
+
After compression, orphaned tool_call / tool_result pairs are cleaned
up so the API never receives mismatched IDs.
"""
n_messages = len(messages)
if n_messages <= self.protect_first_n + self.protect_last_n + 1:
if not self.quiet_mode:
- print(f"โ ๏ธ Cannot compress: only {n_messages} messages (need > {self.protect_first_n + self.protect_last_n + 1})")
+ logger.warning(
+ "Cannot compress: only %d messages (need > %d)",
+ n_messages,
+ self.protect_first_n + self.protect_last_n + 1,
+ )
return messages
- compress_start = self.protect_first_n
- compress_end = n_messages - self.protect_last_n
- if compress_start >= compress_end:
- return messages
+ display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
- # Adjust boundaries to avoid splitting tool_call/result groups.
+ # Phase 1: Prune old tool results (cheap, no LLM call)
+ messages, pruned_count = self._prune_old_tool_results(
+ messages, protect_tail_count=self.protect_last_n * 3,
+ )
+ if pruned_count and not self.quiet_mode:
+ logger.info("Pre-compression: pruned %d old tool result(s)", pruned_count)
+
+ # Phase 2: Determine boundaries
+ compress_start = self.protect_first_n
compress_start = self._align_boundary_forward(messages, compress_start)
- compress_end = self._align_boundary_backward(messages, compress_end)
+
+ # Use token-budget tail protection instead of fixed message count
+ compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
+
if compress_start >= compress_end:
return messages
turns_to_summarize = messages[compress_start:compress_end]
- display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
if not self.quiet_mode:
- print(f"\n๐ฆ Context compression triggered ({display_tokens:,} tokens โฅ {self.threshold_tokens:,} threshold)")
- print(f" ๐ Model context limit: {self.context_length:,} tokens ({self.threshold_percent*100:.0f}% = {self.threshold_tokens:,})")
-
- if not self.quiet_mode:
- print(f" ๐๏ธ Summarizing turns {compress_start+1}-{compress_end} ({len(turns_to_summarize)} turns)")
-
+ logger.info(
+ "Context compression triggered (%d tokens >= %d threshold)",
+ display_tokens,
+ self.threshold_tokens,
+ )
+ logger.info(
+ "Model context limit: %d tokens (%.0f%% = %d)",
+ self.context_length,
+ self.threshold_percent * 100,
+ self.threshold_tokens,
+ )
+ tail_msgs = n_messages - compress_end
+ logger.info(
+ "Summarizing turns %d-%d (%d turns), protecting %d head + %d tail messages",
+ compress_start + 1,
+ compress_end,
+ len(turns_to_summarize),
+ compress_start,
+ tail_msgs,
+ )
+
+ # Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize)
+ # Phase 4: Assemble compressed message list
compressed = []
for i in range(compress_start):
msg = messages[i].copy()
if i == 0 and msg.get("role") == "system" and self.compression_count == 0:
- msg["content"] = (msg.get("content") or "") + "\n\n[Note: Some earlier conversation turns may be summarized to preserve context space.]"
+ msg["content"] = (
+ (msg.get("content") or "")
+ + "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
+ )
compressed.append(msg)
+ _merge_summary_into_tail = False
if summary:
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
- summary_role = "user" if last_head_role in ("assistant", "tool") else "assistant"
- compressed.append({"role": summary_role, "content": summary})
+ first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
+ # Pick a role that avoids consecutive same-role with both neighbors.
+ # Priority: avoid colliding with head (already committed), then tail.
+ if last_head_role in ("assistant", "tool"):
+ summary_role = "user"
+ else:
+ summary_role = "assistant"
+ # If the chosen role collides with the tail AND flipping wouldn't
+ # collide with the head, flip it.
+ if summary_role == first_tail_role:
+ flipped = "assistant" if summary_role == "user" else "user"
+ if flipped != last_head_role:
+ summary_role = flipped
+ else:
+ # Both roles would create consecutive same-role messages
+ # (e.g. head=assistant, tail=user โ neither role works).
+ # Merge the summary into the first tail message instead
+ # of inserting a standalone message that breaks alternation.
+ _merge_summary_into_tail = True
+ if not _merge_summary_into_tail:
+ compressed.append({"role": summary_role, "content": summary})
else:
if not self.quiet_mode:
- print(" โ ๏ธ No summary model available โ middle turns dropped without summary")
+ logger.warning("No summary model available โ middle turns dropped without summary")
for i in range(compress_end, n_messages):
- compressed.append(messages[i].copy())
+ msg = messages[i].copy()
+ if _merge_summary_into_tail and i == compress_end:
+ original = msg.get("content") or ""
+ msg["content"] = summary + "\n\n" + original
+ _merge_summary_into_tail = False
+ compressed.append(msg)
self.compression_count += 1
@@ -304,7 +665,12 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) -
if not self.quiet_mode:
new_estimate = estimate_messages_tokens_rough(compressed)
saved_estimate = display_tokens - new_estimate
- print(f" โ
Compressed: {n_messages} โ {len(compressed)} messages (~{saved_estimate:,} tokens saved)")
- print(f" ๐ก Compression #{self.compression_count} complete")
+ logger.info(
+ "Compressed: %d -> %d messages (~%d tokens saved)",
+ n_messages,
+ len(compressed),
+ saved_estimate,
+ )
+ logger.info("Compression #%d complete", self.compression_count)
return compressed
diff --git a/agent/context_references.py b/agent/context_references.py
new file mode 100644
index 00000000000..09ba982df1a
--- /dev/null
+++ b/agent/context_references.py
@@ -0,0 +1,492 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+import mimetypes
+import os
+import re
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Awaitable, Callable
+
+from agent.model_metadata import estimate_tokens_rough
+
+REFERENCE_PATTERN = re.compile(
+ r"(?diff|staged)\b|(?Pfile|folder|git|url):(?P\S+))"
+)
+TRAILING_PUNCTUATION = ",.;!?"
+_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube")
+_SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",)
+_SENSITIVE_HOME_FILES = (
+ Path(".ssh") / "authorized_keys",
+ Path(".ssh") / "id_rsa",
+ Path(".ssh") / "id_ed25519",
+ Path(".ssh") / "config",
+ Path(".bashrc"),
+ Path(".zshrc"),
+ Path(".profile"),
+ Path(".bash_profile"),
+ Path(".zprofile"),
+ Path(".netrc"),
+ Path(".pgpass"),
+ Path(".npmrc"),
+ Path(".pypirc"),
+)
+
+
+@dataclass(frozen=True)
+class ContextReference:
+ raw: str
+ kind: str
+ target: str
+ start: int
+ end: int
+ line_start: int | None = None
+ line_end: int | None = None
+
+
+@dataclass
+class ContextReferenceResult:
+ message: str
+ original_message: str
+ references: list[ContextReference] = field(default_factory=list)
+ warnings: list[str] = field(default_factory=list)
+ injected_tokens: int = 0
+ expanded: bool = False
+ blocked: bool = False
+
+
+def parse_context_references(message: str) -> list[ContextReference]:
+ refs: list[ContextReference] = []
+ if not message:
+ return refs
+
+ for match in REFERENCE_PATTERN.finditer(message):
+ simple = match.group("simple")
+ if simple:
+ refs.append(
+ ContextReference(
+ raw=match.group(0),
+ kind=simple,
+ target="",
+ start=match.start(),
+ end=match.end(),
+ )
+ )
+ continue
+
+ kind = match.group("kind")
+ value = _strip_trailing_punctuation(match.group("value") or "")
+ line_start = None
+ line_end = None
+ target = value
+
+ if kind == "file":
+ range_match = re.match(r"^(?P.+?):(?P\d+)(?:-(?P\d+))?$", value)
+ if range_match:
+ target = range_match.group("path")
+ line_start = int(range_match.group("start"))
+ line_end = int(range_match.group("end") or range_match.group("start"))
+
+ refs.append(
+ ContextReference(
+ raw=match.group(0),
+ kind=kind,
+ target=target,
+ start=match.start(),
+ end=match.end(),
+ line_start=line_start,
+ line_end=line_end,
+ )
+ )
+
+ return refs
+
+
+def preprocess_context_references(
+ message: str,
+ *,
+ cwd: str | Path,
+ context_length: int,
+ url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,
+ allowed_root: str | Path | None = None,
+) -> ContextReferenceResult:
+ coro = preprocess_context_references_async(
+ message,
+ cwd=cwd,
+ context_length=context_length,
+ url_fetcher=url_fetcher,
+ allowed_root=allowed_root,
+ )
+ # Safe for both CLI (no loop) and gateway (loop already running).
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = None
+ if loop and loop.is_running():
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+ return pool.submit(asyncio.run, coro).result()
+ return asyncio.run(coro)
+
+
+async def preprocess_context_references_async(
+ message: str,
+ *,
+ cwd: str | Path,
+ context_length: int,
+ url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,
+ allowed_root: str | Path | None = None,
+) -> ContextReferenceResult:
+ refs = parse_context_references(message)
+ if not refs:
+ return ContextReferenceResult(message=message, original_message=message)
+
+ cwd_path = Path(cwd).expanduser().resolve()
+ # Default to the current working directory so @ references cannot escape
+ # the active workspace unless a caller explicitly widens the root.
+ allowed_root_path = (
+ Path(allowed_root).expanduser().resolve() if allowed_root is not None else cwd_path
+ )
+ warnings: list[str] = []
+ blocks: list[str] = []
+ injected_tokens = 0
+
+ for ref in refs:
+ warning, block = await _expand_reference(
+ ref,
+ cwd_path,
+ url_fetcher=url_fetcher,
+ allowed_root=allowed_root_path,
+ )
+ if warning:
+ warnings.append(warning)
+ if block:
+ blocks.append(block)
+ injected_tokens += estimate_tokens_rough(block)
+
+ hard_limit = max(1, int(context_length * 0.50))
+ soft_limit = max(1, int(context_length * 0.25))
+ if injected_tokens > hard_limit:
+ warnings.append(
+ f"@ context injection refused: {injected_tokens} tokens exceeds the 50% hard limit ({hard_limit})."
+ )
+ return ContextReferenceResult(
+ message=message,
+ original_message=message,
+ references=refs,
+ warnings=warnings,
+ injected_tokens=injected_tokens,
+ expanded=False,
+ blocked=True,
+ )
+
+ if injected_tokens > soft_limit:
+ warnings.append(
+ f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})."
+ )
+
+ stripped = _remove_reference_tokens(message, refs)
+ final = stripped
+ if warnings:
+ final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
+ if blocks:
+ final = f"{final}\n\n--- Attached Context ---\n\n" + "\n\n".join(blocks)
+
+ return ContextReferenceResult(
+ message=final.strip(),
+ original_message=message,
+ references=refs,
+ warnings=warnings,
+ injected_tokens=injected_tokens,
+ expanded=bool(blocks or warnings),
+ blocked=False,
+ )
+
+
+async def _expand_reference(
+ ref: ContextReference,
+ cwd: Path,
+ *,
+ url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,
+ allowed_root: Path | None = None,
+) -> tuple[str | None, str | None]:
+ try:
+ if ref.kind == "file":
+ return _expand_file_reference(ref, cwd, allowed_root=allowed_root)
+ if ref.kind == "folder":
+ return _expand_folder_reference(ref, cwd, allowed_root=allowed_root)
+ if ref.kind == "diff":
+ return _expand_git_reference(ref, cwd, ["diff"], "git diff")
+ if ref.kind == "staged":
+ return _expand_git_reference(ref, cwd, ["diff", "--staged"], "git diff --staged")
+ if ref.kind == "git":
+ count = max(1, min(int(ref.target or "1"), 10))
+ return _expand_git_reference(ref, cwd, ["log", f"-{count}", "-p"], f"git log -{count} -p")
+ if ref.kind == "url":
+ content = await _fetch_url_content(ref.target, url_fetcher=url_fetcher)
+ if not content:
+ return f"{ref.raw}: no content extracted", None
+ return None, f"๐ {ref.raw} ({estimate_tokens_rough(content)} tokens)\n{content}"
+ except Exception as exc:
+ return f"{ref.raw}: {exc}", None
+
+ return f"{ref.raw}: unsupported reference type", None
+
+
+def _expand_file_reference(
+ ref: ContextReference,
+ cwd: Path,
+ *,
+ allowed_root: Path | None = None,
+) -> tuple[str | None, str | None]:
+ path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)
+ _ensure_reference_path_allowed(path)
+ if not path.exists():
+ return f"{ref.raw}: file not found", None
+ if not path.is_file():
+ return f"{ref.raw}: path is not a file", None
+ if _is_binary_file(path):
+ return f"{ref.raw}: binary files are not supported", None
+
+ text = path.read_text(encoding="utf-8")
+ if ref.line_start is not None:
+ lines = text.splitlines()
+ start_idx = max(ref.line_start - 1, 0)
+ end_idx = min(ref.line_end or ref.line_start, len(lines))
+ text = "\n".join(lines[start_idx:end_idx])
+
+ lang = _code_fence_language(path)
+ label = ref.raw
+ return None, f"๐ {label} ({estimate_tokens_rough(text)} tokens)\n```{lang}\n{text}\n```"
+
+
+def _expand_folder_reference(
+ ref: ContextReference,
+ cwd: Path,
+ *,
+ allowed_root: Path | None = None,
+) -> tuple[str | None, str | None]:
+ path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)
+ _ensure_reference_path_allowed(path)
+ if not path.exists():
+ return f"{ref.raw}: folder not found", None
+ if not path.is_dir():
+ return f"{ref.raw}: path is not a folder", None
+
+ listing = _build_folder_listing(path, cwd)
+ return None, f"๐ {ref.raw} ({estimate_tokens_rough(listing)} tokens)\n{listing}"
+
+
+def _expand_git_reference(
+ ref: ContextReference,
+ cwd: Path,
+ args: list[str],
+ label: str,
+) -> tuple[str | None, str | None]:
+ try:
+ result = subprocess.run(
+ ["git", *args],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ except subprocess.TimeoutExpired:
+ return f"{ref.raw}: git command timed out (30s)", None
+ if result.returncode != 0:
+ stderr = (result.stderr or "").strip() or "git command failed"
+ return f"{ref.raw}: {stderr}", None
+ content = result.stdout.strip()
+ if not content:
+ content = "(no output)"
+ return None, f"๐งพ {label} ({estimate_tokens_rough(content)} tokens)\n```diff\n{content}\n```"
+
+
+async def _fetch_url_content(
+ url: str,
+ *,
+ url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,
+) -> str:
+ fetcher = url_fetcher or _default_url_fetcher
+ content = fetcher(url)
+ if inspect.isawaitable(content):
+ content = await content
+ return str(content or "").strip()
+
+
+async def _default_url_fetcher(url: str) -> str:
+ from tools.web_tools import web_extract_tool
+
+ raw = await web_extract_tool([url], format="markdown", use_llm_processing=True)
+ payload = json.loads(raw)
+ docs = payload.get("data", {}).get("documents", [])
+ if not docs:
+ return ""
+ doc = docs[0]
+ return str(doc.get("content") or doc.get("raw_content") or "").strip()
+
+
+def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -> Path:
+ path = Path(os.path.expanduser(target))
+ if not path.is_absolute():
+ path = cwd / path
+ resolved = path.resolve()
+ if allowed_root is not None:
+ try:
+ resolved.relative_to(allowed_root)
+ except ValueError as exc:
+ raise ValueError("path is outside the allowed workspace") from exc
+ return resolved
+
+
+def _ensure_reference_path_allowed(path: Path) -> None:
+ home = Path(os.path.expanduser("~")).resolve()
+ hermes_home = Path(
+ os.getenv("HERMES_HOME", str(home / ".hermes"))
+ ).expanduser().resolve()
+
+ blocked_exact = {home / rel for rel in _SENSITIVE_HOME_FILES}
+ blocked_exact.add(hermes_home / ".env")
+ blocked_dirs = [home / rel for rel in _SENSITIVE_HOME_DIRS]
+ blocked_dirs.extend(hermes_home / rel for rel in _SENSITIVE_HERMES_DIRS)
+
+ if path in blocked_exact:
+ raise ValueError("path is a sensitive credential file and cannot be attached")
+
+ for blocked_dir in blocked_dirs:
+ try:
+ path.relative_to(blocked_dir)
+ except ValueError:
+ continue
+ raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
+
+
+def _strip_trailing_punctuation(value: str) -> str:
+ stripped = value.rstrip(TRAILING_PUNCTUATION)
+ while stripped.endswith((")", "]", "}")):
+ closer = stripped[-1]
+ opener = {")": "(", "]": "[", "}": "{"}[closer]
+ if stripped.count(closer) > stripped.count(opener):
+ stripped = stripped[:-1]
+ continue
+ break
+ return stripped
+
+
+def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
+ pieces: list[str] = []
+ cursor = 0
+ for ref in refs:
+ pieces.append(message[cursor:ref.start])
+ cursor = ref.end
+ pieces.append(message[cursor:])
+ text = "".join(pieces)
+ text = re.sub(r"\s{2,}", " ", text)
+ text = re.sub(r"\s+([,.;:!?])", r"\1", text)
+ return text.strip()
+
+
+def _is_binary_file(path: Path) -> bool:
+ mime, _ = mimetypes.guess_type(path.name)
+ if mime and not mime.startswith("text/") and not any(
+ path.name.endswith(ext) for ext in (".py", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".js", ".ts")
+ ):
+ return True
+ chunk = path.read_bytes()[:4096]
+ return b"\x00" in chunk
+
+
+def _build_folder_listing(path: Path, cwd: Path, limit: int = 200) -> str:
+ lines = [f"{path.relative_to(cwd)}/"]
+ entries = _iter_visible_entries(path, cwd, limit=limit)
+ for entry in entries:
+ rel = entry.relative_to(cwd)
+ indent = " " * max(len(rel.parts) - len(path.relative_to(cwd).parts) - 1, 0)
+ if entry.is_dir():
+ lines.append(f"{indent}- {entry.name}/")
+ else:
+ meta = _file_metadata(entry)
+ lines.append(f"{indent}- {entry.name} ({meta})")
+ if len(entries) >= limit:
+ lines.append("- ...")
+ return "\n".join(lines)
+
+
+def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
+ rg_entries = _rg_files(path, cwd, limit=limit)
+ if rg_entries is not None:
+ output: list[Path] = []
+ seen_dirs: set[Path] = set()
+ for rel in rg_entries:
+ full = cwd / rel
+ for parent in full.parents:
+ if parent == cwd or parent in seen_dirs or path not in {parent, *parent.parents}:
+ continue
+ seen_dirs.add(parent)
+ output.append(parent)
+ output.append(full)
+ return sorted({p for p in output if p.exists()}, key=lambda p: (not p.is_dir(), str(p)))
+
+ output = []
+ for root, dirs, files in os.walk(path):
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "__pycache__")
+ files = sorted(f for f in files if not f.startswith("."))
+ root_path = Path(root)
+ for d in dirs:
+ output.append(root_path / d)
+ if len(output) >= limit:
+ return output
+ for f in files:
+ output.append(root_path / f)
+ if len(output) >= limit:
+ return output
+ return output
+
+
+def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
+ try:
+ result = subprocess.run(
+ ["rg", "--files", str(path.relative_to(cwd))],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except FileNotFoundError:
+ return None
+ except subprocess.TimeoutExpired:
+ return None
+ if result.returncode != 0:
+ return None
+ files = [Path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
+ return files[:limit]
+
+
+def _file_metadata(path: Path) -> str:
+ if _is_binary_file(path):
+ return f"{path.stat().st_size} bytes"
+ try:
+ line_count = path.read_text(encoding="utf-8").count("\n") + 1
+ except Exception:
+ return f"{path.stat().st_size} bytes"
+ return f"{line_count} lines"
+
+
+def _code_fence_language(path: Path) -> str:
+ mapping = {
+ ".py": "python",
+ ".js": "javascript",
+ ".ts": "typescript",
+ ".tsx": "tsx",
+ ".jsx": "jsx",
+ ".json": "json",
+ ".md": "markdown",
+ ".sh": "bash",
+ ".yml": "yaml",
+ ".yaml": "yaml",
+ ".toml": "toml",
+ }
+ return mapping.get(path.suffix.lower(), "")
diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py
new file mode 100644
index 00000000000..a673e059c34
--- /dev/null
+++ b/agent/copilot_acp_client.py
@@ -0,0 +1,447 @@
+"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`.
+
+This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style
+backend. Each request starts a short-lived ACP session, sends the formatted
+conversation as a single prompt, collects text chunks, and converts the result
+back into the minimal shape Hermes expects from an OpenAI client.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import queue
+import shlex
+import subprocess
+import threading
+import time
+from collections import deque
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+ACP_MARKER_BASE_URL = "acp://copilot"
+_DEFAULT_TIMEOUT_SECONDS = 900.0
+
+
+def _resolve_command() -> str:
+ return (
+ os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
+ or os.getenv("COPILOT_CLI_PATH", "").strip()
+ or "copilot"
+ )
+
+
+def _resolve_args() -> list[str]:
+ raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
+ if not raw:
+ return ["--acp", "--stdio"]
+ return shlex.split(raw)
+
+
+def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
+ return {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "error": {
+ "code": code,
+ "message": message,
+ },
+ }
+
+
+def _format_messages_as_prompt(messages: list[dict[str, Any]], model: str | None = None) -> str:
+ sections: list[str] = [
+ "You are being used as the active ACP agent backend for Hermes.",
+ "Use your own ACP capabilities and respond directly in natural language.",
+ "Do not emit OpenAI tool-call JSON.",
+ ]
+ if model:
+ sections.append(f"Hermes requested model hint: {model}")
+
+ transcript: list[str] = []
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ role = str(message.get("role") or "unknown").strip().lower()
+ if role == "tool":
+ role = "tool"
+ elif role not in {"system", "user", "assistant"}:
+ role = "context"
+
+ content = message.get("content")
+ rendered = _render_message_content(content)
+ if not rendered:
+ continue
+
+ label = {
+ "system": "System",
+ "user": "User",
+ "assistant": "Assistant",
+ "tool": "Tool",
+ "context": "Context",
+ }.get(role, role.title())
+ transcript.append(f"{label}:\n{rendered}")
+
+ if transcript:
+ sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript))
+
+ sections.append("Continue the conversation from the latest user request.")
+ return "\n\n".join(section.strip() for section in sections if section and section.strip())
+
+
+def _render_message_content(content: Any) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content.strip()
+ if isinstance(content, dict):
+ if "text" in content:
+ return str(content.get("text") or "").strip()
+ if "content" in content and isinstance(content.get("content"), str):
+ return str(content.get("content") or "").strip()
+ return json.dumps(content, ensure_ascii=True)
+ if isinstance(content, list):
+ parts: list[str] = []
+ for item in content:
+ if isinstance(item, str):
+ parts.append(item)
+ elif isinstance(item, dict):
+ text = item.get("text")
+ if isinstance(text, str) and text.strip():
+ parts.append(text.strip())
+ return "\n".join(parts).strip()
+ return str(content).strip()
+
+
+def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path:
+ candidate = Path(path_text)
+ if not candidate.is_absolute():
+ raise PermissionError("ACP file-system paths must be absolute.")
+ resolved = candidate.resolve()
+ root = Path(cwd).resolve()
+ try:
+ resolved.relative_to(root)
+ except ValueError as exc:
+ raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc
+ return resolved
+
+
+class _ACPChatCompletions:
+ def __init__(self, client: "CopilotACPClient"):
+ self._client = client
+
+ def create(self, **kwargs: Any) -> Any:
+ return self._client._create_chat_completion(**kwargs)
+
+
+class _ACPChatNamespace:
+ def __init__(self, client: "CopilotACPClient"):
+ self.completions = _ACPChatCompletions(client)
+
+
+class CopilotACPClient:
+ """Minimal OpenAI-client-compatible facade for Copilot ACP."""
+
+ def __init__(
+ self,
+ *,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ default_headers: dict[str, str] | None = None,
+ acp_command: str | None = None,
+ acp_args: list[str] | None = None,
+ acp_cwd: str | None = None,
+ command: str | None = None,
+ args: list[str] | None = None,
+ **_: Any,
+ ):
+ self.api_key = api_key or "copilot-acp"
+ self.base_url = base_url or ACP_MARKER_BASE_URL
+ self._default_headers = dict(default_headers or {})
+ self._acp_command = acp_command or command or _resolve_command()
+ self._acp_args = list(acp_args or args or _resolve_args())
+ self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve())
+ self.chat = _ACPChatNamespace(self)
+ self.is_closed = False
+ self._active_process: subprocess.Popen[str] | None = None
+ self._active_process_lock = threading.Lock()
+
+ def close(self) -> None:
+ proc: subprocess.Popen[str] | None
+ with self._active_process_lock:
+ proc = self._active_process
+ self._active_process = None
+ self.is_closed = True
+ if proc is None:
+ return
+ try:
+ proc.terminate()
+ proc.wait(timeout=2)
+ except Exception:
+ try:
+ proc.kill()
+ except Exception:
+ pass
+
+ def _create_chat_completion(
+ self,
+ *,
+ model: str | None = None,
+ messages: list[dict[str, Any]] | None = None,
+ timeout: float | None = None,
+ **_: Any,
+ ) -> Any:
+ prompt_text = _format_messages_as_prompt(messages or [], model=model)
+ response_text, reasoning_text = self._run_prompt(
+ prompt_text,
+ timeout_seconds=float(timeout or _DEFAULT_TIMEOUT_SECONDS),
+ )
+
+ usage = SimpleNamespace(
+ prompt_tokens=0,
+ completion_tokens=0,
+ total_tokens=0,
+ prompt_tokens_details=SimpleNamespace(cached_tokens=0),
+ )
+ assistant_message = SimpleNamespace(
+ content=response_text,
+ tool_calls=[],
+ reasoning=reasoning_text or None,
+ reasoning_content=reasoning_text or None,
+ reasoning_details=None,
+ )
+ choice = SimpleNamespace(message=assistant_message, finish_reason="stop")
+ return SimpleNamespace(
+ choices=[choice],
+ usage=usage,
+ model=model or "copilot-acp",
+ )
+
+ def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
+ try:
+ proc = subprocess.Popen(
+ [self._acp_command] + self._acp_args,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ cwd=self._acp_cwd,
+ )
+ except FileNotFoundError as exc:
+ raise RuntimeError(
+ f"Could not start Copilot ACP command '{self._acp_command}'. "
+ "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
+ ) from exc
+
+ if proc.stdin is None or proc.stdout is None:
+ proc.kill()
+ raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.")
+
+ self.is_closed = False
+ with self._active_process_lock:
+ self._active_process = proc
+
+ inbox: queue.Queue[dict[str, Any]] = queue.Queue()
+ stderr_tail: deque[str] = deque(maxlen=40)
+
+ def _stdout_reader() -> None:
+ for line in proc.stdout:
+ try:
+ inbox.put(json.loads(line))
+ except Exception:
+ inbox.put({"raw": line.rstrip("\n")})
+
+ def _stderr_reader() -> None:
+ if proc.stderr is None:
+ return
+ for line in proc.stderr:
+ stderr_tail.append(line.rstrip("\n"))
+
+ out_thread = threading.Thread(target=_stdout_reader, daemon=True)
+ err_thread = threading.Thread(target=_stderr_reader, daemon=True)
+ out_thread.start()
+ err_thread.start()
+
+ next_id = 0
+
+ def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any:
+ nonlocal next_id
+ next_id += 1
+ request_id = next_id
+ payload = {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "method": method,
+ "params": params,
+ }
+ proc.stdin.write(json.dumps(payload) + "\n")
+ proc.stdin.flush()
+
+ deadline = time.time() + timeout_seconds
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ break
+ try:
+ msg = inbox.get(timeout=0.1)
+ except queue.Empty:
+ continue
+
+ if self._handle_server_message(
+ msg,
+ process=proc,
+ cwd=self._acp_cwd,
+ text_parts=text_parts,
+ reasoning_parts=reasoning_parts,
+ ):
+ continue
+
+ if msg.get("id") != request_id:
+ continue
+ if "error" in msg:
+ err = msg.get("error") or {}
+ raise RuntimeError(
+ f"Copilot ACP {method} failed: {err.get('message') or err}"
+ )
+ return msg.get("result")
+
+ stderr_text = "\n".join(stderr_tail).strip()
+ if proc.poll() is not None and stderr_text:
+ raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}")
+ raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.")
+
+ try:
+ _request(
+ "initialize",
+ {
+ "protocolVersion": 1,
+ "clientCapabilities": {
+ "fs": {
+ "readTextFile": True,
+ "writeTextFile": True,
+ }
+ },
+ "clientInfo": {
+ "name": "hermes-agent",
+ "title": "Hermes Agent",
+ "version": "0.0.0",
+ },
+ },
+ )
+ session = _request(
+ "session/new",
+ {
+ "cwd": self._acp_cwd,
+ "mcpServers": [],
+ },
+ ) or {}
+ session_id = str(session.get("sessionId") or "").strip()
+ if not session_id:
+ raise RuntimeError("Copilot ACP did not return a sessionId.")
+
+ text_parts: list[str] = []
+ reasoning_parts: list[str] = []
+ _request(
+ "session/prompt",
+ {
+ "sessionId": session_id,
+ "prompt": [
+ {
+ "type": "text",
+ "text": prompt_text,
+ }
+ ],
+ },
+ text_parts=text_parts,
+ reasoning_parts=reasoning_parts,
+ )
+ return "".join(text_parts), "".join(reasoning_parts)
+ finally:
+ self.close()
+
+ def _handle_server_message(
+ self,
+ msg: dict[str, Any],
+ *,
+ process: subprocess.Popen[str],
+ cwd: str,
+ text_parts: list[str] | None,
+ reasoning_parts: list[str] | None,
+ ) -> bool:
+ method = msg.get("method")
+ if not isinstance(method, str):
+ return False
+
+ if method == "session/update":
+ params = msg.get("params") or {}
+ update = params.get("update") or {}
+ kind = str(update.get("sessionUpdate") or "").strip()
+ content = update.get("content") or {}
+ chunk_text = ""
+ if isinstance(content, dict):
+ chunk_text = str(content.get("text") or "")
+ if kind == "agent_message_chunk" and chunk_text and text_parts is not None:
+ text_parts.append(chunk_text)
+ elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None:
+ reasoning_parts.append(chunk_text)
+ return True
+
+ if process.stdin is None:
+ return True
+
+ message_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ if method == "session/request_permission":
+ response = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": {
+ "outcome": {
+ "outcome": "allow_once",
+ }
+ },
+ }
+ elif method == "fs/read_text_file":
+ try:
+ path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
+ content = path.read_text() if path.exists() else ""
+ line = params.get("line")
+ limit = params.get("limit")
+ if isinstance(line, int) and line > 1:
+ lines = content.splitlines(keepends=True)
+ start = line - 1
+ end = start + limit if isinstance(limit, int) and limit > 0 else None
+ content = "".join(lines[start:end])
+ response = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": {
+ "content": content,
+ },
+ }
+ except Exception as exc:
+ response = _jsonrpc_error(message_id, -32602, str(exc))
+ elif method == "fs/write_text_file":
+ try:
+ path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(str(params.get("content") or ""))
+ response = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": None,
+ }
+ except Exception as exc:
+ response = _jsonrpc_error(message_id, -32602, str(exc))
+ else:
+ response = _jsonrpc_error(
+ message_id,
+ -32601,
+ f"ACP client method '{method}' is not supported by Hermes yet.",
+ )
+
+ process.stdin.write(json.dumps(response) + "\n")
+ process.stdin.flush()
+ return True
diff --git a/agent/display.py b/agent/display.py
index 6b8b88b58f4..22b918e1b82 100644
--- a/agent/display.py
+++ b/agent/display.py
@@ -59,6 +59,32 @@ def get_skin_tool_prefix() -> str:
return "โ"
+def get_tool_emoji(tool_name: str, default: str = "โก") -> str:
+ """Get the display emoji for a tool.
+
+ Resolution order:
+ 1. Active skin's ``tool_emojis`` overrides (if a skin is loaded)
+ 2. Tool registry's per-tool ``emoji`` field
+ 3. *default* fallback
+ """
+ # 1. Skin override
+ skin = _get_skin()
+ if skin and skin.tool_emojis:
+ override = skin.tool_emojis.get(tool_name)
+ if override:
+ return override
+ # 2. Registry default
+ try:
+ from tools.registry import registry
+ emoji = registry.get_emoji(tool_name, default="")
+ if emoji:
+ return emoji
+ except Exception:
+ pass
+ # 3. Hardcoded fallback
+ return default
+
+
# =========================================================================
# Tool preview (one-line summary of a tool call's primary argument)
# =========================================================================
@@ -68,7 +94,7 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
-def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
+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
@@ -80,7 +106,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
"image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"skill_view": "name", "skills_list": "category",
- "schedule_cronjob": "name",
+ "cronjob": "action",
"execute_code": "code", "delegate_task": "goal",
"clarify": "question", "skill_manage": "name",
}
@@ -205,7 +231,7 @@ class KawaiiSpinner:
"analyzing", "computing", "synthesizing", "formulating", "brainstorming",
]
- def __init__(self, message: str = "", spinner_type: str = 'dots'):
+ def __init__(self, message: str = "", spinner_type: str = 'dots', print_fn=None):
self.message = message
self.spinner_frames = self.SPINNERS.get(spinner_type, self.SPINNERS['dots'])
self.running = False
@@ -213,13 +239,26 @@ def __init__(self, message: str = "", spinner_type: str = 'dots'):
self.frame_idx = 0
self.start_time = None
self.last_line_len = 0
- self._last_flush_time = 0.0 # Rate-limit flushes for patch_stdout compat
+ # Optional callable to route all output through (e.g. a no-op for silent
+ # background agents). When set, bypasses self._out entirely so that
+ # agents with _print_fn overridden remain fully silent.
+ self._print_fn = print_fn
# Capture stdout NOW, before any redirect_stdout(devnull) from
# child agents can replace sys.stdout with a black hole.
self._out = sys.stdout
def _write(self, text: str, end: str = '\n', flush: bool = False):
- """Write to the stdout captured at spinner creation time."""
+ """Write to the stdout captured at spinner creation time.
+
+ If a print_fn was supplied at construction, all output is routed through
+ it instead โ allowing callers to silence the spinner with a no-op lambda.
+ """
+ if self._print_fn is not None:
+ try:
+ self._print_fn(text)
+ except Exception:
+ pass
+ return
try:
self._out.write(text + end)
if flush:
@@ -227,7 +266,50 @@ def _write(self, text: str, end: str = '\n', flush: bool = False):
except (ValueError, OSError):
pass
+ @property
+ def _is_tty(self) -> bool:
+ """Check if output is a real terminal, safe against closed streams."""
+ try:
+ return hasattr(self._out, 'isatty') and self._out.isatty()
+ except (ValueError, OSError):
+ return False
+
+ def _is_patch_stdout_proxy(self) -> bool:
+ """Return True when stdout is prompt_toolkit's StdoutProxy.
+
+ patch_stdout wraps sys.stdout in a StdoutProxy that queues writes and
+ injects newlines around each flush(). The \\r overwrite never lands on
+ the correct line โ each spinner frame ends up on its own line.
+
+ The CLI already drives a TUI widget (_spinner_text) for spinner display,
+ so KawaiiSpinner's \\r-based animation is redundant under StdoutProxy.
+ """
+ out = self._out
+ # StdoutProxy has a 'raw' attribute (bool) that plain file objects lack.
+ if hasattr(out, 'raw') and type(out).__name__ == 'StdoutProxy':
+ return True
+ return False
+
def _animate(self):
+ # When stdout is not a real terminal (e.g. Docker, systemd, pipe),
+ # skip the animation entirely โ it creates massive log bloat.
+ # Just log the start once and let stop() log the completion.
+ if not self._is_tty:
+ self._write(f" [tool] {self.message}", flush=True)
+ while self.running:
+ time.sleep(0.5)
+ return
+
+ # When running inside prompt_toolkit's patch_stdout context the CLI
+ # renders spinner state via a dedicated TUI widget (_spinner_text).
+ # Driving a \r-based animation here too causes visual overdraw: the
+ # StdoutProxy injects newlines around each flush, so every frame lands
+ # on a new line and overwrites the status bar.
+ if self._is_patch_stdout_proxy():
+ while self.running:
+ time.sleep(0.1)
+ return
+
# Cache skin wings at start (avoid per-frame imports)
skin = _get_skin()
wings = skin.get_spinner_wings() if skin else []
@@ -244,18 +326,7 @@ def _animate(self):
else:
line = f" {frame} {self.message} ({elapsed:.1f}s)"
pad = max(self.last_line_len - len(line), 0)
- # Rate-limit flush() calls to avoid spinner spam under
- # prompt_toolkit's patch_stdout. Each flush() pushes a queue
- # item that may trigger a separate run_in_terminal() call; if
- # items are processed one-at-a-time the \r overwrite is lost
- # and every frame appears on its own line. By flushing at
- # most every 0.4s we guarantee multiple \r-frames are batched
- # into a single write, so the terminal collapses them correctly.
- now = time.time()
- should_flush = (now - self._last_flush_time) >= 0.4
- self._write(f"\r{line}{' ' * pad}", end='', flush=should_flush)
- if should_flush:
- self._last_flush_time = now
+ self._write(f"\r{line}{' ' * pad}", end='', flush=True)
self.last_line_len = len(line)
self.frame_idx += 1
time.sleep(0.12)
@@ -293,12 +364,19 @@ def stop(self, final_message: str = None):
self.running = False
if self.thread:
self.thread.join(timeout=0.5)
- # Clear the spinner line with spaces instead of \033[K to avoid
- # garbled escape codes when prompt_toolkit's patch_stdout is active.
- blanks = ' ' * max(self.last_line_len + 5, 40)
- self._write(f"\r{blanks}\r", end='', flush=True)
+
+ is_tty = self._is_tty
+ if is_tty:
+ # Clear the spinner line with spaces instead of \033[K to avoid
+ # garbled escape codes when prompt_toolkit's patch_stdout is active.
+ blanks = ' ' * max(self.last_line_len + 5, 40)
+ self._write(f"\r{blanks}\r", end='', flush=True)
if final_message:
- self._write(f" {final_message}", flush=True)
+ elapsed = f" ({time.time() - self.start_time:.1f}s)" if self.start_time else ""
+ if is_tty:
+ self._write(f" {final_message}", flush=True)
+ else:
+ self._write(f" [done] {final_message}{elapsed}", flush=True)
def __enter__(self):
self.start()
@@ -513,12 +591,15 @@ def _wrap(line: str) -> str:
return _wrap(f"โ ๐ง reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"โ ๐จ send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
- if tool_name == "schedule_cronjob":
- return _wrap(f"โ โฐ schedule {_trunc(args.get('name', args.get('prompt', 'task')), 30)} {dur}")
- if tool_name == "list_cronjobs":
- return _wrap(f"โ โฐ jobs listing {dur}")
- if tool_name == "remove_cronjob":
- return _wrap(f"โ โฐ remove job {args.get('job_id', '?')} {dur}")
+ if tool_name == "cronjob":
+ action = args.get("action", "?")
+ if action == "create":
+ skills = args.get("skills") or ([] if not args.get("skill") else [args.get("skill")])
+ label = args.get("name") or (skills[0] if skills else None) or args.get("prompt", "task")
+ return _wrap(f"โ โฐ cron create {_trunc(label, 24)} {dur}")
+ if action == "list":
+ return _wrap(f"โ โฐ cron listing {dur}")
+ return _wrap(f"โ โฐ cron {action} {args.get('job_id', '')} {dur}")
if tool_name.startswith("rl_"):
rl = {
"rl_list_environments": "list envs", "rl_select_environment": f"select {args.get('name', '')}",
@@ -540,3 +621,124 @@ def _wrap(line: str) -> str:
preview = build_tool_preview(tool_name, args) or ""
return _wrap(f"โ โก {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
+
+
+# =========================================================================
+# Honcho session line (one-liner with clickable OSC 8 hyperlink)
+# =========================================================================
+
+_DIM = "\033[2m"
+_SKY_BLUE = "\033[38;5;117m"
+_ANSI_RESET = "\033[0m"
+
+
+def honcho_session_url(workspace: str, session_name: str) -> str:
+ """Build a Honcho app URL for a session."""
+ from urllib.parse import quote
+ return (
+ f"https://app.honcho.dev/explore"
+ f"?workspace={quote(workspace, safe='')}"
+ f"&view=sessions"
+ f"&session={quote(session_name, safe='')}"
+ )
+
+
+def _osc8_link(url: str, text: str) -> str:
+ """OSC 8 terminal hyperlink (clickable in iTerm2, Ghostty, WezTerm, etc.)."""
+ return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
+
+
+def honcho_session_line(workspace: str, session_name: str) -> str:
+ """One-line session indicator: `Honcho session: `."""
+ url = honcho_session_url(workspace, session_name)
+ linked_name = _osc8_link(url, f"{_SKY_BLUE}{session_name}{_ANSI_RESET}")
+ return f"{_DIM}Honcho session:{_ANSI_RESET} {linked_name}"
+
+
+def write_tty(text: str) -> None:
+ """Write directly to /dev/tty, bypassing stdout capture."""
+ try:
+ fd = os.open("/dev/tty", os.O_WRONLY)
+ os.write(fd, text.encode("utf-8"))
+ os.close(fd)
+ except OSError:
+ sys.stdout.write(text)
+ sys.stdout.flush()
+
+
+# =========================================================================
+# Context pressure display (CLI user-facing warnings)
+# =========================================================================
+
+# ANSI color codes for context pressure tiers
+_CYAN = "\033[36m"
+_YELLOW = "\033[33m"
+_BOLD = "\033[1m"
+_DIM_ANSI = "\033[2m"
+
+# Bar characters
+_BAR_FILLED = "โฐ"
+_BAR_EMPTY = "โฑ"
+_BAR_WIDTH = 20
+
+
+def format_context_pressure(
+ compaction_progress: float,
+ threshold_tokens: int,
+ threshold_percent: float,
+ compression_enabled: bool = True,
+) -> str:
+ """Build a formatted context pressure line for CLI display.
+
+ The bar and percentage show progress toward the compaction threshold,
+ NOT the raw context window. 100% = compaction fires.
+
+ Args:
+ compaction_progress: How close to compaction (0.0โ1.0, 1.0 = fires).
+ threshold_tokens: Compaction threshold in tokens.
+ threshold_percent: Compaction threshold as a fraction of context window.
+ compression_enabled: Whether auto-compression is active.
+ """
+ pct_int = min(int(compaction_progress * 100), 100)
+ filled = min(int(compaction_progress * _BAR_WIDTH), _BAR_WIDTH)
+ bar = _BAR_FILLED * filled + _BAR_EMPTY * (_BAR_WIDTH - filled)
+
+ threshold_k = f"{threshold_tokens // 1000}k" if threshold_tokens >= 1000 else str(threshold_tokens)
+ threshold_pct_int = int(threshold_percent * 100)
+
+ color = f"{_BOLD}{_YELLOW}"
+ icon = "โ "
+ if compression_enabled:
+ hint = "compaction approaching"
+ else:
+ hint = "no auto-compaction"
+
+ return (
+ f" {color}{icon} context {bar} {pct_int}% to compaction{_ANSI_RESET}"
+ f" {_DIM_ANSI}{threshold_k} threshold ({threshold_pct_int}%) ยท {hint}{_ANSI_RESET}"
+ )
+
+
+def format_context_pressure_gateway(
+ compaction_progress: float,
+ threshold_percent: float,
+ compression_enabled: bool = True,
+) -> str:
+ """Build a plain-text context pressure notification for messaging platforms.
+
+ No ANSI โ just Unicode and plain text suitable for Telegram/Discord/etc.
+ The percentage shows progress toward the compaction threshold.
+ """
+ pct_int = min(int(compaction_progress * 100), 100)
+ filled = min(int(compaction_progress * _BAR_WIDTH), _BAR_WIDTH)
+ bar = _BAR_FILLED * filled + _BAR_EMPTY * (_BAR_WIDTH - filled)
+
+ threshold_pct_int = int(threshold_percent * 100)
+
+ icon = "โ ๏ธ"
+ if compression_enabled:
+ hint = f"Context compaction approaching (threshold: {threshold_pct_int}% of window)."
+ else:
+ hint = "Auto-compaction is disabled โ context may be truncated."
+
+ return f"{icon} Context: {bar} {pct_int}% to compaction\n{hint}"
diff --git a/agent/insights.py b/agent/insights.py
index df3b9e85c84..e6875c40b3f 100644
--- a/agent/insights.py
+++ b/agent/insights.py
@@ -20,65 +20,23 @@
import time
from collections import Counter, defaultdict
from datetime import datetime
-from typing import Any, Dict, List, Optional
-
-# =========================================================================
-# Model pricing (USD per million tokens) โ approximate as of early 2026
-# =========================================================================
-MODEL_PRICING = {
- # OpenAI
- "gpt-4o": {"input": 2.50, "output": 10.00},
- "gpt-4o-mini": {"input": 0.15, "output": 0.60},
- "gpt-4.1": {"input": 2.00, "output": 8.00},
- "gpt-4.1-mini": {"input": 0.40, "output": 1.60},
- "gpt-4.1-nano": {"input": 0.10, "output": 0.40},
- "gpt-4.5-preview": {"input": 75.00, "output": 150.00},
- "gpt-5": {"input": 10.00, "output": 30.00},
- "gpt-5.4": {"input": 10.00, "output": 30.00},
- "o3": {"input": 10.00, "output": 40.00},
- "o3-mini": {"input": 1.10, "output": 4.40},
- "o4-mini": {"input": 1.10, "output": 4.40},
- # Anthropic
- "claude-opus-4-20250514": {"input": 15.00, "output": 75.00},
- "claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00},
- "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
- "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
- "claude-3-opus-20240229": {"input": 15.00, "output": 75.00},
- "claude-3-haiku-20240307": {"input": 0.25, "output": 1.25},
- # DeepSeek
- "deepseek-chat": {"input": 0.14, "output": 0.28},
- "deepseek-reasoner": {"input": 0.55, "output": 2.19},
- # Google
- "gemini-2.5-pro": {"input": 1.25, "output": 10.00},
- "gemini-2.5-flash": {"input": 0.15, "output": 0.60},
- "gemini-2.0-flash": {"input": 0.10, "output": 0.40},
- # Meta (via providers)
- "llama-4-maverick": {"input": 0.50, "output": 0.70},
- "llama-4-scout": {"input": 0.20, "output": 0.30},
- # Z.AI / GLM (direct provider โ pricing not published externally, treat as local)
- "glm-5": {"input": 0.0, "output": 0.0},
- "glm-4.7": {"input": 0.0, "output": 0.0},
- "glm-4.5": {"input": 0.0, "output": 0.0},
- "glm-4.5-flash": {"input": 0.0, "output": 0.0},
- # Kimi / Moonshot (direct provider โ pricing not published externally, treat as local)
- "kimi-k2.5": {"input": 0.0, "output": 0.0},
- "kimi-k2-thinking": {"input": 0.0, "output": 0.0},
- "kimi-k2-turbo-preview": {"input": 0.0, "output": 0.0},
- "kimi-k2-0905-preview": {"input": 0.0, "output": 0.0},
- # MiniMax (direct provider โ pricing not published externally, treat as local)
- "MiniMax-M2.5": {"input": 0.0, "output": 0.0},
- "MiniMax-M2.5-highspeed": {"input": 0.0, "output": 0.0},
- "MiniMax-M2.1": {"input": 0.0, "output": 0.0},
-}
-
-# Fallback: unknown/custom models get zero cost (we can't assume pricing
-# for self-hosted models, custom OAI endpoints, local inference, etc.)
-_DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
-
-
-def _has_known_pricing(model_name: str) -> bool:
+from typing import Any, Dict, List
+
+from agent.usage_pricing import (
+ CanonicalUsage,
+ DEFAULT_PRICING,
+ estimate_usage_cost,
+ format_duration_compact,
+ get_pricing,
+ has_known_pricing,
+)
+
+_DEFAULT_PRICING = DEFAULT_PRICING
+
+
+def _has_known_pricing(model_name: str, provider: str = None, base_url: str = None) -> bool:
"""Check if a model has known pricing (vs unknown/custom endpoint)."""
- return _get_pricing(model_name) is not _DEFAULT_PRICING
+ return has_known_pricing(model_name, provider=provider, base_url=base_url)
def _get_pricing(model_name: str) -> Dict[str, float]:
@@ -87,67 +45,51 @@ def _get_pricing(model_name: str) -> Dict[str, float]:
Returns _DEFAULT_PRICING (zero cost) for unknown/custom models โ
we can't assume costs for self-hosted endpoints, local inference, etc.
"""
- if not model_name:
- return _DEFAULT_PRICING
-
- # Strip provider prefix (e.g., "anthropic/claude-..." -> "claude-...")
- bare = model_name.split("/")[-1].lower()
-
- # Exact match first
- if bare in MODEL_PRICING:
- return MODEL_PRICING[bare]
-
- # Fuzzy prefix match โ prefer the LONGEST matching key to avoid
- # e.g. "gpt-4o" matching before "gpt-4o-mini" for "gpt-4o-mini-2024-07-18"
- best_match = None
- best_len = 0
- for key, price in MODEL_PRICING.items():
- if bare.startswith(key) and len(key) > best_len:
- best_match = price
- best_len = len(key)
- if best_match:
- return best_match
-
- # Keyword heuristics (checked in most-specific-first order)
- if "opus" in bare:
- return {"input": 15.00, "output": 75.00}
- if "sonnet" in bare:
- return {"input": 3.00, "output": 15.00}
- if "haiku" in bare:
- return {"input": 0.80, "output": 4.00}
- if "gpt-4o-mini" in bare:
- return {"input": 0.15, "output": 0.60}
- if "gpt-4o" in bare:
- return {"input": 2.50, "output": 10.00}
- if "gpt-5" in bare:
- return {"input": 10.00, "output": 30.00}
- if "deepseek" in bare:
- return {"input": 0.14, "output": 0.28}
- if "gemini" in bare:
- return {"input": 0.15, "output": 0.60}
-
- return _DEFAULT_PRICING
-
-
-def _estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
- """Estimate the USD cost for a given model and token counts."""
- pricing = _get_pricing(model)
- return (input_tokens * pricing["input"] + output_tokens * pricing["output"]) / 1_000_000
+ return get_pricing(model_name)
+
+
+def _estimate_cost(
+ session_or_model: Dict[str, Any] | str,
+ input_tokens: int = 0,
+ output_tokens: int = 0,
+ *,
+ cache_read_tokens: int = 0,
+ cache_write_tokens: int = 0,
+ provider: str = None,
+ base_url: str = None,
+) -> tuple[float, str]:
+ """Estimate the USD cost for a session row or a model/token tuple."""
+ if isinstance(session_or_model, dict):
+ session = session_or_model
+ model = session.get("model") or ""
+ usage = CanonicalUsage(
+ input_tokens=session.get("input_tokens") or 0,
+ output_tokens=session.get("output_tokens") or 0,
+ cache_read_tokens=session.get("cache_read_tokens") or 0,
+ cache_write_tokens=session.get("cache_write_tokens") or 0,
+ )
+ provider = session.get("billing_provider")
+ base_url = session.get("billing_base_url")
+ else:
+ model = session_or_model or ""
+ usage = CanonicalUsage(
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ cache_read_tokens=cache_read_tokens,
+ cache_write_tokens=cache_write_tokens,
+ )
+ result = estimate_usage_cost(
+ model,
+ usage,
+ provider=provider,
+ base_url=base_url,
+ )
+ return float(result.amount_usd or 0.0), result.status
def _format_duration(seconds: float) -> str:
"""Format seconds into a human-readable duration string."""
- if seconds < 60:
- return f"{seconds:.0f}s"
- minutes = seconds / 60
- if minutes < 60:
- return f"{minutes:.0f}m"
- hours = minutes / 60
- if hours < 24:
- remaining_min = int(minutes % 60)
- return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
- days = hours / 24
- return f"{days:.1f}d"
+ return format_duration_compact(seconds)
def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
@@ -234,24 +176,30 @@ def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:
# Columns we actually need (skip system_prompt, model_config blobs)
_SESSION_COLS = ("id, source, model, started_at, ended_at, "
- "message_count, tool_call_count, input_tokens, output_tokens")
+ "message_count, tool_call_count, input_tokens, output_tokens, "
+ "cache_read_tokens, cache_write_tokens, billing_provider, "
+ "billing_base_url, billing_mode, estimated_cost_usd, "
+ "actual_cost_usd, cost_status, cost_source")
+
+ # Pre-computed query strings โ f-string evaluated once at class definition,
+ # not at runtime, so no user-controlled value can alter the query structure.
+ _GET_SESSIONS_WITH_SOURCE = (
+ f"SELECT {_SESSION_COLS} FROM sessions"
+ " WHERE started_at >= ? AND source = ?"
+ " ORDER BY started_at DESC"
+ )
+ _GET_SESSIONS_ALL = (
+ f"SELECT {_SESSION_COLS} FROM sessions"
+ " WHERE started_at >= ?"
+ " ORDER BY started_at DESC"
+ )
def _get_sessions(self, cutoff: float, source: str = None) -> List[Dict]:
"""Fetch sessions within the time window."""
if source:
- cursor = self._conn.execute(
- f"""SELECT {self._SESSION_COLS} FROM sessions
- WHERE started_at >= ? AND source = ?
- ORDER BY started_at DESC""",
- (cutoff, source),
- )
+ cursor = self._conn.execute(self._GET_SESSIONS_WITH_SOURCE, (cutoff, source))
else:
- cursor = self._conn.execute(
- f"""SELECT {self._SESSION_COLS} FROM sessions
- WHERE started_at >= ?
- ORDER BY started_at DESC""",
- (cutoff,),
- )
+ cursor = self._conn.execute(self._GET_SESSIONS_ALL, (cutoff,))
return [dict(row) for row in cursor.fetchall()]
def _get_tool_usage(self, cutoff: float, source: str = None) -> List[Dict]:
@@ -386,21 +334,30 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
"""Compute high-level overview statistics."""
total_input = sum(s.get("input_tokens") or 0 for s in sessions)
total_output = sum(s.get("output_tokens") or 0 for s in sessions)
- total_tokens = total_input + total_output
+ total_cache_read = sum(s.get("cache_read_tokens") or 0 for s in sessions)
+ total_cache_write = sum(s.get("cache_write_tokens") or 0 for s in sessions)
+ total_tokens = total_input + total_output + total_cache_read + total_cache_write
total_tool_calls = sum(s.get("tool_call_count") or 0 for s in sessions)
total_messages = sum(s.get("message_count") or 0 for s in sessions)
# Cost estimation (weighted by model)
total_cost = 0.0
+ actual_cost = 0.0
models_with_pricing = set()
models_without_pricing = set()
+ unknown_cost_sessions = 0
+ included_cost_sessions = 0
for s in sessions:
model = s.get("model") or ""
- inp = s.get("input_tokens") or 0
- out = s.get("output_tokens") or 0
- total_cost += _estimate_cost(model, inp, out)
+ estimated, status = _estimate_cost(s)
+ total_cost += estimated
+ actual_cost += s.get("actual_cost_usd") or 0.0
display = model.split("/")[-1] if "/" in model else (model or "unknown")
- if _has_known_pricing(model):
+ if status == "included":
+ included_cost_sessions += 1
+ elif status == "unknown":
+ unknown_cost_sessions += 1
+ if _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
models_with_pricing.add(display)
else:
models_without_pricing.add(display)
@@ -427,8 +384,11 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
"total_tool_calls": total_tool_calls,
"total_input_tokens": total_input,
"total_output_tokens": total_output,
+ "total_cache_read_tokens": total_cache_read,
+ "total_cache_write_tokens": total_cache_write,
"total_tokens": total_tokens,
"estimated_cost": total_cost,
+ "actual_cost": actual_cost,
"total_hours": total_hours,
"avg_session_duration": avg_duration,
"avg_messages_per_session": total_messages / len(sessions) if sessions else 0,
@@ -440,12 +400,15 @@ def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
"date_range_end": date_range_end,
"models_with_pricing": sorted(models_with_pricing),
"models_without_pricing": sorted(models_without_pricing),
+ "unknown_cost_sessions": unknown_cost_sessions,
+ "included_cost_sessions": included_cost_sessions,
}
def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
"""Break down usage by model."""
model_data = defaultdict(lambda: {
"sessions": 0, "input_tokens": 0, "output_tokens": 0,
+ "cache_read_tokens": 0, "cache_write_tokens": 0,
"total_tokens": 0, "tool_calls": 0, "cost": 0.0,
})
@@ -457,12 +420,18 @@ def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
d["sessions"] += 1
inp = s.get("input_tokens") or 0
out = s.get("output_tokens") or 0
+ cache_read = s.get("cache_read_tokens") or 0
+ cache_write = s.get("cache_write_tokens") or 0
d["input_tokens"] += inp
d["output_tokens"] += out
- d["total_tokens"] += inp + out
+ d["cache_read_tokens"] += cache_read
+ d["cache_write_tokens"] += cache_write
+ d["total_tokens"] += inp + out + cache_read + cache_write
d["tool_calls"] += s.get("tool_call_count") or 0
- d["cost"] += _estimate_cost(model, inp, out)
- d["has_pricing"] = _has_known_pricing(model)
+ estimate, status = _estimate_cost(s)
+ d["cost"] += estimate
+ d["has_pricing"] = _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
+ d["cost_status"] = status
result = [
{"model": model, **data}
@@ -476,7 +445,8 @@ def _compute_platform_breakdown(self, sessions: List[Dict]) -> List[Dict]:
"""Break down usage by platform/source."""
platform_data = defaultdict(lambda: {
"sessions": 0, "messages": 0, "input_tokens": 0,
- "output_tokens": 0, "total_tokens": 0, "tool_calls": 0,
+ "output_tokens": 0, "cache_read_tokens": 0,
+ "cache_write_tokens": 0, "total_tokens": 0, "tool_calls": 0,
})
for s in sessions:
@@ -486,9 +456,13 @@ def _compute_platform_breakdown(self, sessions: List[Dict]) -> List[Dict]:
d["messages"] += s.get("message_count") or 0
inp = s.get("input_tokens") or 0
out = s.get("output_tokens") or 0
+ cache_read = s.get("cache_read_tokens") or 0
+ cache_write = s.get("cache_write_tokens") or 0
d["input_tokens"] += inp
d["output_tokens"] += out
- d["total_tokens"] += inp + out
+ d["cache_read_tokens"] += cache_read
+ d["cache_write_tokens"] += cache_write
+ d["total_tokens"] += inp + out + cache_read + cache_write
d["tool_calls"] += s.get("tool_call_count") or 0
result = [
@@ -692,7 +666,7 @@ def format_terminal(self, report: Dict) -> str:
cost_cell = " N/A"
lines.append(f" {model_name:<30} {m['sessions']:>8} {m['total_tokens']:>12,} {cost_cell}")
if o.get("models_without_pricing"):
- lines.append(f" * Cost N/A for custom/self-hosted models")
+ lines.append(" * Cost N/A for custom/self-hosted models")
lines.append("")
# Platform breakdown
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index a609ea030ac..162295f81d4 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -10,6 +10,7 @@
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
import requests
import yaml
@@ -18,61 +19,355 @@
logger = logging.getLogger(__name__)
+# Provider names that can appear as a "provider:" prefix before a model ID.
+# Only these are stripped โ Ollama-style "model:tag" colons (e.g. "qwen3.5:27b")
+# are preserved so the full model name reaches cache lookups and server queries.
+_PROVIDER_PREFIXES: frozenset[str] = frozenset({
+ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
+ "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
+ "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
+ "custom", "local",
+ # Common aliases
+ "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
+ "github-models", "kimi", "moonshot", "claude", "deep-seek",
+ "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
+})
+
+
+_OLLAMA_TAG_PATTERN = re.compile(
+ r"^(\d+\.?\d*b|latest|stable|q\d|fp?\d|instruct|chat|coder|vision|text)",
+ re.IGNORECASE,
+)
+
+
+def _strip_provider_prefix(model: str) -> str:
+ """Strip a recognised provider prefix from a model string.
+
+ ``"local:my-model"`` โ ``"my-model"``
+ ``"qwen3.5:27b"`` โ ``"qwen3.5:27b"`` (unchanged โ not a provider prefix)
+ ``"qwen:0.5b"`` โ ``"qwen:0.5b"`` (unchanged โ Ollama model:tag)
+ ``"deepseek:latest"``โ ``"deepseek:latest"``(unchanged โ Ollama model:tag)
+ """
+ if ":" not in model or model.startswith("http"):
+ return model
+ prefix, suffix = model.split(":", 1)
+ prefix_lower = prefix.strip().lower()
+ if prefix_lower in _PROVIDER_PREFIXES:
+ # Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
+ if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
+ return model
+ return suffix
+ return model
+
_model_metadata_cache: Dict[str, Dict[str, Any]] = {}
_model_metadata_cache_time: float = 0
_MODEL_CACHE_TTL = 3600
+_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
+_endpoint_model_metadata_cache_time: Dict[str, float] = {}
+_ENDPOINT_MODEL_CACHE_TTL = 300
# Descending tiers for context length probing when the model is unknown.
-# We start high and step down on context-length errors until one works.
+# We start at 128K (a safe default for most modern models) and step down
+# on context-length errors until one works.
CONTEXT_PROBE_TIERS = [
- 2_000_000,
- 1_000_000,
- 512_000,
- 200_000,
128_000,
64_000,
32_000,
+ 16_000,
+ 8_000,
]
+# Default context length when no detection method succeeds.
+DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]
+
+# Thin fallback defaults โ only broad model family patterns.
+# These fire only when provider is unknown AND models.dev/OpenRouter/Anthropic
+# all miss. Replaced the previous 80+ entry dict.
+# For provider-specific context lengths, models.dev is the primary source.
DEFAULT_CONTEXT_LENGTHS = {
- "anthropic/claude-opus-4": 200000,
- "anthropic/claude-opus-4.5": 200000,
- "anthropic/claude-opus-4.6": 200000,
- "anthropic/claude-sonnet-4": 200000,
- "anthropic/claude-sonnet-4-20250514": 200000,
- "anthropic/claude-haiku-4.5": 200000,
- # Bare Anthropic model IDs (for native API provider)
- "claude-opus-4-6": 200000,
- "claude-sonnet-4-6": 200000,
- "claude-opus-4-5-20251101": 200000,
- "claude-sonnet-4-5-20250929": 200000,
- "claude-opus-4-1-20250805": 200000,
- "claude-opus-4-20250514": 200000,
- "claude-sonnet-4-20250514": 200000,
- "claude-haiku-4-5-20251001": 200000,
- "openai/gpt-4o": 128000,
- "openai/gpt-4-turbo": 128000,
- "openai/gpt-4o-mini": 128000,
- "google/gemini-2.0-flash": 1048576,
- "google/gemini-2.5-pro": 1048576,
- "meta-llama/llama-3.3-70b-instruct": 131072,
- "deepseek/deepseek-chat-v3": 65536,
- "qwen/qwen-2.5-72b-instruct": 32768,
- "glm-4.7": 202752,
- "glm-5": 202752,
- "glm-4.5": 131072,
- "glm-4.5-flash": 131072,
- "kimi-for-coding": 262144,
- "kimi-k2.5": 262144,
- "kimi-k2-thinking": 262144,
- "kimi-k2-thinking-turbo": 262144,
- "kimi-k2-turbo-preview": 262144,
- "kimi-k2-0905-preview": 131072,
- "MiniMax-M2.5": 204800,
- "MiniMax-M2.5-highspeed": 204800,
- "MiniMax-M2.1": 204800,
+ # Anthropic Claude 4.6 (1M context) โ bare IDs only to avoid
+ # fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
+ # substring of "anthropic/claude-sonnet-4.6").
+ # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
+ "claude-opus-4-6": 1000000,
+ "claude-sonnet-4-6": 1000000,
+ "claude-opus-4.6": 1000000,
+ "claude-sonnet-4.6": 1000000,
+ # Catch-all for older Claude models (must sort after specific entries)
+ "claude": 200000,
+ # OpenAI
+ "gpt-4.1": 1047576,
+ "gpt-5": 128000,
+ "gpt-4": 128000,
+ # Google
+ "gemini": 1048576,
+ # DeepSeek
+ "deepseek": 128000,
+ # Meta
+ "llama": 131072,
+ # Qwen
+ "qwen": 131072,
+ # MiniMax
+ "minimax": 204800,
+ # GLM
+ "glm": 202752,
+ # Kimi
+ "kimi": 262144,
+ # Hugging Face Inference Providers โ model IDs use org/name format
+ "Qwen/Qwen3.5-397B-A17B": 131072,
+ "Qwen/Qwen3.5-35B-A3B": 131072,
+ "deepseek-ai/DeepSeek-V3.2": 65536,
+ "moonshotai/Kimi-K2.5": 262144,
+ "moonshotai/Kimi-K2-Thinking": 262144,
+ "MiniMaxAI/MiniMax-M2.5": 204800,
+ "XiaomiMiMo/MiMo-V2-Flash": 32768,
+ "zai-org/GLM-5": 202752,
}
+_CONTEXT_LENGTH_KEYS = (
+ "context_length",
+ "context_window",
+ "max_context_length",
+ "max_position_embeddings",
+ "max_model_len",
+ "max_input_tokens",
+ "max_sequence_length",
+ "max_seq_len",
+ "n_ctx_train",
+ "n_ctx",
+)
+
+_MAX_COMPLETION_KEYS = (
+ "max_completion_tokens",
+ "max_output_tokens",
+ "max_tokens",
+)
+
+# Local server hostnames / address patterns
+_LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
+
+
+def _normalize_base_url(base_url: str) -> str:
+ return (base_url or "").strip().rstrip("/")
+
+
+def _is_openrouter_base_url(base_url: str) -> bool:
+ return "openrouter.ai" in _normalize_base_url(base_url).lower()
+
+
+def _is_custom_endpoint(base_url: str) -> bool:
+ normalized = _normalize_base_url(base_url)
+ return bool(normalized) and not _is_openrouter_base_url(normalized)
+
+
+_URL_TO_PROVIDER: Dict[str, str] = {
+ "api.openai.com": "openai",
+ "chatgpt.com": "openai",
+ "api.anthropic.com": "anthropic",
+ "api.z.ai": "zai",
+ "api.moonshot.ai": "kimi-coding",
+ "api.kimi.com": "kimi-coding",
+ "api.minimax": "minimax",
+ "dashscope.aliyuncs.com": "alibaba",
+ "dashscope-intl.aliyuncs.com": "alibaba",
+ "openrouter.ai": "openrouter",
+ "inference-api.nousresearch.com": "nous",
+ "api.deepseek.com": "deepseek",
+ "api.githubcopilot.com": "copilot",
+ "models.github.ai": "copilot",
+}
+
+
+def _infer_provider_from_url(base_url: str) -> Optional[str]:
+ """Infer the models.dev provider name from a base URL.
+
+ This allows context length resolution via models.dev for custom endpoints
+ like DashScope (Alibaba), Z.AI, Kimi, etc. without requiring the user to
+ explicitly set the provider name in config.
+ """
+ normalized = _normalize_base_url(base_url)
+ if not normalized:
+ return None
+ parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
+ host = parsed.netloc.lower() or parsed.path.lower()
+ for url_part, provider in _URL_TO_PROVIDER.items():
+ if url_part in host:
+ return provider
+ return None
+
+
+def _is_known_provider_base_url(base_url: str) -> bool:
+ return _infer_provider_from_url(base_url) is not None
+
+
+def is_local_endpoint(base_url: str) -> bool:
+ """Return True if base_url points to a local machine (localhost / RFC-1918 / WSL)."""
+ normalized = _normalize_base_url(base_url)
+ if not normalized:
+ return False
+ url = normalized if "://" in normalized else f"http://{normalized}"
+ try:
+ parsed = urlparse(url)
+ host = parsed.hostname or ""
+ except Exception:
+ return False
+ if host in _LOCAL_HOSTS:
+ return True
+ # RFC-1918 private ranges and link-local
+ import ipaddress
+ try:
+ addr = ipaddress.ip_address(host)
+ return addr.is_private or addr.is_loopback or addr.is_link_local
+ except ValueError:
+ pass
+ # Bare IP that looks like a private range (e.g. 172.26.x.x for WSL)
+ parts = host.split(".")
+ if len(parts) == 4:
+ try:
+ first, second = int(parts[0]), int(parts[1])
+ if first == 10:
+ return True
+ if first == 172 and 16 <= second <= 31:
+ return True
+ if first == 192 and second == 168:
+ return True
+ except ValueError:
+ pass
+ return False
+
+
+def detect_local_server_type(base_url: str) -> Optional[str]:
+ """Detect which local server is running at base_url by probing known endpoints.
+
+ Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
+ """
+ import httpx
+
+ normalized = _normalize_base_url(base_url)
+ server_url = normalized
+ if server_url.endswith("/v1"):
+ server_url = server_url[:-3]
+
+ try:
+ with httpx.Client(timeout=2.0) as client:
+ # LM Studio exposes /api/v1/models โ check first (most specific)
+ try:
+ r = client.get(f"{server_url}/api/v1/models")
+ if r.status_code == 200:
+ return "lm-studio"
+ except Exception:
+ pass
+ # Ollama exposes /api/tags and responds with {"models": [...]}
+ # LM Studio returns {"error": "Unexpected endpoint"} with status 200
+ # on this path, so we must verify the response contains "models".
+ try:
+ r = client.get(f"{server_url}/api/tags")
+ if r.status_code == 200:
+ try:
+ data = r.json()
+ if "models" in data:
+ return "ollama"
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
+ try:
+ r = client.get(f"{server_url}/v1/props")
+ if r.status_code != 200:
+ r = client.get(f"{server_url}/props") # fallback for older builds
+ if r.status_code == 200 and "default_generation_settings" in r.text:
+ return "llamacpp"
+ except Exception:
+ pass
+ # vLLM: /version
+ try:
+ r = client.get(f"{server_url}/version")
+ if r.status_code == 200:
+ data = r.json()
+ if "version" in data:
+ return "vllm"
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ return None
+
+
+def _iter_nested_dicts(value: Any):
+ if isinstance(value, dict):
+ yield value
+ for nested in value.values():
+ yield from _iter_nested_dicts(nested)
+ elif isinstance(value, list):
+ for item in value:
+ yield from _iter_nested_dicts(item)
+
+
+def _coerce_reasonable_int(value: Any, minimum: int = 1024, maximum: int = 10_000_000) -> Optional[int]:
+ try:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, str):
+ value = value.strip().replace(",", "")
+ result = int(value)
+ except (TypeError, ValueError):
+ return None
+ if minimum <= result <= maximum:
+ return result
+ return None
+
+
+def _extract_first_int(payload: Dict[str, Any], keys: tuple[str, ...]) -> Optional[int]:
+ keyset = {key.lower() for key in keys}
+ for mapping in _iter_nested_dicts(payload):
+ for key, value in mapping.items():
+ if str(key).lower() not in keyset:
+ continue
+ coerced = _coerce_reasonable_int(value)
+ if coerced is not None:
+ return coerced
+ return None
+
+
+def _extract_context_length(payload: Dict[str, Any]) -> Optional[int]:
+ return _extract_first_int(payload, _CONTEXT_LENGTH_KEYS)
+
+
+def _extract_max_completion_tokens(payload: Dict[str, Any]) -> Optional[int]:
+ return _extract_first_int(payload, _MAX_COMPLETION_KEYS)
+
+
+def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
+ alias_map = {
+ "prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
+ "completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
+ "request": ("request", "request_cost"),
+ "cache_read": ("cache_read", "cached_prompt", "input_cache_read", "cache_read_cost_per_token"),
+ "cache_write": ("cache_write", "cache_creation", "input_cache_write", "cache_write_cost_per_token"),
+ }
+ for mapping in _iter_nested_dicts(payload):
+ normalized = {str(key).lower(): value for key, value in mapping.items()}
+ if not any(any(alias in normalized for alias in aliases) for aliases in alias_map.values()):
+ continue
+ pricing: Dict[str, Any] = {}
+ for target, aliases in alias_map.items():
+ for alias in aliases:
+ if alias in normalized and normalized[alias] not in (None, ""):
+ pricing[target] = normalized[alias]
+ break
+ if pricing:
+ return pricing
+ return {}
+
+
+def _add_model_aliases(cache: Dict[str, Dict[str, Any]], model_id: str, entry: Dict[str, Any]) -> None:
+ cache[model_id] = entry
+ if "/" in model_id:
+ bare_model = model_id.split("/", 1)[1]
+ cache.setdefault(bare_model, entry)
+
def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
"""Fetch model metadata from OpenRouter (cached for 1 hour)."""
@@ -89,15 +384,16 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
cache = {}
for model in data.get("data", []):
model_id = model.get("id", "")
- cache[model_id] = {
+ entry = {
"context_length": model.get("context_length", 128000),
"max_completion_tokens": model.get("top_provider", {}).get("max_completion_tokens", 4096),
"name": model.get("name", model_id),
"pricing": model.get("pricing", {}),
}
+ _add_model_aliases(cache, model_id, entry)
canonical = model.get("canonical_slug", "")
if canonical and canonical != model_id:
- cache[canonical] = cache[model_id]
+ _add_model_aliases(cache, canonical, entry)
_model_metadata_cache = cache
_model_metadata_cache_time = time.time()
@@ -109,6 +405,97 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return _model_metadata_cache or {}
+def fetch_endpoint_model_metadata(
+ base_url: str,
+ api_key: str = "",
+ force_refresh: bool = False,
+) -> Dict[str, Dict[str, Any]]:
+ """Fetch model metadata from an OpenAI-compatible ``/models`` endpoint.
+
+ This is used for explicit custom endpoints where hardcoded global model-name
+ defaults are unreliable. Results are cached in memory per base URL.
+ """
+ normalized = _normalize_base_url(base_url)
+ if not normalized or _is_openrouter_base_url(normalized):
+ return {}
+
+ if not force_refresh:
+ cached = _endpoint_model_metadata_cache.get(normalized)
+ cached_at = _endpoint_model_metadata_cache_time.get(normalized, 0)
+ if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL:
+ return cached
+
+ candidates = [normalized]
+ if normalized.endswith("/v1"):
+ alternate = normalized[:-3].rstrip("/")
+ else:
+ alternate = normalized + "/v1"
+ if alternate and alternate not in candidates:
+ candidates.append(alternate)
+
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
+ last_error: Optional[Exception] = None
+
+ for candidate in candidates:
+ url = candidate.rstrip("/") + "/models"
+ try:
+ response = requests.get(url, headers=headers, timeout=10)
+ response.raise_for_status()
+ payload = response.json()
+ cache: Dict[str, Dict[str, Any]] = {}
+ for model in payload.get("data", []):
+ if not isinstance(model, dict):
+ continue
+ model_id = model.get("id")
+ if not model_id:
+ continue
+ entry: Dict[str, Any] = {"name": model.get("name", model_id)}
+ context_length = _extract_context_length(model)
+ if context_length is not None:
+ entry["context_length"] = context_length
+ max_completion_tokens = _extract_max_completion_tokens(model)
+ if max_completion_tokens is not None:
+ entry["max_completion_tokens"] = max_completion_tokens
+ pricing = _extract_pricing(model)
+ if pricing:
+ entry["pricing"] = pricing
+ _add_model_aliases(cache, model_id, entry)
+
+ # If this is a llama.cpp server, query /props for actual allocated context
+ is_llamacpp = any(
+ m.get("owned_by") == "llamacpp"
+ for m in payload.get("data", []) if isinstance(m, dict)
+ )
+ if is_llamacpp:
+ try:
+ # Try /v1/props first (current llama.cpp); fall back to /props for older builds
+ base = candidate.rstrip("/").replace("/v1", "")
+ props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5)
+ if not props_resp.ok:
+ props_resp = requests.get(base + "/props", headers=headers, timeout=5)
+ if props_resp.ok:
+ props = props_resp.json()
+ gen_settings = props.get("default_generation_settings", {})
+ n_ctx = gen_settings.get("n_ctx")
+ model_alias = props.get("model_alias", "")
+ if n_ctx and model_alias and model_alias in cache:
+ cache[model_alias]["context_length"] = n_ctx
+ except Exception:
+ pass
+
+ _endpoint_model_metadata_cache[normalized] = cache
+ _endpoint_model_metadata_cache_time[normalized] = time.time()
+ return cache
+ except Exception as exc:
+ last_error = exc
+
+ if last_error:
+ logger.debug("Failed to fetch model metadata from %s/models: %s", normalized, last_error)
+ _endpoint_model_metadata_cache[normalized] = {}
+ _endpoint_model_metadata_cache_time[normalized] = time.time()
+ return {}
+
+
def _get_context_cache_path() -> Path:
"""Return path to the persistent context length cache file."""
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
@@ -116,7 +503,7 @@ def _get_context_cache_path() -> Path:
def _load_context_cache() -> Dict[str, int]:
- """Load the model+provider โ context_length cache from disk."""
+ """Load the model+provider -> context_length cache from disk."""
path = _get_context_cache_path()
if not path.exists():
return {}
@@ -145,7 +532,7 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump({"context_lengths": cache}, f, default_flow_style=False)
- logger.info("Cached context length %s โ %s tokens", key, f"{length:,}")
+ logger.info("Cached context length %s -> %s tokens", key, f"{length:,}")
except Exception as e:
logger.debug("Failed to save context length cache: %s", e)
@@ -193,33 +580,317 @@ def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
return None
-def get_model_context_length(model: str, base_url: str = "") -> int:
+def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
+ """Return True if *candidate_id* (from server) matches *lookup_model* (configured).
+
+ Supports two forms:
+ - Exact match: "nvidia-nemotron-super-49b-v1" == "nvidia-nemotron-super-49b-v1"
+ - Slug match: "nvidia/nvidia-nemotron-super-49b-v1" matches "nvidia-nemotron-super-49b-v1"
+ (the part after the last "/" equals lookup_model)
+
+ This covers LM Studio's native API which stores models as "publisher/slug"
+ while users typically configure only the slug after the "local:" prefix.
+ """
+ if candidate_id == lookup_model:
+ return True
+ # Slug match: basename of candidate equals the lookup name
+ if "/" in candidate_id and candidate_id.rsplit("/", 1)[1] == lookup_model:
+ return True
+ return False
+
+
+def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
+ """Query a local server for the model's context length."""
+ import httpx
+
+ # Strip recognised provider prefix (e.g., "local:model-name" โ "model-name").
+ # Ollama "model:tag" colons (e.g. "qwen3.5:27b") are intentionally preserved.
+ model = _strip_provider_prefix(model)
+
+ # Strip /v1 suffix to get the server root
+ server_url = base_url.rstrip("/")
+ if server_url.endswith("/v1"):
+ server_url = server_url[:-3]
+
+ try:
+ server_type = detect_local_server_type(base_url)
+ except Exception:
+ server_type = None
+
+ try:
+ with httpx.Client(timeout=3.0) as client:
+ # Ollama: /api/show returns model details with context info
+ if server_type == "ollama":
+ resp = client.post(f"{server_url}/api/show", json={"name": model})
+ if resp.status_code == 200:
+ data = resp.json()
+ # Check model_info for context length
+ model_info = data.get("model_info", {})
+ for key, value in model_info.items():
+ if "context_length" in key and isinstance(value, (int, float)):
+ return int(value)
+ # Check parameters string for num_ctx
+ params = data.get("parameters", "")
+ if "num_ctx" in params:
+ for line in params.split("\n"):
+ if "num_ctx" in line:
+ parts = line.strip().split()
+ if len(parts) >= 2:
+ try:
+ return int(parts[-1])
+ except ValueError:
+ pass
+
+ # LM Studio native API: /api/v1/models returns max_context_length.
+ # This is more reliable than the OpenAI-compat /v1/models which
+ # doesn't include context window information for LM Studio servers.
+ # Use _model_id_matches for fuzzy matching: LM Studio stores models as
+ # "publisher/slug" but users configure only "slug" after "local:" prefix.
+ if server_type == "lm-studio":
+ resp = client.get(f"{server_url}/api/v1/models")
+ if resp.status_code == 200:
+ data = resp.json()
+ for m in data.get("models", []):
+ if _model_id_matches(m.get("key", ""), model) or _model_id_matches(m.get("id", ""), model):
+ # Prefer loaded instance context (actual runtime value)
+ for inst in m.get("loaded_instances", []):
+ cfg = inst.get("config", {})
+ ctx = cfg.get("context_length")
+ if ctx and isinstance(ctx, (int, float)):
+ return int(ctx)
+ # Fall back to max_context_length (theoretical model max)
+ ctx = m.get("max_context_length") or m.get("context_length")
+ if ctx and isinstance(ctx, (int, float)):
+ return int(ctx)
+
+ # LM Studio / vLLM / llama.cpp: try /v1/models/{model}
+ resp = client.get(f"{server_url}/v1/models/{model}")
+ if resp.status_code == 200:
+ data = resp.json()
+ # vLLM returns max_model_len
+ ctx = data.get("max_model_len") or data.get("context_length") or data.get("max_tokens")
+ if ctx and isinstance(ctx, (int, float)):
+ return int(ctx)
+
+ # Try /v1/models and find the model in the list.
+ # Use _model_id_matches to handle "publisher/slug" vs bare "slug".
+ resp = client.get(f"{server_url}/v1/models")
+ if resp.status_code == 200:
+ data = resp.json()
+ models_list = data.get("data", [])
+ for m in models_list:
+ if _model_id_matches(m.get("id", ""), model):
+ ctx = m.get("max_model_len") or m.get("context_length") or m.get("max_tokens")
+ if ctx and isinstance(ctx, (int, float)):
+ return int(ctx)
+ except Exception:
+ pass
+
+ return None
+
+
+def _normalize_model_version(model: str) -> str:
+ """Normalize version separators for matching.
+
+ Nous uses dashes: claude-opus-4-6, claude-sonnet-4-5
+ OpenRouter uses dots: claude-opus-4.6, claude-sonnet-4.5
+ Normalize both to dashes for comparison.
+ """
+ return model.replace(".", "-")
+
+
+def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:
+ """Query Anthropic's /v1/models endpoint for context length.
+
+ Only works with regular ANTHROPIC_API_KEY (sk-ant-api*).
+ OAuth tokens (sk-ant-oat*) from Claude Code return 401.
+ """
+ if not api_key or api_key.startswith("sk-ant-oat"):
+ return None # OAuth tokens can't access /v1/models
+ try:
+ base = base_url.rstrip("/")
+ if base.endswith("/v1"):
+ base = base[:-3]
+ url = f"{base}/v1/models?limit=1000"
+ headers = {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ }
+ resp = requests.get(url, headers=headers, timeout=10)
+ if resp.status_code != 200:
+ return None
+ data = resp.json()
+ for m in data.get("data", []):
+ if m.get("id") == model:
+ ctx = m.get("max_input_tokens")
+ if isinstance(ctx, int) and ctx > 0:
+ return ctx
+ except Exception as e:
+ logger.debug("Anthropic /v1/models query failed: %s", e)
+ return None
+
+
+def _resolve_nous_context_length(model: str) -> Optional[int]:
+ """Resolve Nous Portal model context length via OpenRouter metadata.
+
+ Nous model IDs are bare (e.g. 'claude-opus-4-6') while OpenRouter uses
+ prefixed IDs (e.g. 'anthropic/claude-opus-4.6'). Try suffix matching
+ with version normalization (dotโdash).
+ """
+ metadata = fetch_model_metadata() # OpenRouter cache
+ # Exact match first
+ if model in metadata:
+ return metadata[model].get("context_length")
+
+ normalized = _normalize_model_version(model).lower()
+
+ for or_id, entry in metadata.items():
+ bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
+ if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized:
+ return entry.get("context_length")
+
+ # Partial prefix match for cases like gemini-3-flash โ gemini-3-flash-preview
+ # Require match to be at a word boundary (followed by -, :, or end of string)
+ model_lower = model.lower()
+ for or_id, entry in metadata.items():
+ bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
+ for candidate, query in [(bare.lower(), model_lower), (_normalize_model_version(bare).lower(), normalized)]:
+ if candidate.startswith(query) and (
+ len(candidate) == len(query) or candidate[len(query)] in "-:."
+ ):
+ return entry.get("context_length")
+
+ return None
+
+
+def get_model_context_length(
+ model: str,
+ base_url: str = "",
+ api_key: str = "",
+ config_context_length: int | None = None,
+ provider: str = "",
+) -> int:
"""Get the context length for a model.
Resolution order:
+ 0. Explicit config override (model.context_length or custom_providers per-model)
1. Persistent cache (previously discovered via probing)
- 2. OpenRouter API metadata
- 3. Hardcoded DEFAULT_CONTEXT_LENGTHS (fuzzy match)
- 4. First probe tier (2M) โ will be narrowed on first context error
+ 2. Active endpoint metadata (/models for explicit custom endpoints)
+ 3. Local server query (for local endpoints)
+ 4. Anthropic /v1/models API (API-key users only, not OAuth)
+ 5. OpenRouter live API metadata
+ 6. Nous suffix-match via OpenRouter cache
+ 7. models.dev registry lookup (provider-aware)
+ 8. Thin hardcoded defaults (broad family patterns)
+ 9. Default fallback (128K)
"""
+ # 0. Explicit config override โ user knows best
+ if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
+ return config_context_length
+
+ # Normalise provider-prefixed model names (e.g. "local:model-name" โ
+ # "model-name") so cache lookups and server queries use the bare ID that
+ # local servers actually know about. Ollama "model:tag" colons are preserved.
+ model = _strip_provider_prefix(model)
+
# 1. Check persistent cache (model+provider)
if base_url:
cached = get_cached_context_length(model, base_url)
if cached is not None:
return cached
- # 2. OpenRouter API metadata
+ # 2. Active endpoint metadata for truly custom/unknown endpoints.
+ # Known providers (Copilot, OpenAI, Anthropic, etc.) skip this โ their
+ # /models endpoint may report a provider-imposed limit (e.g. Copilot
+ # returns 128k) instead of the model's full context (400k). models.dev
+ # has the correct per-provider values and is checked at step 5+.
+ if _is_custom_endpoint(base_url) and not _is_known_provider_base_url(base_url):
+ endpoint_metadata = fetch_endpoint_model_metadata(base_url, api_key=api_key)
+ matched = endpoint_metadata.get(model)
+ if not matched:
+ # Single-model servers: if only one model is loaded, use it
+ if len(endpoint_metadata) == 1:
+ matched = next(iter(endpoint_metadata.values()))
+ else:
+ # Fuzzy match: substring in either direction
+ for key, entry in endpoint_metadata.items():
+ if model in key or key in model:
+ matched = entry
+ break
+ if matched:
+ context_length = matched.get("context_length")
+ if isinstance(context_length, int):
+ return context_length
+ if not _is_known_provider_base_url(base_url):
+ # 3. Try querying local server directly
+ if is_local_endpoint(base_url):
+ local_ctx = _query_local_context_length(model, base_url)
+ if local_ctx and local_ctx > 0:
+ save_context_length(model, base_url, local_ctx)
+ return local_ctx
+ logger.info(
+ "Could not detect context length for model %r at %s โ "
+ "defaulting to %s tokens (probe-down). Set model.context_length "
+ "in config.yaml to override.",
+ model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
+ )
+ return DEFAULT_FALLBACK_CONTEXT
+
+ # 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
+ if provider == "anthropic" or (
+ base_url and "api.anthropic.com" in base_url
+ ):
+ ctx = _query_anthropic_context_length(model, base_url or "https://api.anthropic.com", api_key)
+ if ctx:
+ return ctx
+
+ # 5. Provider-aware lookups (before generic OpenRouter cache)
+ # These are provider-specific and take priority over the generic OR cache,
+ # since the same model can have different context limits per provider
+ # (e.g. claude-opus-4.6 is 1M on Anthropic but 128K on GitHub Copilot).
+ # If provider is generic (openrouter/custom/empty), try to infer from URL.
+ effective_provider = provider
+ if not effective_provider or effective_provider in ("openrouter", "custom"):
+ if base_url:
+ inferred = _infer_provider_from_url(base_url)
+ if inferred:
+ effective_provider = inferred
+
+ if effective_provider == "nous":
+ ctx = _resolve_nous_context_length(model)
+ if ctx:
+ return ctx
+ if effective_provider:
+ from agent.models_dev import lookup_models_dev_context
+ ctx = lookup_models_dev_context(effective_provider, model)
+ if ctx:
+ return ctx
+
+ # 6. OpenRouter live API metadata (provider-unaware fallback)
metadata = fetch_model_metadata()
if model in metadata:
return metadata[model].get("context_length", 128000)
- # 3. Hardcoded defaults (fuzzy match)
- for default_model, length in DEFAULT_CONTEXT_LENGTHS.items():
- if default_model in model or model in default_model:
+ # 8. Hardcoded defaults (fuzzy match โ longest key first for specificity)
+ # Only check `default_model in model` (is the key a substring of the input).
+ # The reverse (`model in default_model`) causes shorter names like
+ # "claude-sonnet-4" to incorrectly match "claude-sonnet-4-6" and return 1M.
+ model_lower = model.lower()
+ for default_model, length in sorted(
+ DEFAULT_CONTEXT_LENGTHS.items(), key=lambda x: len(x[0]), reverse=True
+ ):
+ if default_model in model_lower:
return length
- # 4. Unknown model โ start at highest probe tier
- return CONTEXT_PROBE_TIERS[0]
+ # 9. Query local server as last resort
+ if base_url and is_local_endpoint(base_url):
+ local_ctx = _query_local_context_length(model, base_url)
+ if local_ctx and local_ctx > 0:
+ save_context_length(model, base_url, local_ctx)
+ return local_ctx
+
+ # 10. Default fallback โ 128K
+ return DEFAULT_FALLBACK_CONTEXT
def estimate_tokens_rough(text: str) -> int:
@@ -233,3 +904,26 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
"""Rough token estimate for a message list (pre-flight only)."""
total_chars = sum(len(str(msg)) for msg in messages)
return total_chars // 4
+
+
+def estimate_request_tokens_rough(
+ messages: List[Dict[str, Any]],
+ *,
+ system_prompt: str = "",
+ tools: Optional[List[Dict[str, Any]]] = None,
+) -> int:
+ """Rough token estimate for a full chat-completions request.
+
+ Includes the major payload buckets Hermes sends to providers:
+ system prompt, conversation messages, and tool schemas. With 50+
+ tools enabled, schemas alone can add 20-30K tokens โ a significant
+ blind spot when only counting messages.
+ """
+ total_chars = 0
+ if system_prompt:
+ total_chars += len(system_prompt)
+ if messages:
+ total_chars += sum(len(str(msg)) for msg in messages)
+ if tools:
+ total_chars += len(str(tools))
+ return total_chars // 4
diff --git a/agent/models_dev.py b/agent/models_dev.py
new file mode 100644
index 00000000000..0ef2b62cde5
--- /dev/null
+++ b/agent/models_dev.py
@@ -0,0 +1,171 @@
+"""Models.dev registry integration for provider-aware context length detection.
+
+Fetches model metadata from https://models.dev/api.json โ a community-maintained
+database of 3800+ models across 100+ providers, including per-provider context
+windows, pricing, and capabilities.
+
+Data is cached in memory (1hr TTL) and on disk (~/.hermes/models_dev_cache.json)
+to avoid cold-start network latency.
+"""
+
+import json
+import logging
+import os
+import time
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+MODELS_DEV_URL = "https://models.dev/api.json"
+_MODELS_DEV_CACHE_TTL = 3600 # 1 hour in-memory
+
+# In-memory cache
+_models_dev_cache: Dict[str, Any] = {}
+_models_dev_cache_time: float = 0
+
+# Provider ID mapping: Hermes provider names โ models.dev provider IDs
+PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
+ "openrouter": "openrouter",
+ "anthropic": "anthropic",
+ "zai": "zai",
+ "kimi-coding": "kimi-for-coding",
+ "minimax": "minimax",
+ "minimax-cn": "minimax-cn",
+ "deepseek": "deepseek",
+ "alibaba": "alibaba",
+ "copilot": "github-copilot",
+ "ai-gateway": "vercel",
+ "opencode-zen": "opencode",
+ "opencode-go": "opencode-go",
+ "kilocode": "kilo",
+}
+
+
+def _get_cache_path() -> Path:
+ """Return path to disk cache file."""
+ env_val = os.environ.get("HERMES_HOME", "")
+ hermes_home = Path(env_val) if env_val else Path.home() / ".hermes"
+ return hermes_home / "models_dev_cache.json"
+
+
+def _load_disk_cache() -> Dict[str, Any]:
+ """Load models.dev data from disk cache."""
+ try:
+ cache_path = _get_cache_path()
+ if cache_path.exists():
+ with open(cache_path, encoding="utf-8") as f:
+ return json.load(f)
+ except Exception as e:
+ logger.debug("Failed to load models.dev disk cache: %s", e)
+ return {}
+
+
+def _save_disk_cache(data: Dict[str, Any]) -> None:
+ """Save models.dev data to disk cache."""
+ try:
+ cache_path = _get_cache_path()
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(cache_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, separators=(",", ":"))
+ except Exception as e:
+ logger.debug("Failed to save models.dev disk cache: %s", e)
+
+
+def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
+ """Fetch models.dev registry. In-memory cache (1hr) + disk fallback.
+
+ Returns the full registry dict keyed by provider ID, or empty dict on failure.
+ """
+ global _models_dev_cache, _models_dev_cache_time
+
+ # Check in-memory cache
+ if (
+ not force_refresh
+ and _models_dev_cache
+ and (time.time() - _models_dev_cache_time) < _MODELS_DEV_CACHE_TTL
+ ):
+ return _models_dev_cache
+
+ # Try network fetch
+ try:
+ response = requests.get(MODELS_DEV_URL, timeout=15)
+ response.raise_for_status()
+ data = response.json()
+ if isinstance(data, dict) and len(data) > 0:
+ _models_dev_cache = data
+ _models_dev_cache_time = time.time()
+ _save_disk_cache(data)
+ logger.debug(
+ "Fetched models.dev registry: %d providers, %d total models",
+ len(data),
+ sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
+ )
+ return data
+ except Exception as e:
+ logger.debug("Failed to fetch models.dev: %s", e)
+
+ # Fall back to disk cache โ use a short TTL (5 min) so we retry
+ # the network fetch soon instead of serving stale data for a full hour.
+ if not _models_dev_cache:
+ _models_dev_cache = _load_disk_cache()
+ if _models_dev_cache:
+ _models_dev_cache_time = time.time() - _MODELS_DEV_CACHE_TTL + 300
+ logger.debug("Loaded models.dev from disk cache (%d providers)", len(_models_dev_cache))
+
+ return _models_dev_cache
+
+
+def lookup_models_dev_context(provider: str, model: str) -> Optional[int]:
+ """Look up context_length for a provider+model combo in models.dev.
+
+ Returns the context window in tokens, or None if not found.
+ Handles case-insensitive matching and filters out context=0 entries.
+ """
+ mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
+ if not mdev_provider_id:
+ return None
+
+ data = fetch_models_dev()
+ provider_data = data.get(mdev_provider_id)
+ if not isinstance(provider_data, dict):
+ return None
+
+ models = provider_data.get("models", {})
+ if not isinstance(models, dict):
+ return None
+
+ # Exact match
+ entry = models.get(model)
+ if entry:
+ ctx = _extract_context(entry)
+ if ctx:
+ return ctx
+
+ # Case-insensitive match
+ model_lower = model.lower()
+ for mid, mdata in models.items():
+ if mid.lower() == model_lower:
+ ctx = _extract_context(mdata)
+ if ctx:
+ return ctx
+
+ return None
+
+
+def _extract_context(entry: Dict[str, Any]) -> Optional[int]:
+ """Extract context_length from a models.dev model entry.
+
+ Returns None for invalid/zero values (some audio/image models have context=0).
+ """
+ if not isinstance(entry, dict):
+ return None
+ limit = entry.get("limit")
+ if not isinstance(limit, dict):
+ return None
+ ctx = limit.get("context")
+ if isinstance(ctx, (int, float)) and ctx > 0:
+ return int(ctx)
+ return None
diff --git a/agent/payments/mpp_adapter.py b/agent/payments/mpp_adapter.py
new file mode 100644
index 00000000000..aebe93b2b62
--- /dev/null
+++ b/agent/payments/mpp_adapter.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Dict, Optional, Protocol
+
+from agent.payments.types import (
+ PaymentChallenge,
+ PaymentCredential,
+ PaymentReceipt,
+ PaymentSessionHandle,
+)
+
+
+class PaymentAdapter(Protocol):
+ adapter_name: str
+
+ def supports_response(self, response: Any) -> bool: ...
+
+ def parse_challenge(self, response: Any, request: Dict[str, Any]) -> PaymentChallenge: ...
+
+ def build_credential(
+ self,
+ challenge: PaymentChallenge,
+ request: Dict[str, Any],
+ session: Optional[PaymentSessionHandle],
+ runtime_config: Dict[str, Any],
+ ) -> PaymentCredential: ...
+
+ def extract_receipt(self, response: Any) -> Optional[PaymentReceipt]: ...
+
+ def update_session(
+ self,
+ challenge: PaymentChallenge,
+ receipt: Optional[PaymentReceipt],
+ prior_session: Optional[PaymentSessionHandle],
+ ) -> Optional[PaymentSessionHandle]: ...
+
+
+@dataclass
+class MPPAdapter:
+ adapter_name: str = "mpp"
+
+ @staticmethod
+ def _response_headers(response: Any) -> Dict[str, Any]:
+ source = getattr(response, "response", None) or response
+ headers = getattr(source, "headers", None)
+ if isinstance(headers, dict):
+ return headers
+ return {}
+
+ @staticmethod
+ def _response_body(response: Any) -> Dict[str, Any]:
+ source = getattr(response, "response", None) or response
+ payload = getattr(source, "json", None)
+ if not callable(payload):
+ return {}
+ try:
+ data = payload()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+ def supports_response(self, response: Any) -> bool:
+ status_code = getattr(response, "status_code", None)
+ if status_code == 402:
+ return True
+ nested_response = getattr(response, "response", None)
+ return getattr(nested_response, "status_code", None) == 402
+
+ def parse_challenge(self, response: Any, request: Dict[str, Any]) -> PaymentChallenge:
+ endpoint = str(request.get("base_url") or "")
+ payment_config = request.get("payment_config") or {}
+ headers = self._response_headers(response)
+ body = self._response_body(response)
+ intent = (
+ headers.get("X-MPP-Intent")
+ or body.get("intent")
+ or payment_config.get("intent")
+ or "session"
+ )
+ method = (
+ headers.get("X-MPP-Method")
+ or body.get("method")
+ or payment_config.get("method")
+ or "unknown"
+ )
+ return PaymentChallenge(
+ adapter=self.adapter_name,
+ intent=str(intent),
+ endpoint=endpoint,
+ method=str(method),
+ raw={"response": response, "headers": headers, "body": body},
+ )
+
+ def build_credential(
+ self,
+ challenge: PaymentChallenge,
+ request: Dict[str, Any],
+ session: Optional[PaymentSessionHandle],
+ runtime_config: Dict[str, Any],
+ ) -> PaymentCredential:
+ payment_config = runtime_config.get("payment_config") or {}
+ headers: Dict[str, str] = {}
+ if session and isinstance(session.state.get("headers"), dict):
+ headers = {
+ str(k): str(v)
+ for k, v in session.state["headers"].items()
+ if isinstance(k, str) and v is not None and str(v).strip()
+ }
+ if headers:
+ return PaymentCredential(headers=headers)
+ credential_factory = runtime_config.get("payment_credential_factory")
+ if callable(credential_factory):
+ headers = credential_factory(
+ challenge=challenge,
+ request=request,
+ session=session,
+ runtime_config=runtime_config,
+ ) or {}
+ elif isinstance(payment_config.get("credential_headers"), dict):
+ headers = {
+ str(k): str(v)
+ for k, v in payment_config["credential_headers"].items()
+ if isinstance(k, str) and v is not None and str(v).strip()
+ }
+ return PaymentCredential(headers=headers)
+
+ def extract_receipt(self, response: Any) -> Optional[PaymentReceipt]:
+ headers = self._response_headers(response)
+ body = self._response_body(response)
+ receipt_id = headers.get("X-MPP-Receipt-Id") or body.get("receipt_id")
+ session_id = headers.get("X-MPP-Session-Id") or body.get("session_id")
+ verified_raw = headers.get("X-MPP-Receipt-Verified")
+ if verified_raw is None:
+ verified_raw = body.get("verified")
+ if receipt_id is None and session_id is None:
+ return None
+ verified = str(verified_raw).strip().lower() in {"1", "true", "yes", "on"}
+ return PaymentReceipt(
+ receipt_id=str(receipt_id) if receipt_id is not None else None,
+ session_id=str(session_id) if session_id is not None else None,
+ raw={"headers": headers, "body": body},
+ verified=verified,
+ )
+
+ def update_session(
+ self,
+ challenge: PaymentChallenge,
+ receipt: Optional[PaymentReceipt],
+ prior_session: Optional[PaymentSessionHandle],
+ ) -> Optional[PaymentSessionHandle]:
+ if challenge.intent != "session":
+ return prior_session
+
+ raw_headers = challenge.raw.get("headers") or {}
+ raw_body = challenge.raw.get("body") or {}
+ session_id = (
+ (receipt.session_id if receipt else None)
+ or raw_headers.get("X-MPP-Session-Id")
+ or raw_body.get("session_id")
+ or (prior_session.session_id if prior_session else None)
+ )
+ state = dict(prior_session.state) if prior_session else {}
+ if receipt and receipt.receipt_id:
+ state["receipt_id"] = receipt.receipt_id
+ if not state.get("headers"):
+ state["headers"] = {}
+ return PaymentSessionHandle(
+ adapter=self.adapter_name,
+ endpoint_key=str(challenge.endpoint),
+ session_id=str(session_id) if session_id is not None else None,
+ method=challenge.method,
+ expires_at=prior_session.expires_at if prior_session else None,
+ state=state,
+ )
+
+
+def build_payment_adapter(name: Optional[str]) -> Optional[PaymentAdapter]:
+ if name == "mpp":
+ return MPPAdapter()
+ return None
+
+
+def build_payment_session_key(runtime: Dict[str, Any], model: str) -> str:
+ provider = str(runtime.get("provider") or "")
+ base_url = str(runtime.get("base_url") or "")
+ payment_config = runtime.get("payment_config") or {}
+ method = str(payment_config.get("method") or "")
+ return f"{provider}|{base_url}|{model}|{method}"
diff --git a/agent/payments/mpp_session.py b/agent/payments/mpp_session.py
new file mode 100644
index 00000000000..7c60a289b47
--- /dev/null
+++ b/agent/payments/mpp_session.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Dict, Optional
+
+from agent.payments.types import PaymentSessionHandle
+
+
+@dataclass
+class PaymentSessionStore:
+ _sessions: Dict[str, PaymentSessionHandle] = field(default_factory=dict)
+
+ def get(self, key: str) -> Optional[PaymentSessionHandle]:
+ return self._sessions.get(key)
+
+ def set(self, key: str, session: PaymentSessionHandle) -> None:
+ self._sessions[key] = session
+
+ def invalidate(self, key: str) -> None:
+ self._sessions.pop(key, None)
+
diff --git a/agent/payments/types.py b/agent/payments/types.py
new file mode 100644
index 00000000000..5025578e02b
--- /dev/null
+++ b/agent/payments/types.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, Optional
+
+
+@dataclass
+class PaymentChallenge:
+ adapter: str
+ intent: str
+ endpoint: str
+ method: str
+ raw: Dict[str, Any]
+ retryable: bool = True
+
+
+@dataclass
+class PaymentCredential:
+ headers: Dict[str, str]
+ body: Optional[Dict[str, Any]] = None
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class PaymentReceipt:
+ receipt_id: Optional[str]
+ session_id: Optional[str]
+ raw: Dict[str, Any]
+ verified: bool = False
+
+
+@dataclass
+class PaymentSessionHandle:
+ adapter: str
+ endpoint_key: str
+ session_id: Optional[str]
+ method: str
+ expires_at: Optional[float]
+ state: Dict[str, Any] = field(default_factory=dict)
+
diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index 3dd0f73a7fc..29e2c22f9ff 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -4,12 +4,27 @@
assemble pieces, then combines them with memory and ephemeral prompts.
"""
+import json
import logging
import os
import re
+import threading
+from collections import OrderedDict
from pathlib import Path
+
+from hermes_constants import get_hermes_home
from typing import Optional
+from agent.skill_utils import (
+ extract_skill_conditions,
+ extract_skill_description,
+ get_disabled_skill_names,
+ iter_skill_index_files,
+ parse_frontmatter,
+ skill_matches_platform,
+)
+from utils import atomic_json_write
+
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -56,6 +71,61 @@ def _scan_context_content(content: str, filename: str) -> str:
return content
+
+def _find_git_root(start: Path) -> Optional[Path]:
+ """Walk *start* and its parents looking for a ``.git`` directory.
+
+ Returns the directory containing ``.git``, or ``None`` if we hit the
+ filesystem root without finding one.
+ """
+ current = start.resolve()
+ for parent in [current, *current.parents]:
+ if (parent / ".git").exists():
+ return parent
+ return None
+
+
+_HERMES_MD_NAMES = (".hermes.md", "HERMES.md")
+
+
+def _find_hermes_md(cwd: Path) -> Optional[Path]:
+ """Discover the nearest ``.hermes.md`` or ``HERMES.md``.
+
+ Search order: *cwd* first, then each parent directory up to (and
+ including) the git repository root. Returns the first match, or
+ ``None`` if nothing is found.
+ """
+ stop_at = _find_git_root(cwd)
+ current = cwd.resolve()
+
+ for directory in [current, *current.parents]:
+ for name in _HERMES_MD_NAMES:
+ candidate = directory / name
+ if candidate.is_file():
+ return candidate
+ # Stop walking at the git root (or filesystem root).
+ if stop_at and directory == stop_at:
+ break
+ return None
+
+
+def _strip_yaml_frontmatter(content: str) -> str:
+ """Remove optional YAML frontmatter (``---`` delimited) from *content*.
+
+ The frontmatter may contain structured config (model overrides, tool
+ settings) that will be handled separately in a future PR. For now we
+ strip it so only the human-readable markdown body is injected into the
+ system prompt.
+ """
+ if content.startswith("---"):
+ end = content.find("\n---", 3)
+ if end != -1:
+ # Skip past the closing --- and any trailing newline
+ body = content[end + 4:].lstrip("\n")
+ return body if body else content
+ return content
+
+
# =========================================================================
# Constants
# =========================================================================
@@ -71,21 +141,32 @@ def _scan_context_content(content: str, filename: str) -> str:
)
MEMORY_GUIDANCE = (
- "You have persistent memory across sessions. Proactively save important things "
- "you learn (user preferences, environment details, useful approaches) and do "
- "(like a diary!) using the memory tool -- don't wait to be asked."
+ "You have persistent memory across sessions. Save durable facts using the memory "
+ "tool: user preferences, environment details, tool quirks, and stable conventions. "
+ "Memory is injected into every turn, so keep it compact and focused on facts that "
+ "will still matter later.\n"
+ "Prioritize what reduces future user steering โ the most valuable memory is one "
+ "that prevents the user from having to correct or remind you again. "
+ "User preferences and recurring corrections matter more than procedural task details.\n"
+ "Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO "
+ "state to memory; use session_search to recall those from past transcripts. "
+ "If you've discovered a new way to do something, solved a problem that could be "
+ "necessary later, save it as a skill with the skill tool."
)
SESSION_SEARCH_GUIDANCE = (
"When the user references something from a past conversation or you suspect "
- "relevant prior context exists, use session_search to recall it before asking "
- "them to repeat themselves."
+ "relevant cross-session context exists, use session_search to recall it before "
+ "asking them to repeat themselves."
)
SKILLS_GUIDANCE = (
"After completing a complex task (5+ tool calls), fixing a tricky error, "
- "or discovering a non-trivial workflow, consider saving the approach as a "
- "skill with skill_manage so you can reuse it next time."
+ "or discovering a non-trivial workflow, save the approach as a "
+ "skill with skill_manage so you can reuse it next time.\n"
+ "When using a skill and finding it outdated, incomplete, or wrong, "
+ "patch it immediately with skill_manage(action='patch') โ don't wait to be asked. "
+ "Skills that aren't maintained become liabilities."
)
PLATFORM_HINTS = {
@@ -139,10 +220,22 @@ def _scan_context_content(content: str, filename: str) -> str:
"is preserved for threading. Do not include greetings or sign-offs unless "
"contextually appropriate."
),
+ "cron": (
+ "You are running as a scheduled cron job. There is no user present โ you "
+ "cannot ask questions, request clarification, or wait for follow-up. Execute "
+ "the task fully and autonomously, making reasonable decisions where needed. "
+ "Your final response is automatically delivered to the job's configured "
+ "destination โ put the primary content directly in your response."
+ ),
"cli": (
"You are a CLI AI Agent. Try not to use markdown but simple text "
"renderable inside a terminal."
),
+ "sms": (
+ "You are communicating via SMS. Keep responses concise and use plain text "
+ "only โ no markdown, no formatting. SMS messages are limited to ~1600 "
+ "characters, so be brief and direct."
+ ),
}
CONTEXT_FILE_MAX_CHARS = 20_000
@@ -151,56 +244,141 @@ def _scan_context_content(content: str, filename: str) -> str:
# =========================================================================
-# Skills index
+# Skills prompt cache
# =========================================================================
-def _read_skill_description(skill_file: Path, max_chars: int = 60) -> str:
- """Read the description from a SKILL.md frontmatter, capped at max_chars."""
+_SKILLS_PROMPT_CACHE_MAX = 8
+_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
+_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
+_SKILLS_SNAPSHOT_VERSION = 1
+
+
+def _skills_prompt_snapshot_path() -> Path:
+ return get_hermes_home() / ".skills_prompt_snapshot.json"
+
+
+def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
+ """Drop the in-process skills prompt cache (and optionally the disk snapshot)."""
+ with _SKILLS_PROMPT_CACHE_LOCK:
+ _SKILLS_PROMPT_CACHE.clear()
+ if clear_snapshot:
+ try:
+ _skills_prompt_snapshot_path().unlink(missing_ok=True)
+ except OSError as e:
+ logger.debug("Could not remove skills prompt snapshot: %s", e)
+
+
+def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
+ """Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
+ manifest: dict[str, list[int]] = {}
+ for filename in ("SKILL.md", "DESCRIPTION.md"):
+ for path in iter_skill_index_files(skills_dir, filename):
+ try:
+ st = path.stat()
+ except OSError:
+ continue
+ manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
+ return manifest
+
+
+def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]:
+ """Load the disk snapshot if it exists and its manifest still matches."""
+ snapshot_path = _skills_prompt_snapshot_path()
+ if not snapshot_path.exists():
+ return None
try:
- raw = skill_file.read_text(encoding="utf-8")[:2000]
- match = re.search(
- r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---",
- raw, re.MULTILINE | re.DOTALL,
- )
- if match:
- desc = match.group(1).strip().strip("'\"")
- if len(desc) > max_chars:
- desc = desc[:max_chars - 3] + "..."
- return desc
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+ if not isinstance(snapshot, dict):
+ return None
+ if snapshot.get("version") != _SKILLS_SNAPSHOT_VERSION:
+ return None
+ if snapshot.get("manifest") != _build_skills_manifest(skills_dir):
+ return None
+ return snapshot
+
+
+def _write_skills_snapshot(
+ skills_dir: Path,
+ manifest: dict[str, list[int]],
+ skill_entries: list[dict],
+ category_descriptions: dict[str, str],
+) -> None:
+ """Persist skill metadata to disk for fast cold-start reuse."""
+ payload = {
+ "version": _SKILLS_SNAPSHOT_VERSION,
+ "manifest": manifest,
+ "skills": skill_entries,
+ "category_descriptions": category_descriptions,
+ }
+ try:
+ atomic_json_write(_skills_prompt_snapshot_path(), payload)
except Exception as e:
- logger.debug("Failed to read skill description from %s: %s", skill_file, e)
- return ""
+ logger.debug("Could not write skills prompt snapshot: %s", e)
+
+
+def _build_snapshot_entry(
+ skill_file: Path,
+ skills_dir: Path,
+ frontmatter: dict,
+ description: str,
+) -> dict:
+ """Build a serialisable metadata dict for one skill."""
+ rel_path = skill_file.relative_to(skills_dir)
+ parts = rel_path.parts
+ if len(parts) >= 2:
+ skill_name = parts[-2]
+ category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
+ else:
+ category = "general"
+ skill_name = skill_file.parent.name
+
+ platforms = frontmatter.get("platforms") or []
+ if isinstance(platforms, str):
+ platforms = [platforms]
+
+ return {
+ "skill_name": skill_name,
+ "category": category,
+ "frontmatter_name": str(frontmatter.get("name", skill_name)),
+ "description": description,
+ "platforms": [str(p).strip() for p in platforms if str(p).strip()],
+ "conditions": extract_skill_conditions(frontmatter),
+ }
-def _skill_is_platform_compatible(skill_file: Path) -> bool:
- """Quick check if a SKILL.md is compatible with the current OS platform.
+# =========================================================================
+# Skills index
+# =========================================================================
- Reads just enough to parse the ``platforms`` frontmatter field.
- Skills without the field (the vast majority) are always compatible.
+def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
+ """Read a SKILL.md once and return platform compatibility, frontmatter, and description.
+
+ Returns (is_compatible, frontmatter, description). On any error, returns
+ (True, {}, "") to err on the side of showing the skill.
"""
try:
- from tools.skills_tool import _parse_frontmatter, skill_matches_platform
raw = skill_file.read_text(encoding="utf-8")[:2000]
- frontmatter, _ = _parse_frontmatter(raw)
- return skill_matches_platform(frontmatter)
- except Exception:
- return True # Err on the side of showing the skill
+ frontmatter, _ = parse_frontmatter(raw)
+
+ if not skill_matches_platform(frontmatter):
+ return False, frontmatter, ""
+
+ return True, frontmatter, extract_skill_description(frontmatter)
+ except Exception as e:
+ logger.debug("Failed to parse skill file %s: %s", skill_file, e)
+ return True, {}, ""
def _read_skill_conditions(skill_file: Path) -> dict:
"""Extract conditional activation fields from SKILL.md frontmatter."""
try:
- from tools.skills_tool import _parse_frontmatter
raw = skill_file.read_text(encoding="utf-8")[:2000]
- frontmatter, _ = _parse_frontmatter(raw)
- hermes = frontmatter.get("metadata", {}).get("hermes", {})
- return {
- "fallback_for_toolsets": hermes.get("fallback_for_toolsets", []),
- "requires_toolsets": hermes.get("requires_toolsets", []),
- "fallback_for_tools": hermes.get("fallback_for_tools", []),
- "requires_tools": hermes.get("requires_tools", []),
- }
- except Exception:
+ frontmatter, _ = parse_frontmatter(raw)
+ return extract_skill_conditions(frontmatter)
+ except Exception as e:
+ logger.debug("Failed to read skill conditions from %s: %s", skill_file, e)
return {}
@@ -241,94 +419,153 @@ def build_skills_system_prompt(
) -> str:
"""Build a compact skill index for the system prompt.
- Scans ~/.hermes/skills/ for SKILL.md files grouped by category.
- Includes per-skill descriptions from frontmatter so the model can
- match skills by meaning, not just name.
- Filters out skills incompatible with the current OS platform.
+ Two-layer cache:
+ 1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
+ 2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
+ mtime/size manifest โ survives process restarts
+
+ Falls back to a full filesystem scan when both layers miss.
"""
- hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+ hermes_home = get_hermes_home()
skills_dir = hermes_home / "skills"
if not skills_dir.exists():
return ""
- # Collect skills with descriptions, grouped by category
- # Each entry: (skill_name, description)
- # Supports sub-categories: skills/mlops/training/axolotl/SKILL.md
- # โ category "mlops/training", skill "axolotl"
+ # โโ Layer 1: in-process LRU cache โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ cache_key = (
+ str(skills_dir.resolve()),
+ tuple(sorted(str(t) for t in (available_tools or set()))),
+ tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
+ )
+ with _SKILLS_PROMPT_CACHE_LOCK:
+ cached = _SKILLS_PROMPT_CACHE.get(cache_key)
+ if cached is not None:
+ _SKILLS_PROMPT_CACHE.move_to_end(cache_key)
+ return cached
+
+ disabled = get_disabled_skill_names()
+
+ # โโ Layer 2: disk snapshot โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ snapshot = _load_skills_snapshot(skills_dir)
+
skills_by_category: dict[str, list[tuple[str, str]]] = {}
- for skill_file in skills_dir.rglob("SKILL.md"):
- # Skip skills incompatible with the current OS platform
- if not _skill_is_platform_compatible(skill_file):
- continue
- # Skip skills whose conditional activation rules exclude them
- conditions = _read_skill_conditions(skill_file)
- if not _skill_should_show(conditions, available_tools, available_toolsets):
- continue
- rel_path = skill_file.relative_to(skills_dir)
- parts = rel_path.parts
- if len(parts) >= 2:
- # Category is everything between skills_dir and the skill folder
- # e.g. parts = ("mlops", "training", "axolotl", "SKILL.md")
- # โ category = "mlops/training", skill_name = "axolotl"
- # e.g. parts = ("github", "github-auth", "SKILL.md")
- # โ category = "github", skill_name = "github-auth"
- skill_name = parts[-2]
- category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
- else:
- category = "general"
- skill_name = skill_file.parent.name
- desc = _read_skill_description(skill_file)
- skills_by_category.setdefault(category, []).append((skill_name, desc))
+ category_descriptions: dict[str, str] = {}
- if not skills_by_category:
- return ""
+ if snapshot is not None:
+ # Fast path: use pre-parsed metadata from disk
+ for entry in snapshot.get("skills", []):
+ if not isinstance(entry, dict):
+ continue
+ skill_name = entry.get("skill_name") or ""
+ category = entry.get("category") or "general"
+ frontmatter_name = entry.get("frontmatter_name") or skill_name
+ platforms = entry.get("platforms") or []
+ if not skill_matches_platform({"platforms": platforms}):
+ continue
+ if frontmatter_name in disabled or skill_name in disabled:
+ continue
+ if not _skill_should_show(
+ entry.get("conditions") or {},
+ available_tools,
+ available_toolsets,
+ ):
+ continue
+ skills_by_category.setdefault(category, []).append(
+ (skill_name, entry.get("description", ""))
+ )
+ category_descriptions = {
+ str(k): str(v)
+ for k, v in (snapshot.get("category_descriptions") or {}).items()
+ }
+ else:
+ # Cold path: full filesystem scan + write snapshot for next time
+ skill_entries: list[dict] = []
+ for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
+ is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
+ entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
+ skill_entries.append(entry)
+ if not is_compatible:
+ continue
+ skill_name = entry["skill_name"]
+ if entry["frontmatter_name"] in disabled or skill_name in disabled:
+ continue
+ if not _skill_should_show(
+ extract_skill_conditions(frontmatter),
+ available_tools,
+ available_toolsets,
+ ):
+ continue
+ skills_by_category.setdefault(entry["category"], []).append(
+ (skill_name, entry["description"])
+ )
- # Read category-level descriptions from DESCRIPTION.md
- # Checks both the exact category path and parent directories
- category_descriptions = {}
- for category in skills_by_category:
- cat_path = Path(category)
- desc_file = skills_dir / cat_path / "DESCRIPTION.md"
- if desc_file.exists():
+ # Read category-level DESCRIPTION.md files
+ for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
try:
content = desc_file.read_text(encoding="utf-8")
- match = re.search(r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---", content, re.MULTILINE | re.DOTALL)
- if match:
- category_descriptions[category] = match.group(1).strip()
+ fm, _ = parse_frontmatter(content)
+ cat_desc = fm.get("description")
+ if not cat_desc:
+ continue
+ rel = desc_file.relative_to(skills_dir)
+ cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general"
+ category_descriptions[cat] = str(cat_desc).strip().strip("'\"")
except Exception as e:
logger.debug("Could not read skill description %s: %s", desc_file, e)
- index_lines = []
- for category in sorted(skills_by_category.keys()):
- cat_desc = category_descriptions.get(category, "")
- if cat_desc:
- index_lines.append(f" {category}: {cat_desc}")
- else:
- index_lines.append(f" {category}:")
- # Deduplicate and sort skills within each category
- seen = set()
- for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
- if name in seen:
- continue
- seen.add(name)
- if desc:
- index_lines.append(f" - {name}: {desc}")
+ _write_skills_snapshot(
+ skills_dir,
+ _build_skills_manifest(skills_dir),
+ skill_entries,
+ category_descriptions,
+ )
+
+ if not skills_by_category:
+ result = ""
+ else:
+ index_lines = []
+ for category in sorted(skills_by_category.keys()):
+ cat_desc = category_descriptions.get(category, "")
+ if cat_desc:
+ index_lines.append(f" {category}: {cat_desc}")
else:
- index_lines.append(f" - {name}")
-
- return (
- "## Skills (mandatory)\n"
- "Before replying, scan the skills below. If one clearly matches your task, "
- "load it with skill_view(name) and follow its instructions. "
- "If a skill has issues, fix it with skill_manage(action='patch').\n"
- "\n"
- "\n"
- + "\n".join(index_lines) + "\n"
- " \n"
- "\n"
- "If none match, proceed normally without loading a skill."
- )
+ index_lines.append(f" {category}:")
+ # Deduplicate and sort skills within each category
+ seen = set()
+ for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
+ if name in seen:
+ continue
+ seen.add(name)
+ if desc:
+ index_lines.append(f" - {name}: {desc}")
+ else:
+ index_lines.append(f" - {name}")
+
+ result = (
+ "## Skills (mandatory)\n"
+ "Before replying, scan the skills below. If one clearly matches your task, "
+ "load it with skill_view(name) and follow its instructions. "
+ "If a skill has issues, fix it with skill_manage(action='patch').\n"
+ "After difficult/iterative tasks, offer to save as a skill. "
+ "If a skill you loaded was missing steps, had wrong commands, or needed "
+ "pitfalls you discovered, update it before finishing.\n"
+ "\n"
+ "\n"
+ + "\n".join(index_lines) + "\n"
+ " \n"
+ "\n"
+ "If none match, proceed normally without loading a skill."
+ )
+
+ # โโ Store in LRU cache โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ with _SKILLS_PROMPT_CACHE_LOCK:
+ _SKILLS_PROMPT_CACHE[cache_key] = result
+ _SKILLS_PROMPT_CACHE.move_to_end(cache_key)
+ while len(_SKILLS_PROMPT_CACHE) > _SKILLS_PROMPT_CACHE_MAX:
+ _SKILLS_PROMPT_CACHE.popitem(last=False)
+
+ return result
# =========================================================================
@@ -347,51 +584,91 @@ def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE
return head + marker + tail
-def build_context_files_prompt(cwd: Optional[str] = None) -> str:
- """Discover and load context files for the system prompt.
+def load_soul_md() -> Optional[str]:
+ """Load SOUL.md from HERMES_HOME and return its content, or None.
- Discovery: AGENTS.md (recursive), .cursorrules / .cursor/rules/*.mdc,
- SOUL.md (cwd then ~/.hermes/ fallback). Each capped at 20,000 chars.
+ Used as the agent identity (slot #1 in the system prompt). When this
+ returns content, ``build_context_files_prompt`` should be called with
+ ``skip_soul=True`` so SOUL.md isn't injected twice.
"""
- if cwd is None:
- cwd = os.getcwd()
+ try:
+ from hermes_cli.config import ensure_hermes_home
+ ensure_hermes_home()
+ except Exception as e:
+ logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e)
- cwd_path = Path(cwd).resolve()
- sections = []
+ soul_path = get_hermes_home() / "SOUL.md"
+ if not soul_path.exists():
+ return None
+ try:
+ content = soul_path.read_text(encoding="utf-8").strip()
+ if not content:
+ return None
+ content = _scan_context_content(content, "SOUL.md")
+ content = _truncate_content(content, "SOUL.md")
+ return content
+ except Exception as e:
+ logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
+ return None
+
+
+def _load_hermes_md(cwd_path: Path) -> str:
+ """.hermes.md / HERMES.md โ walk to git root."""
+ hermes_md_path = _find_hermes_md(cwd_path)
+ if not hermes_md_path:
+ return ""
+ try:
+ content = hermes_md_path.read_text(encoding="utf-8").strip()
+ if not content:
+ return ""
+ content = _strip_yaml_frontmatter(content)
+ rel = hermes_md_path.name
+ try:
+ rel = str(hermes_md_path.relative_to(cwd_path))
+ except ValueError:
+ pass
+ content = _scan_context_content(content, rel)
+ result = f"## {rel}\n\n{content}"
+ return _truncate_content(result, ".hermes.md")
+ except Exception as e:
+ logger.debug("Could not read %s: %s", hermes_md_path, e)
+ return ""
- # AGENTS.md (hierarchical, recursive)
- top_level_agents = None
+
+def _load_agents_md(cwd_path: Path) -> str:
+ """AGENTS.md โ top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name
if candidate.exists():
- top_level_agents = candidate
- break
+ try:
+ content = candidate.read_text(encoding="utf-8").strip()
+ if content:
+ content = _scan_context_content(content, name)
+ result = f"## {name}\n\n{content}"
+ return _truncate_content(result, "AGENTS.md")
+ except Exception as e:
+ logger.debug("Could not read %s: %s", candidate, e)
+ return ""
- if top_level_agents:
- agents_files = []
- for root, dirs, files in os.walk(cwd_path):
- dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('node_modules', '__pycache__', 'venv', '.venv')]
- for f in files:
- if f.lower() == "agents.md":
- agents_files.append(Path(root) / f)
- agents_files.sort(key=lambda p: len(p.parts))
-
- total_agents_content = ""
- for agents_path in agents_files:
+
+def _load_claude_md(cwd_path: Path) -> str:
+ """CLAUDE.md / claude.md โ cwd only."""
+ for name in ["CLAUDE.md", "claude.md"]:
+ candidate = cwd_path / name
+ if candidate.exists():
try:
- content = agents_path.read_text(encoding="utf-8").strip()
+ content = candidate.read_text(encoding="utf-8").strip()
if content:
- rel_path = agents_path.relative_to(cwd_path)
- content = _scan_context_content(content, str(rel_path))
- total_agents_content += f"## {rel_path}\n\n{content}\n\n"
+ content = _scan_context_content(content, name)
+ result = f"## {name}\n\n{content}"
+ return _truncate_content(result, "CLAUDE.md")
except Exception as e:
- logger.debug("Could not read %s: %s", agents_path, e)
+ logger.debug("Could not read %s: %s", candidate, e)
+ return ""
- if total_agents_content:
- total_agents_content = _truncate_content(total_agents_content, "AGENTS.md")
- sections.append(total_agents_content)
- # .cursorrules
+def _load_cursorrules(cwd_path: Path) -> str:
+ """.cursorrules + .cursor/rules/*.mdc โ cwd only."""
cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules"
if cursorrules_file.exists():
@@ -415,35 +692,47 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str:
except Exception as e:
logger.debug("Could not read %s: %s", mdc_file, e)
- if cursorrules_content:
- cursorrules_content = _truncate_content(cursorrules_content, ".cursorrules")
- sections.append(cursorrules_content)
+ if not cursorrules_content:
+ return ""
+ return _truncate_content(cursorrules_content, ".cursorrules")
- # SOUL.md (cwd first, then ~/.hermes/ fallback)
- soul_path = None
- for name in ["SOUL.md", "soul.md"]:
- candidate = cwd_path / name
- if candidate.exists():
- soul_path = candidate
- break
- if not soul_path:
- global_soul = Path.home() / ".hermes" / "SOUL.md"
- if global_soul.exists():
- soul_path = global_soul
- if soul_path:
- try:
- content = soul_path.read_text(encoding="utf-8").strip()
- if content:
- content = _scan_context_content(content, "SOUL.md")
- content = _truncate_content(content, "SOUL.md")
- sections.append(
- f"## SOUL.md\n\nIf SOUL.md is present, embody its persona and tone. "
- f"Avoid stiff, generic replies; follow its guidance unless higher-priority "
- f"instructions override it.\n\n{content}"
- )
- except Exception as e:
- logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
+def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
+ """Discover and load context files for the system prompt.
+
+ Priority (first found wins โ only ONE project context type is loaded):
+ 1. .hermes.md / HERMES.md (walk to git root)
+ 2. AGENTS.md / agents.md (cwd only)
+ 3. CLAUDE.md / claude.md (cwd only)
+ 4. .cursorrules / .cursor/rules/*.mdc (cwd only)
+
+ SOUL.md from HERMES_HOME is independent and always included when present.
+ Each context source is capped at 20,000 chars.
+
+ When *skip_soul* is True, SOUL.md is not included here (it was already
+ loaded via ``load_soul_md()`` for the identity slot).
+ """
+ if cwd is None:
+ cwd = os.getcwd()
+
+ cwd_path = Path(cwd).resolve()
+ sections = []
+
+ # Priority-based project context: first match wins
+ project_context = (
+ _load_hermes_md(cwd_path)
+ or _load_agents_md(cwd_path)
+ or _load_claude_md(cwd_path)
+ or _load_cursorrules(cwd_path)
+ )
+ if project_context:
+ sections.append(project_context)
+
+ # SOUL.md from HERMES_HOME only โ skip when already loaded as identity
+ if not skip_soul:
+ soul_content = load_soul_md()
+ if soul_content:
+ sections.append(soul_content)
if not sections:
return ""
diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py
index aa80b2ddfa1..d80f58ea40a 100644
--- a/agent/prompt_caching.py
+++ b/agent/prompt_caching.py
@@ -12,21 +12,24 @@
from typing import Any, Dict, List
-def _apply_cache_marker(msg: dict, cache_marker: dict) -> None:
+def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = False) -> None:
"""Add cache_control to a single message, handling all format variations."""
role = msg.get("role", "")
content = msg.get("content")
if role == "tool":
- msg["cache_control"] = cache_marker
+ if native_anthropic:
+ msg["cache_control"] = cache_marker
return
- if content is None:
+ if content is None or content == "":
msg["cache_control"] = cache_marker
return
if isinstance(content, str):
- msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
+ msg["content"] = [
+ {"type": "text", "text": content, "cache_control": cache_marker}
+ ]
return
if isinstance(content, list) and content:
@@ -38,6 +41,7 @@ def _apply_cache_marker(msg: dict, cache_marker: dict) -> None:
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",
+ native_anthropic: bool = False,
) -> List[Dict[str, Any]]:
"""Apply system_and_3 caching strategy to messages for Anthropic models.
@@ -57,12 +61,12 @@ def apply_anthropic_cache_control(
breakpoints_used = 0
if messages[0].get("role") == "system":
- _apply_cache_marker(messages[0], marker)
+ _apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic)
breakpoints_used += 1
remaining = 4 - breakpoints_used
non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"]
for idx in non_sys[-remaining:]:
- _apply_cache_marker(messages[idx], marker)
+ _apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
return messages
diff --git a/agent/redact.py b/agent/redact.py
index 1af6eaa0595..d298ffb0300 100644
--- a/agent/redact.py
+++ b/agent/redact.py
@@ -47,7 +47,7 @@
)
# JSON field patterns: "apiKey": "value", "token": "value", etc.
-_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer)"
+_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_FIELD_RE = re.compile(
rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"',
re.IGNORECASE,
@@ -100,6 +100,10 @@ def redact_sensitive_text(text: str) -> str:
Safe to call on any string -- non-matching text passes through unchanged.
Disabled when security.redact_secrets is false in config.yaml.
"""
+ if text is None:
+ return None
+ if not isinstance(text, str):
+ text = str(text)
if not text:
return text
if os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off"):
diff --git a/agent/skill_commands.py b/agent/skill_commands.py
index 4466ba35cab..b266ad251c8 100644
--- a/agent/skill_commands.py
+++ b/agent/skill_commands.py
@@ -1,16 +1,151 @@
-"""Skill slash commands โ scan installed skills and build invocation messages.
+"""Shared slash command helpers for skills and built-in prompt-style modes.
Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces
-can invoke skills via /skill-name commands.
+can invoke skills via /skill-name commands and prompt-only built-ins like
+/plan.
"""
+import json
import logging
+import re
+from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {}
+_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
+
+
+def build_plan_path(
+ user_instruction: str = "",
+ *,
+ now: datetime | None = None,
+) -> Path:
+ """Return the default workspace-relative markdown path for a /plan invocation.
+
+ Relative paths are intentional: file tools are task/backend-aware and resolve
+ them against the active working directory for local, docker, ssh, modal,
+ daytona, and similar terminal backends. That keeps the plan with the active
+ workspace instead of the Hermes host's global home directory.
+ """
+ slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else ""
+ slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-")
+ if slug:
+ slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-")
+ slug = slug or "conversation-plan"
+ timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S")
+ return Path(".hermes") / "plans" / f"{timestamp}-{slug}.md"
+
+
+def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
+ """Load a skill by name/path and return (loaded_payload, skill_dir, display_name)."""
+ raw_identifier = (skill_identifier or "").strip()
+ if not raw_identifier:
+ return None
+
+ try:
+ from tools.skills_tool import SKILLS_DIR, skill_view
+
+ identifier_path = Path(raw_identifier).expanduser()
+ if identifier_path.is_absolute():
+ try:
+ normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
+ except Exception:
+ normalized = raw_identifier
+ else:
+ normalized = raw_identifier.lstrip("/")
+
+ loaded_skill = json.loads(skill_view(normalized, task_id=task_id))
+ except Exception:
+ return None
+
+ if not loaded_skill.get("success"):
+ return None
+
+ skill_name = str(loaded_skill.get("name") or normalized)
+ skill_path = str(loaded_skill.get("path") or "")
+ skill_dir = None
+ if skill_path:
+ try:
+ skill_dir = SKILLS_DIR / Path(skill_path).parent
+ except Exception:
+ skill_dir = None
+
+ return loaded_skill, skill_dir, skill_name
+
+
+def _build_skill_message(
+ loaded_skill: dict[str, Any],
+ skill_dir: Path | None,
+ activation_note: str,
+ user_instruction: str = "",
+ runtime_note: str = "",
+) -> str:
+ """Format a loaded skill into a user/system message payload."""
+ from tools.skills_tool import SKILLS_DIR
+
+ content = str(loaded_skill.get("content") or "")
+
+ parts = [activation_note, "", content.strip()]
+
+ if loaded_skill.get("setup_skipped"):
+ parts.extend(
+ [
+ "",
+ "[Skill setup note: Required environment setup was skipped. Continue loading the skill and explain any reduced functionality if it matters.]",
+ ]
+ )
+ elif loaded_skill.get("gateway_setup_hint"):
+ parts.extend(
+ [
+ "",
+ f"[Skill setup note: {loaded_skill['gateway_setup_hint']}]",
+ ]
+ )
+ elif loaded_skill.get("setup_needed") and loaded_skill.get("setup_note"):
+ parts.extend(
+ [
+ "",
+ f"[Skill setup note: {loaded_skill['setup_note']}]",
+ ]
+ )
+
+ supporting = []
+ linked_files = loaded_skill.get("linked_files") or {}
+ for entries in linked_files.values():
+ if isinstance(entries, list):
+ supporting.extend(entries)
+
+ if not supporting and skill_dir:
+ for subdir in ("references", "templates", "scripts", "assets"):
+ subdir_path = skill_dir / subdir
+ if subdir_path.exists():
+ for f in sorted(subdir_path.rglob("*")):
+ if f.is_file():
+ rel = str(f.relative_to(skill_dir))
+ supporting.append(rel)
+
+ if supporting and skill_dir:
+ skill_view_target = str(skill_dir.relative_to(SKILLS_DIR))
+ parts.append("")
+ parts.append("[This skill has supporting files you can load with the skill_view tool:]")
+ for sf in supporting:
+ parts.append(f"- {sf}")
+ parts.append(
+ f'\nTo view any of these, use: skill_view(name="{skill_view_target}", file_path="")'
+ )
+
+ if user_instruction:
+ parts.append("")
+ parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}")
+
+ if runtime_note:
+ parts.append("")
+ parts.append(f"[Runtime note: {runtime_note}]")
+
+ return "\n".join(parts)
def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
@@ -22,9 +157,10 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
global _skill_commands
_skill_commands = {}
try:
- from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform
+ from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
if not SKILLS_DIR.exists():
return _skill_commands
+ disabled = _get_disabled_skill_names()
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
@@ -35,6 +171,9 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get('name', skill_md.parent.name)
+ # Respect user's disabled skills config
+ if name in disabled:
+ continue
description = frontmatter.get('description', '')
if not description:
for line in body.strip().split('\n'):
@@ -63,7 +202,12 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
-def build_skill_invocation_message(cmd_key: str, user_instruction: str = "") -> Optional[str]:
+def build_skill_invocation_message(
+ cmd_key: str,
+ user_instruction: str = "",
+ task_id: str | None = None,
+ runtime_note: str = "",
+) -> Optional[str]:
"""Build the user message content for a skill slash command invocation.
Args:
@@ -78,39 +222,61 @@ def build_skill_invocation_message(cmd_key: str, user_instruction: str = "") ->
if not skill_info:
return None
- skill_md_path = Path(skill_info["skill_md_path"])
- skill_dir = Path(skill_info["skill_dir"])
- skill_name = skill_info["name"]
+ loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
+ if not loaded:
+ return f"[Failed to load skill: {skill_info['name']}]"
- try:
- content = skill_md_path.read_text(encoding='utf-8')
- except Exception:
- return f"[Failed to load skill: {skill_name}]"
+ loaded_skill, skill_dir, skill_name = loaded
+ activation_note = (
+ f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want '
+ "you to follow its instructions. The full skill content is loaded below.]"
+ )
+ return _build_skill_message(
+ loaded_skill,
+ skill_dir,
+ activation_note,
+ user_instruction=user_instruction,
+ runtime_note=runtime_note,
+ )
- parts = [
- f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
- "",
- content.strip(),
- ]
- supporting = []
- for subdir in ("references", "templates", "scripts", "assets"):
- subdir_path = skill_dir / subdir
- if subdir_path.exists():
- for f in sorted(subdir_path.rglob("*")):
- if f.is_file():
- rel = str(f.relative_to(skill_dir))
- supporting.append(rel)
-
- if supporting:
- parts.append("")
- parts.append("[This skill has supporting files you can load with the skill_view tool:]")
- for sf in supporting:
- parts.append(f"- {sf}")
- parts.append(f'\nTo view any of these, use: skill_view(name="{skill_name}", file="")')
+def build_preloaded_skills_prompt(
+ skill_identifiers: list[str],
+ task_id: str | None = None,
+) -> tuple[str, list[str], list[str]]:
+ """Load one or more skills for session-wide CLI preloading.
- if user_instruction:
- parts.append("")
- parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}")
+ Returns (prompt_text, loaded_skill_names, missing_identifiers).
+ """
+ prompt_parts: list[str] = []
+ loaded_names: list[str] = []
+ missing: list[str] = []
- return "\n".join(parts)
+ seen: set[str] = set()
+ for raw_identifier in skill_identifiers:
+ identifier = (raw_identifier or "").strip()
+ if not identifier or identifier in seen:
+ continue
+ seen.add(identifier)
+
+ loaded = _load_skill_payload(identifier, task_id=task_id)
+ if not loaded:
+ missing.append(identifier)
+ continue
+
+ loaded_skill, skill_dir, skill_name = loaded
+ activation_note = (
+ f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill '
+ "preloaded. Treat its instructions as active guidance for the duration of this "
+ "session unless the user overrides them.]"
+ )
+ prompt_parts.append(
+ _build_skill_message(
+ loaded_skill,
+ skill_dir,
+ activation_note,
+ )
+ )
+ loaded_names.append(skill_name)
+
+ return "\n\n".join(prompt_parts), loaded_names, missing
diff --git a/agent/skill_utils.py b/agent/skill_utils.py
new file mode 100644
index 00000000000..5cb2a710503
--- /dev/null
+++ b/agent/skill_utils.py
@@ -0,0 +1,203 @@
+"""Lightweight skill metadata utilities shared by prompt_builder and skills_tool.
+
+This module intentionally avoids importing the tool registry, CLI config, or any
+heavy dependency chain. It is safe to import at module level without triggering
+tool registration or provider resolution.
+"""
+
+import logging
+import os
+import re
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Tuple
+
+from hermes_constants import get_hermes_home
+
+logger = logging.getLogger(__name__)
+
+# โโ Platform mapping โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+PLATFORM_MAP = {
+ "macos": "darwin",
+ "linux": "linux",
+ "windows": "win32",
+}
+
+EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
+
+# โโ Lazy YAML loader โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_yaml_load_fn = None
+
+
+def yaml_load(content: str):
+ """Parse YAML with lazy import and CSafeLoader preference."""
+ global _yaml_load_fn
+ if _yaml_load_fn is None:
+ import yaml
+
+ loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader
+
+ def _load(value: str):
+ return yaml.load(value, Loader=loader)
+
+ _yaml_load_fn = _load
+ return _yaml_load_fn(content)
+
+
+# โโ Frontmatter parsing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
+ """Parse YAML frontmatter from a markdown string.
+
+ Uses yaml with CSafeLoader for full YAML support (nested metadata, lists)
+ with a fallback to simple key:value splitting for robustness.
+
+ Returns:
+ (frontmatter_dict, remaining_body)
+ """
+ frontmatter: Dict[str, Any] = {}
+ body = content
+
+ if not content.startswith("---"):
+ return frontmatter, body
+
+ end_match = re.search(r"\n---\s*\n", content[3:])
+ if not end_match:
+ return frontmatter, body
+
+ yaml_content = content[3 : end_match.start() + 3]
+ body = content[end_match.end() + 3 :]
+
+ try:
+ parsed = yaml_load(yaml_content)
+ if isinstance(parsed, dict):
+ frontmatter = parsed
+ except Exception:
+ # Fallback: simple key:value parsing for malformed YAML
+ for line in yaml_content.strip().split("\n"):
+ if ":" not in line:
+ continue
+ key, value = line.split(":", 1)
+ frontmatter[key.strip()] = value.strip()
+
+ return frontmatter, body
+
+
+# โโ Platform matching โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
+ """Return True when the skill is compatible with the current OS.
+
+ Skills declare platform requirements via a top-level ``platforms`` list
+ in their YAML frontmatter::
+
+ platforms: [macos] # macOS only
+ platforms: [macos, linux] # macOS and Linux
+
+ If the field is absent or empty the skill is compatible with **all**
+ platforms (backward-compatible default).
+ """
+ platforms = frontmatter.get("platforms")
+ if not platforms:
+ return True
+ if not isinstance(platforms, list):
+ platforms = [platforms]
+ current = sys.platform
+ for platform in platforms:
+ normalized = str(platform).lower().strip()
+ mapped = PLATFORM_MAP.get(normalized, normalized)
+ if current.startswith(mapped):
+ return True
+ return False
+
+
+# โโ Disabled skills โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def get_disabled_skill_names() -> Set[str]:
+ """Read disabled skill names from config.yaml.
+
+ Resolves platform from ``HERMES_PLATFORM`` env var, falls back to
+ the global disabled list. Reads the config file directly (no CLI
+ config imports) to stay lightweight.
+ """
+ config_path = get_hermes_home() / "config.yaml"
+ if not config_path.exists():
+ return set()
+ try:
+ parsed = yaml_load(config_path.read_text(encoding="utf-8"))
+ except Exception as e:
+ logger.debug("Could not read skill config %s: %s", config_path, e)
+ return set()
+ if not isinstance(parsed, dict):
+ return set()
+
+ skills_cfg = parsed.get("skills")
+ if not isinstance(skills_cfg, dict):
+ return set()
+
+ resolved_platform = os.getenv("HERMES_PLATFORM")
+ if resolved_platform:
+ platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
+ resolved_platform
+ )
+ if platform_disabled is not None:
+ return _normalize_string_set(platform_disabled)
+ return _normalize_string_set(skills_cfg.get("disabled"))
+
+
+def _normalize_string_set(values) -> Set[str]:
+ if values is None:
+ return set()
+ if isinstance(values, str):
+ values = [values]
+ return {str(v).strip() for v in values if str(v).strip()}
+
+
+# โโ Condition extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:
+ """Extract conditional activation fields from parsed frontmatter."""
+ hermes = (frontmatter.get("metadata") or {}).get("hermes") or {}
+ return {
+ "fallback_for_toolsets": hermes.get("fallback_for_toolsets", []),
+ "requires_toolsets": hermes.get("requires_toolsets", []),
+ "fallback_for_tools": hermes.get("fallback_for_tools", []),
+ "requires_tools": hermes.get("requires_tools", []),
+ }
+
+
+# โโ Description extraction โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
+ """Extract a truncated description from parsed frontmatter."""
+ raw_desc = frontmatter.get("description", "")
+ if not raw_desc:
+ return ""
+ desc = str(raw_desc).strip().strip("'\"")
+ if len(desc) > 60:
+ return desc[:57] + "..."
+ return desc
+
+
+# โโ File iteration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def iter_skill_index_files(skills_dir: Path, filename: str):
+ """Walk skills_dir yielding sorted paths matching *filename*.
+
+ Excludes ``.git``, ``.github``, ``.hub`` directories.
+ """
+ matches = []
+ for root, dirs, files in os.walk(skills_dir):
+ dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
+ if filename in files:
+ matches.append(Path(root) / filename)
+ for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
+ yield path
diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py
new file mode 100644
index 00000000000..38b1a86e446
--- /dev/null
+++ b/agent/smart_model_routing.py
@@ -0,0 +1,205 @@
+"""Helpers for optional cheap-vs-strong model routing."""
+
+from __future__ import annotations
+
+import os
+import re
+from typing import Any, Dict, Optional
+
+_COMPLEX_KEYWORDS = {
+ "debug",
+ "debugging",
+ "implement",
+ "implementation",
+ "refactor",
+ "patch",
+ "traceback",
+ "stacktrace",
+ "exception",
+ "error",
+ "analyze",
+ "analysis",
+ "investigate",
+ "architecture",
+ "design",
+ "compare",
+ "benchmark",
+ "optimize",
+ "optimise",
+ "review",
+ "terminal",
+ "shell",
+ "tool",
+ "tools",
+ "pytest",
+ "test",
+ "tests",
+ "plan",
+ "planning",
+ "delegate",
+ "subagent",
+ "cron",
+ "docker",
+ "kubernetes",
+}
+
+_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
+
+
+def _coerce_bool(value: Any, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+
+def _coerce_int(value: Any, default: int) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ """Return the configured cheap-model route when a message looks simple.
+
+ Conservative by design: if the message has signs of code/tool/debugging/
+ long-form work, keep the primary model.
+ """
+ cfg = routing_config or {}
+ if not _coerce_bool(cfg.get("enabled"), False):
+ return None
+
+ cheap_model = cfg.get("cheap_model") or {}
+ if not isinstance(cheap_model, dict):
+ return None
+ provider = str(cheap_model.get("provider") or "").strip().lower()
+ model = str(cheap_model.get("model") or "").strip()
+ if not provider or not model:
+ return None
+
+ text = (user_message or "").strip()
+ if not text:
+ return None
+
+ max_chars = _coerce_int(cfg.get("max_simple_chars"), 160)
+ max_words = _coerce_int(cfg.get("max_simple_words"), 28)
+
+ if len(text) > max_chars:
+ return None
+ if len(text.split()) > max_words:
+ return None
+ if text.count("\n") > 1:
+ return None
+ if "```" in text or "`" in text:
+ return None
+ if _URL_RE.search(text):
+ return None
+
+ lowered = text.lower()
+ words = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()}
+ if words & _COMPLEX_KEYWORDS:
+ return None
+
+ route = dict(cheap_model)
+ route["provider"] = provider
+ route["model"] = model
+ route["routing_reason"] = "simple_turn"
+ return route
+
+
+def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]:
+ """Resolve the effective model/runtime for one turn.
+
+ Returns a dict with model/runtime/signature/label fields.
+ """
+ route = choose_cheap_model_route(user_message, routing_config)
+ if not route:
+ return {
+ "model": primary.get("model"),
+ "runtime": {
+ "api_key": primary.get("api_key"),
+ "base_url": primary.get("base_url"),
+ "provider": primary.get("provider"),
+ "api_mode": primary.get("api_mode"),
+ "command": primary.get("command"),
+ "args": list(primary.get("args") or []),
+ "request_headers_resolver": primary.get("request_headers_resolver"),
+ "payment_adapter": primary.get("payment_adapter"),
+ "payment_config": primary.get("payment_config"),
+ },
+ "label": None,
+ "signature": (
+ primary.get("model"),
+ primary.get("provider"),
+ primary.get("base_url"),
+ primary.get("api_mode"),
+ primary.get("command"),
+ tuple(primary.get("args") or ()),
+ ),
+ }
+
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ explicit_api_key = None
+ api_key_env = str(route.get("api_key_env") or "").strip()
+ if api_key_env:
+ explicit_api_key = os.getenv(api_key_env) or None
+
+ try:
+ runtime = resolve_runtime_provider(
+ requested=route.get("provider"),
+ explicit_api_key=explicit_api_key,
+ explicit_base_url=route.get("base_url"),
+ )
+ except Exception:
+ return {
+ "model": primary.get("model"),
+ "runtime": {
+ "api_key": primary.get("api_key"),
+ "base_url": primary.get("base_url"),
+ "provider": primary.get("provider"),
+ "api_mode": primary.get("api_mode"),
+ "command": primary.get("command"),
+ "args": list(primary.get("args") or []),
+ "request_headers_resolver": primary.get("request_headers_resolver"),
+ "payment_adapter": primary.get("payment_adapter"),
+ "payment_config": primary.get("payment_config"),
+ },
+ "label": None,
+ "signature": (
+ primary.get("model"),
+ primary.get("provider"),
+ primary.get("base_url"),
+ primary.get("api_mode"),
+ primary.get("command"),
+ tuple(primary.get("args") or ()),
+ ),
+ }
+
+ return {
+ "model": route.get("model"),
+ "runtime": {
+ "api_key": runtime.get("api_key"),
+ "base_url": runtime.get("base_url"),
+ "provider": runtime.get("provider"),
+ "api_mode": runtime.get("api_mode"),
+ "command": runtime.get("command"),
+ "args": list(runtime.get("args") or []),
+ "request_headers_resolver": runtime.get("request_headers_resolver"),
+ "payment_adapter": runtime.get("payment_adapter"),
+ "payment_config": runtime.get("payment_config"),
+ },
+ "label": f"smart route โ {route.get('model')} ({runtime.get('provider')})",
+ "signature": (
+ route.get("model"),
+ runtime.get("provider"),
+ runtime.get("base_url"),
+ runtime.get("api_mode"),
+ runtime.get("command"),
+ tuple(runtime.get("args") or ()),
+ ),
+ }
diff --git a/agent/title_generator.py b/agent/title_generator.py
new file mode 100644
index 00000000000..9a18aab58be
--- /dev/null
+++ b/agent/title_generator.py
@@ -0,0 +1,125 @@
+"""Auto-generate short session titles from the first user/assistant exchange.
+
+Runs asynchronously after the first response is delivered so it never
+adds latency to the user-facing reply.
+"""
+
+import logging
+import threading
+from typing import Optional
+
+from agent.auxiliary_client import call_llm
+
+logger = logging.getLogger(__name__)
+
+_TITLE_PROMPT = (
+ "Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
+ "following exchange. The title should capture the main topic or intent. "
+ "Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
+)
+
+
+def generate_title(user_message: str, assistant_response: str, timeout: float = 15.0) -> Optional[str]:
+ """Generate a session title from the first exchange.
+
+ Uses the auxiliary LLM client (cheapest/fastest available model).
+ Returns the title string or None on failure.
+ """
+ # Truncate long messages to keep the request small
+ user_snippet = user_message[:500] if user_message else ""
+ assistant_snippet = assistant_response[:500] if assistant_response else ""
+
+ messages = [
+ {"role": "system", "content": _TITLE_PROMPT},
+ {"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
+ ]
+
+ try:
+ response = call_llm(
+ task="compression", # reuse compression task config (cheap/fast model)
+ messages=messages,
+ max_tokens=30,
+ temperature=0.3,
+ timeout=timeout,
+ )
+ title = (response.choices[0].message.content or "").strip()
+ # Clean up: remove quotes, trailing punctuation, prefixes like "Title: "
+ title = title.strip('"\'')
+ if title.lower().startswith("title:"):
+ title = title[6:].strip()
+ # Enforce reasonable length
+ if len(title) > 80:
+ title = title[:77] + "..."
+ return title if title else None
+ except Exception as e:
+ logger.debug("Title generation failed: %s", e)
+ return None
+
+
+def auto_title_session(
+ session_db,
+ session_id: str,
+ user_message: str,
+ assistant_response: str,
+) -> None:
+ """Generate and set a session title if one doesn't already exist.
+
+ Called in a background thread after the first exchange completes.
+ Silently skips if:
+ - session_db is None
+ - session already has a title (user-set or previously auto-generated)
+ - title generation fails
+ """
+ if not session_db or not session_id:
+ return
+
+ # Check if title already exists (user may have set one via /title before first response)
+ try:
+ existing = session_db.get_session_title(session_id)
+ if existing:
+ return
+ except Exception:
+ return
+
+ title = generate_title(user_message, assistant_response)
+ if not title:
+ return
+
+ try:
+ session_db.set_session_title(session_id, title)
+ logger.debug("Auto-generated session title: %s", title)
+ except Exception as e:
+ logger.debug("Failed to set auto-generated title: %s", e)
+
+
+def maybe_auto_title(
+ session_db,
+ session_id: str,
+ user_message: str,
+ assistant_response: str,
+ conversation_history: list,
+) -> None:
+ """Fire-and-forget title generation after the first exchange.
+
+ Only generates a title when:
+ - This appears to be the first userโassistant exchange
+ - No title is already set
+ """
+ if not session_db or not session_id or not user_message or not assistant_response:
+ return
+
+ # Count user messages in history to detect first exchange.
+ # conversation_history includes the exchange that just happened,
+ # so for a first exchange we expect exactly 1 user message
+ # (or 2 counting system). Be generous: generate on first 2 exchanges.
+ user_msg_count = sum(1 for m in (conversation_history or []) if m.get("role") == "user")
+ if user_msg_count > 2:
+ return
+
+ thread = threading.Thread(
+ target=auto_title_session,
+ args=(session_db, session_id, user_message, assistant_response),
+ daemon=True,
+ name="auto-title",
+ )
+ thread.start()
diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py
new file mode 100644
index 00000000000..cfd0f88c4e9
--- /dev/null
+++ b/agent/usage_pricing.py
@@ -0,0 +1,656 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from decimal import Decimal
+from typing import Any, Dict, Literal, Optional
+
+from agent.model_metadata import fetch_endpoint_model_metadata, fetch_model_metadata
+
+DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
+
+_ZERO = Decimal("0")
+_ONE_MILLION = Decimal("1000000")
+
+CostStatus = Literal["actual", "estimated", "included", "unknown"]
+CostSource = Literal[
+ "provider_cost_api",
+ "provider_generation_api",
+ "provider_models_api",
+ "official_docs_snapshot",
+ "user_override",
+ "custom_contract",
+ "none",
+]
+
+
+@dataclass(frozen=True)
+class CanonicalUsage:
+ input_tokens: int = 0
+ output_tokens: int = 0
+ cache_read_tokens: int = 0
+ cache_write_tokens: int = 0
+ reasoning_tokens: int = 0
+ request_count: int = 1
+ raw_usage: Optional[dict[str, Any]] = None
+
+ @property
+ def prompt_tokens(self) -> int:
+ return self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
+
+ @property
+ def total_tokens(self) -> int:
+ return self.prompt_tokens + self.output_tokens
+
+
+@dataclass(frozen=True)
+class BillingRoute:
+ provider: str
+ model: str
+ base_url: str = ""
+ billing_mode: str = "unknown"
+
+
+@dataclass(frozen=True)
+class PricingEntry:
+ input_cost_per_million: Optional[Decimal] = None
+ output_cost_per_million: Optional[Decimal] = None
+ cache_read_cost_per_million: Optional[Decimal] = None
+ cache_write_cost_per_million: Optional[Decimal] = None
+ request_cost: Optional[Decimal] = None
+ source: CostSource = "none"
+ source_url: Optional[str] = None
+ pricing_version: Optional[str] = None
+ fetched_at: Optional[datetime] = None
+
+
+@dataclass(frozen=True)
+class CostResult:
+ amount_usd: Optional[Decimal]
+ status: CostStatus
+ source: CostSource
+ label: str
+ fetched_at: Optional[datetime] = None
+ pricing_version: Optional[str] = None
+ notes: tuple[str, ...] = ()
+
+
+_UTC_NOW = lambda: datetime.now(timezone.utc)
+
+
+# Official docs snapshot entries. Models whose published pricing and cache
+# semantics are stable enough to encode exactly.
+_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
+ (
+ "anthropic",
+ "claude-opus-4-20250514",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("15.00"),
+ output_cost_per_million=Decimal("75.00"),
+ cache_read_cost_per_million=Decimal("1.50"),
+ cache_write_cost_per_million=Decimal("18.75"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-prompt-caching-2026-03-16",
+ ),
+ (
+ "anthropic",
+ "claude-sonnet-4-20250514",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("3.00"),
+ output_cost_per_million=Decimal("15.00"),
+ cache_read_cost_per_million=Decimal("0.30"),
+ cache_write_cost_per_million=Decimal("3.75"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-prompt-caching-2026-03-16",
+ ),
+ # OpenAI
+ (
+ "openai",
+ "gpt-4o",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("2.50"),
+ output_cost_per_million=Decimal("10.00"),
+ cache_read_cost_per_million=Decimal("1.25"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "gpt-4o-mini",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.15"),
+ output_cost_per_million=Decimal("0.60"),
+ cache_read_cost_per_million=Decimal("0.075"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "gpt-4.1",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("2.00"),
+ output_cost_per_million=Decimal("8.00"),
+ cache_read_cost_per_million=Decimal("0.50"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "gpt-4.1-mini",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.40"),
+ output_cost_per_million=Decimal("1.60"),
+ cache_read_cost_per_million=Decimal("0.10"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "gpt-4.1-nano",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.10"),
+ output_cost_per_million=Decimal("0.40"),
+ cache_read_cost_per_million=Decimal("0.025"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "o3",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("10.00"),
+ output_cost_per_million=Decimal("40.00"),
+ cache_read_cost_per_million=Decimal("2.50"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ (
+ "openai",
+ "o3-mini",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("1.10"),
+ output_cost_per_million=Decimal("4.40"),
+ cache_read_cost_per_million=Decimal("0.55"),
+ source="official_docs_snapshot",
+ source_url="https://openai.com/api/pricing/",
+ pricing_version="openai-pricing-2026-03-16",
+ ),
+ # Anthropic older models (pre-4.6 generation)
+ (
+ "anthropic",
+ "claude-3-5-sonnet-20241022",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("3.00"),
+ output_cost_per_million=Decimal("15.00"),
+ cache_read_cost_per_million=Decimal("0.30"),
+ cache_write_cost_per_million=Decimal("3.75"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-pricing-2026-03-16",
+ ),
+ (
+ "anthropic",
+ "claude-3-5-haiku-20241022",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.80"),
+ output_cost_per_million=Decimal("4.00"),
+ cache_read_cost_per_million=Decimal("0.08"),
+ cache_write_cost_per_million=Decimal("1.00"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-pricing-2026-03-16",
+ ),
+ (
+ "anthropic",
+ "claude-3-opus-20240229",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("15.00"),
+ output_cost_per_million=Decimal("75.00"),
+ cache_read_cost_per_million=Decimal("1.50"),
+ cache_write_cost_per_million=Decimal("18.75"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-pricing-2026-03-16",
+ ),
+ (
+ "anthropic",
+ "claude-3-haiku-20240307",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.25"),
+ output_cost_per_million=Decimal("1.25"),
+ cache_read_cost_per_million=Decimal("0.03"),
+ cache_write_cost_per_million=Decimal("0.30"),
+ source="official_docs_snapshot",
+ source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
+ pricing_version="anthropic-pricing-2026-03-16",
+ ),
+ # DeepSeek
+ (
+ "deepseek",
+ "deepseek-chat",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.14"),
+ output_cost_per_million=Decimal("0.28"),
+ source="official_docs_snapshot",
+ source_url="https://api-docs.deepseek.com/quick_start/pricing",
+ pricing_version="deepseek-pricing-2026-03-16",
+ ),
+ (
+ "deepseek",
+ "deepseek-reasoner",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.55"),
+ output_cost_per_million=Decimal("2.19"),
+ source="official_docs_snapshot",
+ source_url="https://api-docs.deepseek.com/quick_start/pricing",
+ pricing_version="deepseek-pricing-2026-03-16",
+ ),
+ # Google Gemini
+ (
+ "google",
+ "gemini-2.5-pro",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("1.25"),
+ output_cost_per_million=Decimal("10.00"),
+ source="official_docs_snapshot",
+ source_url="https://ai.google.dev/pricing",
+ pricing_version="google-pricing-2026-03-16",
+ ),
+ (
+ "google",
+ "gemini-2.5-flash",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.15"),
+ output_cost_per_million=Decimal("0.60"),
+ source="official_docs_snapshot",
+ source_url="https://ai.google.dev/pricing",
+ pricing_version="google-pricing-2026-03-16",
+ ),
+ (
+ "google",
+ "gemini-2.0-flash",
+ ): PricingEntry(
+ input_cost_per_million=Decimal("0.10"),
+ output_cost_per_million=Decimal("0.40"),
+ source="official_docs_snapshot",
+ source_url="https://ai.google.dev/pricing",
+ pricing_version="google-pricing-2026-03-16",
+ ),
+}
+
+
+def _to_decimal(value: Any) -> Optional[Decimal]:
+ if value is None:
+ return None
+ try:
+ return Decimal(str(value))
+ except Exception:
+ return None
+
+
+def _to_int(value: Any) -> int:
+ try:
+ return int(value or 0)
+ except Exception:
+ return 0
+
+
+def resolve_billing_route(
+ model_name: str,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+) -> BillingRoute:
+ provider_name = (provider or "").strip().lower()
+ base = (base_url or "").strip().lower()
+ model = (model_name or "").strip()
+ if not provider_name and "/" in model:
+ inferred_provider, bare_model = model.split("/", 1)
+ if inferred_provider in {"anthropic", "openai", "google"}:
+ provider_name = inferred_provider
+ model = bare_model
+
+ if provider_name == "openai-codex":
+ return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
+ if provider_name == "openrouter" or "openrouter.ai" in base:
+ return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
+ if provider_name == "anthropic":
+ return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
+ if provider_name == "openai":
+ return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
+ if provider_name in {"custom", "local"} or (base and "localhost" in base):
+ return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown")
+ return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
+
+
+def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
+ return _OFFICIAL_DOCS_PRICING.get((route.provider, route.model.lower()))
+
+
+def _openrouter_pricing_entry(route: BillingRoute) -> Optional[PricingEntry]:
+ return _pricing_entry_from_metadata(
+ fetch_model_metadata(),
+ route.model,
+ source_url="https://openrouter.ai/docs/api/api-reference/models/get-models",
+ pricing_version="openrouter-models-api",
+ )
+
+
+def _pricing_entry_from_metadata(
+ metadata: Dict[str, Dict[str, Any]],
+ model_id: str,
+ *,
+ source_url: str,
+ pricing_version: str,
+) -> Optional[PricingEntry]:
+ if model_id not in metadata:
+ return None
+ pricing = metadata[model_id].get("pricing") or {}
+ prompt = _to_decimal(pricing.get("prompt"))
+ completion = _to_decimal(pricing.get("completion"))
+ request = _to_decimal(pricing.get("request"))
+ cache_read = _to_decimal(
+ pricing.get("cache_read")
+ or pricing.get("cached_prompt")
+ or pricing.get("input_cache_read")
+ )
+ cache_write = _to_decimal(
+ pricing.get("cache_write")
+ or pricing.get("cache_creation")
+ or pricing.get("input_cache_write")
+ )
+ if prompt is None and completion is None and request is None:
+ return None
+
+ def _per_token_to_per_million(value: Optional[Decimal]) -> Optional[Decimal]:
+ if value is None:
+ return None
+ return value * _ONE_MILLION
+
+ return PricingEntry(
+ input_cost_per_million=_per_token_to_per_million(prompt),
+ output_cost_per_million=_per_token_to_per_million(completion),
+ cache_read_cost_per_million=_per_token_to_per_million(cache_read),
+ cache_write_cost_per_million=_per_token_to_per_million(cache_write),
+ request_cost=request,
+ source="provider_models_api",
+ source_url=source_url,
+ pricing_version=pricing_version,
+ fetched_at=_UTC_NOW(),
+ )
+
+
+def get_pricing_entry(
+ model_name: str,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> Optional[PricingEntry]:
+ route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
+ if route.billing_mode == "subscription_included":
+ return PricingEntry(
+ input_cost_per_million=_ZERO,
+ output_cost_per_million=_ZERO,
+ cache_read_cost_per_million=_ZERO,
+ cache_write_cost_per_million=_ZERO,
+ source="none",
+ pricing_version="included-route",
+ )
+ if route.provider == "openrouter":
+ return _openrouter_pricing_entry(route)
+ if route.base_url:
+ entry = _pricing_entry_from_metadata(
+ fetch_endpoint_model_metadata(route.base_url, api_key=api_key or ""),
+ route.model,
+ source_url=f"{route.base_url.rstrip('/')}/models",
+ pricing_version="openai-compatible-models-api",
+ )
+ if entry:
+ return entry
+ return _lookup_official_docs_pricing(route)
+
+
+def normalize_usage(
+ response_usage: Any,
+ *,
+ provider: Optional[str] = None,
+ api_mode: Optional[str] = None,
+) -> CanonicalUsage:
+ """Normalize raw API response usage into canonical token buckets.
+
+ Handles three API shapes:
+ - Anthropic: input_tokens/output_tokens/cache_read_input_tokens/cache_creation_input_tokens
+ - Codex Responses: input_tokens includes cache tokens; input_tokens_details.cached_tokens separates them
+ - OpenAI Chat Completions: prompt_tokens includes cache tokens; prompt_tokens_details.cached_tokens separates them
+
+ In both Codex and OpenAI modes, input_tokens is derived by subtracting cache
+ tokens from the total โ the API contract is that input/prompt totals include
+ cached tokens and the details object breaks them out.
+ """
+ if not response_usage:
+ return CanonicalUsage()
+
+ provider_name = (provider or "").strip().lower()
+ mode = (api_mode or "").strip().lower()
+
+ if mode == "anthropic_messages" or provider_name == "anthropic":
+ input_tokens = _to_int(getattr(response_usage, "input_tokens", 0))
+ output_tokens = _to_int(getattr(response_usage, "output_tokens", 0))
+ cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0))
+ cache_write_tokens = _to_int(getattr(response_usage, "cache_creation_input_tokens", 0))
+ elif mode == "codex_responses":
+ input_total = _to_int(getattr(response_usage, "input_tokens", 0))
+ output_tokens = _to_int(getattr(response_usage, "output_tokens", 0))
+ details = getattr(response_usage, "input_tokens_details", None)
+ cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
+ cache_write_tokens = _to_int(
+ getattr(details, "cache_creation_tokens", 0) if details else 0
+ )
+ input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens)
+ else:
+ prompt_total = _to_int(getattr(response_usage, "prompt_tokens", 0))
+ output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
+ details = getattr(response_usage, "prompt_tokens_details", None)
+ cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
+ cache_write_tokens = _to_int(
+ getattr(details, "cache_write_tokens", 0) if details else 0
+ )
+ input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
+
+ reasoning_tokens = 0
+ output_details = getattr(response_usage, "output_tokens_details", None)
+ if output_details:
+ reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0))
+
+ return CanonicalUsage(
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ cache_read_tokens=cache_read_tokens,
+ cache_write_tokens=cache_write_tokens,
+ reasoning_tokens=reasoning_tokens,
+ )
+
+
+def estimate_usage_cost(
+ model_name: str,
+ usage: CanonicalUsage,
+ *,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> CostResult:
+ route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
+ if route.billing_mode == "subscription_included":
+ return CostResult(
+ amount_usd=_ZERO,
+ status="included",
+ source="none",
+ label="included",
+ pricing_version="included-route",
+ )
+
+ entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
+ if not entry:
+ return CostResult(amount_usd=None, status="unknown", source="none", label="n/a")
+
+ notes: list[str] = []
+ amount = _ZERO
+
+ if usage.input_tokens and entry.input_cost_per_million is None:
+ return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a")
+ if usage.output_tokens and entry.output_cost_per_million is None:
+ return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a")
+ if usage.cache_read_tokens:
+ if entry.cache_read_cost_per_million is None:
+ return CostResult(
+ amount_usd=None,
+ status="unknown",
+ source=entry.source,
+ label="n/a",
+ notes=("cache-read pricing unavailable for route",),
+ )
+ if usage.cache_write_tokens:
+ if entry.cache_write_cost_per_million is None:
+ return CostResult(
+ amount_usd=None,
+ status="unknown",
+ source=entry.source,
+ label="n/a",
+ notes=("cache-write pricing unavailable for route",),
+ )
+
+ if entry.input_cost_per_million is not None:
+ amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION
+ if entry.output_cost_per_million is not None:
+ amount += Decimal(usage.output_tokens) * entry.output_cost_per_million / _ONE_MILLION
+ if entry.cache_read_cost_per_million is not None:
+ amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION
+ if entry.cache_write_cost_per_million is not None:
+ amount += Decimal(usage.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_MILLION
+ if entry.request_cost is not None and usage.request_count:
+ amount += Decimal(usage.request_count) * entry.request_cost
+
+ status: CostStatus = "estimated"
+ label = f"~${amount:.2f}"
+ if entry.source == "none" and amount == _ZERO:
+ status = "included"
+ label = "included"
+
+ if route.provider == "openrouter":
+ notes.append("OpenRouter cost is estimated from the models API until reconciled.")
+
+ return CostResult(
+ amount_usd=amount,
+ status=status,
+ source=entry.source,
+ label=label,
+ fetched_at=entry.fetched_at,
+ pricing_version=entry.pricing_version,
+ notes=tuple(notes),
+ )
+
+
+def has_known_pricing(
+ model_name: str,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> bool:
+ """Check whether we have pricing data for this model+route.
+
+ Uses direct lookup instead of routing through the full estimation
+ pipeline โ avoids creating dummy usage objects just to check status.
+ """
+ route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
+ if route.billing_mode == "subscription_included":
+ return True
+ entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
+ return entry is not None
+
+
+def get_pricing(
+ model_name: str,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> Dict[str, float]:
+ """Backward-compatible thin wrapper for legacy callers.
+
+ Returns only non-cache input/output fields when a pricing entry exists.
+ Unknown routes return zeroes.
+ """
+ entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
+ if not entry:
+ return {"input": 0.0, "output": 0.0}
+ return {
+ "input": float(entry.input_cost_per_million or _ZERO),
+ "output": float(entry.output_cost_per_million or _ZERO),
+ }
+
+
+def estimate_cost_usd(
+ model: str,
+ input_tokens: int,
+ output_tokens: int,
+ *,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
+ api_key: Optional[str] = None,
+) -> float:
+ """Backward-compatible helper for legacy callers.
+
+ This uses non-cached input/output only. New code should call
+ `estimate_usage_cost()` with canonical usage buckets.
+ """
+ result = estimate_usage_cost(
+ model,
+ CanonicalUsage(input_tokens=input_tokens, output_tokens=output_tokens),
+ provider=provider,
+ base_url=base_url,
+ api_key=api_key,
+ )
+ return float(result.amount_usd or _ZERO)
+
+
+def format_duration_compact(seconds: float) -> str:
+ if seconds < 60:
+ return f"{seconds:.0f}s"
+ minutes = seconds / 60
+ if minutes < 60:
+ return f"{minutes:.0f}m"
+ hours = minutes / 60
+ if hours < 24:
+ remaining_min = int(minutes % 60)
+ return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
+ days = hours / 24
+ return f"{days:.1f}d"
+
+
+def format_token_count_compact(value: int) -> str:
+ abs_value = abs(int(value))
+ if abs_value < 1_000:
+ return str(int(value))
+
+ sign = "-" if value < 0 else ""
+ units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
+ for threshold, suffix in units:
+ if abs_value >= threshold:
+ scaled = abs_value / threshold
+ if scaled < 10:
+ text = f"{scaled:.2f}"
+ elif scaled < 100:
+ text = f"{scaled:.1f}"
+ else:
+ text = f"{scaled:.0f}"
+ if "." in text:
+ text = text.rstrip("0").rstrip(".")
+ return f"{sign}{text}{suffix}"
+
+ return f"{value:,}"
diff --git a/batch_runner.py b/batch_runner.py
index 865c10f3935..ed00665eab8 100644
--- a/batch_runner.py
+++ b/batch_runner.py
@@ -128,6 +128,7 @@ def _extract_tool_stats(messages: List[Dict[str, Any]]) -> Dict[str, Dict[str, i
# Track tool calls from assistant messages
if msg["role"] == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
for tool_call in msg["tool_calls"]:
+ if not tool_call or not isinstance(tool_call, dict): continue
tool_name = tool_call["function"]["name"]
tool_call_id = tool_call["id"]
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 2bfe297e3fd..acdc4ff2deb 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -51,6 +51,20 @@ model:
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data
# # data_collection: "deny"
+# =============================================================================
+# Smart Model Routing (optional)
+# =============================================================================
+# Use a cheaper model for short/simple turns while keeping your main model for
+# more complex requests. Disabled by default.
+#
+# smart_model_routing:
+# enabled: true
+# max_simple_chars: 160
+# max_simple_words: 28
+# cheap_model:
+# provider: openrouter
+# model: google/gemini-2.5-flash
+
# =============================================================================
# Git Worktree Isolation
# =============================================================================
@@ -76,8 +90,9 @@ model:
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
terminal:
backend: "local"
- cwd: "." # For local backend: "." = current directory. Ignored for remote backends.
+ cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
timeout: 180
+ docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
lifetime_seconds: 300
# sudo_password: "" # Enable sudo commands (pipes via sudo -S) - SECURITY WARNING: plaintext!
@@ -107,6 +122,13 @@ terminal:
# timeout: 180
# lifetime_seconds: 300
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
+# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
+# # Optional: explicitly forward selected env vars into Docker.
+# # These values come from your current shell first, then ~/.hermes/.env.
+# # Warning: anything forwarded here is visible to commands run in the container.
+# docker_forward_env:
+# - "GITHUB_TOKEN"
+# - "NPM_TOKEN"
# -----------------------------------------------------------------------------
# OPTION 4: Singularity/Apptainer container
@@ -178,6 +200,20 @@ terminal:
# Example (add to your terminal section):
# sudo_password: "your-password-here"
+# =============================================================================
+# Security Scanning (tirith)
+# =============================================================================
+# Optional pre-exec command security scanning via tirith.
+# Detects homograph URLs, pipe-to-shell, terminal injection, env manipulation.
+# Install: brew install sheeki03/tap/tirith
+# Docs: https://github.com/sheeki03/tirith
+#
+# security:
+# tirith_enabled: true # Enable/disable tirith scanning
+# tirith_path: "tirith" # Path to tirith binary (supports ~ expansion)
+# tirith_timeout: 5 # Scan timeout in seconds
+# tirith_fail_open: true # Allow commands if tirith unavailable
+
# =============================================================================
# Browser Tool Configuration
# =============================================================================
@@ -196,19 +232,34 @@ browser:
# 1. Tracks actual token usage from API responses (not estimates)
# 2. When prompt_tokens >= threshold% of model's context_length, triggers compression
# 3. Protects first 3 turns (system prompt, initial request, first response)
-# 4. Protects last 4 turns (recent context is most relevant)
+# 4. Protects last N turns (default 20 messages = ~10 full turns of recent context)
# 5. Summarizes middle turns using a fast/cheap model
# 6. Inserts summary as a user message, continues conversation seamlessly
#
+# Post-compression tail budget is target_ratio ร threshold ร context_length:
+# 200K context, threshold 0.50, ratio 0.20 โ 20K tokens of recent tail preserved
+# 1M context, threshold 0.50, ratio 0.20 โ 100K tokens of recent tail preserved
+#
compression:
# Enable automatic context compression (default: true)
# Set to false if you prefer to manage context manually or want errors on overflow
enabled: true
- # Trigger compression at this % of model's context limit (default: 0.85 = 85%)
+ # Trigger compression at this % of model's context limit (default: 0.50 = 50%)
# Lower values = more aggressive compression, higher values = compress later
- threshold: 0.85
+ threshold: 0.50
+ # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)
+ # e.g. 20% of 50% threshold = 10% of total context kept as recent messages.
+ # Summary output is separately capped at 12K tokens (Gemini output limit).
+ # Range: 0.10 - 0.80
+ target_ratio: 0.20
+
+ # Number of most-recent messages to always preserve (default: 20 โ 10 full turns)
+ # Higher values keep more recent conversation intact at the cost of more aggressive
+ # compression of older turns.
+ protect_last_n: 20
+
# Model to use for generating summaries (fast/cheap recommended)
# This model compresses the middle turns into a concise summary.
# IMPORTANT: it receives the full middle section of the conversation, so it
@@ -319,6 +370,25 @@ session_reset:
idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
+# When true, group/channel chats use one session per participant when the platform
+# provides a user ID. This is the secure default and prevents users in the same
+# room from sharing context, interrupts, and token costs. Set false only if you
+# explicitly want one shared "room brain" per group/channel.
+group_sessions_per_user: true
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Gateway Streaming
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Stream tokens to messaging platforms in real-time. The bot sends a message
+# on first token, then progressively edits it as more tokens arrive.
+# Disabled by default โ enable to try the streaming UX on Telegram/Discord/Slack.
+streaming:
+ enabled: false
+ # transport: edit # "edit" = progressive editMessageText
+ # edit_interval: 0.3 # seconds between message edits
+ # buffer_threshold: 40 # chars before forcing an edit flush
+ # cursor: " โ" # cursor shown during streaming
+
# =============================================================================
# Skills Configuration
# =============================================================================
@@ -369,7 +439,7 @@ agent:
# Toolsets
# =============================================================================
# Control which tools the agent has access to.
-# Use "all" to enable everything, or specify individual toolsets.
+# Use `hermes tools` to interactively enable/disable tools per platform.
# =============================================================================
# Platform Toolsets (per-platform tool configuration)
@@ -442,7 +512,7 @@ platform_toolsets:
# moa - mixture_of_agents (requires OPENROUTER_API_KEY)
# todo - todo (in-memory task planning, no deps)
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI key)
-# cronjob - schedule_cronjob, list_cronjobs, remove_cronjob
+# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks)
# rl - rl_list_environments, rl_start_training, etc. (requires TINKER_API_KEY)
#
# PRESETS (curated bundles):
@@ -478,53 +548,11 @@ platform_toolsets:
# debugging - terminal + web + file (for troubleshooting)
# safe - web + vision + moa (no terminal access)
-# -----------------------------------------------------------------------------
-# OPTION 1: Enable all tools (default)
-# -----------------------------------------------------------------------------
-toolsets:
- - all
-
-# -----------------------------------------------------------------------------
-# OPTION 2: Minimal - just web search and terminal
-# Great for: Simple coding tasks, quick lookups
-# -----------------------------------------------------------------------------
-# toolsets:
-# - web
-# - terminal
-
-# -----------------------------------------------------------------------------
-# OPTION 3: Research mode - no execution capabilities
-# Great for: Safe information gathering, research tasks
-# -----------------------------------------------------------------------------
-# toolsets:
-# - web
-# - vision
-# - skills
-
-# -----------------------------------------------------------------------------
-# OPTION 4: Full automation - browser + terminal
-# Great for: Web scraping, automation tasks, testing
-# -----------------------------------------------------------------------------
-# toolsets:
-# - terminal
-# - browser
-# - web
-
-# -----------------------------------------------------------------------------
-# OPTION 5: Creative mode - vision + image generation
-# Great for: Design work, image analysis, creative tasks
-# -----------------------------------------------------------------------------
-# toolsets:
-# - vision
-# - image_gen
-# - web
-
-# -----------------------------------------------------------------------------
-# OPTION 6: Safe mode - no terminal or browser
-# Great for: Restricted environments, untrusted queries
-# -----------------------------------------------------------------------------
-# toolsets:
-# - safe
+# NOTE: The top-level "toolsets" key is deprecated and ignored.
+# Tool configuration is managed per-platform via platform_toolsets above.
+# Use `hermes tools` to configure interactively, or edit platform_toolsets directly.
+#
+# CLI override: hermes chat --toolsets terminal,web,file
# =============================================================================
# MCP (Model Context Protocol) Servers
@@ -660,6 +688,12 @@ display:
# Toggle at runtime with /verbose in the CLI
tool_progress: all
+ # What Enter does when Hermes is already busy in the CLI.
+ # interrupt: Interrupt the current run and redirect Hermes (default)
+ # queue: Queue your message for the next turn
+ # Ctrl+C always interrupts regardless of this setting.
+ busy_input_mode: interrupt
+
# Background process notifications (gateway/messaging only).
# Controls how chatty the process watcher is when you use
# terminal(background=true, check_interval=...) from Telegram/Discord/etc.
@@ -669,6 +703,7 @@ display:
# all: Running output updates + final message (default)
background_process_notifications: all
+
# Play terminal bell when agent finishes a response.
# Useful for long-running tasks โ your terminal will ding when the agent is done.
# Works over SSH. Most terminals can be configured to flash the taskbar or play a sound.
@@ -679,6 +714,12 @@ display:
# Toggle at runtime with /reasoning show or /reasoning hide.
show_reasoning: false
+ # Stream tokens to the terminal as they arrive instead of waiting for the
+ # full response. The response box opens on first token and text appears
+ # line-by-line. Tool calls are still captured silently.
+ # Stream tokens to the terminal in real-time. Disable to wait for full responses.
+ streaming: true
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Skin / Theme
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -719,3 +760,14 @@ display:
# tool_prefix: "โ" # Tool output line prefix (default: โ)
#
skin: default
+
+# =============================================================================
+# Privacy
+# =============================================================================
+# privacy:
+# # Redact PII from the LLM context prompt.
+# # When true, phone numbers are stripped and user/chat IDs are replaced
+# # with deterministic hashes before being sent to the model.
+# # Names and usernames are NOT affected (user-chosen, publicly visible).
+# # Routing/delivery still uses the original values internally.
+# redact_pii: false
diff --git a/cli.py b/cli.py
old mode 100755
new mode 100644
index 04794230867..9c7f4594ab5
--- a/cli.py
+++ b/cli.py
@@ -8,6 +8,7 @@
Usage:
python cli.py # Start interactive mode with all tools
python cli.py --toolsets web,terminal # Start with specific toolsets
+ python cli.py --skills hermes-agent-dev,github-auth
python cli.py -q "your question" # Single query mode
python cli.py --list-tools # List available tools and exit
"""
@@ -18,6 +19,8 @@
import sys
import json
import atexit
+import tempfile
+import time
import uuid
import textwrap
from contextlib import contextmanager
@@ -28,7 +31,6 @@
logger = logging.getLogger(__name__)
# Suppress startup messages for clean CLI experience
-os.environ["MSWEA_SILENT_STARTUP"] = "1" # mini-swe-agent
os.environ["HERMES_QUIET"] = "1" # Our own modules
import yaml
@@ -55,29 +57,26 @@
import threading
import queue
+from agent.usage_pricing import (
+ CanonicalUsage,
+ estimate_usage_cost,
+ format_duration_compact,
+ format_token_count_compact,
+)
+from hermes_cli.banner import _format_context_length
+
_COMMAND_SPINNER_FRAMES = ("โ ", "โ ", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ", "โ ")
-# Load .env from ~/.hermes/.env first, then project root as dev fallback
-from dotenv import load_dotenv
-from hermes_constants import OPENROUTER_BASE_URL
+# Load .env from ~/.hermes/.env first, then project root as dev fallback.
+# User-managed env files should override stale shell exports on restart.
+from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
+from hermes_cli.env_loader import load_hermes_dotenv
-_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
-_user_env = _hermes_home / ".env"
+_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
-if _user_env.exists():
- try:
- load_dotenv(dotenv_path=_user_env, encoding="utf-8")
- except UnicodeDecodeError:
- load_dotenv(dotenv_path=_user_env, encoding="latin-1")
-elif _project_env.exists():
- try:
- load_dotenv(dotenv_path=_project_env, encoding="utf-8")
- except UnicodeDecodeError:
- load_dotenv(dotenv_path=_project_env, encoding="latin-1")
+load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
-# Point mini-swe-agent at ~/.hermes/ so it shares our config
-os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home))
# =============================================================================
# Configuration Loading
@@ -96,7 +95,7 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
return []
path = Path(file_path).expanduser()
if not path.is_absolute():
- path = Path.home() / ".hermes" / path
+ path = _hermes_home / path
if not path.exists():
logger.warning("Prefill messages file not found: %s", path)
return []
@@ -113,21 +112,12 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
def _parse_reasoning_config(effort: str) -> dict | None:
- """Parse a reasoning effort level into an OpenRouter reasoning config dict.
-
- Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
- Returns None to use the default (medium), or a config dict to override.
- """
- if not effort or not effort.strip():
- return None
- effort = effort.strip().lower()
- if effort == "none":
- return {"enabled": False}
- valid = ("xhigh", "high", "medium", "low", "minimal")
- if effort in valid:
- return {"enabled": True, "effort": effort}
- logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
- return None
+ """Parse a reasoning effort level into an OpenRouter reasoning config dict."""
+ from hermes_constants import parse_reasoning_effort
+ result = parse_reasoning_effort(effort)
+ if effort and effort.strip() and result is None:
+ logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
+ return result
def load_cli_config() -> Dict[str, Any]:
@@ -141,16 +131,16 @@ def load_cli_config() -> Dict[str, Any]:
Environment variables take precedence over config file values.
Returns default values if no config file exists.
"""
- # Check user config first (~/.hermes/config.yaml)
- user_config_path = Path.home() / '.hermes' / 'config.yaml'
+ # Check user config first ({HERMES_HOME}/config.yaml)
+ user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
-
+
# Use user config if it exists, otherwise project config
if user_config_path.exists():
config_path = user_config_path
else:
config_path = project_config_path
-
+
# Default configuration
defaults = {
"model": {
@@ -163,11 +153,13 @@ def load_cli_config() -> Dict[str, Any]:
"cwd": ".", # "." is resolved to os.getcwd() at runtime
"timeout": 60,
"lifetime_seconds": 300,
- "docker_image": "python:3.11",
- "singularity_image": "docker://python:3.11",
- "modal_image": "python:3.11",
+ "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
+ "docker_forward_env": [],
+ "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
+ "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_volumes": [], # host:container volume mounts for Docker backend
+ "docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
},
"browser": {
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
@@ -176,7 +168,13 @@ def load_cli_config() -> Dict[str, Any]:
"compression": {
"enabled": True, # Auto-compress when approaching context limit
"threshold": 0.50, # Compress at 50% of model's context limit
- "summary_model": "google/gemini-3-flash-preview", # Fast/cheap model for summaries
+ "summary_model": "", # Model for summaries (empty = use main model)
+ },
+ "smart_model_routing": {
+ "enabled": False,
+ "max_simple_chars": 160,
+ "max_simple_words": 28,
+ "cheap_model": {},
},
"agent": {
"max_turns": 90, # Default max tool-calling iterations (shared with subagents)
@@ -201,11 +199,14 @@ def load_cli_config() -> Dict[str, Any]:
"hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
},
},
- "toolsets": ["all"],
+
"display": {
"compact": False,
"resume_display": "full",
"show_reasoning": False,
+ "streaming": True,
+ "busy_input_mode": "interrupt",
+
"skin": "default",
},
"clarify": {
@@ -215,11 +216,27 @@ def load_cli_config() -> Dict[str, Any]:
"timeout": 300, # Max seconds a sandbox script can run before being killed (5 min)
"max_tool_calls": 50, # Max RPC tool calls per execution
},
+ "auxiliary": {
+ "vision": {
+ "provider": "auto",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ },
+ "web_extract": {
+ "provider": "auto",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ },
+ },
"delegation": {
"max_iterations": 45, # Max tool-calling turns per child agent
"default_toolsets": ["terminal", "file", "web"], # Default toolsets for subagents
"model": "", # Subagent model override (empty = inherit parent model)
"provider": "", # Subagent provider override (empty = inherit parent provider)
+ "base_url": "", # Direct OpenAI-compatible endpoint for subagents
+ "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
},
}
@@ -245,6 +262,18 @@ def load_cli_config() -> Dict[str, Any]:
elif isinstance(file_config["model"], dict):
# Old format: model is a dict with default/base_url
defaults["model"].update(file_config["model"])
+
+ # Root-level provider and base_url override model config.
+ # Users may write:
+ # model: kimi-k2.5:cloud
+ # provider: custom
+ # base_url: http://localhost:11434/v1
+ # These root-level keys must be merged into defaults["model"] so
+ # they are picked up by CLI provider resolution.
+ if "provider" in file_config and file_config["provider"]:
+ defaults["model"]["provider"] = file_config["provider"]
+ if "base_url" in file_config and file_config["base_url"]:
+ defaults["model"]["base_url"] = file_config["base_url"]
# Deep merge file_config into defaults.
# First: merge keys that exist in both (deep-merge dicts, overwrite scalars)
@@ -273,7 +302,11 @@ def load_cli_config() -> Dict[str, Any]:
defaults["agent"]["max_turns"] = file_config["max_turns"]
except Exception as e:
logger.warning("Failed to load cli-config.yaml: %s", e)
-
+
+ # Expand ${ENV_VAR} references in config values before bridging to env vars.
+ from hermes_cli.config import _expand_env_vars
+ defaults = _expand_env_vars(defaults)
+
# Apply terminal config to environment variables (so terminal_tool picks them up)
terminal_config = defaults.get("terminal", {})
@@ -303,6 +336,7 @@ def load_cli_config() -> Dict[str, Any]:
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
+ "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
@@ -317,7 +351,10 @@ def load_cli_config() -> Dict[str, Any]:
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
+ "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
+ # Persistent shell (non-local backends)
+ "persistent_shell": "TERMINAL_PERSISTENT_SHELL",
# Sudo support (works with all backends)
"sudo_password": "SUDO_PASSWORD",
}
@@ -347,41 +384,51 @@ def load_cli_config() -> Dict[str, Any]:
if config_key in browser_config:
os.environ[env_var] = str(browser_config[config_key])
- # Apply compression config to environment variables
- compression_config = defaults.get("compression", {})
- compression_env_mappings = {
- "enabled": "CONTEXT_COMPRESSION_ENABLED",
- "threshold": "CONTEXT_COMPRESSION_THRESHOLD",
- "summary_model": "CONTEXT_COMPRESSION_MODEL",
- "summary_provider": "CONTEXT_COMPRESSION_PROVIDER",
- }
-
- for config_key, env_var in compression_env_mappings.items():
- if config_key in compression_config:
- os.environ[env_var] = str(compression_config[config_key])
-
- # Apply auxiliary model overrides to environment variables.
- # Vision and web_extract each have their own provider + model pair.
- # (Compression is handled in the compression section above.)
+ # Apply auxiliary model/direct-endpoint overrides to environment variables.
+ # Vision and web_extract each have their own provider/model/base_url/api_key tuple.
+ # Compression config is read directly from config.yaml by run_agent.py and
+ # auxiliary_client.py โ no env var bridging needed.
# Only set env vars for non-empty / non-default values so auto-detection
# still works.
auxiliary_config = defaults.get("auxiliary", {})
auxiliary_task_env = {
- # config key โ (provider env var, model env var)
- "vision": ("AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL"),
- "web_extract": ("AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL"),
+ # config key โ env var mapping
+ "vision": {
+ "provider": "AUXILIARY_VISION_PROVIDER",
+ "model": "AUXILIARY_VISION_MODEL",
+ "base_url": "AUXILIARY_VISION_BASE_URL",
+ "api_key": "AUXILIARY_VISION_API_KEY",
+ },
+ "web_extract": {
+ "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER",
+ "model": "AUXILIARY_WEB_EXTRACT_MODEL",
+ "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL",
+ "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY",
+ },
+ "approval": {
+ "provider": "AUXILIARY_APPROVAL_PROVIDER",
+ "model": "AUXILIARY_APPROVAL_MODEL",
+ "base_url": "AUXILIARY_APPROVAL_BASE_URL",
+ "api_key": "AUXILIARY_APPROVAL_API_KEY",
+ },
}
- for task_key, (prov_env, model_env) in auxiliary_task_env.items():
+ for task_key, env_map in auxiliary_task_env.items():
task_cfg = auxiliary_config.get(task_key, {})
if not isinstance(task_cfg, dict):
continue
prov = str(task_cfg.get("provider", "")).strip()
model = str(task_cfg.get("model", "")).strip()
+ base_url = str(task_cfg.get("base_url", "")).strip()
+ api_key = str(task_cfg.get("api_key", "")).strip()
if prov and prov != "auto":
- os.environ[prov_env] = prov
+ os.environ[env_map["provider"]] = prov
if model:
- os.environ[model_env] = model
+ os.environ[env_map["model"]] = model
+ if base_url:
+ os.environ[env_map["base_url"]] = base_url
+ if api_key:
+ os.environ[env_map["api_key"]] = api_key
# Security settings
security_config = defaults.get("security", {})
@@ -402,10 +449,22 @@ def load_cli_config() -> Dict[str, Any]:
except Exception:
pass # Skin engine is optional โ default skin used if unavailable
+# 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
+# close TCP transports bound to dead worker loops โ producing
+# "Event loop is closed" / "Press ENTER to continue..." errors.
+try:
+ from agent.auxiliary_client import neuter_async_httpx_del
+ neuter_async_httpx_del()
+except Exception:
+ pass
+
from rich import box as rich_box
from rich.console import Console
+from rich.markup import escape as _escape
from rich.panel import Panel
-from rich.table import Table
+from rich.text import Text as _RichText
import fire
@@ -414,22 +473,18 @@ def load_cli_config() -> Dict[str, Any]:
from model_tools import get_tool_definitions, get_toolset_for_tool
# Extracted CLI modules (Phase 3)
-from hermes_cli.banner import (
- cprint as _cprint, _GOLD, _BOLD, _DIM, _RST,
- VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
- get_available_skills as _get_available_skills,
- build_welcome_banner,
-)
-from hermes_cli.commands import COMMANDS, SlashCommandCompleter
-from hermes_cli import callbacks as _callbacks
-from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
+from hermes_cli.banner import build_welcome_banner
+from hermes_cli.commands import SlashCommandCompleter, SlashCommandAutoSuggest
+from toolsets import get_all_toolsets, get_toolset_info, validate_toolset
-# Cron job system for scheduled tasks (CRUD only โ execution is handled by the gateway)
-from cron import create_job, list_jobs, remove_job, get_job
+# Cron job system for scheduled tasks (execution is handled by the gateway)
+from cron import get_job
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
from tools.terminal_tool import set_sudo_password_callback, set_approval_callback
+from tools.skills_tool import set_secret_capture_callback
+from hermes_cli.callbacks import prompt_for_secret
from tools.browser_tool import _emergency_cleanup_all_sessions as _cleanup_all_browsers
# Guard to prevent cleanup from running multiple times on exit
@@ -454,6 +509,14 @@ def _run_cleanup():
shutdown_mcp_servers()
except Exception:
pass
+ # Close cached auxiliary LLM clients (sync + async) so that
+ # AsyncHttpxClientWrapper.__del__ doesn't fire on a closed event loop
+ # and trigger prompt_toolkit's "Press ENTER to continue..." handler.
+ try:
+ from agent.auxiliary_client import shutdown_cached_clients
+ shutdown_cached_clients()
+ except Exception:
+ pass
# =============================================================================
@@ -479,6 +542,15 @@ def _git_repo_root() -> Optional[str]:
return None
+def _path_is_within_root(path: Path, root: Path) -> bool:
+ """Return True when a resolved path stays within the expected root."""
+ try:
+ path.relative_to(root)
+ return True
+ except ValueError:
+ return False
+
+
def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
"""Create an isolated git worktree for this CLI session.
@@ -532,12 +604,29 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
include_file = Path(repo_root) / ".worktreeinclude"
if include_file.exists():
try:
+ repo_root_resolved = Path(repo_root).resolve()
+ wt_path_resolved = wt_path.resolve()
for line in include_file.read_text().splitlines():
entry = line.strip()
if not entry or entry.startswith("#"):
continue
src = Path(repo_root) / entry
dst = wt_path / entry
+ # Prevent path traversal and symlink escapes: both the resolved
+ # source and the resolved destination must stay inside their
+ # expected roots before any file or symlink operation happens.
+ try:
+ src_resolved = src.resolve(strict=False)
+ dst_resolved = dst.resolve(strict=False)
+ except (OSError, ValueError):
+ logger.debug("Skipping invalid .worktreeinclude entry: %s", entry)
+ continue
+ if not _path_is_within_root(src_resolved, repo_root_resolved):
+ logger.warning("Skipping .worktreeinclude entry outside repo root: %s", entry)
+ continue
+ if not _path_is_within_root(dst_resolved, wt_path_resolved):
+ logger.warning("Skipping .worktreeinclude entry that escapes worktree: %s", entry)
+ continue
if src.is_file():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(src), str(dst))
@@ -545,7 +634,7 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
# Symlink directories (faster, saves disk)
if not dst.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
- os.symlink(str(src.resolve()), str(dst))
+ os.symlink(str(src_resolved), str(dst))
except Exception as e:
logger.debug("Error copying .worktreeinclude entries: %s", e)
@@ -689,11 +778,29 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
# - Dim: #B8860B (muted text)
# ANSI building blocks for conversation display
-_GOLD = "\033[1;33m" # Bold yellow โ closest universal match to the gold theme
+_GOLD = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold โ matches Rich Panel gold
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
+def _accent_hex() -> str:
+ """Return the active skin accent color for legacy CLI output lines."""
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ return get_active_skin().get_color("ui_accent", "#FFBF00")
+ except Exception:
+ return "#FFBF00"
+
+
+def _rich_text_from_ansi(text: str) -> _RichText:
+ """Safely render assistant/tool output that may contain ANSI escapes.
+
+ Using Rich Text.from_ansi preserves literal bracketed text like
+ ``[not markup]`` while still interpreting real ANSI color codes.
+ """
+ return _RichText.from_ansi(text or "")
+
+
def _cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's native renderer.
@@ -716,7 +823,12 @@ class ChatConsole:
def __init__(self):
from io import StringIO
self._buffer = StringIO()
- self._inner = Console(file=self._buffer, force_terminal=True, highlight=False)
+ self._inner = Console(
+ file=self._buffer,
+ force_terminal=True,
+ color_system="truecolor",
+ highlight=False,
+ )
def print(self, *args, **kwargs):
self._buffer.seek(0)
@@ -783,240 +895,52 @@ def _build_compact_banner() -> str:
)
-def _get_available_skills() -> Dict[str, List[str]]:
- """
- Scan ~/.hermes/skills/ and return skills grouped by category.
-
- Returns:
- Dict mapping category name to list of skill names
- """
- import os
-
- hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- skills_dir = hermes_home / "skills"
- skills_by_category = {}
-
- if not skills_dir.exists():
- return skills_by_category
-
- for skill_file in skills_dir.rglob("SKILL.md"):
- rel_path = skill_file.relative_to(skills_dir)
- parts = rel_path.parts
-
- if len(parts) >= 2:
- category = parts[0]
- skill_name = parts[-2]
- else:
- category = "general"
- skill_name = skill_file.parent.name
-
- skills_by_category.setdefault(category, []).append(skill_name)
-
- return skills_by_category
+# ============================================================================
+# Skill Slash Commands โ dynamic commands generated from installed skills
+# ============================================================================
-def _format_context_length(tokens: int) -> str:
- """Format a token count for display (e.g. 128000 โ '128K', 1048576 โ '1M')."""
- if tokens >= 1_000_000:
- val = tokens / 1_000_000
- return f"{val:g}M"
- elif tokens >= 1_000:
- val = tokens / 1_000
- return f"{val:g}K"
- return str(tokens)
+from agent.skill_commands import (
+ scan_skill_commands,
+ build_skill_invocation_message,
+ build_plan_path,
+ build_preloaded_skills_prompt,
+)
+_skill_commands = scan_skill_commands()
-def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dict] = None, enabled_toolsets: List[str] = None, session_id: str = None, context_length: int = None):
- """
- Build and print a Claude Code-style welcome banner with caduceus on left and info on right.
-
- Args:
- console: Rich Console instance for printing
- model: The current model name (e.g., "anthropic/claude-opus-4")
- cwd: Current working directory
- tools: List of tool definitions
- enabled_toolsets: List of enabled toolset names
- session_id: Unique session identifier for logging
- context_length: Model's context window size in tokens
- """
- from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
-
- tools = tools or []
- enabled_toolsets = enabled_toolsets or []
-
- # Get unavailable tools info for coloring
- _, unavailable_toolsets = check_tool_availability(quiet=True)
- disabled_tools = set()
- for item in unavailable_toolsets:
- disabled_tools.update(item.get("tools", []))
-
- # Build the side-by-side content using a table for precise control
- layout_table = Table.grid(padding=(0, 2))
- layout_table.add_column("left", justify="center")
- layout_table.add_column("right", justify="left")
-
- # Build left content: caduceus + model info
- # Resolve skin colors for the banner
+
+def _get_plugin_cmd_handler_names() -> set:
+ """Return plugin command names (without slash prefix) for dispatch matching."""
try:
- from hermes_cli.skin_engine import get_active_skin
- _bskin = get_active_skin()
- _accent = _bskin.get_color("banner_accent", "#FFBF00")
- _dim = _bskin.get_color("banner_dim", "#B8860B")
- _text = _bskin.get_color("banner_text", "#FFF8DC")
- _session_c = _bskin.get_color("session_border", "#8B8682")
- _title_c = _bskin.get_color("banner_title", "#FFD700")
- _border_c = _bskin.get_color("banner_border", "#CD7F32")
- _agent_name = _bskin.get_branding("agent_name", "Hermes Agent")
+ from hermes_cli.plugins import get_plugin_manager
+ return set(get_plugin_manager()._plugin_commands.keys())
except Exception:
- _bskin = None
- _accent, _dim, _text = "#FFBF00", "#B8860B", "#FFF8DC"
- _session_c, _title_c, _border_c = "#8B8682", "#FFD700", "#CD7F32"
- _agent_name = "Hermes Agent"
-
- _hero = _bskin.banner_hero if hasattr(_bskin, 'banner_hero') and _bskin.banner_hero else HERMES_CADUCEUS
- left_lines = ["", _hero, ""]
-
- # Shorten model name for display
- model_short = model.split("/")[-1] if "/" in model else model
- if len(model_short) > 28:
- model_short = model_short[:25] + "..."
-
- ctx_str = f" [dim {_dim}]ยท[/] [dim {_dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
- left_lines.append(f"[{_accent}]{model_short}[/]{ctx_str} [dim {_dim}]ยท[/] [dim {_dim}]Nous Research[/]")
- left_lines.append(f"[dim {_dim}]{cwd}[/]")
-
- # Add session ID if provided
- if session_id:
- left_lines.append(f"[dim {_session_c}]Session: {session_id}[/]")
- left_content = "\n".join(left_lines)
-
- # Build right content: tools list grouped by toolset
- right_lines = []
- right_lines.append(f"[bold {_accent}]Available Tools[/]")
-
- # Group tools by toolset (include all possible tools, both enabled and disabled)
- toolsets_dict = {}
-
- # First, add all enabled tools
- for tool in tools:
- tool_name = tool["function"]["name"]
- toolset = get_toolset_for_tool(tool_name) or "other"
- if toolset not in toolsets_dict:
- toolsets_dict[toolset] = []
- toolsets_dict[toolset].append(tool_name)
-
- # Also add disabled toolsets so they show in the banner
- for item in unavailable_toolsets:
- # Map the internal toolset ID to display name
- toolset_id = item.get("id", item.get("name", "unknown"))
- display_name = f"{toolset_id}_tools" if not toolset_id.endswith("_tools") else toolset_id
- if display_name not in toolsets_dict:
- toolsets_dict[display_name] = []
- for tool_name in item.get("tools", []):
- if tool_name not in toolsets_dict[display_name]:
- toolsets_dict[display_name].append(tool_name)
-
- # Display tools grouped by toolset (compact format, max 8 groups)
- sorted_toolsets = sorted(toolsets_dict.keys())
- display_toolsets = sorted_toolsets[:8]
- remaining_toolsets = len(sorted_toolsets) - 8
-
- for toolset in display_toolsets:
- tool_names = toolsets_dict[toolset]
- # Color each tool name - red if disabled, normal if enabled
- colored_names = []
- for name in sorted(tool_names):
- if name in disabled_tools:
- colored_names.append(f"[red]{name}[/]")
- else:
- colored_names.append(f"[{_text}]{name}[/]")
-
- tools_str = ", ".join(colored_names)
- # Truncate if too long (accounting for markup)
- if len(", ".join(sorted(tool_names))) > 45:
- # Rebuild with truncation
- short_names = []
- length = 0
- for name in sorted(tool_names):
- if length + len(name) + 2 > 42:
- short_names.append("...")
- break
- short_names.append(name)
- length += len(name) + 2
- # Re-color the truncated list
- colored_names = []
- for name in short_names:
- if name == "...":
- colored_names.append("[dim]...[/]")
- elif name in disabled_tools:
- colored_names.append(f"[red]{name}[/]")
- else:
- colored_names.append(f"[{_text}]{name}[/]")
- tools_str = ", ".join(colored_names)
-
- right_lines.append(f"[dim {_dim}]{toolset}:[/] {tools_str}")
-
- if remaining_toolsets > 0:
- right_lines.append(f"[dim {_dim}](and {remaining_toolsets} more toolsets...)[/]")
-
- right_lines.append("")
-
- # Add skills section
- right_lines.append(f"[bold {_accent}]Available Skills[/]")
- skills_by_category = _get_available_skills()
- total_skills = sum(len(s) for s in skills_by_category.values())
-
- if skills_by_category:
- for category in sorted(skills_by_category.keys()):
- skill_names = sorted(skills_by_category[category])
- # Show first 8 skills, then "..." if more
- if len(skill_names) > 8:
- display_names = skill_names[:8]
- skills_str = ", ".join(display_names) + f" +{len(skill_names) - 8} more"
- else:
- skills_str = ", ".join(skill_names)
- # Truncate if still too long
- if len(skills_str) > 50:
- skills_str = skills_str[:47] + "..."
- right_lines.append(f"[dim {_dim}]{category}:[/] [{_text}]{skills_str}[/]")
- else:
- right_lines.append(f"[dim {_dim}]No skills installed[/]")
-
- right_lines.append("")
- right_lines.append(f"[dim {_dim}]{len(tools)} tools ยท {total_skills} skills ยท /help for commands[/]")
-
- right_content = "\n".join(right_lines)
-
- # Add to table
- layout_table.add_row(left_content, right_content)
-
- # Wrap in a panel with the title
- outer_panel = Panel(
- layout_table,
- title=f"[bold {_title_c}]{_agent_name} v{VERSION} ({RELEASE_DATE})[/]",
- border_style=_border_c,
- padding=(0, 2),
- )
-
- # Print the big logo โ use skin's custom logo if available
- console.print()
- term_width = shutil.get_terminal_size().columns
- if term_width >= 95:
- _logo = _bskin.banner_logo if hasattr(_bskin, 'banner_logo') and _bskin.banner_logo else HERMES_AGENT_LOGO
- console.print(_logo)
- console.print()
-
- # Print the panel with caduceus and info
- console.print(outer_panel)
+ return set()
-# ============================================================================
-# Skill Slash Commands โ dynamic commands generated from installed skills
-# ============================================================================
-
-from agent.skill_commands import scan_skill_commands, get_skill_commands, build_skill_invocation_message
+def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) -> list[str]:
+ """Normalize a CLI skills flag into a deduplicated list of skill identifiers."""
+ if not skills:
+ return []
-_skill_commands = scan_skill_commands()
+ if isinstance(skills, str):
+ raw_values = [skills]
+ elif isinstance(skills, (list, tuple)):
+ raw_values = [str(item) for item in skills if item is not None]
+ else:
+ raw_values = [str(skills)]
+
+ parsed: list[str] = []
+ seen: set[str] = set()
+ for raw in raw_values:
+ for part in raw.split(","):
+ normalized = part.strip()
+ if not normalized or normalized in seen:
+ continue
+ seen.add(normalized)
+ parsed.append(normalized)
+ return parsed
def save_config_value(key_path: str, value: any) -> bool:
@@ -1035,7 +959,7 @@ def save_config_value(key_path: str, value: any) -> bool:
True if successful, False otherwise
"""
# Use the same precedence as load_cli_config: user config first, then project config
- user_config_path = Path.home() / '.hermes' / 'config.yaml'
+ user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
config_path = user_config_path if user_config_path.exists() else project_config_path
@@ -1075,6 +999,8 @@ def save_config_value(key_path: str, value: any) -> bool:
return False
+
+
# ============================================================================
# HermesCLI Class
# ============================================================================
@@ -1121,15 +1047,31 @@ def __init__(
self.config = CLI_CONFIG
self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False)
# tool_progress: "off", "new", "all", "verbose" (from config.yaml display section)
- self.tool_progress_mode = CLI_CONFIG["display"].get("tool_progress", "all")
+ # YAML 1.1 parses bare `off` as boolean False โ normalise to string.
+ _raw_tp = CLI_CONFIG["display"].get("tool_progress", "all")
+ self.tool_progress_mode = "off" if _raw_tp is False else str(_raw_tp)
# resume_display: "full" (show history) | "minimal" (one-liner only)
self.resume_display = CLI_CONFIG["display"].get("resume_display", "full")
# bell_on_complete: play terminal bell (\a) when agent finishes a response
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
+ # busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
+ _bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
+ self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
+
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
+ # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml)
+ self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False)
+
+ # Streaming display state
+ self._stream_buf = "" # Partial line buffer for line-buffered rendering
+ self._stream_started = False # True once first delta arrives
+ self._stream_box_opened = False # True once the response box header is printed
+ self._reasoning_stream_started = False # True once live reasoning starts streaming
+ self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output
+
# Configuration - priority: CLI args > env vars > config file
# Model comes from: CLI arg or config.yaml (single source of truth).
# LLM_MODEL/OPENAI_MODEL env vars are NOT checked โ config.yaml is
@@ -1137,11 +1079,25 @@ def __init__(
# env vars would stomp each other.
_model_config = CLI_CONFIG.get("model", {})
_config_model = _model_config.get("default", "") if isinstance(_model_config, dict) else (_model_config or "")
- self.model = model or _config_model or "anthropic/claude-opus-4.6"
+ _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", "") 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
+ _detected = _auto_detect_local_model(_base_url)
+ if _detected:
+ self.model = _detected
# Track whether model was explicitly chosen by the user or fell back
# to the global default. Provider-specific normalisation may override
# the default silently but should warn when overriding an explicit choice.
- self._model_is_default = not model
+ # A config model that matches the global fallback is NOT considered an
+ # 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 == _FALLBACK_MODEL
+ )
self._explicit_api_key = api_key
self._explicit_base_url = base_url
@@ -1149,13 +1105,19 @@ def __init__(
# Provider selection is resolved lazily at use-time via _ensure_runtime_credentials().
self.requested_provider = (
provider
- or os.getenv("HERMES_INFERENCE_PROVIDER")
or CLI_CONFIG["model"].get("provider")
+ or os.getenv("HERMES_INFERENCE_PROVIDER")
or "auto"
)
self._provider_source: Optional[str] = None
self.provider = self.requested_provider
self.api_mode = "chat_completions"
+ self.acp_command: Optional[str] = None
+ self.acp_args: list[str] = []
+ self._request_headers_resolver = None
+ self._request_headers_key = None
+ self._payment_adapter = None
+ self._payment_config = None
self.base_url = (
base_url
or os.getenv("OPENAI_BASE_URL")
@@ -1164,7 +1126,7 @@ def __init__(
# Match key to resolved base_url: OpenRouter URL โ prefer OPENROUTER_API_KEY,
# custom endpoint โ prefer OPENAI_API_KEY (issue #560).
# Note: _ensure_runtime_credentials() re-resolves this before first use.
- if "openrouter.ai" in self.base_url:
+ if self.base_url and "openrouter.ai" in self.base_url:
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
else:
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
@@ -1228,6 +1190,10 @@ def __init__(
fb = CLI_CONFIG.get("fallback_model") or {}
self._fallback_model = fb if fb.get("provider") and fb.get("model") else None
+ # Optional cheap-vs-strong routing for simple turns
+ self._smart_model_routing = CLI_CONFIG.get("smart_model_routing", {}) or {}
+ self._active_agent_route_signature = None
+
# Agent will be initialized on first use
self.agent: Optional[AIAgent] = None
self._app = None # prompt_toolkit Application (set in run())
@@ -1241,8 +1207,8 @@ def __init__(
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
- except Exception:
- pass
+ except Exception as e:
+ logger.warning("Failed to initialize SessionDB โ session will NOT be indexed for search: %s", e)
# Deferred title: stored in memory until the session is created in the DB
self._pending_title: Optional[str] = None
@@ -1257,11 +1223,49 @@ def __init__(
self.session_id = f"{timestamp_str}_{short_uuid}"
# History file for persistent input recall across sessions
- self._history_file = Path.home() / ".hermes_history"
+ self._history_file = _hermes_home / ".hermes_history"
self._last_invalidate: float = 0.0 # throttle UI repaints
+ self._app = None
+
+ # State shared by interactive run() and single-query chat mode.
+ # These must exist before any direct chat() call because single-query
+ # mode does not go through run().
+ self._agent_running = False
+ self._pending_input = queue.Queue()
+ self._interrupt_queue = queue.Queue()
+ self._should_exit = False
+ self._last_ctrl_c_time = 0
+ self._clarify_state = None
+ self._clarify_freetext = False
+ self._clarify_deadline = 0
+ self._sudo_state = None
+ self._sudo_deadline = 0
+ self._approval_state = None
+ self._approval_deadline = 0
+ self._approval_lock = threading.Lock()
+ self._secret_state = None
+ self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
self._command_running = False
self._command_status = ""
+ self._attached_images: list[Path] = []
+ self._image_counter = 0
+ self.preloaded_skills: list[str] = []
+ self._startup_skills_line_shown = False
+
+ # Voice mode state (also reinitialized inside run() for interactive TUI).
+ self._voice_lock = threading.Lock()
+ self._voice_mode = False
+ self._voice_tts = False
+ self._voice_recorder = None
+ self._voice_recording = False
+ self._voice_processing = False
+ self._voice_continuous = False
+ self._voice_tts_done = threading.Event()
+ self._voice_tts_done.set()
+
+ # Status bar visibility (toggled via /statusbar)
+ self._status_bar_visible = True
# Background task tracking: {task_id: threading.Thread}
self._background_tasks: Dict[str, threading.Thread] = {}
@@ -1275,28 +1279,206 @@ def _invalidate(self, min_interval: float = 0.25) -> None:
self._last_invalidate = now
self._app.invalidate()
- def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
- """Strip provider prefixes and swap the default model for Codex.
+ def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
+ if percent_used is None:
+ return "class:status-bar-dim"
+ if percent_used >= 95:
+ return "class:status-bar-critical"
+ if percent_used > 80:
+ return "class:status-bar-bad"
+ if percent_used >= 50:
+ return "class:status-bar-warn"
+ return "class:status-bar-good"
+
+ def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> str:
+ safe_percent = max(0, min(100, percent_used or 0))
+ filled = round((safe_percent / 100) * width)
+ return f"[{('โ' * filled) + ('โ' * max(0, width - filled))}]"
+
+ def _get_status_bar_snapshot(self) -> Dict[str, Any]:
+ model_name = self.model or "unknown"
+ model_short = model_name.split("/")[-1] if "/" in model_name else model_name
+ if model_short.endswith(".gguf"):
+ model_short = model_short[:-5]
+ if len(model_short) > 26:
+ model_short = f"{model_short[:23]}..."
+
+ elapsed_seconds = max(0.0, (datetime.now() - self.session_start).total_seconds())
+ snapshot = {
+ "model_name": model_name,
+ "model_short": model_short,
+ "duration": format_duration_compact(elapsed_seconds),
+ "context_tokens": 0,
+ "context_length": None,
+ "context_percent": None,
+ "session_input_tokens": 0,
+ "session_output_tokens": 0,
+ "session_cache_read_tokens": 0,
+ "session_cache_write_tokens": 0,
+ "session_prompt_tokens": 0,
+ "session_completion_tokens": 0,
+ "session_total_tokens": 0,
+ "session_api_calls": 0,
+ "compressions": 0,
+ }
+
+ agent = getattr(self, "agent", None)
+ if not agent:
+ return snapshot
+
+ snapshot["session_input_tokens"] = getattr(agent, "session_input_tokens", 0) or 0
+ snapshot["session_output_tokens"] = getattr(agent, "session_output_tokens", 0) or 0
+ snapshot["session_cache_read_tokens"] = getattr(agent, "session_cache_read_tokens", 0) or 0
+ snapshot["session_cache_write_tokens"] = getattr(agent, "session_cache_write_tokens", 0) or 0
+ snapshot["session_prompt_tokens"] = getattr(agent, "session_prompt_tokens", 0) or 0
+ snapshot["session_completion_tokens"] = getattr(agent, "session_completion_tokens", 0) or 0
+ snapshot["session_total_tokens"] = getattr(agent, "session_total_tokens", 0) or 0
+ snapshot["session_api_calls"] = getattr(agent, "session_api_calls", 0) or 0
+
+ compressor = getattr(agent, "context_compressor", None)
+ if compressor:
+ context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
+ context_length = getattr(compressor, "context_length", 0) or 0
+ snapshot["context_tokens"] = context_tokens
+ snapshot["context_length"] = context_length or None
+ snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
+ if context_length:
+ snapshot["context_percent"] = max(0, min(100, round((context_tokens / context_length) * 100)))
+
+ return snapshot
+
+ def _build_status_bar_text(self, width: Optional[int] = None) -> str:
+ try:
+ snapshot = self._get_status_bar_snapshot()
+ if width is None:
+ try:
+ from prompt_toolkit.application import get_app
+ width = get_app().output.get_size().columns
+ except Exception:
+ width = shutil.get_terminal_size((80, 24)).columns
+ percent = snapshot["context_percent"]
+ percent_label = f"{percent}%" if percent is not None else "--"
+ duration_label = snapshot["duration"]
+
+ if width < 52:
+ return f"โ {snapshot['model_short']} ยท {duration_label}"
+ if width < 76:
+ parts = [f"โ {snapshot['model_short']}", percent_label]
+ parts.append(duration_label)
+ return " ยท ".join(parts)
+
+ 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 --"
- When the resolved provider is ``openai-codex``:
+ parts = [f"โ {snapshot['model_short']}", context_label, percent_label]
+ parts.append(duration_label)
+ return " โ ".join(parts)
+ except Exception:
+ return f"โ {self.model if getattr(self, 'model', None) else 'Hermes'}"
- 1. Strip any ``provider/`` prefix (the Codex Responses API only
- accepts bare model slugs like ``gpt-5.4``, not ``openai/gpt-5.4``).
- 2. If the active model is still the *untouched default* (user never
- explicitly chose a model), replace it with a Codex-compatible
- default so the first session doesn't immediately error.
+ def _get_status_bar_fragments(self):
+ if not self._status_bar_visible:
+ return []
+ try:
+ snapshot = self._get_status_bar_snapshot()
+ # Use prompt_toolkit's own terminal width when running inside the
+ # TUI โ shutil.get_terminal_size() can return stale or fallback
+ # values (especially on SSH) that differ from what prompt_toolkit
+ # actually renders, causing the fragments to overflow to a second
+ # line and produce duplicated status bar rows over long sessions.
+ try:
+ from prompt_toolkit.application import get_app
+ width = get_app().output.get_size().columns
+ except Exception:
+ width = shutil.get_terminal_size((80, 24)).columns
+ duration_label = snapshot["duration"]
- If the user explicitly chose a model โ *any* model โ we trust them
- and let the API be the judge. No allowlists, no slug checks.
+ if width < 52:
+ return [
+ ("class:status-bar", " โ "),
+ ("class:status-bar-strong", snapshot["model_short"]),
+ ("class:status-bar-dim", " ยท "),
+ ("class:status-bar-dim", duration_label),
+ ("class:status-bar", " "),
+ ]
- Returns True when the active model was changed.
- """
- if resolved_provider != "openai-codex":
- return False
+ 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:
+ 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),
+ ]
+ 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()} ")]
+ def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
+ """Normalize provider-specific model IDs and routing."""
current_model = (self.model or "").strip()
changed = False
+ if resolved_provider == "copilot":
+ try:
+ from hermes_cli.models import copilot_model_api_mode, normalize_copilot_model_id
+
+ canonical = normalize_copilot_model_id(current_model, api_key=self.api_key)
+ if canonical and canonical != current_model:
+ if not self._model_is_default:
+ self.console.print(
+ f"[yellow]โ ๏ธ Normalized Copilot model '{current_model}' to '{canonical}'.[/]"
+ )
+ self.model = canonical
+ current_model = canonical
+ changed = True
+
+ resolved_mode = copilot_model_api_mode(current_model, api_key=self.api_key)
+ if resolved_mode != self.api_mode:
+ self.api_mode = resolved_mode
+ changed = True
+ except Exception:
+ pass
+ return changed
+
+ if resolved_provider != "openai-codex":
+ return False
+
# 1. Strip provider prefix ("openai/gpt-5.4" โ "gpt-5.4")
if "/" in current_model:
slug = current_model.split("/", 1)[1]
@@ -1331,9 +1513,326 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool:
def _on_thinking(self, text: str) -> None:
"""Called by agent when thinking starts/stops. Updates TUI spinner."""
+ if not text:
+ self._flush_reasoning_preview(force=True)
self._spinner_text = text or ""
self._invalidate()
+ # โโ Streaming display โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def _current_reasoning_callback(self):
+ """Return the active reasoning display callback for the current mode."""
+ if self.show_reasoning and self.streaming_enabled:
+ return self._stream_reasoning_delta
+ if self.verbose and not self.show_reasoning:
+ return self._on_reasoning
+ return None
+
+ def _emit_reasoning_preview(self, reasoning_text: str) -> None:
+ """Render a buffered reasoning preview as a single [thinking] block."""
+ import re
+ import textwrap
+
+ preview_text = reasoning_text.strip()
+ if not preview_text:
+ return
+
+ try:
+ term_width = shutil.get_terminal_size().columns
+ except Exception:
+ term_width = 80
+ prefix = " [thinking] "
+ wrap_width = max(30, term_width - len(prefix) - 2)
+
+ paragraphs = []
+ raw_paragraphs = re.split(r"\n\s*\n+", preview_text.replace("\r\n", "\n"))
+ for paragraph in raw_paragraphs:
+ compact = " ".join(line.strip() for line in paragraph.splitlines() if line.strip())
+ if compact:
+ paragraphs.append(textwrap.fill(compact, width=wrap_width))
+ preview_text = "\n".join(paragraphs)
+ if not preview_text:
+ return
+
+ if self.verbose:
+ _cprint(f" {_DIM}[thinking] {preview_text}{_RST}")
+ return
+
+ lines = preview_text.splitlines()
+ if len(lines) > 5:
+ preview = "\n".join(lines[:5])
+ preview += f"\n ... ({len(lines) - 5} more lines)"
+ else:
+ preview = preview_text
+ _cprint(f" {_DIM}[thinking] {preview}{_RST}")
+
+ def _flush_reasoning_preview(self, *, force: bool = False) -> None:
+ """Flush buffered reasoning text at natural boundaries.
+
+ Some providers stream reasoning in tiny word or punctuation chunks.
+ Buffer them here so the preview path does not print one `[thinking]`
+ line per token.
+ """
+ buf = getattr(self, "_reasoning_preview_buf", "")
+ if not buf:
+ return
+
+ try:
+ term_width = shutil.get_terminal_size().columns
+ except Exception:
+ term_width = 80
+ target_width = max(40, term_width - len(" [thinking] ") - 4)
+
+ flush_text = ""
+
+ if force:
+ flush_text = buf
+ buf = ""
+ else:
+ line_break = buf.rfind("\n")
+ min_newline_flush = max(16, target_width // 3)
+ if line_break != -1 and (
+ line_break >= min_newline_flush
+ or buf.endswith("\n\n")
+ or buf.endswith(".\n")
+ or buf.endswith("!\n")
+ or buf.endswith("?\n")
+ or buf.endswith(":\n")
+ ):
+ flush_text = buf[: line_break + 1]
+ buf = buf[line_break + 1 :]
+ elif len(buf) >= target_width:
+ search_start = max(20, target_width // 2)
+ search_end = min(len(buf), max(target_width + (target_width // 3), target_width + 8))
+ cut = -1
+ for boundary in (" ", "\t", ".", "!", "?", ",", ";", ":"):
+ cut = max(cut, buf.rfind(boundary, search_start, search_end))
+ if cut != -1:
+ flush_text = buf[: cut + 1]
+ buf = buf[cut + 1 :]
+
+ self._reasoning_preview_buf = buf.lstrip() if flush_text else buf
+ if flush_text:
+ self._emit_reasoning_preview(flush_text)
+
+ def _stream_reasoning_delta(self, text: str) -> None:
+ """Stream reasoning/thinking tokens into a dim box above the response.
+
+ Opens a dim reasoning box on first token, streams line-by-line.
+ The box is closed automatically when content tokens start arriving
+ (via _stream_delta โ _emit_stream_text).
+
+ Once the response box is open, suppress any further reasoning
+ rendering โ a late thinking block (e.g. after an interrupt) would
+ otherwise draw a reasoning box inside the response box.
+ """
+ if not text:
+ return
+ self._reasoning_stream_started = True
+ self._reasoning_shown_this_turn = True
+ if getattr(self, "_stream_box_opened", False):
+ return
+
+ # Open reasoning box on first reasoning token
+ if not getattr(self, "_reasoning_box_opened", False):
+ self._reasoning_box_opened = True
+ w = shutil.get_terminal_size().columns
+ r_label = " Reasoning "
+ r_fill = w - 2 - len(r_label)
+ _cprint(f"\n{_DIM}โโ{r_label}{'โ' * max(r_fill - 1, 0)}โ{_RST}")
+
+ self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text
+
+ # Emit complete lines, and force-flush long partial lines so
+ # reasoning is visible in real-time even without newlines.
+ while "\n" in self._reasoning_buf:
+ line, self._reasoning_buf = self._reasoning_buf.split("\n", 1)
+ _cprint(f"{_DIM}{line}{_RST}")
+ if len(self._reasoning_buf) > 80:
+ _cprint(f"{_DIM}{self._reasoning_buf}{_RST}")
+ self._reasoning_buf = ""
+
+ def _close_reasoning_box(self) -> None:
+ """Close the live reasoning box if it's open."""
+ if getattr(self, "_reasoning_box_opened", False):
+ # Flush remaining reasoning buffer
+ buf = getattr(self, "_reasoning_buf", "")
+ if buf:
+ _cprint(f"{_DIM}{buf}{_RST}")
+ self._reasoning_buf = ""
+ w = shutil.get_terminal_size().columns
+ _cprint(f"{_DIM}โ{'โ' * (w - 2)}โ{_RST}")
+ self._reasoning_box_opened = False
+
+ def _stream_delta(self, text) -> None:
+ """Line-buffered streaming callback for real-time token rendering.
+
+ Receives text deltas from the agent as tokens arrive. Buffers
+ partial lines and emits complete lines via _cprint to work
+ reliably with prompt_toolkit's patch_stdout.
+
+ Reasoning/thinking blocks (, , etc.)
+ are suppressed during streaming since they'd display raw XML tags.
+ The agent strips them from the final response anyway.
+
+ A ``None`` value signals an intermediate turn boundary (tools are
+ about to execute). Flushes any open boxes and resets state so
+ tool feed lines render cleanly between turns.
+ """
+ if text is None:
+ self._flush_stream()
+ self._reset_stream_state()
+ return
+ if not text:
+ return
+
+ self._stream_started = True
+
+ # โโ Tag-based reasoning suppression โโ
+ # Track whether we're inside a reasoning/thinking block.
+ # These tags are model-generated (system prompt tells the model
+ # to use them) and get stripped from final_response. We must
+ # suppress them during streaming too โ unless show_reasoning is
+ # enabled, in which case we route the inner content to the
+ # reasoning display box instead of discarding it.
+ _OPEN_TAGS = ("", "", "", "", "")
+ _CLOSE_TAGS = (" ", " ", "", "", "")
+
+ # Append to a pre-filter buffer first
+ self._stream_prefilt = getattr(self, "_stream_prefilt", "") + text
+
+ # Check if we're entering a reasoning block
+ if not getattr(self, "_in_reasoning_block", False):
+ for tag in _OPEN_TAGS:
+ idx = self._stream_prefilt.find(tag)
+ if idx != -1:
+ # Emit everything before the tag
+ before = self._stream_prefilt[:idx]
+ if before:
+ self._emit_stream_text(before)
+ self._in_reasoning_block = True
+ self._stream_prefilt = self._stream_prefilt[idx + len(tag):]
+ break
+
+ # Could also be a partial open tag at the end โ hold it back
+ if not getattr(self, "_in_reasoning_block", False):
+ # Check for partial tag match at the end
+ safe = self._stream_prefilt
+ for tag in _OPEN_TAGS:
+ for i in range(1, len(tag)):
+ if self._stream_prefilt.endswith(tag[:i]):
+ safe = self._stream_prefilt[:-i]
+ break
+ if safe:
+ self._emit_stream_text(safe)
+ self._stream_prefilt = self._stream_prefilt[len(safe):]
+ return
+
+ # Inside a reasoning block โ look for close tag.
+ # Keep accumulating _stream_prefilt because close tags can arrive
+ # split across multiple tokens (e.g. "...").
+ if getattr(self, "_in_reasoning_block", False):
+ for tag in _CLOSE_TAGS:
+ idx = self._stream_prefilt.find(tag)
+ if idx != -1:
+ self._in_reasoning_block = False
+ # When show_reasoning is on, route inner content to
+ # the reasoning display box instead of discarding.
+ if self.show_reasoning:
+ inner = self._stream_prefilt[:idx]
+ if inner:
+ self._stream_reasoning_delta(inner)
+ after = self._stream_prefilt[idx + len(tag):]
+ self._stream_prefilt = ""
+ # Process remaining text after close tag through full
+ # filtering (it could contain another open tag)
+ if after:
+ self._stream_delta(after)
+ return
+ # When show_reasoning is on, stream reasoning content live
+ # instead of silently accumulating. Keep only the tail that
+ # could be a partial close tag prefix.
+ max_tag_len = max(len(t) for t in _CLOSE_TAGS)
+ if len(self._stream_prefilt) > max_tag_len:
+ if self.show_reasoning:
+ # Route the safe prefix to reasoning display
+ safe_reasoning = self._stream_prefilt[:-max_tag_len]
+ self._stream_reasoning_delta(safe_reasoning)
+ self._stream_prefilt = self._stream_prefilt[-max_tag_len:]
+ return
+
+ def _emit_stream_text(self, text: str) -> None:
+ """Emit filtered text to the streaming display."""
+ if not text:
+ return
+
+ # Close the live reasoning box before opening the response box
+ self._close_reasoning_box()
+
+ # Open the response box header on the very first visible text
+ if not self._stream_box_opened:
+ # Strip leading whitespace/newlines before first visible content
+ text = text.lstrip("\n")
+ if not text:
+ return
+ self._stream_box_opened = True
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ _skin = get_active_skin()
+ label = _skin.get_branding("response_label", "โ Hermes")
+ _text_hex = _skin.get_color("banner_text", "#FFF8DC")
+ except Exception:
+ label = "โ Hermes"
+ _text_hex = "#FFF8DC"
+ # Build a true-color ANSI escape for the response text color
+ # so streamed content matches the Rich Panel appearance.
+ try:
+ _r = int(_text_hex[1:3], 16)
+ _g = int(_text_hex[3:5], 16)
+ _b = int(_text_hex[5:7], 16)
+ self._stream_text_ansi = f"\033[38;2;{_r};{_g};{_b}m"
+ except (ValueError, IndexError):
+ self._stream_text_ansi = ""
+ w = shutil.get_terminal_size().columns
+ fill = w - 2 - len(label)
+ _cprint(f"\n{_GOLD}โญโ{label}{'โ' * max(fill - 1, 0)}โฎ{_RST}")
+
+ self._stream_buf += text
+
+ # Emit complete lines, keep partial remainder in buffer
+ _tc = getattr(self, "_stream_text_ansi", "")
+ while "\n" in self._stream_buf:
+ line, self._stream_buf = self._stream_buf.split("\n", 1)
+ _cprint(f"{_tc}{line}{_RST}" if _tc else line)
+
+ def _flush_stream(self) -> None:
+ """Emit any remaining partial line from the stream buffer and close the box."""
+ # Close reasoning box if still open (in case no content tokens arrived)
+ self._close_reasoning_box()
+
+ if self._stream_buf:
+ _tc = getattr(self, "_stream_text_ansi", "")
+ _cprint(f"{_tc}{self._stream_buf}{_RST}" if _tc else self._stream_buf)
+ self._stream_buf = ""
+
+ # Close the response box
+ if self._stream_box_opened:
+ w = shutil.get_terminal_size().columns
+ _cprint(f"{_GOLD}โฐ{'โ' * (w - 2)}โฏ{_RST}")
+
+ def _reset_stream_state(self) -> None:
+ """Reset streaming state before each agent invocation."""
+ self._stream_buf = ""
+ self._stream_started = False
+ self._stream_box_opened = False
+ self._reasoning_stream_started = False
+ self._stream_text_ansi = ""
+ self._stream_prefilt = ""
+ self._in_reasoning_block = False
+ self._reasoning_box_opened = False
+ self._reasoning_buf = ""
+ self._reasoning_preview_buf = ""
+
def _slow_command_status(self, command: str) -> str:
"""Return a user-facing status message for slower slash commands."""
cmd_lower = command.lower().strip()
@@ -1349,6 +1848,8 @@ def _slow_command_status(self, command: str) -> str:
return "Processing skills command..."
if cmd_lower == "/reload-mcp":
return "Reloading MCP servers..."
+ if cmd_lower.startswith("/browser"):
+ return "Configuring browser..."
return "Processing command..."
def _command_spinner_frame(self) -> str:
@@ -1399,9 +1900,29 @@ def _ensure_runtime_credentials(self) -> bool:
base_url = runtime.get("base_url")
resolved_provider = runtime.get("provider", "openrouter")
resolved_api_mode = runtime.get("api_mode", self.api_mode)
+ resolved_acp_command = runtime.get("command")
+ resolved_acp_args = list(runtime.get("args") or [])
+ resolved_request_headers_resolver = runtime.get("request_headers_resolver")
+ resolved_request_headers_key = runtime.get("request_headers_key")
+ resolved_payment_adapter = runtime.get("payment_adapter")
+ resolved_payment_config = runtime.get("payment_config")
if not isinstance(api_key, str) or not api_key:
- self.console.print("[bold red]Provider resolver returned an empty API key.[/]")
- return False
+ # Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
+ # don't require authentication. When a base_url IS configured but
+ # no API key was found, use a placeholder so the OpenAI SDK
+ # doesn't reject the request and local servers just ignore it.
+ _source = runtime.get("source", "")
+ _has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url
+ if _has_custom_base:
+ api_key = "no-key-required"
+ logger.debug(
+ "No API key for custom endpoint %s (source=%s), "
+ "using placeholder โ local servers typically ignore auth",
+ base_url, _source,
+ )
+ else:
+ self.console.print("[bold red]Provider resolver returned an empty API key.[/]")
+ return False
if not isinstance(base_url, str) or not base_url:
self.console.print("[bold red]Provider resolver returned an empty base URL.[/]")
return False
@@ -1410,9 +1931,20 @@ def _ensure_runtime_credentials(self) -> bool:
routing_changed = (
resolved_provider != self.provider
or resolved_api_mode != self.api_mode
+ or resolved_acp_command != self.acp_command
+ or resolved_acp_args != self.acp_args
+ or resolved_request_headers_key != self._request_headers_key
+ or resolved_payment_adapter != self._payment_adapter
+ or resolved_payment_config != self._payment_config
)
self.provider = resolved_provider
self.api_mode = resolved_api_mode
+ self.acp_command = resolved_acp_command
+ self.acp_args = resolved_acp_args
+ self._request_headers_resolver = resolved_request_headers_resolver
+ self._request_headers_key = resolved_request_headers_key
+ self._payment_adapter = resolved_payment_adapter
+ self._payment_config = resolved_payment_config
self._provider_source = runtime.get("source")
self.api_key = api_key
self.base_url = base_url
@@ -1425,10 +1957,32 @@ def _ensure_runtime_credentials(self) -> bool:
# routing, or the effective model changed.
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
self.agent = None
+ self._active_agent_route_signature = None
return True
- def _init_agent(self) -> bool:
+ def _resolve_turn_agent_config(self, user_message: str) -> dict:
+ """Resolve model/runtime overrides for a single user turn."""
+ from agent.smart_model_routing import resolve_turn_route
+
+ return resolve_turn_route(
+ user_message,
+ self._smart_model_routing,
+ {
+ "model": self.model,
+ "api_key": self.api_key,
+ "base_url": self.base_url,
+ "provider": self.provider,
+ "api_mode": self.api_mode,
+ "command": self.acp_command,
+ "args": list(self.acp_args or []),
+ "request_headers_resolver": self._request_headers_resolver,
+ "payment_adapter": self._payment_adapter,
+ "payment_config": self._payment_config,
+ },
+ )
+
+ def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None) -> bool:
"""
Initialize the agent on first use.
When resuming a session, restores conversation history from SQLite.
@@ -1448,7 +2002,7 @@ def _init_agent(self) -> bool:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception as e:
- logger.debug("SQLite session store not available: %s", e)
+ logger.warning("SQLite session store not available โ session will NOT be indexed: %s", e)
# If resuming, validate the session exists and load its history.
# _preload_resumed_session() may have already loaded it (called from
@@ -1467,13 +2021,16 @@ def _init_agent(self) -> bool:
title_part = ""
if session_meta.get("title"):
title_part = f" \"{session_meta['title']}\""
- _cprint(
- f"{_GOLD}โป Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
- f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
- f"{len(restored)} total messages){_RST}"
+ ChatConsole().print(
+ f"[bold {_accent_hex()}]โป Resumed session[/] "
+ f"[bold]{_escape(self.session_id)}[/]"
+ f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
+ f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
else:
- _cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}")
+ ChatConsole().print(
+ f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
+ )
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
@@ -1485,16 +2042,33 @@ def _init_agent(self) -> bool:
pass
try:
+ runtime = runtime_override or {
+ "api_key": self.api_key,
+ "base_url": self.base_url,
+ "provider": self.provider,
+ "api_mode": self.api_mode,
+ "command": self.acp_command,
+ "args": list(self.acp_args or []),
+ "request_headers_resolver": self._request_headers_resolver,
+ "payment_adapter": self._payment_adapter,
+ "payment_config": self._payment_config,
+ }
+ effective_model = model_override or self.model
self.agent = AIAgent(
- model=self.model,
- api_key=self.api_key,
- base_url=self.base_url,
- provider=self.provider,
- api_mode=self.api_mode,
+ model=effective_model,
+ api_key=runtime.get("api_key"),
+ base_url=runtime.get("base_url"),
+ provider=runtime.get("provider"),
+ api_mode=runtime.get("api_mode"),
+ acp_command=runtime.get("command"),
+ acp_args=runtime.get("args"),
+ request_headers_resolver=runtime.get("request_headers_resolver"),
+ payment_adapter=runtime.get("payment_adapter"),
+ payment_config=runtime.get("payment_config"),
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
verbose_logging=self.verbose,
- quiet_mode=True,
+ quiet_mode=not self.verbose,
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
prefill_messages=self.prefill_messages or None,
reasoning_config=self.reasoning_config,
@@ -1508,15 +2082,29 @@ def _init_agent(self) -> bool:
platform="cli",
session_db=self._session_db,
clarify_callback=self._clarify_callback,
- reasoning_callback=self._on_reasoning if self.show_reasoning else None,
- honcho_session_key=self.session_id,
+ reasoning_callback=self._current_reasoning_callback(),
+ honcho_session_key=None, # resolved by run_agent via config sessions map / title
fallback_model=self._fallback_model,
thinking_callback=self._on_thinking,
checkpoints_enabled=self.checkpoints_enabled,
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
pass_session_id=self.pass_session_id,
+ tool_progress_callback=self._on_tool_progress,
+ stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
+ tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
+ )
+ # Route agent status output through prompt_toolkit so ANSI escape
+ # sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
+ self.agent._print_fn = _cprint
+ self._active_agent_route_signature = (
+ effective_model,
+ runtime.get("provider"),
+ runtime.get("base_url"),
+ runtime.get("api_mode"),
+ runtime.get("command"),
+ tuple(runtime.get("args") or ()),
)
- # Apply any pending title now that the session exists in the DB
+
if self._pending_title and self._session_db:
try:
self._session_db.set_session_title(self.session_id, self._pending_title)
@@ -1733,6 +2321,19 @@ def _strip_reasoning(text: str) -> str:
from rich.panel import Panel
from rich.text import Text
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ _skin = get_active_skin()
+ _history_text_c = _skin.get_color("banner_text", "#FFF8DC")
+ _session_label_c = _skin.get_color("session_label", "#DAA520")
+ _session_border_c = _skin.get_color("session_border", "#8B8682")
+ _assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
+ except Exception:
+ _history_text_c = "#FFF8DC"
+ _session_label_c = "#DAA520"
+ _session_border_c = "#8B8682"
+ _assistant_label_c = "#8FBC8F"
+
lines = Text()
if skipped:
lines.append(
@@ -1742,14 +2343,14 @@ def _strip_reasoning(text: str) -> str:
for i, (role, text) in enumerate(entries):
if role == "user":
- lines.append(" โ You: ", style="dim bold #DAA520")
+ lines.append(" โ You: ", style=f"dim bold {_session_label_c}")
# Show first line inline, indent rest
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
else:
- lines.append(" โ Hermes: ", style="dim bold #8FBC8F")
+ lines.append(" โ Hermes: ", style=f"dim bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
@@ -1759,9 +2360,10 @@ def _strip_reasoning(text: str) -> str:
panel = Panel(
lines,
- title="[dim #DAA520]Previous Conversation[/]",
- border_style="dim #8B8682",
+ title=f"[dim {_session_label_c}]Previous Conversation[/]",
+ border_style=f"dim {_session_border_c}",
padding=(0, 1),
+ style=_history_text_c,
)
self.console.print(panel)
@@ -1773,7 +2375,7 @@ def _try_attach_clipboard_image(self) -> bool:
"""
from hermes_cli.clipboard import save_clipboard_image
- img_dir = Path.home() / ".hermes" / "images"
+ img_dir = get_hermes_home() / "images"
self._image_counter += 1
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
@@ -1785,8 +2387,15 @@ def _try_attach_clipboard_image(self) -> bool:
return False
def _handle_rollback_command(self, command: str):
- """Handle /rollback โ list or restore filesystem checkpoints."""
- from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
+ """Handle /rollback โ list, diff, or restore filesystem checkpoints.
+
+ Syntax:
+ /rollback โ list checkpoints
+ /rollback โ restore checkpoint N (also undoes last chat turn)
+ /rollback diff โ preview changes since checkpoint N
+ /rollback โ restore a single file from checkpoint N
+ """
+ from tools.checkpoint_manager import format_checkpoint_list
if not hasattr(self, 'agent') or not self.agent:
print(" No active agent session.")
@@ -1800,41 +2409,111 @@ def _handle_rollback_command(self, command: str):
return
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
- parts = command.split(maxsplit=1)
- arg = parts[1].strip() if len(parts) > 1 else ""
+ parts = command.split()
+ args = parts[1:] if len(parts) > 1 else []
- if not arg:
+ if not args:
# List checkpoints
checkpoints = mgr.list_checkpoints(cwd)
print(format_checkpoint_list(checkpoints, cwd))
- else:
- # Restore by number or hash
+ return
+
+ # Handle /rollback diff
+ if args[0].lower() == "diff":
+ if len(args) < 2:
+ print(" Usage: /rollback diff ")
+ return
checkpoints = mgr.list_checkpoints(cwd)
if not checkpoints:
print(f" No checkpoints found for {cwd}")
return
-
- target_hash = None
- try:
- idx = int(arg) - 1 # 1-indexed for user
- if 0 <= idx < len(checkpoints):
- target_hash = checkpoints[idx]["hash"]
- else:
- print(f" Invalid checkpoint number. Use 1-{len(checkpoints)}.")
- return
- except ValueError:
- # Try as a git hash
- target_hash = arg
-
- result = mgr.restore(cwd, target_hash)
+ target_hash = self._resolve_checkpoint_ref(args[1], checkpoints)
+ if not target_hash:
+ return
+ result = mgr.diff(cwd, target_hash)
if result["success"]:
- print(f" โ
Restored to checkpoint {result['restored_to']}: {result['reason']}")
- print(f" A pre-rollback snapshot was saved automatically.")
+ stat = result.get("stat", "")
+ diff = result.get("diff", "")
+ if not stat and not diff:
+ print(" No changes since this checkpoint.")
+ else:
+ if stat:
+ print(f"\n{stat}")
+ if diff:
+ # Limit diff output to avoid terminal flood
+ diff_lines = diff.splitlines()
+ if len(diff_lines) > 80:
+ print("\n".join(diff_lines[:80]))
+ print(f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)")
+ else:
+ print(f"\n{diff}")
else:
print(f" โ {result['error']}")
+ return
- def _handle_paste_command(self):
- """Handle /paste โ explicitly check clipboard for an image.
+ # Resolve checkpoint reference (number or hash)
+ checkpoints = mgr.list_checkpoints(cwd)
+ if not checkpoints:
+ print(f" No checkpoints found for {cwd}")
+ return
+
+ target_hash = self._resolve_checkpoint_ref(args[0], checkpoints)
+ if not target_hash:
+ return
+
+ # Check for file-level restore: /rollback
+ file_path = args[1] if len(args) > 1 else None
+
+ result = mgr.restore(cwd, target_hash, file_path=file_path)
+ if result["success"]:
+ if file_path:
+ print(f" โ
Restored {file_path} from checkpoint {result['restored_to']}: {result['reason']}")
+ else:
+ print(f" โ
Restored to checkpoint {result['restored_to']}: {result['reason']}")
+ print(" A pre-rollback snapshot was saved automatically.")
+
+ # Also undo the last conversation turn so the agent's context
+ # matches the restored filesystem state
+ if self.conversation_history:
+ self.undo_last()
+ print(" Chat turn undone to match restored file state.")
+ else:
+ print(f" โ {result['error']}")
+
+ def _resolve_checkpoint_ref(self, ref: str, checkpoints: list) -> str | None:
+ """Resolve a checkpoint number or hash to a full commit hash."""
+ try:
+ idx = int(ref) - 1 # 1-indexed for user
+ if 0 <= idx < len(checkpoints):
+ return checkpoints[idx]["hash"]
+ else:
+ print(f" Invalid checkpoint number. Use 1-{len(checkpoints)}.")
+ return None
+ except ValueError:
+ # Treat as a git hash
+ return ref
+
+ def _handle_stop_command(self):
+ """Handle /stop โ kill all running background processes.
+
+ Inspired by OpenAI Codex's separation of interrupt (stop current turn)
+ from /stop (clean up background processes). See openai/codex#14602.
+ """
+ from tools.process_registry import process_registry
+
+ processes = process_registry.list_sessions()
+ running = [p for p in processes if p.get("status") == "running"]
+
+ if not running:
+ print(" No running background processes.")
+ return
+
+ print(f" Stopping {len(running)} background process(es)...")
+ killed = process_registry.kill_all()
+ print(f" โ
Stopped {killed} process(es).")
+
+ def _handle_paste_command(self):
+ """Handle /paste โ explicitly check clipboard for an image.
This is the reliable fallback for terminals where BracketedPaste
doesn't fire for image-only clipboard content (e.g., VSCode terminal,
@@ -1916,7 +2595,7 @@ def _preprocess_images_with_vision(self, text: str, images: list) -> str:
def _show_tool_availability_warnings(self):
"""Show warnings about disabled tools due to missing API keys."""
try:
- from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
+ from model_tools import check_tool_availability
available, unavailable = check_tool_availability()
@@ -1971,19 +2650,30 @@ def show_help(self):
"""Display help information with categorized commands."""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
- _cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
- _cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}")
- _cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
+ try:
+ from hermes_cli.skin_engine import get_active_help_header
+ header = get_active_help_header("(^_^)? Available Commands")
+ except Exception:
+ header = "(^_^)? Available Commands"
+ header = (header or "").strip() or "(^_^)? Available Commands"
+ inner_width = 55
+ if len(header) > inner_width:
+ header = header[:inner_width]
+ _cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}")
+ _cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}")
+ _cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}")
for category, commands in COMMANDS_BY_CATEGORY.items():
_cprint(f"\n {_BOLD}โโ {category} โโ{_RST}")
for cmd, desc in commands.items():
- _cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}")
+ ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
if _skill_commands:
_cprint(f"\n โก {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
for cmd, info in sorted(_skill_commands.items()):
- _cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}")
+ ChatConsole().print(
+ f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
+ )
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
@@ -2030,7 +2720,69 @@ def show_tools(self):
print(f" Total: {len(tools)} tools ใฝ(^o^)ใ")
print()
-
+
+ def _handle_tools_command(self, cmd: str):
+ """Handle /tools [list|disable|enable] slash commands.
+
+ /tools (no args) shows the tool list.
+ /tools list shows enabled/disabled status per toolset.
+ /tools disable/enable saves the change to config and resets
+ the session so the new tool set takes effect cleanly (no
+ prompt-cache breakage mid-conversation).
+ """
+ import shlex
+ from argparse import Namespace
+ from hermes_cli.tools_config import tools_disable_enable_command
+
+ try:
+ parts = shlex.split(cmd)
+ except ValueError:
+ parts = cmd.split()
+
+ subcommand = parts[1] if len(parts) > 1 else ""
+ if subcommand not in ("list", "disable", "enable"):
+ self.show_tools()
+ return
+
+ if subcommand == "list":
+ tools_disable_enable_command(
+ Namespace(tools_action="list", platform="cli"))
+ return
+
+ names = parts[2:]
+ if not names:
+ print(f"(._.) Usage: /tools {subcommand} [name ...]")
+ print(f" Built-in toolset: /tools {subcommand} web")
+ print(f" MCP tool: /tools {subcommand} github:create_issue")
+ return
+
+ # Confirm session reset before applying
+ verb = "Disable" if subcommand == "disable" else "Enable"
+ label = ", ".join(names)
+ _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"))
+
+ # Reset session so the new tool config is picked up from a clean state
+ from hermes_cli.tools_config import _get_platform_tools
+ from hermes_cli.config import load_config
+ self.enabled_toolsets = _get_platform_tools(load_config(), "cli")
+ self.new_session()
+ _cprint(f"{_DIM}Session reset. New tool configuration is active.{_RST}")
+
def show_toolsets(self):
"""Display available toolsets with kawaii ASCII art."""
all_toolsets = get_all_toolsets()
@@ -2069,7 +2821,7 @@ def show_config(self):
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
- user_config_path = Path.home() / '.hermes' / 'config.yaml'
+ user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
if user_config_path.exists():
config_path = user_config_path
@@ -2178,15 +2930,140 @@ def flush_tool_summary():
flush_tool_summary()
print()
- def reset_conversation(self):
- """Reset the conversation history."""
+ def new_session(self, silent=False):
+ """Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
try:
self.agent.flush_memories(self.conversation_history)
+ except (Exception, KeyboardInterrupt):
+ pass
+
+ old_session_id = self.session_id
+ if self._session_db and old_session_id:
+ try:
+ self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
+
+ self.session_start = datetime.now()
+ timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
+ short_uuid = uuid.uuid4().hex[:6]
+ self.session_id = f"{timestamp_str}_{short_uuid}"
self.conversation_history = []
- print("(^_^)b Conversation reset!")
+ self._pending_title = None
+ self._resumed = False
+
+ if self.agent:
+ self.agent.session_id = self.session_id
+ self.agent.session_start = self.session_start
+ self.agent.reset_session_state()
+ if hasattr(self.agent, "_last_flushed_db_idx"):
+ self.agent._last_flushed_db_idx = 0
+ if hasattr(self.agent, "_todo_store"):
+ try:
+ from tools.todo_tool import TodoStore
+ self.agent._todo_store = TodoStore()
+ except Exception:
+ pass
+ if hasattr(self.agent, "_invalidate_system_prompt"):
+ self.agent._invalidate_system_prompt()
+
+ if self._session_db:
+ try:
+ self._session_db.create_session(
+ session_id=self.session_id,
+ source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
+ model=self.model,
+ model_config={
+ "max_iterations": self.max_turns,
+ "reasoning_config": self.reasoning_config,
+ },
+ )
+ except Exception:
+ pass
+
+ if not silent:
+ print("(^_^)v New session started!")
+
+ def _handle_resume_command(self, cmd_original: str) -> None:
+ """Handle /resume โ switch to a previous session mid-conversation."""
+ parts = cmd_original.split(None, 1)
+ target = parts[1].strip() if len(parts) > 1 else ""
+
+ if not target:
+ _cprint(" Usage: /resume ")
+ _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
+ return
+
+ if not self._session_db:
+ _cprint(" Session database not available.")
+ return
+
+ # Resolve title or ID
+ from hermes_cli.main import _resolve_session_by_name_or_id
+ resolved = _resolve_session_by_name_or_id(target)
+ target_id = resolved or target
+
+ session_meta = self._session_db.get_session(target_id)
+ if not session_meta:
+ _cprint(f" Session not found: {target}")
+ _cprint(" Use /history or `hermes sessions list` to see available sessions.")
+ return
+
+ if target_id == self.session_id:
+ _cprint(" Already on that session.")
+ return
+
+ # End current session
+ try:
+ self._session_db.end_session(self.session_id, "resumed_other")
+ except Exception:
+ pass
+
+ # Switch to the target session
+ self.session_id = target_id
+ self._resumed = True
+ self._pending_title = None
+
+ # Load conversation history
+ restored = self._session_db.get_messages_as_conversation(target_id)
+ self.conversation_history = restored or []
+
+ # Re-open the target session so it's not marked as ended
+ try:
+ self._session_db.reopen_session(target_id)
+ except Exception:
+ pass
+
+ # Sync the agent if already initialised
+ if self.agent:
+ self.agent.session_id = target_id
+ self.agent.reset_session_state()
+ if hasattr(self.agent, "_last_flushed_db_idx"):
+ self.agent._last_flushed_db_idx = len(self.conversation_history)
+ if hasattr(self.agent, "_todo_store"):
+ try:
+ from tools.todo_tool import TodoStore
+ self.agent._todo_store = TodoStore()
+ except Exception:
+ pass
+ if hasattr(self.agent, "_invalidate_system_prompt"):
+ self.agent._invalidate_system_prompt()
+
+ title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
+ msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
+ if self.conversation_history:
+ _cprint(
+ f" โป Resumed session {target_id}{title_part}"
+ f" ({msg_count} user message{'s' if msg_count != 1 else ''},"
+ f" {len(self.conversation_history)} total)"
+ )
+ else:
+ _cprint(f" โป Resumed session {target_id}{title_part} โ no messages, starting fresh.")
+
+ def reset_conversation(self):
+ """Reset the conversation by starting a new session."""
+ self.new_session()
def save_conversation(self):
"""Save the current conversation to a file."""
@@ -2270,10 +3147,10 @@ def undo_last(self):
print(f" {remaining} message(s) remaining in history.")
def _show_model_and_providers(self):
- """Unified /model and /provider display.
+ """Show current model + provider and list all authenticated providers.
Shows current model + provider, then lists all authenticated
- providers with their available models so users can switch easily.
+ providers with their available models.
"""
from hermes_cli.models import (
curated_models_for_provider, list_available_providers,
@@ -2315,25 +3192,25 @@ def _show_model_and_providers(self):
for mid, desc in curated:
current_marker = " โ current" if (is_active and mid == self.model) else ""
print(f" {mid}{current_marker}")
+ elif p["id"] == "custom":
+ from hermes_cli.models import _get_custom_base_url
+ custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
+ if custom_url:
+ print(f" endpoint: {custom_url}")
+ if is_active:
+ print(f" model: {self.model} โ current")
+ print(" (use hermes model to change)")
else:
- print(f" (use /model {p['id']}:)")
+ print(" (use hermes model to change)")
print()
if unauthed:
names = ", ".join(p["label"] for p in unauthed)
print(f" Not configured: {names}")
- print(f" Run: hermes setup")
+ print(" Run: hermes setup")
print()
- print(" Switch model: /model ")
- print(" Switch provider: /model :")
- if authed and len(authed) > 1:
- # Show a concrete example with a non-active provider
- other = next((p for p in authed if p["id"] != current), authed[0])
- other_models = curated_models_for_provider(other["id"])
- if other_models:
- example_model = other_models[0][0]
- print(f" Example: /model {other['id']}:{example_model}")
+ print(" To change model or provider, use: hermes model")
def _handle_prompt_command(self, cmd: str):
"""Handle the /prompt command to view or set system prompt."""
@@ -2354,9 +3231,9 @@ def _handle_prompt_command(self, cmd: str):
self.system_prompt = new_prompt
self.agent = None # Force re-init
if save_config_value("agent.system_prompt", new_prompt):
- print(f"(^_^)b System prompt set (saved to config)")
+ print("(^_^)b System prompt set (saved to config)")
else:
- print(f"(^_^) System prompt set (session only)")
+ print("(^_^) System prompt set (session only)")
print(f" \"{new_prompt[:60]}{'...' if len(new_prompt) > 60 else ''}\"")
else:
# Show current prompt
@@ -2449,139 +3326,248 @@ def _handle_personality_command(self, cmd: str):
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
- parts = cmd.split(maxsplit=2)
-
- if len(parts) == 1:
- # /cron - show help and list
+ import shlex
+ from tools.cronjob_tools import cronjob as cronjob_tool
+
+ def _cron_api(**kwargs):
+ return json.loads(cronjob_tool(**kwargs))
+
+ def _normalize_skills(values):
+ normalized = []
+ for value in values:
+ text = str(value or "").strip()
+ if text and text not in normalized:
+ normalized.append(text)
+ return normalized
+
+ def _parse_flags(tokens):
+ opts = {
+ "name": None,
+ "deliver": None,
+ "repeat": None,
+ "skills": [],
+ "add_skills": [],
+ "remove_skills": [],
+ "clear_skills": False,
+ "all": False,
+ "prompt": None,
+ "schedule": None,
+ "positionals": [],
+ }
+ i = 0
+ while i < len(tokens):
+ token = tokens[i]
+ if token == "--name" and i + 1 < len(tokens):
+ opts["name"] = tokens[i + 1]
+ i += 2
+ elif token == "--deliver" and i + 1 < len(tokens):
+ opts["deliver"] = tokens[i + 1]
+ i += 2
+ elif token == "--repeat" and i + 1 < len(tokens):
+ try:
+ opts["repeat"] = int(tokens[i + 1])
+ except ValueError:
+ print("(._.) --repeat must be an integer")
+ return None
+ i += 2
+ elif token == "--skill" and i + 1 < len(tokens):
+ opts["skills"].append(tokens[i + 1])
+ i += 2
+ elif token == "--add-skill" and i + 1 < len(tokens):
+ opts["add_skills"].append(tokens[i + 1])
+ i += 2
+ elif token == "--remove-skill" and i + 1 < len(tokens):
+ opts["remove_skills"].append(tokens[i + 1])
+ i += 2
+ elif token == "--clear-skills":
+ opts["clear_skills"] = True
+ i += 1
+ elif token == "--all":
+ opts["all"] = True
+ i += 1
+ elif token == "--prompt" and i + 1 < len(tokens):
+ opts["prompt"] = tokens[i + 1]
+ i += 2
+ elif token == "--schedule" and i + 1 < len(tokens):
+ opts["schedule"] = tokens[i + 1]
+ i += 2
+ else:
+ opts["positionals"].append(token)
+ i += 1
+ return opts
+
+ tokens = shlex.split(cmd)
+
+ if len(tokens) == 1:
print()
- print("+" + "-" * 60 + "+")
- print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|")
- print("+" + "-" * 60 + "+")
+ print("+" + "-" * 68 + "+")
+ print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|")
+ print("+" + "-" * 68 + "+")
print()
print(" Commands:")
- print(" /cron - List scheduled jobs")
- print(" /cron list - List scheduled jobs")
- print(' /cron add - Add a new job')
- print(" /cron remove - Remove a job")
+ print(" /cron list")
+ print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
+ print(' /cron edit --schedule "every 4h" --prompt "New task"')
+ print(" /cron edit --skill blogwatcher --skill find-nearby")
+ print(" /cron edit --remove-skill blogwatcher")
+ print(" /cron edit --clear-skills")
+ print(" /cron pause ")
+ print(" /cron resume ")
+ print(" /cron run ")
+ print(" /cron remove ")
print()
- print(" Schedule formats:")
- print(" 30m, 2h, 1d - One-shot delay")
- print(' "every 30m", "every 2h" - Recurring interval')
- print(' "0 9 * * *" - Cron expression')
- print()
-
- # Show current jobs
- jobs = list_jobs()
+ result = _cron_api(action="list")
+ jobs = result.get("jobs", []) if result.get("success") else []
if jobs:
print(" Current Jobs:")
- print(" " + "-" * 55)
+ print(" " + "-" * 63)
for job in jobs:
- # Format repeat status
- times = job["repeat"].get("times")
- completed = job["repeat"].get("completed", 0)
- if times is None:
- repeat_str = "forever"
- else:
- repeat_str = f"{completed}/{times}"
-
- print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}")
- prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt']
- print(f" {prompt_preview}")
+ repeat_str = job.get("repeat", "?")
+ print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}")
+ if job.get("skills"):
+ print(f" Skills: {', '.join(job['skills'])}")
+ print(f" {job.get('prompt_preview', '')}")
if job.get("next_run_at"):
- from datetime import datetime
- next_run = datetime.fromisoformat(job["next_run_at"])
- print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}")
+ print(f" Next: {job['next_run_at']}")
print()
else:
print(" No scheduled jobs. Use '/cron add' to create one.")
print()
return
-
- subcommand = parts[1].lower()
-
+
+ subcommand = tokens[1].lower()
+ opts = _parse_flags(tokens[2:])
+ if opts is None:
+ return
+
if subcommand == "list":
- # /cron list - just show jobs
- jobs = list_jobs()
+ result = _cron_api(action="list", include_disabled=opts["all"])
+ jobs = result.get("jobs", []) if result.get("success") else []
if not jobs:
print("(._.) No scheduled jobs.")
return
-
+
print()
print("Scheduled Jobs:")
- print("-" * 70)
+ print("-" * 80)
for job in jobs:
- times = job["repeat"].get("times")
- completed = job["repeat"].get("completed", 0)
- repeat_str = "forever" if times is None else f"{completed}/{times}"
-
- print(f" ID: {job['id']}")
+ print(f" ID: {job['job_id']}")
print(f" Name: {job['name']}")
- print(f" Schedule: {job['schedule_display']} ({repeat_str})")
+ print(f" State: {job.get('state', '?')}")
+ print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})")
print(f" Next run: {job.get('next_run_at', 'N/A')}")
- print(f" Prompt: {job['prompt'][:80]}{'...' if len(job['prompt']) > 80 else ''}")
+ if job.get("skills"):
+ print(f" Skills: {', '.join(job['skills'])}")
+ print(f" Prompt: {job.get('prompt_preview', '')}")
if job.get("last_run_at"):
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
print()
-
- elif subcommand == "add":
- # /cron add
- if len(parts) < 3:
+ return
+
+ if subcommand in {"add", "create"}:
+ positionals = opts["positionals"]
+ if not positionals:
print("(._.) Usage: /cron add ")
- print(" Example: /cron add 30m Remind me to take a break")
- print(' Example: /cron add "every 2h" Check server status at 192.168.1.1')
return
-
- # Parse schedule and prompt
- rest = parts[2].strip()
-
- # Handle quoted schedule (e.g., "every 30m" or "0 9 * * *")
- if rest.startswith('"'):
- # Find closing quote
- close_quote = rest.find('"', 1)
- if close_quote == -1:
- print("(._.) Unmatched quote in schedule")
- return
- schedule = rest[1:close_quote]
- prompt = rest[close_quote + 1:].strip()
- else:
- # First word is schedule
- schedule_parts = rest.split(maxsplit=1)
- schedule = schedule_parts[0]
- prompt = schedule_parts[1] if len(schedule_parts) > 1 else ""
-
- if not prompt:
- print("(._.) Please provide a prompt for the job")
+ schedule = opts["schedule"] or positionals[0]
+ prompt = opts["prompt"] or " ".join(positionals[1:])
+ skills = _normalize_skills(opts["skills"])
+ if not prompt and not skills:
+ print("(._.) Please provide a prompt or at least one skill")
return
-
- try:
- job = create_job(prompt=prompt, schedule=schedule)
- print(f"(^_^)b Created job: {job['id']}")
- print(f" Schedule: {job['schedule_display']}")
- print(f" Next run: {job['next_run_at']}")
- except Exception as e:
- print(f"(x_x) Failed to create job: {e}")
-
- elif subcommand == "remove" or subcommand == "rm" or subcommand == "delete":
- # /cron remove
- if len(parts) < 3:
- print("(._.) Usage: /cron remove ")
+ result = _cron_api(
+ action="create",
+ schedule=schedule,
+ prompt=prompt or None,
+ name=opts["name"],
+ deliver=opts["deliver"],
+ repeat=opts["repeat"],
+ skills=skills or None,
+ )
+ if result.get("success"):
+ print(f"(^_^)b Created job: {result['job_id']}")
+ print(f" Schedule: {result['schedule']}")
+ if result.get("skills"):
+ print(f" Skills: {', '.join(result['skills'])}")
+ print(f" Next run: {result['next_run_at']}")
+ else:
+ print(f"(x_x) Failed to create job: {result.get('error')}")
+ return
+
+ if subcommand == "edit":
+ positionals = opts["positionals"]
+ if not positionals:
+ print("(._.) Usage: /cron edit [--schedule ...] [--prompt ...] [--skill ...]")
return
-
- job_id = parts[2].strip()
- job = get_job(job_id)
-
- if not job:
+ job_id = positionals[0]
+ existing = get_job(job_id)
+ if not existing:
print(f"(._.) Job not found: {job_id}")
return
-
- if remove_job(job_id):
- print(f"(^_^)b Removed job: {job['name']} ({job_id})")
+
+ final_skills = None
+ replacement_skills = _normalize_skills(opts["skills"])
+ add_skills = _normalize_skills(opts["add_skills"])
+ remove_skills = set(_normalize_skills(opts["remove_skills"]))
+ existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")]))
+ if opts["clear_skills"]:
+ final_skills = []
+ elif replacement_skills:
+ final_skills = replacement_skills
+ elif add_skills or remove_skills:
+ final_skills = [skill for skill in existing_skills if skill not in remove_skills]
+ for skill in add_skills:
+ if skill not in final_skills:
+ final_skills.append(skill)
+
+ result = _cron_api(
+ action="update",
+ job_id=job_id,
+ schedule=opts["schedule"],
+ prompt=opts["prompt"],
+ name=opts["name"],
+ deliver=opts["deliver"],
+ repeat=opts["repeat"],
+ skills=final_skills,
+ )
+ if result.get("success"):
+ job = result["job"]
+ print(f"(^_^)b Updated job: {job['job_id']}")
+ print(f" Schedule: {job['schedule']}")
+ if job.get("skills"):
+ print(f" Skills: {', '.join(job['skills'])}")
+ else:
+ print(" Skills: none")
else:
- print(f"(x_x) Failed to remove job: {job_id}")
-
- else:
- print(f"(._.) Unknown cron command: {subcommand}")
- print(" Available: list, add, remove")
+ print(f"(x_x) Failed to update job: {result.get('error')}")
+ return
+
+ if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
+ positionals = opts["positionals"]
+ if not positionals:
+ print(f"(._.) Usage: /cron {subcommand} ")
+ return
+ job_id = positionals[0]
+ action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
+ result = _cron_api(action=action, job_id=job_id, reason="paused from /cron" if action == "pause" else None)
+ if not result.get("success"):
+ print(f"(x_x) Failed to {action} job: {result.get('error')}")
+ return
+ if action == "pause":
+ print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})")
+ elif action == "resume":
+ print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})")
+ print(f" Next run: {result['job'].get('next_run_at')}")
+ elif action == "run":
+ print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})")
+ print(" It will run on the next scheduler tick.")
+ else:
+ removed = result.get("removed_job", {})
+ print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})")
+ return
+
+ print(f"(._.) Unknown cron command: {subcommand}")
+ print(" Available: list, add, edit, pause, resume, run, remove")
def _handle_skills_command(self, cmd: str):
"""Handle /skills slash command โ delegates to hermes_cli.skills_hub."""
@@ -2632,7 +3618,7 @@ def _show_gateway_status(self):
print(" To start the gateway:")
print(" python cli.py --gateway")
print()
- print(" Configuration file: ~/.hermes/gateway.json")
+ print(" Configuration file: ~/.hermes/config.yaml")
print()
except Exception as e:
@@ -2642,7 +3628,7 @@ def _show_gateway_status(self):
print(" 1. Set environment variables:")
print(" TELEGRAM_BOT_TOKEN=your_token")
print(" DISCORD_BOT_TOKEN=your_token")
- print(" 2. Or create ~/.hermes/gateway.json")
+ print(" 2. Or configure settings in ~/.hermes/config.yaml")
print()
def process_command(self, command: str) -> bool:
@@ -2658,24 +3644,26 @@ def process_command(self, command: str) -> bool:
# Lowercase only for dispatch matching; preserve original case for arguments
cmd_lower = command.lower().strip()
cmd_original = command.strip()
+
+ # Resolve aliases via central registry so adding an alias is a one-line
+ # change in hermes_cli/commands.py instead of touching every dispatch site.
+ from hermes_cli.commands import resolve_command as _resolve_cmd
+ _base_word = cmd_lower.split()[0].lstrip("/")
+ _cmd_def = _resolve_cmd(_base_word)
+ canonical = _cmd_def.name if _cmd_def else _base_word
- if cmd_lower in ("/quit", "/exit", "/q"):
+ if canonical in ("quit", "exit", "q"):
return False
- elif cmd_lower == "/help":
+ elif canonical == "help":
self.show_help()
- elif cmd_lower == "/tools":
- self.show_tools()
- elif cmd_lower == "/toolsets":
+ elif canonical == "tools":
+ self._handle_tools_command(cmd_original)
+ elif canonical == "toolsets":
self.show_toolsets()
- elif cmd_lower == "/config":
+ elif canonical == "config":
self.show_config()
- elif cmd_lower == "/clear":
- # Flush memories before clearing
- if self.agent and self.conversation_history:
- try:
- self.agent.flush_memories(self.conversation_history)
- except Exception:
- pass
+ elif canonical == "clear":
+ self.new_session(silent=True)
# Clear terminal screen. Inside the TUI, Rich's console.clear()
# goes through patch_stdout's StdoutProxy which swallows the
# screen-clear escape sequences. Use prompt_toolkit's output
@@ -2687,8 +3675,6 @@ def process_command(self, command: str) -> bool:
out.flush()
else:
self.console.clear()
- # Reset conversation
- self.conversation_history = []
# Show fresh banner. Inside the TUI we must route Rich output
# through ChatConsole (which uses prompt_toolkit's native ANSI
# renderer) instead of self.console (which writes raw to stdout
@@ -2717,9 +3703,9 @@ def process_command(self, command: str) -> bool:
else:
self.show_banner()
print(" โจ (โโฟโ)โจ Fresh start! Screen cleared and conversation reset.\n")
- elif cmd_lower == "/history":
+ elif canonical == "history":
self.show_history()
- elif cmd_lower.startswith("/title"):
+ elif canonical == "title":
parts = cmd_original.split(maxsplit=1)
if len(parts) > 1:
raw_title = parts[1].strip()
@@ -2739,6 +3725,28 @@ def process_command(self, command: str) -> bool:
try:
if self._session_db.set_session_title(self.session_id, new_title):
_cprint(f" Session title set: {new_title}")
+ # Re-map Honcho session key to new title
+ if self.agent and getattr(self.agent, '_honcho', None):
+ try:
+ hcfg = self.agent._honcho_config
+ new_key = (
+ hcfg.resolve_session_name(
+ session_title=new_title,
+ session_id=self.agent.session_id,
+ )
+ if hcfg else new_title
+ )
+ if new_key and new_key != self.agent._honcho_session_key:
+ old_key = self.agent._honcho_session_key
+ self.agent._honcho.get_or_create(new_key)
+ self.agent._honcho_session_key = new_key
+ from tools.honcho_tools import set_session_context
+ set_session_context(self.agent._honcho, new_key)
+ from agent.display import honcho_session_line, write_tty
+ write_tty(honcho_session_line(hcfg.workspace_id, new_key) + "\n")
+ _cprint(f" Honcho session: {old_key} โ {new_key}")
+ except Exception:
+ pass
else:
_cprint(" Session not found in database.")
except ValueError as e:
@@ -2757,145 +3765,112 @@ def process_command(self, command: str) -> bool:
else:
_cprint(" Usage: /title ")
else:
- # Show current title if no argument given
+ # Show current title and session ID if no argument given
if self._session_db:
+ _cprint(f" Session ID: {self.session_id}")
session = self._session_db.get_session(self.session_id)
if session and session.get("title"):
- _cprint(f" Session title: {session['title']}")
+ _cprint(f" Title: {session['title']}")
elif self._pending_title:
- _cprint(f" Session title (pending): {self._pending_title}")
+ _cprint(f" Title (pending): {self._pending_title}")
else:
- _cprint(f" No title set. Usage: /title ")
+ _cprint(" No title set. Usage: /title ")
else:
_cprint(" Session database not available.")
- elif cmd_lower in ("/reset", "/new"):
- self.reset_conversation()
- elif cmd_lower.startswith("/model"):
- # Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
- parts = cmd_original.split(maxsplit=1)
- if len(parts) > 1:
- from hermes_cli.auth import resolve_provider
- from hermes_cli.models import (
- parse_model_input,
- validate_requested_model,
- _PROVIDER_LABELS,
- )
-
- raw_input = parts[1].strip()
-
- # Parse provider:model syntax (e.g. "openrouter:anthropic/claude-sonnet-4.5")
- current_provider = self.provider or self.requested_provider or "openrouter"
- target_provider, new_model = parse_model_input(raw_input, current_provider)
- provider_changed = target_provider != current_provider
-
- # If provider is changing, re-resolve credentials for the new provider
- api_key_for_probe = self.api_key
- base_url_for_probe = self.base_url
- if provider_changed:
- try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=target_provider)
- api_key_for_probe = runtime.get("api_key", "")
- base_url_for_probe = runtime.get("base_url", "")
- except Exception as e:
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- if target_provider == "custom":
- print(f"(>_<) Custom endpoint not configured. Set OPENAI_BASE_URL and OPENAI_API_KEY,")
- print(f" or run: hermes setup โ Custom OpenAI-compatible endpoint")
- else:
- print(f"(>_<) Could not resolve credentials for provider '{provider_label}': {e}")
- print(f"(^_^) Current model unchanged: {self.model}")
- return True
-
- try:
- validation = validate_requested_model(
- new_model,
- target_provider,
- api_key=api_key_for_probe,
- base_url=base_url_for_probe,
- )
- except Exception:
- validation = {"accepted": True, "persist": True, "recognized": False, "message": None}
-
- if not validation.get("accepted"):
- print(f"(>_<) {validation.get('message')}")
- print(f" Model unchanged: {self.model}")
- if "Did you mean" not in (validation.get("message") or ""):
- print(" Tip: Use /model to see available models, /provider to see providers")
- else:
- self.model = new_model
- self.agent = None # Force re-init
-
- if provider_changed:
- self.requested_provider = target_provider
- self.provider = target_provider
- self.api_key = api_key_for_probe
- self.base_url = base_url_for_probe
-
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- provider_note = f" [provider: {provider_label}]" if provider_changed else ""
-
- if validation.get("persist"):
- saved_model = save_config_value("model.default", new_model)
- if provider_changed:
- save_config_value("model.provider", target_provider)
- if saved_model:
- print(f"(^_^)b Model changed to: {new_model}{provider_note} (saved to config)")
- else:
- print(f"(^_^) Model changed to: {new_model}{provider_note} (this session only)")
- else:
- message = validation.get("message") or ""
- print(f"(^_^) Model changed to: {new_model}{provider_note} (this session only)")
- if message:
- print(f" Reason: {message}")
- print(" Note: Model will revert on restart. Use a verified model to save to config.")
- else:
- self._show_model_and_providers()
- elif cmd_lower == "/provider":
+ elif canonical == "new":
+ self.new_session()
+ elif canonical == "resume":
+ self._handle_resume_command(cmd_original)
+ elif canonical == "provider":
self._show_model_and_providers()
- elif cmd_lower.startswith("/prompt"):
+ elif canonical == "prompt":
# Use original case so prompt text isn't lowercased
self._handle_prompt_command(cmd_original)
- elif cmd_lower.startswith("/personality"):
+ elif canonical == "personality":
# Use original case (handler lowercases the personality name itself)
self._handle_personality_command(cmd_original)
- elif cmd_lower == "/retry":
+ elif canonical == "plan":
+ self._handle_plan_command(cmd_original)
+ elif canonical == "retry":
retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'):
# Re-queue the message so process_loop sends it to the agent
self._pending_input.put(retry_msg)
- elif cmd_lower == "/undo":
+ elif canonical == "undo":
self.undo_last()
- elif cmd_lower == "/save":
+ elif canonical == "save":
self.save_conversation()
- elif cmd_lower.startswith("/cron"):
+ elif canonical == "cron":
self._handle_cron_command(cmd_original)
- elif cmd_lower.startswith("/skills"):
+ elif canonical == "skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._handle_skills_command(cmd_original)
- elif cmd_lower == "/platforms" or cmd_lower == "/gateway":
+ elif canonical == "platforms":
self._show_gateway_status()
- elif cmd_lower == "/verbose":
+ elif canonical == "statusbar":
+ self._status_bar_visible = not self._status_bar_visible
+ state = "visible" if self._status_bar_visible else "hidden"
+ self.console.print(f" Status bar {state}")
+ elif canonical == "verbose":
self._toggle_verbose()
- elif cmd_lower.startswith("/reasoning"):
+ elif canonical == "reasoning":
self._handle_reasoning_command(cmd_original)
- elif cmd_lower == "/compress":
+ elif canonical == "compress":
self._manual_compress()
- elif cmd_lower == "/usage":
+ elif canonical == "usage":
self._show_usage()
- elif cmd_lower.startswith("/insights"):
+ elif canonical == "insights":
self._show_insights(cmd_original)
- elif cmd_lower == "/paste":
+ elif canonical == "paste":
self._handle_paste_command()
- elif cmd_lower == "/reload-mcp":
+ elif canonical == "reload-mcp":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
- elif cmd_lower.startswith("/rollback"):
+ elif canonical == "browser":
+ self._handle_browser_command(cmd_original)
+ elif canonical == "plugins":
+ try:
+ from hermes_cli.plugins import get_plugin_manager
+ mgr = get_plugin_manager()
+ plugins = mgr.list_plugins()
+ if not plugins:
+ print("No plugins installed.")
+ print("Drop plugin directories into ~/.hermes/plugins/ to get started.")
+ else:
+ print(f"Plugins ({len(plugins)}):")
+ for p in plugins:
+ status = "โ" if p["enabled"] else "โ"
+ version = f" v{p['version']}" if p["version"] else ""
+ tools = f"{p['tools']} tools" if p["tools"] else ""
+ hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
+ parts = [x for x in [tools, hooks] if x]
+ detail = f" ({', '.join(parts)})" if parts else ""
+ error = f" โ {p['error']}" if p["error"] else ""
+ print(f" {status} {p['name']}{version}{detail}{error}")
+ except Exception as e:
+ print(f"Plugin system error: {e}")
+ elif canonical == "rollback":
self._handle_rollback_command(cmd_original)
- elif cmd_lower.startswith("/background"):
+ elif canonical == "stop":
+ self._handle_stop_command()
+ elif canonical == "background":
self._handle_background_command(cmd_original)
- elif cmd_lower.startswith("/skin"):
+ elif canonical == "queue":
+ # Extract prompt after "/queue " or "/q "
+ parts = cmd_original.split(None, 1)
+ payload = parts[1].strip() if len(parts) > 1 else ""
+ if not payload:
+ _cprint(" Usage: /queue ")
+ else:
+ self._pending_input.put(payload)
+ if self._agent_running:
+ _cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
+ else:
+ _cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
+ elif canonical == "skin":
self._handle_skin_command(cmd_original)
+ elif canonical == "voice":
+ self._handle_voice_command(cmd_original)
else:
# Check for user-defined quick commands (bypass agent loop, no LLM call)
base_cmd = cmd_lower.split()[0]
@@ -2912,19 +3887,45 @@ def process_command(self, command: str) -> bool:
text=True, timeout=30
)
output = result.stdout.strip() or result.stderr.strip()
- self.console.print(output if output else "[dim]Command returned no output[/]")
+ if output:
+ self.console.print(_rich_text_from_ansi(output))
+ else:
+ self.console.print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self.console.print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self.console.print(f"[bold red]Quick command error: {e}[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
+ elif qcmd.get("type") == "alias":
+ target = qcmd.get("target", "").strip()
+ if target:
+ target = target if target.startswith("/") else f"/{target}"
+ user_args = cmd_original[len(base_cmd):].strip()
+ aliased_command = f"{target} {user_args}".strip()
+ return self.process_command(aliased_command)
+ else:
+ self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
- self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (only 'exec' is supported)[/]")
+ self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
+ # Check for plugin-registered slash commands
+ elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
+ from hermes_cli.plugins import get_plugin_command_handler
+ plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
+ if plugin_handler:
+ user_args = cmd_original[len(base_cmd):].strip()
+ try:
+ result = plugin_handler(user_args)
+ if result:
+ _cprint(str(result))
+ except Exception as e:
+ _cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
# Check for skill slash commands (/gif-search, /axolotl, etc.)
elif base_cmd in _skill_commands:
user_instruction = cmd_original[len(base_cmd):].strip()
- msg = build_skill_invocation_message(base_cmd, user_instruction)
+ msg = build_skill_invocation_message(
+ base_cmd, user_instruction, task_id=self.session_id
+ )
if msg:
skill_name = _skill_commands[base_cmd]["name"]
print(f"\nโก Loading skill: {skill_name}")
@@ -2933,11 +3934,74 @@ def process_command(self, command: str) -> bool:
else:
self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]")
else:
- self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
- self.console.print("[dim #B8860B]Type /help for available commands[/]")
+ # Prefix matching: if input uniquely identifies one command, execute it.
+ # Matches against both built-in COMMANDS and installed skill commands so
+ # that execution-time resolution agrees with tab-completion.
+ from hermes_cli.commands import COMMANDS
+ typed_base = cmd_lower.split()[0]
+ all_known = set(COMMANDS) | set(_skill_commands)
+ matches = [c for c in all_known if c.startswith(typed_base)]
+ if len(matches) > 1:
+ # Prefer an exact match (typed the full command name)
+ exact = [c for c in matches if c == typed_base]
+ if len(exact) == 1:
+ matches = exact
+ else:
+ # Prefer the unique shortest match:
+ # /qui โ /quit (5) wins over /quint-pipeline (15)
+ min_len = min(len(c) for c in matches)
+ shortest = [c for c in matches if len(c) == min_len]
+ if len(shortest) == 1:
+ matches = shortest
+ if len(matches) == 1:
+ # Expand the prefix to the full command name, preserving arguments.
+ # Guard against redispatching the same token to avoid infinite
+ # recursion when the expanded name still doesn't hit an exact branch
+ # (e.g. /config with extra args that are not yet handled above).
+ full_name = matches[0]
+ if full_name == typed_base:
+ # Already an exact token โ no expansion possible; fall through
+ _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
+ else:
+ remainder = cmd_original.strip()[len(typed_base):]
+ full_cmd = full_name + remainder
+ return self.process_command(full_cmd)
+ elif len(matches) > 1:
+ _cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
+ else:
+ _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
return True
+ def _handle_plan_command(self, cmd: str):
+ """Handle /plan [request] โ load the bundled plan skill."""
+ parts = cmd.strip().split(maxsplit=1)
+ user_instruction = parts[1].strip() if len(parts) > 1 else ""
+
+ plan_path = build_plan_path(user_instruction)
+ msg = build_skill_invocation_message(
+ "/plan",
+ user_instruction,
+ task_id=self.session_id,
+ runtime_note=(
+ "Save the markdown plan with write_file to this exact relative path "
+ f"inside the active workspace/backend cwd: {plan_path}"
+ ),
+ )
+
+ if not msg:
+ self.console.print("[bold red]Failed to load the bundled /plan skill[/]")
+ return
+
+ _cprint(f" ๐ Plan mode queued via skill. Markdown plan target: {plan_path}")
+ if hasattr(self, '_pending_input'):
+ self._pending_input.put(msg)
+ else:
+ self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]")
+
def _handle_background_command(self, cmd: str):
"""Handle /background โ run a prompt in a separate background session.
@@ -2964,16 +4028,23 @@ def _handle_background_command(self, cmd: str):
_cprint(f" ๐ Background task #{task_num} started: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
_cprint(f" Task ID: {task_id}")
- _cprint(f" You can continue chatting โ results will appear when done.\n")
+ _cprint(" You can continue chatting โ results will appear when done.\n")
+
+ turn_route = self._resolve_turn_agent_config(prompt)
def run_background():
try:
bg_agent = AIAgent(
- model=self.model,
- api_key=self.api_key,
- base_url=self.base_url,
- provider=self.provider,
- api_mode=self.api_mode,
+ model=turn_route["model"],
+ api_key=turn_route["runtime"].get("api_key"),
+ base_url=turn_route["runtime"].get("base_url"),
+ provider=turn_route["runtime"].get("provider"),
+ api_mode=turn_route["runtime"].get("api_mode"),
+ acp_command=turn_route["runtime"].get("command"),
+ acp_args=turn_route["runtime"].get("args"),
+ request_headers_resolver=turn_route["runtime"].get("request_headers_resolver"),
+ payment_adapter=turn_route["runtime"].get("payment_adapter"),
+ payment_config=turn_route["runtime"].get("payment_config"),
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
quiet_mode=True,
@@ -3000,28 +4071,37 @@ def run_background():
if not response and result and result.get("error"):
response = f"Error: {result['error']}"
- # Display result in the CLI (thread-safe via patch_stdout)
+ # Display result in the CLI (thread-safe via patch_stdout).
+ # Force a TUI refresh first so spinner/status bar don't overlap
+ # with the output (fixes #2718).
+ if self._app:
+ self._app.invalidate()
+ import time as _tmod
+ _tmod.sleep(0.05) # brief pause for refresh
print()
- _cprint(f"{_GOLD}{'โ' * 40}{_RST}")
+ ChatConsole().print(f"[{_accent_hex()}]{'โ' * 40}[/]")
_cprint(f" โ
Background task #{task_num} complete")
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
- _cprint(f"{_GOLD}{'โ' * 40}{_RST}")
+ ChatConsole().print(f"[{_accent_hex()}]{'โ' * 40}[/]")
if response:
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "โ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
+ _resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "โ Hermes"
_resp_color = "#CD7F32"
+ _resp_text = "#FFF8DC"
_chat_console = ChatConsole()
_chat_console.print(Panel(
- response,
- title=f"[bold]{label} (background #{task_num})[/bold]",
+ _rich_text_from_ansi(response),
+ title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
title_align="left",
border_style=_resp_color,
+ style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
@@ -3034,6 +4114,11 @@ def run_background():
sys.stdout.flush()
except Exception as e:
+ # Same TUI refresh pattern as success path (#2718)
+ if self._app:
+ self._app.invalidate()
+ import time as _tmod
+ _tmod.sleep(0.05)
print()
_cprint(f" โ Background task #{task_num} failed: {e}")
finally:
@@ -3045,6 +4130,209 @@ def run_background():
self._background_tasks[task_id] = thread
thread.start()
+ @staticmethod
+ def _try_launch_chrome_debug(port: int, system: str) -> bool:
+ """Try to launch Chrome/Chromium with remote debugging enabled.
+
+ Returns True if a launch command was executed (doesn't guarantee success).
+ """
+ import shutil
+ import subprocess as _sp
+
+ candidates = []
+ if system == "Darwin":
+ # macOS: try common app bundle locations
+ for app in (
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ ):
+ if os.path.isfile(app):
+ candidates.append(app)
+ else:
+ # Linux: try common binary names
+ for name in ("google-chrome", "google-chrome-stable", "chromium-browser",
+ "chromium", "brave-browser", "microsoft-edge"):
+ path = shutil.which(name)
+ if path:
+ candidates.append(path)
+
+ if not candidates:
+ return False
+
+ chrome = candidates[0]
+ try:
+ _sp.Popen(
+ [chrome, f"--remote-debugging-port={port}"],
+ stdout=_sp.DEVNULL,
+ stderr=_sp.DEVNULL,
+ start_new_session=True, # detach from terminal
+ )
+ return True
+ except Exception:
+ return False
+
+ def _handle_browser_command(self, cmd: str):
+ """Handle /browser connect|disconnect|status โ manage live Chrome CDP connection."""
+ import platform as _plat
+
+ parts = cmd.strip().split(None, 1)
+ sub = parts[1].lower().strip() if len(parts) > 1 else "status"
+
+ _DEFAULT_CDP = "http://localhost:9222"
+ current = os.environ.get("BROWSER_CDP_URL", "").strip()
+
+ if sub.startswith("connect"):
+ # Optionally accept a custom CDP URL: /browser connect ws://host:port
+ connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."]
+ cdp_url = connect_parts[2].strip() if len(connect_parts) > 2 else _DEFAULT_CDP
+
+ # Clear any existing browser sessions so the next tool call uses the new backend
+ try:
+ from tools.browser_tool import cleanup_all_browsers
+ cleanup_all_browsers()
+ except Exception:
+ pass
+
+ print()
+
+ # Extract port for connectivity checks
+ _port = 9222
+ try:
+ _port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0])
+ except (ValueError, IndexError):
+ pass
+
+ # Check if Chrome is already listening on the debug port
+ import socket
+ _already_open = False
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ s.connect(("127.0.0.1", _port))
+ s.close()
+ _already_open = True
+ except (OSError, socket.timeout):
+ pass
+
+ if _already_open:
+ print(f" โ Chrome is already listening on port {_port}")
+ elif cdp_url == _DEFAULT_CDP:
+ # Try to auto-launch Chrome with remote debugging
+ print(" Chrome isn't running with remote debugging โ attempting to launch...")
+ _launched = self._try_launch_chrome_debug(_port, _plat.system())
+ if _launched:
+ # Wait for the port to come up
+ import time as _time
+ for _wait in range(10):
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ s.connect(("127.0.0.1", _port))
+ s.close()
+ _already_open = True
+ break
+ except (OSError, socket.timeout):
+ _time.sleep(0.5)
+ if _already_open:
+ print(f" โ Chrome launched and listening on port {_port}")
+ else:
+ print(f" โ Chrome launched but port {_port} isn't responding yet")
+ print(" You may need to close existing Chrome windows first and retry")
+ else:
+ print(" โ Could not auto-launch Chrome")
+ # Show manual instructions as fallback
+ sys_name = _plat.system()
+ if sys_name == "Darwin":
+ chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222'
+ elif sys_name == "Windows":
+ chrome_cmd = 'chrome.exe --remote-debugging-port=9222'
+ else:
+ chrome_cmd = "google-chrome --remote-debugging-port=9222"
+ print(f" Launch Chrome manually: {chrome_cmd}")
+ else:
+ print(f" โ Port {_port} is not reachable at {cdp_url}")
+
+ os.environ["BROWSER_CDP_URL"] = cdp_url
+ print()
+ print("๐ Browser connected to live Chrome via CDP")
+ print(f" Endpoint: {cdp_url}")
+ print()
+
+ # Inject context message so the model knows
+ if hasattr(self, '_pending_input'):
+ self._pending_input.put(
+ "[System note: The user has connected your browser tools to their live Chrome browser "
+ "via Chrome DevTools Protocol. Your browser_navigate, browser_snapshot, browser_click, "
+ "and other browser tools now control their real browser โ including any pages they have "
+ "open, logged-in sessions, and cookies. They likely opened specific sites or logged into "
+ "services before connecting. Please await their instruction before attempting to operate "
+ "the browser. When you do act, be mindful that your actions affect their real browser โ "
+ "don't close tabs or navigate away from pages without asking.]"
+ )
+
+ elif sub == "disconnect":
+ if current:
+ os.environ.pop("BROWSER_CDP_URL", None)
+ try:
+ from tools.browser_tool import cleanup_all_browsers
+ cleanup_all_browsers()
+ except Exception:
+ pass
+ print()
+ print("๐ Browser disconnected from live Chrome")
+ print(" Browser tools reverted to default mode (local headless or Browserbase)")
+ print()
+
+ if hasattr(self, '_pending_input'):
+ self._pending_input.put(
+ "[System note: The user has disconnected the browser tools from their live Chrome. "
+ "Browser tools are back to default mode (headless local browser or Browserbase cloud).]"
+ )
+ else:
+ print()
+ print("Browser is not connected to live Chrome (already using default mode)")
+ print()
+
+ elif sub == "status":
+ print()
+ if current:
+ print("๐ Browser: connected to live Chrome via CDP")
+ print(f" Endpoint: {current}")
+
+ _port = 9222
+ try:
+ _port = int(current.rsplit(":", 1)[-1].split("/")[0])
+ except (ValueError, IndexError):
+ pass
+ try:
+ import socket
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ s.connect(("127.0.0.1", _port))
+ s.close()
+ print(" Status: โ reachable")
+ except (OSError, Exception):
+ print(" Status: โ not reachable (Chrome may not be running)")
+ elif os.environ.get("BROWSERBASE_API_KEY"):
+ print("๐ Browser: Browserbase (cloud)")
+ else:
+ print("๐ Browser: local headless Chromium (agent-browser)")
+ print()
+ print(" /browser connect โ connect to your live Chrome")
+ print(" /browser disconnect โ revert to default")
+ print()
+
+ else:
+ print()
+ print("Usage: /browser connect|disconnect|status")
+ print()
+ print(" connect Connect browser tools to your live Chrome session")
+ print(" disconnect Revert to default browser backend")
+ print(" status Show current browser mode")
+ print()
+
def _handle_skin_command(self, cmd: str):
"""Handle /skin [name] โ show or change the display skin."""
try:
@@ -3059,13 +4347,13 @@ def _handle_skin_command(self, cmd: str):
current = get_active_skin_name()
skins = list_skins()
print(f"\n Current skin: {current}")
- print(f" Available skins:")
+ print(" Available skins:")
for s in skins:
marker = " โ" if s["name"] == current else " "
source = f" ({s['source']})" if s["source"] == "user" else ""
print(f" {marker} {s['name']}{source} โ {s['description']}")
- print(f"\n Usage: /skin ")
- print(f" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
+ print("\n Usage: /skin ")
+ print(" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
return
new_skin = parts[1].strip().lower()
@@ -3081,6 +4369,8 @@ def _handle_skin_command(self, cmd: str):
else:
print(f" Skin set to: {new_skin}")
print(" Note: banner colors will update on next session start.")
+ if self._apply_tui_skin_style():
+ print(" Prompt + TUI colors updated.")
def _toggle_verbose(self):
"""Cycle tool progress mode: off โ new โ all โ verbose โ off."""
@@ -3095,14 +4385,20 @@ def _toggle_verbose(self):
if self.agent:
self.agent.verbose_logging = self.verbose
self.agent.quiet_mode = not self.verbose
+ self.agent.reasoning_callback = self._current_reasoning_callback()
+ # Use raw ANSI codes via _cprint so the output is routed through
+ # prompt_toolkit's renderer. self.console.print() with Rich markup
+ # writes directly to stdout which patch_stdout's StdoutProxy mangles
+ # into garbled sequences like '?[33mTool progress: NEW?[0m' (#2262).
+ from hermes_cli.colors import Colors as _Colors
labels = {
- "off": "[dim]Tool progress: OFF[/] โ silent mode, just the final response.",
- "new": "[yellow]Tool progress: NEW[/] โ show each new tool (skip repeats).",
- "all": "[green]Tool progress: ALL[/] โ show every tool call.",
- "verbose": "[bold green]Tool progress: VERBOSE[/] โ full args, results, and debug logs.",
+ "off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} โ silent mode, just the final response.",
+ "new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} โ show each new tool (skip repeats).",
+ "all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} โ show every tool call.",
+ "verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} โ full args, results, think blocks, and debug logs.",
}
- self.console.print(labels.get(self.tool_progress_mode, ""))
+ _cprint(labels.get(self.tool_progress_mode, ""))
def _handle_reasoning_command(self, cmd: str):
"""Handle /reasoning โ manage effort level and display toggle.
@@ -3136,7 +4432,7 @@ def _handle_reasoning_command(self, cmd: str):
if arg in ("show", "on"):
self.show_reasoning = True
if self.agent:
- self.agent.reasoning_callback = self._on_reasoning
+ self.agent.reasoning_callback = self._current_reasoning_callback()
save_config_value("display.show_reasoning", True)
_cprint(f" {_GOLD}โ Reasoning display: ON (saved){_RST}")
_cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}")
@@ -3144,7 +4440,7 @@ def _handle_reasoning_command(self, cmd: str):
if arg in ("hide", "off"):
self.show_reasoning = False
if self.agent:
- self.agent.reasoning_callback = None
+ self.agent.reasoning_callback = self._current_reasoning_callback()
save_config_value("display.show_reasoning", False)
_cprint(f" {_GOLD}โ Reasoning display: OFF (saved){_RST}")
return
@@ -3167,13 +4463,10 @@ def _handle_reasoning_command(self, cmd: str):
def _on_reasoning(self, reasoning_text: str):
"""Callback for intermediate reasoning display during tool-call loops."""
- lines = reasoning_text.strip().splitlines()
- if len(lines) > 5:
- preview = "\n".join(lines[:5])
- preview += f"\n ... ({len(lines) - 5} more lines)"
- else:
- preview = reasoning_text.strip()
- _cprint(f" {_DIM}[thinking] {preview}{_RST}")
+ if not reasoning_text:
+ return
+ self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
+ self._flush_reasoning_preview(force=False)
def _manual_compress(self):
"""Manually trigger context compression on the current conversation."""
@@ -3207,6 +4500,12 @@ def _manual_compress(self):
f" โ
Compressed: {original_count} โ {new_count} messages "
f"(~{approx_tokens:,} โ ~{new_tokens:,} tokens)"
)
+ # Flush Honcho async queue so queued messages land before context resets
+ if self.agent and getattr(self.agent, '_honcho', None):
+ try:
+ self.agent._honcho.flush_all()
+ except Exception:
+ pass
except Exception as e:
print(f" โ Compression failed: {e}")
@@ -3217,6 +4516,10 @@ def _show_usage(self):
return
agent = self.agent
+ input_tokens = getattr(agent, "session_input_tokens", 0) or 0
+ output_tokens = getattr(agent, "session_output_tokens", 0) or 0
+ cache_read_tokens = getattr(agent, "session_cache_read_tokens", 0) or 0
+ cache_write_tokens = getattr(agent, "session_cache_write_tokens", 0) or 0
prompt = agent.session_prompt_tokens
completion = agent.session_completion_tokens
total = agent.session_total_tokens
@@ -3234,17 +4537,46 @@ def _show_usage(self):
compressions = compressor.compression_count
msg_count = len(self.conversation_history)
+ cost_result = estimate_usage_cost(
+ agent.model,
+ CanonicalUsage(
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ cache_read_tokens=cache_read_tokens,
+ cache_write_tokens=cache_write_tokens,
+ ),
+ provider=getattr(agent, "provider", None),
+ base_url=getattr(agent, "base_url", None),
+ )
+ elapsed = format_duration_compact((datetime.now() - self.session_start).total_seconds())
- print(f" ๐ Session Token Usage")
+ print(" ๐ Session Token Usage")
print(f" {'โ' * 40}")
- print(f" Prompt tokens (input): {prompt:>10,}")
- print(f" Completion tokens (output): {completion:>9,}")
+ print(f" Model: {agent.model}")
+ print(f" Input tokens: {input_tokens:>10,}")
+ print(f" Cache read tokens: {cache_read_tokens:>10,}")
+ print(f" Cache write tokens: {cache_write_tokens:>10,}")
+ print(f" Output tokens: {output_tokens:>10,}")
+ print(f" Prompt tokens (total): {prompt:>10,}")
+ print(f" Completion tokens: {completion:>10,}")
print(f" Total tokens: {total:>10,}")
print(f" API calls: {calls:>10,}")
+ print(f" Session duration: {elapsed:>10}")
+ print(f" Cost status: {cost_result.status:>10}")
+ print(f" Cost source: {cost_result.source:>10}")
+ if cost_result.amount_usd is not None:
+ prefix = "~" if cost_result.status == "estimated" else ""
+ print(f" Total cost: {prefix}${float(cost_result.amount_usd):>10.4f}")
+ elif cost_result.status == "included":
+ print(f" Total cost: {'included':>10}")
+ else:
+ print(f" Total cost: {'n/a':>10}")
print(f" {'โ' * 40}")
print(f" Current context: {last_prompt:,} / {ctx_len:,} ({pct:.0f}%)")
print(f" Messages: {msg_count}")
print(f" Compressions: {compressions}")
+ if cost_result.status == "unknown":
+ print(f" Note: Pricing unknown for {agent.model}")
if self.verbose:
logging.getLogger().setLevel(logging.DEBUG)
@@ -3252,7 +4584,7 @@ def _show_usage(self):
logging.getLogger(noisy).setLevel(logging.WARNING)
else:
logging.getLogger().setLevel(logging.INFO)
- for quiet_logger in ('tools', 'minisweagent', 'run_agent', 'trajectory_compressor', 'cron', 'hermes_cli'):
+ for quiet_logger in ('tools', 'run_agent', 'trajectory_compressor', 'cron', 'hermes_cli'):
logging.getLogger(quiet_logger).setLevel(logging.ERROR)
def _show_insights(self, command: str = "/insights"):
@@ -3288,6 +4620,56 @@ def _show_insights(self, command: str = "/insights"):
except Exception as e:
print(f" Error generating insights: {e}")
+ def _check_config_mcp_changes(self) -> None:
+ """Detect mcp_servers changes in config.yaml and auto-reload MCP connections.
+
+ Called from process_loop every CONFIG_WATCH_INTERVAL seconds.
+ Compares config.yaml mtime + mcp_servers section against the last
+ known state. When a change is detected, triggers _reload_mcp() and
+ informs the user so they know the tool list has been refreshed.
+ """
+ import time
+ import yaml as _yaml
+
+ CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls
+
+ now = time.monotonic()
+ if now - self._last_config_check < CONFIG_WATCH_INTERVAL:
+ return
+ self._last_config_check = now
+
+ from hermes_cli.config import get_config_path as _get_config_path
+ cfg_path = _get_config_path()
+ if not cfg_path.exists():
+ return
+
+ try:
+ mtime = cfg_path.stat().st_mtime
+ except OSError:
+ return
+
+ if mtime == self._config_mtime:
+ return # File unchanged โ fast path
+
+ # File changed โ check whether mcp_servers section changed
+ self._config_mtime = mtime
+ try:
+ with open(cfg_path, encoding="utf-8") as f:
+ new_cfg = _yaml.safe_load(f) or {}
+ except Exception:
+ return
+
+ new_mcp = new_cfg.get("mcp_servers") or {}
+ if new_mcp == self._config_mcp_servers:
+ return # mcp_servers unchanged (some other section was edited)
+
+ self._config_mcp_servers = new_mcp
+ # Notify user and reload
+ print()
+ print("๐ MCP server config changed โ reloading connections...")
+ with self._busy_command(self._slow_command_status("/reload-mcp")):
+ self._reload_mcp()
+
def _reload_mcp(self):
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
@@ -3295,7 +4677,7 @@ def _reload_mcp(self):
sees the updated tools on the next turn.
"""
try:
- from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
+ from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
# Capture old server names
with _lock:
@@ -3374,6 +4756,438 @@ def _reload_mcp(self):
except Exception as e:
print(f" โ MCP reload failed: {e}")
+ # ====================================================================
+ # Tool-call generation indicator (shown during streaming)
+ # ====================================================================
+
+ def _on_tool_gen_start(self, tool_name: str) -> None:
+ """Called when the model begins generating tool-call arguments.
+
+ Closes any open streaming boxes (reasoning / response) exactly once,
+ then prints a short status line so the user sees activity instead of
+ a frozen screen while a large payload (e.g. 45 KB write_file) streams.
+ """
+ if getattr(self, "_stream_box_opened", False):
+ self._flush_stream()
+ self._stream_box_opened = False
+ self._close_reasoning_box()
+
+ from agent.display import get_tool_emoji
+ emoji = get_tool_emoji(tool_name, default="โก")
+ _cprint(f" โ {emoji} preparing {tool_name}โฆ")
+
+ # ====================================================================
+ # Tool progress callback (audio cues for voice mode)
+ # ====================================================================
+
+ def _on_tool_progress(self, function_name: str, preview: str, function_args: dict):
+ """Called when a tool starts executing.
+
+ Updates the TUI spinner widget so the user can see what the agent
+ is doing during tool execution (fills the gap between thinking
+ spinner and next response). Also plays audio cue in voice mode.
+ """
+ if not function_name.startswith("_"):
+ from agent.display import get_tool_emoji
+ emoji = get_tool_emoji(function_name)
+ label = preview or function_name
+ if len(label) > 50:
+ label = label[:47] + "..."
+ self._spinner_text = f"{emoji} {label}"
+ self._invalidate()
+
+ if not self._voice_mode:
+ return
+ if function_name.startswith("_"):
+ return
+ try:
+ from tools.voice_mode import play_beep
+ threading.Thread(
+ target=play_beep,
+ kwargs={"frequency": 1200, "duration": 0.06, "count": 1},
+ daemon=True,
+ ).start()
+ except Exception:
+ pass
+
+ # ====================================================================
+ # Voice mode methods
+ # ====================================================================
+
+ def _voice_start_recording(self):
+ """Start capturing audio from the microphone."""
+ if getattr(self, '_should_exit', False):
+ return
+ from tools.voice_mode import AudioRecorder, check_voice_requirements
+
+ reqs = check_voice_requirements()
+ if not reqs["audio_available"]:
+ raise RuntimeError(
+ "Voice mode requires sounddevice and numpy.\n"
+ "Install with: pip install sounddevice numpy\n"
+ "Or: pip install hermes-agent[voice]"
+ )
+ if not reqs.get("stt_available", reqs.get("stt_key_set")):
+ raise RuntimeError(
+ "Voice mode requires an STT provider for transcription.\n"
+ "Option 1: pip install faster-whisper (free, local)\n"
+ "Option 2: Set GROQ_API_KEY (free tier)\n"
+ "Option 3: Set VOICE_TOOLS_OPENAI_KEY (paid)"
+ )
+
+ # Prevent double-start from concurrent threads (atomic check-and-set)
+ with self._voice_lock:
+ if self._voice_recording:
+ return
+ self._voice_recording = True
+
+ # Load silence detection params from config
+ voice_cfg = {}
+ try:
+ from hermes_cli.config import load_config
+ voice_cfg = load_config().get("voice", {})
+ except Exception:
+ pass
+
+ if self._voice_recorder is None:
+ self._voice_recorder = AudioRecorder()
+
+ # Apply config-driven silence params
+ self._voice_recorder._silence_threshold = voice_cfg.get("silence_threshold", 200)
+ self._voice_recorder._silence_duration = voice_cfg.get("silence_duration", 3.0)
+
+ def _on_silence():
+ """Called by AudioRecorder when silence is detected after speech."""
+ with self._voice_lock:
+ if not self._voice_recording:
+ return
+ _cprint(f"\n{_DIM}Silence detected, auto-stopping...{_RST}")
+ if hasattr(self, '_app') and self._app:
+ self._app.invalidate()
+ self._voice_stop_and_transcribe()
+
+ # Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
+ try:
+ from tools.voice_mode import play_beep
+ play_beep(frequency=880, count=1)
+ except Exception:
+ pass
+
+ try:
+ self._voice_recorder.start(on_silence_stop=_on_silence)
+ except Exception:
+ with self._voice_lock:
+ self._voice_recording = False
+ raise
+ _cprint(f"\n{_GOLD}โ Recording...{_RST} {_DIM}(auto-stops on silence | Ctrl+B to stop & exit continuous){_RST}")
+
+ # Periodically refresh prompt to update audio level indicator
+ def _refresh_level():
+ while True:
+ with self._voice_lock:
+ still_recording = self._voice_recording
+ if not still_recording:
+ break
+ if hasattr(self, '_app') and self._app:
+ self._app.invalidate()
+ time.sleep(0.15)
+ threading.Thread(target=_refresh_level, daemon=True).start()
+
+ def _voice_stop_and_transcribe(self):
+ """Stop recording, transcribe via STT, and queue the transcript as input."""
+ # Atomic guard: only one thread can enter stop-and-transcribe.
+ # Set _voice_processing immediately so concurrent Ctrl+B presses
+ # don't race into the START path while recorder.stop() holds its lock.
+ with self._voice_lock:
+ if not self._voice_recording:
+ return
+ self._voice_recording = False
+ self._voice_processing = True
+
+ submitted = False
+ wav_path = None
+ try:
+ if self._voice_recorder is None:
+ return
+
+ wav_path = self._voice_recorder.stop()
+
+ # Audio cue: double beep after stream stopped (no CoreAudio conflict)
+ try:
+ from tools.voice_mode import play_beep
+ play_beep(frequency=660, count=2)
+ except Exception:
+ pass
+
+ if wav_path is None:
+ _cprint(f"{_DIM}No speech detected.{_RST}")
+ return
+
+ # _voice_processing is already True (set atomically above)
+ if hasattr(self, '_app') and self._app:
+ self._app.invalidate()
+ _cprint(f"{_DIM}Transcribing...{_RST}")
+
+ # Get STT model from config
+ stt_model = None
+ try:
+ from hermes_cli.config import load_config
+ stt_config = load_config().get("stt", {})
+ stt_model = stt_config.get("model")
+ except Exception:
+ pass
+
+ from tools.voice_mode import transcribe_recording
+ result = transcribe_recording(wav_path, model=stt_model)
+
+ if result.get("success") and result.get("transcript", "").strip():
+ transcript = result["transcript"].strip()
+ self._pending_input.put(transcript)
+ submitted = True
+ elif result.get("success"):
+ _cprint(f"{_DIM}No speech detected.{_RST}")
+ else:
+ error = result.get("error", "Unknown error")
+ _cprint(f"\n{_DIM}Transcription failed: {error}{_RST}")
+
+ except Exception as e:
+ _cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
+ finally:
+ with self._voice_lock:
+ self._voice_processing = False
+ if hasattr(self, '_app') and self._app:
+ self._app.invalidate()
+ # Clean up temp file
+ try:
+ if wav_path and os.path.isfile(wav_path):
+ os.unlink(wav_path)
+ except Exception:
+ pass
+
+ # Track consecutive no-speech cycles to avoid infinite restart loops.
+ if not submitted:
+ self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1
+ if self._no_speech_count >= 3:
+ self._voice_continuous = False
+ self._no_speech_count = 0
+ _cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
+ return
+ else:
+ self._no_speech_count = 0
+
+ # If no transcript was submitted but continuous mode is active,
+ # restart recording so the user can keep talking.
+ # (When transcript IS submitted, process_loop handles restart
+ # after chat() completes.)
+ if self._voice_continuous and not submitted and not self._voice_recording:
+ def _restart_recording():
+ try:
+ self._voice_start_recording()
+ if hasattr(self, '_app') and self._app:
+ self._app.invalidate()
+ except Exception as e:
+ _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
+ threading.Thread(target=_restart_recording, daemon=True).start()
+
+ def _voice_speak_response(self, text: str):
+ """Speak the agent's response aloud using TTS (runs in background thread)."""
+ if not self._voice_tts:
+ return
+ self._voice_tts_done.clear()
+ try:
+ from tools.tts_tool import text_to_speech_tool
+ from tools.voice_mode import play_audio_file
+ import re
+
+ # Strip markdown and non-speech content for cleaner TTS
+ tts_text = text[:4000] if len(text) > 4000 else text
+ tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
+ tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
+ tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
+ tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
+ tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic
+ tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code
+ tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers
+ tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list items
+ tts_text = re.sub(r'---+', '', tts_text) # horizontal rules
+ tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excessive newlines
+ tts_text = tts_text.strip()
+ if not tts_text:
+ return
+
+ # Use MP3 output for CLI playback (afplay doesn't handle OGG well).
+ # The TTS tool may auto-convert MP3->OGG, but the original MP3 remains.
+ os.makedirs(os.path.join(tempfile.gettempdir(), "hermes_voice"), exist_ok=True)
+ mp3_path = os.path.join(
+ tempfile.gettempdir(), "hermes_voice",
+ f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
+ )
+
+ text_to_speech_tool(text=tts_text, output_path=mp3_path)
+
+ # Play the MP3 directly (the TTS tool returns OGG path but MP3 still exists)
+ if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
+ play_audio_file(mp3_path)
+ # Clean up
+ try:
+ os.unlink(mp3_path)
+ ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
+ if os.path.isfile(ogg_path):
+ os.unlink(ogg_path)
+ except OSError:
+ pass
+ except Exception as e:
+ logger.warning("Voice TTS playback failed: %s", e)
+ _cprint(f"{_DIM}TTS playback failed: {e}{_RST}")
+ finally:
+ self._voice_tts_done.set()
+
+ def _handle_voice_command(self, command: str):
+ """Handle /voice [on|off|tts|status] command."""
+ parts = command.strip().split(maxsplit=1)
+ subcommand = parts[1].lower().strip() if len(parts) > 1 else ""
+
+ if subcommand == "on":
+ self._enable_voice_mode()
+ elif subcommand == "off":
+ self._disable_voice_mode()
+ elif subcommand == "tts":
+ self._toggle_voice_tts()
+ elif subcommand == "status":
+ self._show_voice_status()
+ elif subcommand == "":
+ # Toggle
+ if self._voice_mode:
+ self._disable_voice_mode()
+ else:
+ self._enable_voice_mode()
+ else:
+ _cprint(f"Unknown voice subcommand: {subcommand}")
+ _cprint("Usage: /voice [on|off|tts|status]")
+
+ def _enable_voice_mode(self):
+ """Enable voice mode after checking requirements."""
+ if self._voice_mode:
+ _cprint(f"{_DIM}Voice mode is already enabled.{_RST}")
+ return
+
+ from tools.voice_mode import check_voice_requirements, detect_audio_environment
+
+ # Environment detection -- warn and block in incompatible environments
+ env_check = detect_audio_environment()
+ if not env_check["available"]:
+ _cprint(f"\n{_GOLD}Voice mode unavailable in this environment:{_RST}")
+ for warning in env_check["warnings"]:
+ _cprint(f" {_DIM}{warning}{_RST}")
+ return
+
+ reqs = check_voice_requirements()
+ if not reqs["available"]:
+ _cprint(f"\n{_GOLD}Voice mode requirements not met:{_RST}")
+ for line in reqs["details"].split("\n"):
+ _cprint(f" {_DIM}{line}{_RST}")
+ if reqs["missing_packages"]:
+ _cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}")
+ _cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}")
+ return
+
+ with self._voice_lock:
+ self._voice_mode = True
+
+ # Check config for auto_tts
+ try:
+ from hermes_cli.config import load_config
+ voice_config = load_config().get("voice", {})
+ if voice_config.get("auto_tts", False):
+ with self._voice_lock:
+ self._voice_tts = True
+ except Exception:
+ pass
+
+ # Voice mode instruction is injected as a user message prefix (not a
+ # system prompt change) to avoid invalidating the prompt cache. See
+ # _voice_message_prefix property and its usage in _process_message().
+
+ tts_status = " (TTS enabled)" if self._voice_tts else ""
+ try:
+ from hermes_cli.config import load_config
+ _raw_ptt = load_config().get("voice", {}).get("record_key", "ctrl+b")
+ _ptt_key = _raw_ptt.lower().replace("ctrl+", "c-").replace("alt+", "a-")
+ except Exception:
+ _ptt_key = "c-b"
+ _ptt_display = _ptt_key.replace("c-", "Ctrl+").upper()
+ _cprint(f"\n{_GOLD}Voice mode enabled{tts_status}{_RST}")
+ _cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}")
+ _cprint(f" {_DIM}/voice tts to toggle speech output{_RST}")
+ _cprint(f" {_DIM}/voice off to disable voice mode{_RST}")
+
+ def _disable_voice_mode(self):
+ """Disable voice mode, cancel any active recording, and stop TTS."""
+ recorder = None
+ with self._voice_lock:
+ if self._voice_recording and self._voice_recorder:
+ self._voice_recorder.cancel()
+ self._voice_recording = False
+ recorder = self._voice_recorder
+ self._voice_mode = False
+ self._voice_tts = False
+ self._voice_continuous = False
+
+ # Shut down the persistent audio stream in background
+ if recorder is not None:
+ def _bg_shutdown(rec=recorder):
+ try:
+ rec.shutdown()
+ except Exception:
+ pass
+ threading.Thread(target=_bg_shutdown, daemon=True).start()
+ self._voice_recorder = None
+
+ # Stop any active TTS playback
+ try:
+ from tools.voice_mode import stop_playback
+ stop_playback()
+ except Exception:
+ pass
+ self._voice_tts_done.set()
+
+ _cprint(f"\n{_DIM}Voice mode disabled.{_RST}")
+
+ def _toggle_voice_tts(self):
+ """Toggle TTS output for voice mode."""
+ if not self._voice_mode:
+ _cprint(f"{_DIM}Enable voice mode first: /voice on{_RST}")
+ return
+
+ with self._voice_lock:
+ self._voice_tts = not self._voice_tts
+ status = "enabled" if self._voice_tts else "disabled"
+
+ if self._voice_tts:
+ from tools.tts_tool import check_tts_requirements
+ if not check_tts_requirements():
+ _cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
+
+ _cprint(f"{_GOLD}Voice TTS {status}.{_RST}")
+
+ def _show_voice_status(self):
+ """Show current voice mode status."""
+ from hermes_cli.config import load_config
+ from tools.voice_mode import check_voice_requirements
+
+ reqs = check_voice_requirements()
+
+ _cprint(f"\n{_BOLD}Voice Mode Status{_RST}")
+ _cprint(f" Mode: {'ON' if self._voice_mode else 'OFF'}")
+ _cprint(f" TTS: {'ON' if self._voice_tts else 'OFF'}")
+ _cprint(f" Recording: {'YES' if self._voice_recording else 'no'}")
+ _raw_key = load_config().get("voice", {}).get("record_key", "ctrl+b")
+ _display_key = _raw_key.replace("ctrl+", "Ctrl+").upper() if "ctrl+" in _raw_key.lower() else _raw_key
+ _cprint(f" Record key: {_display_key}")
+ _cprint(f"\n {_BOLD}Requirements:{_RST}")
+ for line in reqs["details"].split("\n"):
+ _cprint(f" {line}")
+
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -3484,53 +5298,199 @@ def _sudo_password_callback(self) -> str:
_cprint(f"\n{_DIM} โฑ Timeout โ continuing without sudo{_RST}")
return ""
- def _approval_callback(self, command: str, description: str) -> str:
+ def _approval_callback(self, command: str, description: str,
+ *, allow_permanent: bool = True) -> str:
"""
Prompt for dangerous command approval through the prompt_toolkit UI.
-
+
Called from the agent thread. Shows a selection UI similar to clarify
- with choices: once / session / always / deny.
+ with choices: once / session / always / deny. When allow_permanent
+ is False (tirith warnings present), the 'always' option is hidden.
+ Long commands also get a 'view' option so the full command can be
+ expanded before deciding.
+
+ Uses _approval_lock to serialize concurrent requests (e.g. from
+ parallel delegation subtasks) so each prompt gets its own turn
+ and the shared _approval_state / _approval_deadline aren't clobbered.
"""
import time as _time
- timeout = 60
- response_queue = queue.Queue()
- choices = ["once", "session", "always", "deny"]
+ with self._approval_lock:
+ timeout = 60
+ response_queue = queue.Queue()
- self._approval_state = {
- "command": command,
- "description": description,
- "choices": choices,
- "selected": 0,
- "response_queue": response_queue,
- }
- self._approval_deadline = _time.monotonic() + timeout
+ self._approval_state = {
+ "command": command,
+ "description": description,
+ "choices": self._approval_choices(command, allow_permanent=allow_permanent),
+ "selected": 0,
+ "response_queue": response_queue,
+ }
+ self._approval_deadline = _time.monotonic() + timeout
- self._invalidate()
+ self._invalidate()
- # Same throttled countdown as _clarify_callback โ repaint only
- # every 5 s to avoid flicker in Kitty / ghostty / etc.
- _last_countdown_refresh = _time.monotonic()
- while True:
- try:
- result = response_queue.get(timeout=1)
- self._approval_state = None
- self._approval_deadline = 0
- self._invalidate()
- return result
- except queue.Empty:
- remaining = self._approval_deadline - _time.monotonic()
- if remaining <= 0:
- break
- now = _time.monotonic()
- if now - _last_countdown_refresh >= 5.0:
- _last_countdown_refresh = now
+ _last_countdown_refresh = _time.monotonic()
+ while True:
+ try:
+ result = response_queue.get(timeout=1)
+ self._approval_state = None
+ self._approval_deadline = 0
self._invalidate()
+ return result
+ except queue.Empty:
+ remaining = self._approval_deadline - _time.monotonic()
+ if remaining <= 0:
+ break
+ now = _time.monotonic()
+ if now - _last_countdown_refresh >= 5.0:
+ _last_countdown_refresh = now
+ self._invalidate()
+
+ self._approval_state = None
+ self._approval_deadline = 0
+ self._invalidate()
+ _cprint(f"\n{_DIM} โฑ Timeout โ denying command{_RST}")
+ return "deny"
+
+ def _approval_choices(self, command: str, *, allow_permanent: bool = True) -> list[str]:
+ """Return approval choices for a dangerous command prompt."""
+ choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
+ if len(command) > 70:
+ choices.append("view")
+ return choices
+
+ def _handle_approval_selection(self) -> None:
+ """Process the currently selected dangerous-command approval choice."""
+ state = self._approval_state
+ if not state:
+ return
+
+ selected = state.get("selected", 0)
+ choices = state.get("choices") or []
+ if not (0 <= selected < len(choices)):
+ return
+ chosen = choices[selected]
+ if chosen == "view":
+ state["show_full"] = True
+ state["choices"] = [choice for choice in choices if choice != "view"]
+ if state["selected"] >= len(state["choices"]):
+ state["selected"] = max(0, len(state["choices"]) - 1)
+ self._invalidate()
+ return
+
+ state["response_queue"].put(chosen)
self._approval_state = None
- self._approval_deadline = 0
self._invalidate()
- return "deny"
+
+ def _get_approval_display_fragments(self):
+ """Render the dangerous-command approval panel for the prompt_toolkit UI."""
+ state = self._approval_state
+ if not state:
+ return []
+
+ def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 46, max_width: int = 76) -> int:
+ term_cols = shutil.get_terminal_size((100, 20)).columns
+ longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])
+ inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
+ return inner + 2
+
+ def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
+ wrapped = textwrap.wrap(
+ text,
+ width=max(8, width),
+ replace_whitespace=False,
+ drop_whitespace=False,
+ subsequent_indent=subsequent_indent,
+ )
+ return wrapped or [""]
+
+ def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
+ inner_width = max(0, box_width - 2)
+ lines.append((border_style, "โ "))
+ lines.append((content_style, text.ljust(inner_width)))
+ lines.append((border_style, " โ\n"))
+
+ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
+ lines.append((border_style, "โ" + (" " * box_width) + "โ\n"))
+
+ command = state["command"]
+ description = state["description"]
+ choices = state["choices"]
+ selected = state.get("selected", 0)
+ show_full = state.get("show_full", False)
+
+ title = "โ ๏ธ Dangerous Command"
+ cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
+ choice_labels = {
+ "once": "Allow once",
+ "session": "Allow for this session",
+ "always": "Add to permanent allowlist",
+ "deny": "Deny",
+ "view": "Show full command",
+ }
+
+ preview_lines = _wrap_panel_text(description, 60)
+ preview_lines.extend(_wrap_panel_text(cmd_display, 60))
+ for i, choice in enumerate(choices):
+ prefix = 'โฏ ' if i == selected else ' '
+ preview_lines.extend(_wrap_panel_text(
+ f"{prefix}{choice_labels.get(choice, choice)}",
+ 60,
+ subsequent_indent=" ",
+ ))
+
+ box_width = _panel_box_width(title, preview_lines)
+ inner_text_width = max(8, box_width - 2)
+
+ lines = []
+ lines.append(('class:approval-border', 'โญ' + ('โ' * box_width) + 'โฎ\n'))
+ _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
+ _append_blank_panel_line(lines, 'class:approval-border', box_width)
+ for wrapped in _wrap_panel_text(description, inner_text_width):
+ _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
+ for wrapped in _wrap_panel_text(cmd_display, inner_text_width):
+ _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
+ _append_blank_panel_line(lines, 'class:approval-border', box_width)
+ for i, choice in enumerate(choices):
+ label = choice_labels.get(choice, choice)
+ style = 'class:approval-selected' if i == selected else 'class:approval-choice'
+ prefix = 'โฏ ' if i == selected else ' '
+ for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "):
+ _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
+ _append_blank_panel_line(lines, 'class:approval-border', box_width)
+ lines.append(('class:approval-border', 'โฐ' + ('โ' * box_width) + 'โฏ\n'))
+ return lines
+
+ def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
+ return prompt_for_secret(self, var_name, prompt, metadata)
+
+ def _submit_secret_response(self, value: str) -> None:
+ if not self._secret_state:
+ return
+ self._secret_state["response_queue"].put(value)
+ self._secret_state = None
+ self._secret_deadline = 0
+ self._invalidate()
+
+ def _cancel_secret_capture(self) -> None:
+ self._submit_secret_response("")
+
+ def _clear_secret_input_buffer(self) -> None:
+ if getattr(self, "_app", None):
+ try:
+ self._app.current_buffer.reset()
+ except Exception:
+ pass
+
+ def _clear_current_input(self) -> None:
+ if getattr(self, "_app", None):
+ try:
+ self._app.current_buffer.text = ""
+ except Exception:
+ pass
+
def chat(self, message, images: list = None) -> Optional[str]:
"""
@@ -3551,12 +5511,24 @@ def chat(self, message, images: list = None) -> Optional[str]:
Returns:
The agent's response, or None on error
"""
+ # Single-query and direct chat callers do not go through run(), so
+ # register secure secret capture here as well.
+ set_secret_capture_callback(self._secret_capture_callback)
+
# Refresh provider credentials if needed (handles key rotation transparently)
if not self._ensure_runtime_credentials():
return None
+ turn_route = self._resolve_turn_agent_config(message)
+ if turn_route["signature"] != self._active_agent_route_signature:
+ self.agent = None
+
# Initialize agent if needed
- if not self._init_agent():
+ if not self._init_agent(
+ model_override=turn_route["model"],
+ runtime_override=turn_route["runtime"],
+ route_label=turn_route["label"],
+ ):
return None
# Pre-process images through the vision tool (Gemini Flash) so the
@@ -3567,28 +5539,140 @@ def chat(self, message, images: list = None) -> Optional[str]:
message if isinstance(message, str) else "", images
)
+ # Expand @ context references (e.g. @file:main.py, @diff, @folder:src/)
+ if isinstance(message, str) and "@" in message:
+ try:
+ from agent.context_references import preprocess_context_references
+ from agent.model_metadata import get_model_context_length
+ _ctx_len = get_model_context_length(
+ self.model, base_url=self.base_url or "", api_key=self.api_key or "")
+ _ctx_result = preprocess_context_references(
+ message, cwd=os.getcwd(), context_length=_ctx_len)
+ if _ctx_result.expanded or _ctx_result.blocked:
+ if _ctx_result.references:
+ _cprint(
+ f" {_DIM}[@ context: {len(_ctx_result.references)} ref(s), "
+ f"{_ctx_result.injected_tokens} tokens]{_RST}")
+ for w in _ctx_result.warnings:
+ _cprint(f" {_DIM}โ {w}{_RST}")
+ if _ctx_result.blocked:
+ return "\n".join(_ctx_result.warnings) or "Context injection refused."
+ message = _ctx_result.message
+ except Exception as e:
+ logging.debug("@ context reference expansion failed: %s", e)
+
# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
-
- _cprint(f"{_GOLD}{'โ' * 40}{_RST}")
+
+ ChatConsole().print(f"[{_accent_hex()}]{'โ' * 40}[/]")
print(flush=True)
try:
# Run the conversation with interrupt monitoring
result = None
-
+
+ # Reset streaming display state for this turn
+ self._reset_stream_state()
+ # Separate from _reset_stream_state because this must persist
+ # across intermediate turn boundaries (tool-calling loops) โ only
+ # reset at the start of each user turn.
+ self._reasoning_shown_this_turn = False
+
+ # --- Streaming TTS setup ---
+ # When ElevenLabs is the TTS provider and sounddevice is available,
+ # we stream audio sentence-by-sentence as the agent generates tokens
+ # instead of waiting for the full response.
+ use_streaming_tts = False
+ _streaming_box_opened = False
+ text_queue = None
+ tts_thread = None
+ stream_callback = None
+ stop_event = None
+
+ if self._voice_tts:
+ try:
+ from tools.tts_tool import (
+ _load_tts_config as _load_tts_cfg,
+ _get_provider as _get_prov,
+ _import_elevenlabs,
+ _import_sounddevice,
+ stream_tts_to_speaker,
+ )
+ _tts_cfg = _load_tts_cfg()
+ if _get_prov(_tts_cfg) == "elevenlabs":
+ # Verify both ElevenLabs SDK and audio output are available
+ _import_elevenlabs()
+ _import_sounddevice()
+ use_streaming_tts = True
+ except (ImportError, OSError):
+ pass
+ except Exception:
+ pass
+
+ if use_streaming_tts:
+ text_queue = queue.Queue()
+ stop_event = threading.Event()
+
+ def display_callback(sentence: str):
+ """Called by TTS consumer when a sentence is ready to display + speak."""
+ nonlocal _streaming_box_opened
+ if not _streaming_box_opened:
+ _streaming_box_opened = True
+ w = self.console.width
+ label = " โ Hermes "
+ fill = w - 2 - len(label)
+ _cprint(f"\n{_GOLD}โญโ{label}{'โ' * max(fill - 1, 0)}โฎ{_RST}")
+ _cprint(sentence.rstrip())
+
+ tts_thread = threading.Thread(
+ target=stream_tts_to_speaker,
+ args=(text_queue, stop_event, self._voice_tts_done),
+ kwargs={"display_callback": display_callback},
+ daemon=True,
+ )
+ tts_thread.start()
+
+ def stream_callback(delta: str):
+ if text_queue is not None:
+ text_queue.put(delta)
+
+ # When voice mode is active, prepend a brief instruction so the
+ # model responds concisely. The prefix is API-call-local only โ
+ # run_conversation persists the original clean user message.
+ _voice_prefix = ""
+ if self._voice_mode and isinstance(message, str):
+ _voice_prefix = (
+ "[Voice input โ respond concisely and conversationally, "
+ "2-3 sentences max. No code blocks or markdown.] "
+ )
+
def run_agent():
nonlocal result
- result = self.agent.run_conversation(
- user_message=message,
- conversation_history=self.conversation_history[:-1], # Exclude the message we just added
- task_id=self.session_id,
- )
-
+ agent_message = _voice_prefix + message if _voice_prefix else message
+ try:
+ result = self.agent.run_conversation(
+ user_message=agent_message,
+ conversation_history=self.conversation_history[:-1], # Exclude the message we just added
+ stream_callback=stream_callback,
+ task_id=self.session_id,
+ persist_user_message=message if _voice_prefix else None,
+ )
+ except Exception as exc:
+ logging.error("run_conversation raised: %s", exc, exc_info=True)
+ _summary = getattr(self.agent, '_summarize_api_error', lambda e: str(e)[:300])(exc)
+ result = {
+ "final_response": f"Error: {_summary}",
+ "messages": [],
+ "api_calls": 0,
+ "completed": False,
+ "failed": True,
+ "error": _summary,
+ }
+
# Start agent in background thread
agent_thread = threading.Thread(target=run_agent)
agent_thread.start()
-
+
# Monitor the dedicated interrupt queue while the agent runs.
# _interrupt_queue is separate from _pending_input, so process_loop
# and chat() never compete for the same queue.
@@ -3606,12 +5690,14 @@ def run_agent():
# But if it does (race condition), don't interrupt.
if self._clarify_state or self._clarify_freetext:
continue
- print(f"\nโก New message detected, interrupting...")
+ print("\nโก New message detected, interrupting...")
+ # Signal TTS to stop on interrupt
+ if stop_event is not None:
+ stop_event.set()
self.agent.interrupt(interrupt_msg)
# Debug: log to file (stdout may be devnull from redirect_stdout)
try:
- import pathlib as _pl
- _dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
+ _dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a") as _f:
import time as _t
_f.write(f"{_t.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, "
@@ -3623,13 +5709,37 @@ def run_agent():
pass
break
except queue.Empty:
- pass # Queue empty or timeout, continue waiting
+ # Force prompt_toolkit to flush any pending stdout
+ # output from the agent thread. Without this, the
+ # StdoutProxy buffer only flushes on renderer passes
+ # triggered by input events โ on macOS this causes
+ # the CLI to appear frozen until the user types. (#1624)
+ self._invalidate(min_interval=0.15)
else:
# Fallback for non-interactive mode (e.g., single-query)
agent_thread.join(0.1)
-
+
agent_thread.join() # Ensure agent thread completes
+ # Proactively clean up async clients whose event loop is dead.
+ # The agent thread may have created AsyncOpenAI clients bound
+ # to a per-thread event loop; if that loop is now closed, those
+ # clients' __del__ would crash prompt_toolkit's loop on GC.
+ try:
+ from agent.auxiliary_client import cleanup_stale_async_clients
+ cleanup_stale_async_clients()
+ except Exception:
+ pass
+
+ # Flush any remaining streamed text and close the box
+ self._flush_stream()
+
+ # Signal end-of-text to TTS consumer and wait for it to finish
+ if use_streaming_tts and text_queue is not None:
+ text_queue.put(None) # sentinel
+ if tts_thread is not None:
+ tts_thread.join(timeout=120)
+
# Drain any remaining agent output still in the StdoutProxy
# buffer so tool/status lines render ABOVE our response box.
# The flush pushes data into the renderer queue; the short
@@ -3640,15 +5750,36 @@ def run_agent():
# Update history with full conversation
self.conversation_history = result.get("messages", self.conversation_history) if result else self.conversation_history
-
+
# Get the final response
response = result.get("final_response", "") if result else ""
-
- # Handle failed results (e.g., non-retryable errors like invalid model)
- if result and result.get("failed") and not response:
+
+ # Auto-generate session title after first exchange (non-blocking)
+ if response and result and not result.get("failed") and not result.get("partial"):
+ try:
+ from agent.title_generator import maybe_auto_title
+ maybe_auto_title(
+ self._session_db,
+ self.session_id,
+ message,
+ response,
+ self.conversation_history,
+ )
+ except Exception:
+ pass
+
+ # Handle failed or partial results (e.g., non-retryable errors, rate limits,
+ # truncated output, invalid tool calls). Both "failed" and "partial" with
+ # an empty final_response mean the agent couldn't produce a usable answer.
+ if result and (result.get("failed") or result.get("partial")) and not response:
error_detail = result.get("error", "Unknown error")
response = f"Error: {error_detail}"
-
+ # Stop continuous voice mode on persistent errors (e.g. 429 rate limit)
+ # to avoid an infinite error โ record โ error loop
+ if self._voice_continuous:
+ self._voice_continuous = False
+ _cprint(f"\n{_DIM}Continuous voice mode stopped due to error.{_RST}")
+
# Handle interrupt - check if we were interrupted
pending_message = None
if result and result.get("interrupted"):
@@ -3656,9 +5787,17 @@ def run_agent():
# Add indicator that we were interrupted
if response and pending_message:
response = response + "\n\n---\n_[Interrupted - processing new message]_"
-
- # Display reasoning (thinking) box if enabled and available
- if self.show_reasoning and result:
+
+ response_previewed = result.get("response_previewed", False) if result else False
+
+ # Display reasoning (thinking) box if enabled and available.
+ # Skip when streaming already showed reasoning live. Use the
+ # turn-persistent flag (_reasoning_shown_this_turn) instead of
+ # _reasoning_stream_started โ the latter gets reset during
+ # intermediate turn boundaries (tool-calling loops), which caused
+ # the reasoning box to re-render after the final response.
+ _reasoning_already_shown = getattr(self, '_reasoning_shown_this_turn', False)
+ if self.show_reasoning and result and not _reasoning_already_shown:
reasoning = result.get("last_reasoning")
if reasoning:
w = shutil.get_terminal_size().columns
@@ -3675,34 +5814,58 @@ def run_agent():
display_reasoning = reasoning.strip()
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
- if response:
- # Use a Rich Panel for the response box โ adapts to terminal
- # width at render time instead of hard-coding border length.
+ if response and not response_previewed:
+ # Use skin engine for label/color with fallback
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "โ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
+ _resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "โ Hermes"
_resp_color = "#CD7F32"
+ _resp_text = "#FFF8DC"
+
+ is_error_response = result and (result.get("failed") or result.get("partial"))
+ already_streamed = self._stream_started and self._stream_box_opened and not is_error_response
+ if use_streaming_tts and _streaming_box_opened and not is_error_response:
+ # Text was already printed sentence-by-sentence; just close the box
+ w = shutil.get_terminal_size().columns
+ _cprint(f"\n{_GOLD}โฐ{'โ' * (w - 2)}โฏ{_RST}")
+ elif already_streamed:
+ # Response was already streamed token-by-token with box framing;
+ # _flush_stream() already closed the box. Skip Rich Panel.
+ pass
+ else:
+ _chat_console = ChatConsole()
+ _chat_console.print(Panel(
+ _rich_text_from_ansi(response),
+ title=f"[{_resp_color} bold]{label}[/]",
+ title_align="left",
+ border_style=_resp_color,
+ style=_resp_text,
+ box=rich_box.HORIZONTALS,
+ padding=(1, 2),
+ ))
+
- _chat_console = ChatConsole()
- _chat_console.print(Panel(
- response,
- title=f"[bold]{label}[/bold]",
- title_align="left",
- border_style=_resp_color,
- box=rich_box.HORIZONTALS,
- padding=(1, 2),
- ))
-
# Play terminal bell when agent finishes (if enabled).
# Works over SSH โ the bell propagates to the user's terminal.
if self.bell_on_complete:
sys.stdout.write("\a")
sys.stdout.flush()
-
+
+ # Speak response aloud if voice TTS is enabled
+ # Skip batch TTS when streaming TTS already handled it
+ if self._voice_tts and response and not use_streaming_tts:
+ threading.Thread(
+ target=self._voice_speak_response,
+ args=(response,),
+ daemon=True,
+ ).start()
+
+
# Combine all interrupt messages (user may have typed multiple while waiting)
# and re-queue as one prompt for process_loop
if pending_message and hasattr(self, '_pending_input'):
@@ -3723,6 +5886,20 @@ def run_agent():
except Exception as e:
print(f"Error: {e}")
return None
+ finally:
+ # Ensure streaming TTS resources are cleaned up even on error.
+ # Normal path sends the sentinel at line ~3568; this is a safety
+ # net for exception paths that skip it. Duplicate sentinels are
+ # harmless โ stream_tts_to_speaker exits on the first None.
+ if text_queue is not None:
+ try:
+ text_queue.put_nowait(None)
+ except Exception:
+ pass
+ if stop_event is not None:
+ stop_event.set()
+ if tts_thread is not None and tts_thread.is_alive():
+ tts_thread.join(timeout=5)
def _print_exit_summary(self):
"""Print session resume info on exit, similar to Claude Code."""
@@ -3741,19 +5918,192 @@ def _print_exit_summary(self):
else:
duration_str = f"{seconds}s"
- print(f"Resume this session with:")
+ print("Resume this session with:")
print(f" hermes --resume {self.session_id}")
print()
print(f"Session: {self.session_id}")
print(f"Duration: {duration_str}")
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
else:
- print("Goodbye! โ")
+ try:
+ from hermes_cli.skin_engine import get_active_goodbye
+ goodbye = get_active_goodbye("Goodbye! โ")
+ except Exception:
+ goodbye = "Goodbye! โ"
+ print(goodbye)
+
+ def _get_tui_prompt_symbols(self) -> tuple[str, str]:
+ """Return ``(normal_prompt, state_suffix)`` for the active skin.
+
+ ``normal_prompt`` is the full ``branding.prompt_symbol``.
+ ``state_suffix`` is what special states (sudo/secret/approval/agent)
+ should render after their leading icon.
+ """
+ try:
+ from hermes_cli.skin_engine import get_active_prompt_symbol
+ symbol = get_active_prompt_symbol("โฏ ")
+ except Exception:
+ symbol = "โฏ "
+
+ symbol = (symbol or "โฏ ").rstrip() + " "
+ stripped = symbol.rstrip()
+ if not stripped:
+ return "โฏ ", "โฏ "
+
+ parts = stripped.split()
+ candidate = parts[-1] if parts else ""
+ arrow_chars = ("โฏ", ">", "$", "#", "โบ", "ยป", "โ")
+ if any(ch in candidate for ch in arrow_chars):
+ return symbol, candidate.rstrip() + " "
+
+ # Icon-only custom prompts should still remain visible in special states.
+ return symbol, symbol
+
+ def _audio_level_bar(self) -> str:
+ """Return a visual audio level indicator based on current RMS."""
+ _LEVEL_BARS = " โโโโโ
โโ"
+ rec = getattr(self, "_voice_recorder", None)
+ if rec is None:
+ return ""
+ rms = rec.current_rms
+ # Normalize RMS (0-32767) to 0-7 index, with log-ish scaling
+ # Typical speech RMS is 500-5000, we cap display at ~8000
+ level = min(rms, 8000) * 7 // 8000
+ return _LEVEL_BARS[level]
+
+ def _get_tui_prompt_fragments(self):
+ """Return the prompt_toolkit fragments for the current interactive state."""
+ symbol, state_suffix = self._get_tui_prompt_symbols()
+ if self._voice_recording:
+ bar = self._audio_level_bar()
+ return [("class:voice-recording", f"โ {bar} {state_suffix}")]
+ if self._voice_processing:
+ return [("class:voice-processing", f"โ {state_suffix}")]
+ if self._sudo_state:
+ return [("class:sudo-prompt", f"๐ {state_suffix}")]
+ if self._secret_state:
+ return [("class:sudo-prompt", f"๐ {state_suffix}")]
+ if self._approval_state:
+ return [("class:prompt-working", f"โ {state_suffix}")]
+ if self._clarify_freetext:
+ return [("class:clarify-selected", f"โ {state_suffix}")]
+ if self._clarify_state:
+ return [("class:prompt-working", f"? {state_suffix}")]
+ if self._command_running:
+ return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")]
+ if self._agent_running:
+ return [("class:prompt-working", f"โ {state_suffix}")]
+ if self._voice_mode:
+ return [("class:voice-prompt", f"๐ค {state_suffix}")]
+ return [("class:prompt", symbol)]
+
+ def _get_tui_prompt_text(self) -> str:
+ """Return the visible prompt text for width calculations."""
+ return "".join(text for _, text in self._get_tui_prompt_fragments())
+
+ def _build_tui_style_dict(self) -> dict[str, str]:
+ """Layer the active skin's prompt_toolkit colors over the base TUI style."""
+ style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
+ try:
+ from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
+ style_dict.update(get_prompt_toolkit_style_overrides())
+ except Exception:
+ pass
+ return style_dict
+
+ def _apply_tui_skin_style(self) -> bool:
+ """Refresh prompt_toolkit styling for a running interactive TUI."""
+ if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None):
+ return False
+ self._app.style = PTStyle.from_dict(self._build_tui_style_dict())
+ self._invalidate(min_interval=0.0)
+ return True
+
+ # --- Protected TUI extension hooks for wrapper CLIs ---
+
+ def _get_extra_tui_widgets(self) -> list:
+ """Return extra prompt_toolkit widgets to insert into the TUI layout.
+
+ Wrapper CLIs can override this to inject widgets (e.g. a mini-player,
+ overlay menu) into the layout without overriding ``run()``. Widgets
+ are inserted between the spacer and the status bar.
+ """
+ return []
+
+ def _register_extra_tui_keybindings(self, kb, *, input_area) -> None:
+ """Register extra keybindings on the TUI ``KeyBindings`` object.
+
+ Wrapper CLIs can override this to add keybindings (e.g. transport
+ controls, modal shortcuts) without overriding ``run()``.
+
+ Parameters
+ ----------
+ kb : KeyBindings
+ The active keybinding registry for the prompt_toolkit application.
+ input_area : TextArea
+ The main input widget, for wrappers that need to inspect or
+ manipulate user input from a keybinding handler.
+ """
+
+ def _build_tui_layout_children(
+ self,
+ *,
+ sudo_widget,
+ secret_widget,
+ approval_widget,
+ clarify_widget,
+ spinner_widget,
+ spacer,
+ status_bar,
+ input_rule_top,
+ image_bar,
+ input_area,
+ input_rule_bot,
+ voice_status_bar,
+ completions_menu,
+ ) -> list:
+ """Assemble the ordered list of children for the root ``HSplit``.
+
+ Wrapper CLIs typically override ``_get_extra_tui_widgets`` instead of
+ this method. Override this only when you need full control over widget
+ ordering.
+ """
+ return [
+ Window(height=0),
+ sudo_widget,
+ secret_widget,
+ approval_widget,
+ clarify_widget,
+ spinner_widget,
+ spacer,
+ *self._get_extra_tui_widgets(),
+ status_bar,
+ input_rule_top,
+ image_bar,
+ input_area,
+ input_rule_bot,
+ voice_status_bar,
+ completions_menu,
+ ]
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
self.show_banner()
+ # One-line Honcho session indicator (TTY-only, not captured by agent).
+ # Only show when the user explicitly configured Honcho for Hermes
+ # (not auto-enabled from a stray HONCHO_API_KEY env var).
+ try:
+ from honcho_integration.client import HonchoClientConfig
+ from agent.display import honcho_session_line, write_tty
+ hcfg = HonchoClientConfig.from_global_config()
+ if hcfg.enabled and hcfg.api_key and hcfg.explicitly_configured:
+ sname = hcfg.resolve_session_name(session_id=self.session_id)
+ if sname:
+ write_tty(honcho_session_line(hcfg.workspace_id, sname) + "\n")
+ except Exception:
+ pass
+
# If resuming a session, load history and display it immediately
# so the user has context before typing their first message.
if self._resumed:
@@ -3769,6 +6119,12 @@ def run(self):
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC"
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
+ if self.preloaded_skills and not self._startup_skills_line_shown:
+ skills_label = ", ".join(self.preloaded_skills)
+ self.console.print(
+ f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
+ )
+ self._startup_skills_line_shown = True
self.console.print()
# State for async operation
@@ -3777,6 +6133,12 @@ def run(self):
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
+ # 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()
+ self._config_mtime: float = _cfg_path.stat().st_mtime if _cfg_path.exists() else 0.0
+ self._config_mcp_servers: dict = self.config.get("mcp_servers") or {}
+ self._last_config_check: float = 0.0 # monotonic time of last check
# Clarify tool state: interactive question/answer with the user.
# When the agent calls the clarify tool, _clarify_state is set and
@@ -3792,18 +6154,50 @@ def run(self):
# Dangerous command approval state (similar mechanism to clarify)
self._approval_state = None # dict with command, description, choices, selected, response_queue
self._approval_deadline = 0
+ self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
# Slash command loading state
self._command_running = False
self._command_status = ""
+ # Secure secret capture state for skill setup
+ self._secret_state = None # dict with var_name, prompt, metadata, response_queue
+ self._secret_deadline = 0
+
# Clipboard image attachments (paste images into the CLI)
self._attached_images: list[Path] = []
self._image_counter = 0
+ # Voice mode state (protected by _voice_lock for cross-thread access)
+ self._voice_lock = threading.Lock()
+ self._voice_mode = False # Whether voice mode is enabled
+ self._voice_tts = False # Whether TTS output is enabled
+ self._voice_recorder = None # AudioRecorder instance (lazy init)
+ self._voice_recording = False # Whether currently recording
+ self._voice_processing = False # Whether STT is in progress
+ self._voice_continuous = False # Whether to auto-restart after agent responds
+ self._voice_tts_done = threading.Event() # Signals TTS playback finished
+ self._voice_tts_done.set() # Initially "done" (no TTS pending)
+
# Register callbacks so terminal_tool prompts route through our UI
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
+ set_secret_capture_callback(self._secret_capture_callback)
+
+ # Ensure tirith security scanner is available (downloads if needed).
+ # Warn the user if tirith is enabled in config but not available,
+ # so they know command security scanning is degraded.
+ try:
+ from tools.tirith_security import ensure_installed
+ tirith_path = ensure_installed(log_failures=False)
+ if tirith_path is None:
+ security_cfg = self.config.get("security", {}) or {}
+ tirith_enabled = security_cfg.get("tirith_enabled", True)
+ if tirith_enabled:
+ _cprint(f" {_DIM}โ tirith security scanner enabled but not available "
+ f"โ command scanning will use pattern matching only{_RST}")
+ except Exception:
+ pass # Non-fatal โ fail-open at scan time if unavailable
# Key bindings for the input area
kb = KeyBindings()
@@ -3831,24 +6225,17 @@ def handle_enter(event):
event.app.invalidate()
return
+ # --- Secret prompt: submit the typed secret ---
+ if self._secret_state:
+ text = event.app.current_buffer.text
+ self._submit_secret_response(text)
+ event.app.current_buffer.reset()
+ event.app.invalidate()
+ return
+
# --- Approval selection: confirm the highlighted choice ---
if self._approval_state:
- state = self._approval_state
- selected = state["selected"]
- choices = state["choices"]
- if 0 <= selected < len(choices):
- chosen = choices[selected]
- if chosen == "view":
- # Toggle full command display without closing the prompt
- state["show_full"] = True
- # Remove the "view" option since it's been used
- state["choices"] = [c for c in choices if c != "view"]
- if state["selected"] >= len(state["choices"]):
- state["selected"] = len(state["choices"]) - 1
- event.app.invalidate()
- return
- state["response_queue"].put(chosen)
- self._approval_state = None
+ self._handle_approval_selection()
event.app.invalidate()
return
@@ -3889,17 +6276,22 @@ def handle_enter(event):
# Bundle text + images as a tuple when images are present
payload = (text, images) if images else text
if self._agent_running and not (text and text.startswith("/")):
- self._interrupt_queue.put(payload)
- # Debug: log to file when message enters interrupt queue
- try:
- import pathlib as _pl
- _dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
- with open(_dbg, "a") as _f:
- import time as _t
- _f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
- f"agent_running={self._agent_running}\n")
- except Exception:
- pass
+ if self.busy_input_mode == "queue":
+ # Queue for the next turn instead of interrupting
+ self._pending_input.put(payload)
+ preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
+ _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
+ else:
+ self._interrupt_queue.put(payload)
+ # Debug: log to file when message enters interrupt queue
+ try:
+ _dbg = _hermes_home / "interrupt_debug.log"
+ with open(_dbg, "a") as _f:
+ import time as _t
+ _f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
+ f"agent_running={self._agent_running}\n")
+ except Exception:
+ pass
else:
self._pending_input.put(payload)
event.app.current_buffer.reset(append_to_history=True)
@@ -3914,6 +6306,39 @@ def handle_ctrl_enter(event):
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
+ @kb.add('tab', eager=True)
+ def handle_tab(event):
+ """Tab: accept completion, auto-suggestion, or start completions.
+
+ Priority:
+ 1. Completion menu open โ accept selected completion
+ 2. Ghost text suggestion available โ accept auto-suggestion
+ 3. Otherwise โ start completion menu
+
+ After accepting a provider like 'anthropic:', the completion menu
+ closes and complete_while_typing doesn't fire (no keystroke).
+ This binding re-triggers completions so stage-2 models appear
+ immediately.
+ """
+ buf = event.current_buffer
+ if buf.complete_state:
+ # Completion menu is open โ accept the selection
+ completion = buf.complete_state.current_completion
+ if completion is None:
+ # Menu open but nothing selected โ select first then grab it
+ buf.go_to_completion(0)
+ completion = buf.complete_state and buf.complete_state.current_completion
+ if completion is None:
+ return
+ # Accept the selected completion
+ buf.apply_completion(completion)
+ elif buf.suggestion and buf.suggestion.text:
+ # No completion menu, but there's a ghost text auto-suggestion โ accept it
+ buf.insert_text(buf.suggestion.text)
+ else:
+ # No menu and no suggestion โ start completions from scratch
+ buf.start_completion()
+
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
@@ -3952,7 +6377,7 @@ def approval_down(event):
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
# history browsing when on the first/last line (or single-line input).
_normal_input = Condition(
- lambda: not self._clarify_state and not self._approval_state and not self._sudo_state
+ lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
)
@kb.add('up', filter=_normal_input)
@@ -3970,6 +6395,7 @@ def handle_ctrl_c(event):
"""Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit.
Priority:
+ 0. Cancel active voice recording
1. Cancel active sudo/approval/clarify prompt
2. Interrupt the running agent (first press)
3. Force exit (second press within 2s, or when idle)
@@ -3977,6 +6403,25 @@ def handle_ctrl_c(event):
import time as _time
now = _time.time()
+ # Cancel active voice recording.
+ # Run cancel() in a background thread to prevent blocking the
+ # event loop if AudioRecorder._lock or CoreAudio takes time.
+ _should_cancel_voice = False
+ _recorder_ref = None
+ with cli_ref._voice_lock:
+ if cli_ref._voice_recording and cli_ref._voice_recorder:
+ _recorder_ref = cli_ref._voice_recorder
+ cli_ref._voice_recording = False
+ cli_ref._voice_continuous = False
+ _should_cancel_voice = True
+ if _should_cancel_voice:
+ _cprint(f"\n{_DIM}Recording cancelled.{_RST}")
+ threading.Thread(
+ target=_recorder_ref.cancel, daemon=True
+ ).start()
+ event.app.invalidate()
+ return
+
# Cancel sudo prompt
if self._sudo_state:
self._sudo_state["response_queue"].put("")
@@ -3985,6 +6430,13 @@ def handle_ctrl_c(event):
event.app.invalidate()
return
+ # Cancel secret prompt
+ if self._secret_state:
+ self._cancel_secret_capture()
+ event.app.current_buffer.reset()
+ event.app.invalidate()
+ return
+
# Cancel approval prompt (deny)
if self._approval_state:
self._approval_state["response_queue"].put("deny")
@@ -4030,6 +6482,75 @@ def handle_ctrl_d(event):
self._should_exit = True
event.app.exit()
+ # Voice push-to-talk key: configurable via config.yaml (voice.record_key)
+ # Default: Ctrl+B (avoids conflict with Ctrl+R readline reverse-search)
+ # Config uses "ctrl+b" format; prompt_toolkit expects "c-b" format.
+ try:
+ from hermes_cli.config import load_config
+ _raw_key = load_config().get("voice", {}).get("record_key", "ctrl+b")
+ _voice_key = _raw_key.lower().replace("ctrl+", "c-").replace("alt+", "a-")
+ except Exception:
+ _voice_key = "c-b"
+
+ @kb.add(_voice_key)
+ def handle_voice_record(event):
+ """Toggle voice recording when voice mode is active.
+
+ IMPORTANT: This handler runs in prompt_toolkit's event-loop thread.
+ Any blocking call here (locks, sd.wait, disk I/O) freezes the
+ entire UI. All heavy work is dispatched to daemon threads.
+ """
+ if not cli_ref._voice_mode:
+ return
+ # Always allow STOPPING a recording (even when agent is running)
+ if cli_ref._voice_recording:
+ # Manual stop via push-to-talk key: stop continuous mode
+ with cli_ref._voice_lock:
+ cli_ref._voice_continuous = False
+ # Flag clearing is handled atomically inside _voice_stop_and_transcribe
+ event.app.invalidate()
+ threading.Thread(
+ target=cli_ref._voice_stop_and_transcribe,
+ daemon=True,
+ ).start()
+ else:
+ # Guard: don't START recording during agent run or interactive prompts
+ if cli_ref._agent_running:
+ return
+ if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:
+ return
+ # Guard: don't start while a previous stop/transcribe cycle is
+ # still running โ recorder.stop() holds AudioRecorder._lock and
+ # start() would block the event-loop thread waiting for it.
+ if cli_ref._voice_processing:
+ return
+
+ # Interrupt TTS if playing, so user can start talking.
+ # stop_playback() is fast (just terminates a subprocess).
+ if not cli_ref._voice_tts_done.is_set():
+ try:
+ from tools.voice_mode import stop_playback
+ stop_playback()
+ cli_ref._voice_tts_done.set()
+ except Exception:
+ pass
+
+ with cli_ref._voice_lock:
+ cli_ref._voice_continuous = True
+
+ # Dispatch to a daemon thread so play_beep(sd.wait),
+ # AudioRecorder.start(lock acquire), and config I/O
+ # never block the prompt_toolkit event loop.
+ def _start_recording():
+ try:
+ cli_ref._voice_start_recording()
+ if hasattr(cli_ref, '_app') and cli_ref._app:
+ cli_ref._app.invalidate()
+ except Exception as e:
+ _cprint(f"\n{_DIM}Voice recording failed: {e}{_RST}")
+
+ threading.Thread(target=_start_recording, daemon=True).start()
+ event.app.invalidate()
from prompt_toolkit.keys import Keys
@kb.add(Keys.BracketedPaste, eager=True)
@@ -4039,12 +6560,31 @@ def handle_paste(event):
When the terminal supports bracketed paste, Ctrl+V / Cmd+V
triggers this with the pasted text. We also check the
clipboard for an image on every paste event.
+
+ Large pastes (5+ lines) are collapsed to a file reference
+ placeholder while preserving any existing user text in the
+ buffer.
"""
pasted_text = event.data or ""
if self._try_attach_clipboard_image():
event.app.invalidate()
if pasted_text:
- event.current_buffer.insert_text(pasted_text)
+ line_count = pasted_text.count('\n')
+ buf = event.current_buffer
+ if line_count >= 5 and not buf.text.strip().startswith('/'):
+ _paste_counter[0] += 1
+ paste_dir = _hermes_home / "pastes"
+ paste_dir.mkdir(parents=True, exist_ok=True)
+ paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
+ paste_file.write_text(pasted_text, encoding="utf-8")
+ placeholder = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
+ prefix = ""
+ if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n':
+ prefix = "\n"
+ _paste_just_collapsed[0] = True
+ buf.insert_text(prefix + placeholder)
+ else:
+ buf.insert_text(pasted_text)
@kb.add('c-v')
def handle_ctrl_v(event):
@@ -4081,21 +6621,15 @@ def handle_alt_v(event):
cli_ref = self
def get_prompt():
- if cli_ref._sudo_state:
- return [('class:sudo-prompt', '๐ โฏ ')]
- if cli_ref._approval_state:
- return [('class:prompt-working', 'โ โฏ ')]
- if cli_ref._clarify_freetext:
- return [('class:clarify-selected', 'โ โฏ ')]
- if cli_ref._clarify_state:
- return [('class:prompt-working', '? โฏ ')]
- if cli_ref._command_running:
- return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} โฏ ")]
- if cli_ref._agent_running:
- return [('class:prompt-working', 'โ โฏ ')]
- return [('class:prompt', 'โฏ ')]
+ return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
+
+
+ _completer = SlashCommandCompleter(
+ skill_commands_provider=lambda: _skill_commands,
+ )
input_area = TextArea(
height=Dimension(min=1, max=8, preferred=1),
prompt=get_prompt,
@@ -4104,17 +6638,21 @@ def get_prompt():
wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)),
- completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
+ completer=_completer,
complete_while_typing=True,
+ auto_suggest=SlashCommandAutoSuggest(
+ history_suggest=AutoSuggestFromHistory(),
+ completer=_completer,
+ ),
)
# Dynamic height: accounts for both explicit newlines AND visual
# wrapping of long lines so the input area always fits its content.
- # The prompt characters ("โฏ " etc.) consume ~4 columns.
def _input_height():
try:
doc = input_area.buffer.document
- available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width
+ prompt_width = max(2, len(self._get_tui_prompt_text()))
+ available_width = shutil.get_terminal_size().columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
@@ -4133,24 +6671,34 @@ def _input_height():
# Paste collapsing: detect large pastes and save to temp file
_paste_counter = [0]
_prev_text_len = [0]
+ _paste_just_collapsed = [False]
def _on_text_changed(buf):
- """Detect large pastes and collapse them to a file reference."""
+ """Detect large pastes and collapse them to a file reference.
+
+ When bracketed paste is available, handle_paste collapses
+ large pastes directly. This handler is a fallback for
+ terminals without bracketed paste support.
+ """
text = buf.text
- line_count = text.count('\n')
chars_added = len(text) - _prev_text_len[0]
_prev_text_len[0] = len(text)
+ if _paste_just_collapsed[0]:
+ _paste_just_collapsed[0] = False
+ return
+ line_count = text.count('\n')
# Heuristic: a real paste adds many characters at once (not just a
# single newline from Alt+Enter) AND the result has 5+ lines.
+ # Fallback for terminals without bracketed paste support.
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
_paste_counter[0] += 1
# Save to temp file
- paste_dir = Path(os.path.expanduser("~/.hermes/pastes"))
+ paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
# Replace buffer with compact reference
- buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines โ {paste_file}]"
+ buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
buf.cursor_position = len(buf.text)
input_area.buffer.on_text_changed += _on_text_changed
@@ -4161,7 +6709,9 @@ def _on_text_changed(buf):
input_area.control.input_processors.append(
ConditionalProcessor(
PasswordProcessor(),
- filter=Condition(lambda: bool(cli_ref._sudo_state)),
+ filter=Condition(
+ lambda: bool(cli_ref._sudo_state) or bool(cli_ref._secret_state)
+ ),
)
)
@@ -4179,8 +6729,14 @@ def apply_transformation(self, ti):
return Transformation(fragments=ti.fragments)
def _get_placeholder():
+ if cli_ref._voice_recording:
+ return "recording... Ctrl+B to stop, Ctrl+C to cancel"
+ if cli_ref._voice_processing:
+ return "transcribing..."
if cli_ref._sudo_state:
return "type password (hidden), Enter to skip"
+ if cli_ref._secret_state:
+ return "type secret (hidden), Enter to skip"
if cli_ref._approval_state:
return ""
if cli_ref._clarify_freetext:
@@ -4193,6 +6749,8 @@ def _get_placeholder():
return f"{frame} {status}"
if cli_ref._agent_running:
return "type a message + Enter to interrupt, Ctrl+C to cancel"
+ if cli_ref._voice_mode:
+ return "type or Ctrl+B to record"
return ""
input_area.control.input_processors.append(_PlaceholderProcessor(_get_placeholder))
@@ -4210,6 +6768,13 @@ def get_hint_text():
('class:clarify-countdown', f' ({remaining}s)'),
]
+ if cli_ref._secret_state:
+ remaining = max(0, int(cli_ref._secret_deadline - _time.monotonic()))
+ return [
+ ('class:hint', ' secret hidden ยท Enter to skip'),
+ ('class:clarify-countdown', f' ({remaining}s)'),
+ ]
+
if cli_ref._approval_state:
remaining = max(0, int(cli_ref._approval_deadline - _time.monotonic()))
return [
@@ -4239,7 +6804,7 @@ def get_hint_text():
return []
def get_hint_height():
- if cli_ref._sudo_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
+ if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
return 1
# Keep a 1-line spacer while agent runs so output doesn't push
# right up against the top rule of the input area
@@ -4395,57 +6960,47 @@ def _get_sudo_display():
filter=Condition(lambda: cli_ref._sudo_state is not None),
)
- # --- Dangerous command approval: display widget ---
-
- def _get_approval_display():
- state = cli_ref._approval_state
+ def _get_secret_display():
+ state = cli_ref._secret_state
if not state:
return []
- command = state["command"]
- description = state["description"]
- choices = state["choices"]
- selected = state.get("selected", 0)
- show_full = state.get("show_full", False)
-
- if show_full or len(command) <= 70:
- cmd_display = command
- else:
- cmd_display = command[:70] + '...'
- choice_labels = {
- "once": "Allow once",
- "session": "Allow for this session",
- "always": "Add to permanent allowlist",
- "deny": "Deny",
- "view": "Show full command",
- }
- preview_lines = _wrap_panel_text(description, 60)
- preview_lines.extend(_wrap_panel_text(cmd_display, 60))
- for i, choice in enumerate(choices):
- prefix = 'โฏ ' if i == selected else ' '
- preview_lines.extend(_wrap_panel_text(f"{prefix}{choice_labels.get(choice, choice)}", 60, subsequent_indent=" "))
- box_width = _panel_box_width("โ ๏ธ Dangerous Command", preview_lines)
- inner_text_width = max(8, box_width - 2)
+ title = '๐ Skill Setup Required'
+ prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}"
+ metadata = state.get("metadata") or {}
+ help_text = metadata.get("help")
+ body = 'Enter secret below (hidden), or press Enter to skip'
+ content_lines = [prompt, body]
+ if help_text:
+ content_lines.insert(1, str(help_text))
+ box_width = _panel_box_width(title, content_lines)
lines = []
- lines.append(('class:approval-border', 'โญโ '))
- lines.append(('class:approval-title', 'โ ๏ธ Dangerous Command'))
- lines.append(('class:approval-border', ' ' + ('โ' * max(0, box_width - len("โ ๏ธ Dangerous Command") - 3)) + 'โฎ\n'))
- _append_blank_panel_line(lines, 'class:approval-border', box_width)
- for wrapped in _wrap_panel_text(description, inner_text_width):
- _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
- for wrapped in _wrap_panel_text(cmd_display, inner_text_width):
- _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
- _append_blank_panel_line(lines, 'class:approval-border', box_width)
- for i, choice in enumerate(choices):
- label = choice_labels.get(choice, choice)
- style = 'class:approval-selected' if i == selected else 'class:approval-choice'
- prefix = 'โฏ ' if i == selected else ' '
- for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "):
- _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
- _append_blank_panel_line(lines, 'class:approval-border', box_width)
- lines.append(('class:approval-border', 'โฐ' + ('โ' * box_width) + 'โฏ\n'))
+ lines.append(('class:sudo-border', 'โญโ '))
+ lines.append(('class:sudo-title', title))
+ lines.append(('class:sudo-border', ' ' + ('โ' * max(0, box_width - len(title) - 3)) + 'โฎ\n'))
+ _append_blank_panel_line(lines, 'class:sudo-border', box_width)
+ _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', prompt, box_width)
+ if help_text:
+ _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', str(help_text), box_width)
+ _append_blank_panel_line(lines, 'class:sudo-border', box_width)
+ _append_panel_line(lines, 'class:sudo-border', 'class:sudo-text', body, box_width)
+ _append_blank_panel_line(lines, 'class:sudo-border', box_width)
+ lines.append(('class:sudo-border', 'โฐ' + ('โ' * box_width) + 'โฏ\n'))
return lines
+ secret_widget = ConditionalContainer(
+ Window(
+ FormattedTextControl(_get_secret_display),
+ wrap_lines=True,
+ ),
+ filter=Condition(lambda: cli_ref._secret_state is not None),
+ )
+
+ # --- Dangerous command approval: display widget ---
+
+ def _get_approval_display():
+ return cli_ref._get_approval_display_fragments()
+
approval_widget = ConditionalContainer(
Window(
FormattedTextControl(_get_approval_display),
@@ -4487,32 +7042,83 @@ def _get_image_bar():
height=Condition(lambda: bool(cli_ref._attached_images)),
)
+ # Persistent voice mode status bar (visible only when voice mode is on)
+ def _get_voice_status():
+ if cli_ref._voice_recording:
+ return [('class:voice-status-recording', ' โ REC Ctrl+B to stop ')]
+ if cli_ref._voice_processing:
+ return [('class:voice-status', ' โ Transcribing... ')]
+ tts = " | TTS on" if cli_ref._voice_tts else ""
+ cont = " | Continuous" if cli_ref._voice_continuous else ""
+ return [('class:voice-status', f' ๐ค Voice mode{tts}{cont} โ Ctrl+B to record ')]
+
+ voice_status_bar = ConditionalContainer(
+ Window(
+ FormattedTextControl(_get_voice_status),
+ height=1,
+ ),
+ filter=Condition(lambda: cli_ref._voice_mode),
+ )
+
+ status_bar = ConditionalContainer(
+ Window(
+ content=FormattedTextControl(lambda: cli_ref._get_status_bar_fragments()),
+ height=1,
+ # Prevent fragments that overflow the terminal width from
+ # wrapping onto a second line, which causes the status bar to
+ # appear duplicated (one full + one partial row) during long
+ # sessions, especially on SSH where shutil.get_terminal_size
+ # may return stale values. _get_status_bar_fragments now reads
+ # width from prompt_toolkit's own output object, so fragments
+ # will always fit; wrap_lines=False is the belt-and-suspenders
+ # guard against any future width mismatch.
+ wrap_lines=False,
+ ),
+ filter=Condition(lambda: cli_ref._status_bar_visible),
+ )
+
+ # Allow wrapper CLIs to register extra keybindings.
+ self._register_extra_tui_keybindings(kb, input_area=input_area)
+
# Layout: interactive prompt widgets + ruled input at bottom.
# The sudo, approval, and clarify widgets appear above the input when
# the corresponding interactive prompt is active.
+ completions_menu = CompletionsMenu(max_height=12, scroll_offset=1)
+
layout = Layout(
- HSplit([
- Window(height=0),
- sudo_widget,
- approval_widget,
- clarify_widget,
- spinner_widget,
- spacer,
- input_rule_top,
- image_bar,
- input_area,
- input_rule_bot,
- CompletionsMenu(max_height=12, scroll_offset=1),
- ])
+ HSplit(
+ self._build_tui_layout_children(
+ sudo_widget=sudo_widget,
+ secret_widget=secret_widget,
+ approval_widget=approval_widget,
+ clarify_widget=clarify_widget,
+ spinner_widget=spinner_widget,
+ spacer=spacer,
+ status_bar=status_bar,
+ input_rule_top=input_rule_top,
+ image_bar=image_bar,
+ input_area=input_area,
+ input_rule_bot=input_rule_bot,
+ voice_status_bar=voice_status_bar,
+ completions_menu=completions_menu,
+ )
+ )
)
# Style for the application
- style = PTStyle.from_dict({
+ self._tui_style_base = {
'input-area': '#FFF8DC',
'placeholder': '#555555 italic',
'prompt': '#FFF8DC',
'prompt-working': '#888888 italic',
'hint': '#555555 italic',
+ 'status-bar': 'bg:#1a1a2e #C0C0C0',
+ 'status-bar-strong': 'bg:#1a1a2e #FFD700 bold',
+ 'status-bar-dim': 'bg:#1a1a2e #8B8682',
+ 'status-bar-good': 'bg:#1a1a2e #8FBC8F bold',
+ 'status-bar-warn': 'bg:#1a1a2e #FFD700 bold',
+ 'status-bar-bad': 'bg:#1a1a2e #FF8C00 bold',
+ 'status-bar-critical': 'bg:#1a1a2e #FF6B6B bold',
# Bronze horizontal rules around the input area
'input-rule': '#CD7F32',
# Clipboard image attachment badges
@@ -4542,7 +7148,14 @@ def _get_image_bar():
'approval-cmd': '#AAAAAA italic',
'approval-choice': '#AAAAAA',
'approval-selected': '#FFD700 bold',
- })
+ # Voice mode
+ 'voice-prompt': '#87CEEB',
+ 'voice-recording': '#FF4444 bold',
+ 'voice-processing': '#FFA500 italic',
+ 'voice-status': 'bg:#1a1a2e #87CEEB',
+ 'voice-status-recording': 'bg:#1a1a2e #FF4444 bold',
+ }
+ style = PTStyle.from_dict(self._build_tui_style_dict())
# Create the application
app = Application(
@@ -4558,12 +7171,20 @@ def _get_image_bar():
def spinner_loop():
import time as _time
+ last_idle_refresh = 0.0
while not self._should_exit:
- if self._command_running and self._app:
+ if not self._app:
+ _time.sleep(0.1)
+ continue
+ if self._command_running:
self._invalidate(min_interval=0.1)
_time.sleep(0.1)
else:
- _time.sleep(0.05)
+ now = _time.monotonic()
+ if now - last_idle_refresh >= 1.0:
+ last_idle_refresh = now
+ self._invalidate(min_interval=1.0)
+ _time.sleep(0.2)
spinner_thread = threading.Thread(target=spinner_loop, daemon=True)
spinner_thread.start()
@@ -4576,6 +7197,9 @@ def process_loop():
try:
user_input = self._pending_input.get(timeout=0.1)
except queue.Empty:
+ # Periodic config watcher โ auto-reload MCP on mcp_servers change
+ if not self._agent_running:
+ self._check_config_mcp_changes()
continue
if not user_input:
@@ -4598,27 +7222,48 @@ def process_loop():
# Expand paste references back to full content
import re as _re
- paste_match = _re.match(r'\[Pasted text #\d+: \d+ lines โ (.+)\]', user_input) if isinstance(user_input, str) else None
- if paste_match:
- paste_path = Path(paste_match.group(1))
- if paste_path.exists():
- full_text = paste_path.read_text(encoding="utf-8")
- line_count = full_text.count('\n') + 1
- print()
- _cprint(f"{_GOLD}โ{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}")
- user_input = full_text
+ _paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
+ paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else []
+ if paste_refs:
+ def _expand_ref(m):
+ p = Path(m.group(1))
+ return p.read_text(encoding="utf-8") if p.exists() else m.group(0)
+ expanded = _paste_ref_re.sub(_expand_ref, user_input)
+ total_lines = expanded.count('\n') + 1
+ n_pastes = len(paste_refs)
+ _user_bar = f"[{_accent_hex()}]{'โ' * 40}[/]"
+ print()
+ ChatConsole().print(_user_bar)
+ # Show any surrounding user text alongside the paste summary
+ split_parts = _paste_ref_re.split(user_input)
+ visible_user_text = " ".join(
+ split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip()
+ )
+ if visible_user_text:
+ ChatConsole().print(
+ f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] "
+ f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]"
+ )
else:
- print()
- _cprint(f"{_GOLD}โ{_RST} {_BOLD}{user_input}{_RST}")
+ ChatConsole().print(
+ f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]"
+ )
+ user_input = expanded
else:
+ _user_bar = f"[{_accent_hex()}]{'โ' * 40}[/]"
if '\n' in user_input:
first_line = user_input.split('\n')[0]
line_count = user_input.count('\n') + 1
print()
- _cprint(f"{_GOLD}โ{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}")
+ ChatConsole().print(_user_bar)
+ ChatConsole().print(
+ f"[bold {_accent_hex()}]โ[/] [bold]{_escape(first_line)}[/] "
+ f"[dim](+{line_count - 1} lines)[/]"
+ )
else:
print()
- _cprint(f"{_GOLD}โ{_RST} {_BOLD}{user_input}{_RST}")
+ ChatConsole().print(_user_bar)
+ ChatConsole().print(f"[bold {_accent_hex()}]โ[/] [bold]{_escape(user_input)}[/]")
# Show image attachment count
if submit_images:
@@ -4628,13 +7273,29 @@ def process_loop():
# Regular chat - run agent
self._agent_running = True
app.invalidate() # Refresh status line
-
+
try:
self.chat(user_input, images=submit_images or None)
finally:
self._agent_running = False
self._spinner_text = ""
app.invalidate() # Refresh status line
+
+ # Continuous voice: auto-restart recording after agent responds.
+ # Dispatch to a daemon thread so play_beep (sd.wait) and
+ # AudioRecorder.start (lock acquire) never block process_loop โ
+ # otherwise queued user input would stall silently.
+ if self._voice_mode and self._voice_continuous and not self._voice_recording:
+ def _restart_recording():
+ try:
+ if self._voice_tts:
+ self._voice_tts_done.wait(timeout=60)
+ time.sleep(0.3)
+ self._voice_start_recording()
+ app.invalidate()
+ except Exception as e:
+ _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
+ threading.Thread(target=_restart_recording, daemon=True).start()
except Exception as e:
print(f"Error: {e}")
@@ -4646,9 +7307,28 @@ def process_loop():
# Register atexit cleanup so resources are freed even on unexpected exit
atexit.register(_run_cleanup)
+ # Install a custom asyncio exception handler that suppresses the
+ # "Event loop is closed" RuntimeError from httpx transport cleanup.
+ # This is defense-in-depth โ the primary fix is neuter_async_httpx_del
+ # which disables __del__ entirely, but older clients or SDK upgrades
+ # could bypass it.
+ def _suppress_closed_loop_errors(loop, context):
+ exc = context.get("exception")
+ if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc):
+ return # silently suppress
+ # Fall back to default handler for everything else
+ loop.default_exception_handler(context)
+
# Run the application with patch_stdout for proper output handling
try:
with patch_stdout():
+ # Set the custom handler on prompt_toolkit's event loop
+ try:
+ import asyncio as _aio
+ _loop = _aio.get_event_loop()
+ _loop.set_exception_handler(_suppress_closed_loop_errors)
+ except Exception:
+ pass
app.run()
except (EOFError, KeyboardInterrupt):
pass
@@ -4658,16 +7338,36 @@ def process_loop():
if self.agent and self.conversation_history:
try:
self.agent.flush_memories(self.conversation_history)
+ except (Exception, KeyboardInterrupt):
+ pass
+ # Shut down voice recorder (release persistent audio stream)
+ if hasattr(self, '_voice_recorder') and self._voice_recorder:
+ try:
+ self._voice_recorder.shutdown()
except Exception:
pass
- # Unregister terminal_tool callbacks to avoid dangling references
+ self._voice_recorder = None
+ # Clean up old temp voice recordings
+ try:
+ from tools.voice_mode import cleanup_temp_recordings
+ cleanup_temp_recordings()
+ except Exception:
+ pass
+ # Unregister callbacks to avoid dangling references
set_sudo_password_callback(None)
set_approval_callback(None)
+ set_secret_capture_callback(None)
+ # Flush + shut down Honcho async writer (drains queue before exit)
+ if self.agent and getattr(self.agent, '_honcho', None):
+ try:
+ self.agent._honcho.shutdown()
+ except (Exception, KeyboardInterrupt):
+ pass
# Close session in SQLite
if hasattr(self, '_session_db') and self._session_db and self.agent:
try:
self._session_db.end_session(self.agent.session_id, "cli_close")
- except Exception as e:
+ except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
_run_cleanup()
self._print_exit_summary()
@@ -4681,6 +7381,7 @@ def main(
query: str = None,
q: str = None,
toolsets: str = None,
+ skills: str | list[str] | tuple[str, ...] = None,
model: str = None,
provider: str = None,
api_key: str = None,
@@ -4705,6 +7406,7 @@ def main(
query: Single query to execute (then exit). Alias: -q
q: Shorthand for --query
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
+ skills: Comma-separated or repeated list of skills to preload for the session
model: Model to use (default: anthropic/claude-opus-4-20250514)
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
api_key: API key for authentication
@@ -4721,6 +7423,7 @@ def main(
Examples:
python cli.py # Start interactive mode
python cli.py --toolsets web,terminal # Use specific toolsets
+ python cli.py --skills hermes-agent-dev,github-auth
python cli.py -q "What is Python?" # Single query mode
python cli.py --list-tools # List tools and exit
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
@@ -4783,13 +7486,12 @@ def main(
else:
toolsets_list.append(str(t))
else:
- # Check config for CLI toolsets, fallback to hermes-cli
- config_cli_toolsets = CLI_CONFIG.get("platform_toolsets", {}).get("cli")
- if config_cli_toolsets and isinstance(config_cli_toolsets, list):
- toolsets_list = config_cli_toolsets
- else:
- toolsets_list = ["hermes-cli"]
+ # Use the shared resolver so MCP servers are included at runtime
+ from hermes_cli.tools_config import _get_platform_tools
+ toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
+ parsed_skills = _parse_skills_argument(skills)
+
# Create CLI instance
cli = HermesCLI(
model=model,
@@ -4805,6 +7507,20 @@ def main(
pass_session_id=pass_session_id,
)
+ if parsed_skills:
+ skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(
+ parsed_skills,
+ task_id=cli.session_id,
+ )
+ if missing_skills:
+ missing_display = ", ".join(missing_skills)
+ raise ValueError(f"Unknown skill(s): {missing_display}")
+ if skills_prompt:
+ cli.system_prompt = "\n\n".join(
+ part for part in (cli.system_prompt, skills_prompt) if part
+ ).strip()
+ cli.preloaded_skills = loaded_skills
+
# Inject worktree context into agent's system prompt
if wt_info:
wt_note = (
@@ -4836,13 +7552,24 @@ def main(
# Quiet mode: suppress banner, spinner, tool previews.
# Only print the final response and parseable session info.
cli.tool_progress_mode = "off"
- if cli._init_agent():
- cli.agent.quiet_mode = True
- result = cli.agent.run_conversation(query)
- response = result.get("final_response", "") if isinstance(result, dict) else str(result)
- if response:
- print(response)
- print(f"\nsession_id: {cli.session_id}")
+ if cli._ensure_runtime_credentials():
+ turn_route = cli._resolve_turn_agent_config(query)
+ if turn_route["signature"] != cli._active_agent_route_signature:
+ cli.agent = None
+ if cli._init_agent(
+ model_override=turn_route["model"],
+ runtime_override=turn_route["runtime"],
+ route_label=turn_route["label"],
+ ):
+ cli.agent.quiet_mode = True
+ result = cli.agent.run_conversation(
+ user_message=query,
+ conversation_history=cli.conversation_history,
+ )
+ response = result.get("final_response", "") if isinstance(result, dict) else str(result)
+ if response:
+ print(response)
+ print(f"\nsession_id: {cli.session_id}")
else:
cli.show_banner()
cli.console.print(f"[bold blue]Query:[/] {query}")
diff --git a/cron/__init__.py b/cron/__init__.py
index 6a8f3ecbaf3..2c44cabf6b8 100644
--- a/cron/__init__.py
+++ b/cron/__init__.py
@@ -7,7 +7,8 @@
- Execute tasks in isolated sessions (no prior context)
Cron jobs are executed automatically by the gateway daemon:
- hermes gateway install # Install as system service (recommended)
+ hermes gateway install # Install as a user service
+ sudo hermes gateway install --system # Linux servers: boot-time system service
hermes gateway # Or run in foreground
The gateway ticks the scheduler every 60 seconds. A file lock prevents
@@ -20,6 +21,9 @@
list_jobs,
remove_job,
update_job,
+ pause_job,
+ resume_job,
+ trigger_job,
JOBS_FILE,
)
from cron.scheduler import tick
@@ -30,6 +34,9 @@
"list_jobs",
"remove_job",
"update_job",
+ "pause_job",
+ "resume_job",
+ "trigger_job",
"tick",
"JOBS_FILE",
]
diff --git a/cron/jobs.py b/cron/jobs.py
index 6cbb168f0c5..5e3d7067bd1 100644
--- a/cron/jobs.py
+++ b/cron/jobs.py
@@ -5,15 +5,20 @@
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
"""
+import copy
import json
+import logging
import tempfile
import os
import re
import uuid
from datetime import datetime, timedelta
from pathlib import Path
+from hermes_constants import get_hermes_home
from typing import Optional, Dict, List, Any
+logger = logging.getLogger(__name__)
+
from hermes_time import now as _hermes_now
try:
@@ -26,10 +31,37 @@
# Configuration
# =============================================================================
-HERMES_DIR = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+HERMES_DIR = get_hermes_home()
CRON_DIR = HERMES_DIR / "cron"
JOBS_FILE = CRON_DIR / "jobs.json"
OUTPUT_DIR = CRON_DIR / "output"
+ONESHOT_GRACE_SECONDS = 120
+
+
+def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
+ """Normalize legacy/single-skill and multi-skill inputs into a unique ordered list."""
+ if skills is None:
+ raw_items = [skill] if skill else []
+ elif isinstance(skills, str):
+ raw_items = [skills]
+ else:
+ raw_items = list(skills)
+
+ normalized: List[str] = []
+ for item in raw_items:
+ text = str(item or "").strip()
+ if text and text not in normalized:
+ normalized.append(text)
+ return normalized
+
+
+def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:
+ """Return a job dict with canonical `skills` and legacy `skill` fields aligned."""
+ normalized = dict(job)
+ skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills"))
+ normalized["skills"] = skills
+ normalized["skill"] = skills[0] if skills else None
+ return normalized
def _secure_dir(path: Path):
@@ -138,6 +170,10 @@ def parse_schedule(schedule: str) -> Dict[str, Any]:
try:
# Parse and validate
dt = datetime.fromisoformat(schedule.replace('Z', '+00:00'))
+ # Make naive timestamps timezone-aware at parse time so the stored
+ # value doesn't depend on the system timezone matching at check time.
+ if dt.tzinfo is None:
+ dt = dt.astimezone() # Interpret as local timezone
return {
"kind": "once",
"run_at": dt.isoformat(),
@@ -186,6 +222,65 @@ def _ensure_aware(dt: datetime) -> datetime:
return dt.astimezone(target_tz)
+def _recoverable_oneshot_run_at(
+ schedule: Dict[str, Any],
+ now: datetime,
+ *,
+ last_run_at: Optional[str] = None,
+) -> Optional[str]:
+ """Return a one-shot run time if it is still eligible to fire.
+
+ One-shot jobs get a small grace window so jobs created a few seconds after
+ their requested minute still run on the next tick. Once a one-shot has
+ already run, it is never eligible again.
+ """
+ if schedule.get("kind") != "once":
+ return None
+ if last_run_at:
+ return None
+
+ run_at = schedule.get("run_at")
+ if not run_at:
+ return None
+
+ run_at_dt = _ensure_aware(datetime.fromisoformat(run_at))
+ if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS):
+ return run_at
+ return None
+
+
+def _compute_grace_seconds(schedule: dict) -> int:
+ """Compute how late a job can be and still catch up instead of fast-forwarding.
+
+ Uses half the schedule period, clamped between 120 seconds and 2 hours.
+ This ensures daily jobs can catch up if missed by up to 2 hours,
+ while frequent jobs (every 5-10 min) still fast-forward quickly.
+ """
+ MIN_GRACE = 120
+ MAX_GRACE = 7200 # 2 hours
+
+ kind = schedule.get("kind")
+
+ if kind == "interval":
+ period_seconds = schedule.get("minutes", 1) * 60
+ grace = period_seconds // 2
+ return max(MIN_GRACE, min(grace, MAX_GRACE))
+
+ if kind == "cron" and HAS_CRONITER:
+ try:
+ now = _hermes_now()
+ cron = croniter(schedule["expr"], now)
+ first = cron.get_next(datetime)
+ second = cron.get_next(datetime)
+ period_seconds = int((second - first).total_seconds())
+ grace = period_seconds // 2
+ return max(MIN_GRACE, min(grace, MAX_GRACE))
+ except Exception:
+ pass
+
+ return MIN_GRACE
+
+
def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]:
"""
Compute the next run time for a schedule.
@@ -195,9 +290,7 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
now = _hermes_now()
if schedule["kind"] == "once":
- run_at = _ensure_aware(datetime.fromisoformat(schedule["run_at"]))
- # If in the future, return it; if in the past, no more runs
- return schedule["run_at"] if run_at > now else None
+ return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at)
elif schedule["kind"] == "interval":
minutes = schedule["minutes"]
@@ -263,39 +356,67 @@ def create_job(
name: Optional[str] = None,
repeat: Optional[int] = None,
deliver: Optional[str] = None,
- origin: Optional[Dict[str, Any]] = None
+ origin: Optional[Dict[str, Any]] = None,
+ skill: Optional[str] = None,
+ skills: Optional[List[str]] = None,
+ model: Optional[str] = None,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
-
+
Args:
- prompt: The prompt to run (must be self-contained)
+ prompt: The prompt to run (must be self-contained, or a task instruction when skill is set)
schedule: Schedule string (see parse_schedule)
name: Optional friendly name
repeat: How many times to run (None = forever, 1 = once)
deliver: Where to deliver output ("origin", "local", "telegram", etc.)
origin: Source info where job was created (for "origin" delivery)
-
+ skill: Optional legacy single skill name to load before running the prompt
+ skills: Optional ordered list of skills to load before running the prompt
+ model: Optional per-job model override
+ provider: Optional per-job provider override
+ base_url: Optional per-job base URL override
+
Returns:
The created job dict
"""
parsed_schedule = parse_schedule(schedule)
-
+
+ # Normalize repeat: treat 0 or negative values as None (infinite)
+ if repeat is not None and repeat <= 0:
+ repeat = None
+
# Auto-set repeat=1 for one-shot schedules if not specified
if parsed_schedule["kind"] == "once" and repeat is None:
repeat = 1
-
+
# Default delivery to origin if available, otherwise local
if deliver is None:
deliver = "origin" if origin else "local"
-
+
job_id = uuid.uuid4().hex[:12]
now = _hermes_now().isoformat()
-
+
+ normalized_skills = _normalize_skill_list(skill, skills)
+ normalized_model = str(model).strip() if isinstance(model, str) else None
+ normalized_provider = str(provider).strip() if isinstance(provider, str) else None
+ normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None
+ normalized_model = normalized_model or None
+ normalized_provider = normalized_provider or None
+ normalized_base_url = normalized_base_url or None
+
+ label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job"
job = {
"id": job_id,
- "name": name or prompt[:50].strip(),
+ "name": name or label_source[:50].strip(),
"prompt": prompt,
+ "skills": normalized_skills,
+ "skill": normalized_skills[0] if normalized_skills else None,
+ "model": normalized_model,
+ "provider": normalized_provider,
+ "base_url": normalized_base_url,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
"repeat": {
@@ -303,6 +424,9 @@ def create_job(
"completed": 0
},
"enabled": True,
+ "state": "scheduled",
+ "paused_at": None,
+ "paused_reason": None,
"created_at": now,
"next_run_at": compute_next_run(parsed_schedule),
"last_run_at": None,
@@ -312,11 +436,11 @@ def create_job(
"deliver": deliver,
"origin": origin, # Tracks where job was created for "origin" delivery
}
-
+
jobs = load_jobs()
jobs.append(job)
save_jobs(jobs)
-
+
return job
@@ -325,29 +449,100 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:
jobs = load_jobs()
for job in jobs:
if job["id"] == job_id:
- return job
+ return _apply_skill_fields(job)
return None
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
"""List all jobs, optionally including disabled ones."""
- jobs = load_jobs()
+ jobs = [_apply_skill_fields(j) for j in load_jobs()]
if not include_disabled:
jobs = [j for j in jobs if j.get("enabled", True)]
return jobs
def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- """Update a job by ID."""
+ """Update a job by ID, refreshing derived schedule fields when needed."""
jobs = load_jobs()
for i, job in enumerate(jobs):
- if job["id"] == job_id:
- jobs[i] = {**job, **updates}
- save_jobs(jobs)
- return jobs[i]
+ if job["id"] != job_id:
+ continue
+
+ updated = _apply_skill_fields({**job, **updates})
+ schedule_changed = "schedule" in updates
+
+ if "skills" in updates or "skill" in updates:
+ normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
+ updated["skills"] = normalized_skills
+ updated["skill"] = normalized_skills[0] if normalized_skills else None
+
+ if schedule_changed:
+ updated_schedule = updated["schedule"]
+ updated["schedule_display"] = updates.get(
+ "schedule_display",
+ updated_schedule.get("display", updated.get("schedule_display")),
+ )
+ if updated.get("state") != "paused":
+ updated["next_run_at"] = compute_next_run(updated_schedule)
+
+ if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
+ updated["next_run_at"] = compute_next_run(updated["schedule"])
+
+ jobs[i] = updated
+ save_jobs(jobs)
+ return _apply_skill_fields(jobs[i])
return None
+def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """Pause a job without deleting it."""
+ return update_job(
+ job_id,
+ {
+ "enabled": False,
+ "state": "paused",
+ "paused_at": _hermes_now().isoformat(),
+ "paused_reason": reason,
+ },
+ )
+
+
+def resume_job(job_id: str) -> Optional[Dict[str, Any]]:
+ """Resume a paused job and compute the next future run from now."""
+ job = get_job(job_id)
+ if not job:
+ return None
+
+ next_run_at = compute_next_run(job["schedule"])
+ return update_job(
+ job_id,
+ {
+ "enabled": True,
+ "state": "scheduled",
+ "paused_at": None,
+ "paused_reason": None,
+ "next_run_at": next_run_at,
+ },
+ )
+
+
+def trigger_job(job_id: str) -> Optional[Dict[str, Any]]:
+ """Schedule a job to run on the next scheduler tick."""
+ job = get_job(job_id)
+ if not job:
+ return None
+ return update_job(
+ job_id,
+ {
+ "enabled": True,
+ "state": "scheduled",
+ "paused_at": None,
+ "paused_reason": None,
+ "next_run_at": _hermes_now().isoformat(),
+ },
+ )
+
+
def remove_job(job_id: str) -> bool:
"""Remove a job by ID."""
jobs = load_jobs()
@@ -381,7 +576,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
# Check if we've hit the repeat limit
times = job["repeat"].get("times")
completed = job["repeat"]["completed"]
- if times is not None and completed >= times:
+ if times is not None and times > 0 and completed >= times:
# Remove the job (limit reached)
jobs.pop(i)
save_jobs(jobs)
@@ -389,35 +584,124 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
# Compute next run
job["next_run_at"] = compute_next_run(job["schedule"], now)
-
+
# If no next run (one-shot completed), disable
if job["next_run_at"] is None:
job["enabled"] = False
-
+ job["state"] = "completed"
+ elif job.get("state") != "paused":
+ job["state"] = "scheduled"
+
save_jobs(jobs)
return
save_jobs(jobs)
+def advance_next_run(job_id: str) -> bool:
+ """Preemptively advance next_run_at for a recurring job before execution.
+
+ Call this BEFORE run_job() so that if the process crashes mid-execution,
+ the job won't re-fire on the next gateway restart. This converts the
+ scheduler from at-least-once to at-most-once for recurring jobs โ missing
+ one run is far better than firing dozens of times in a crash loop.
+
+ One-shot jobs are left unchanged so they can still retry on restart.
+
+ Returns True if next_run_at was advanced, False otherwise.
+ """
+ jobs = load_jobs()
+ for job in jobs:
+ if job["id"] == job_id:
+ kind = job.get("schedule", {}).get("kind")
+ if kind not in ("cron", "interval"):
+ return False
+ now = _hermes_now().isoformat()
+ new_next = compute_next_run(job["schedule"], now)
+ if new_next and new_next != job.get("next_run_at"):
+ job["next_run_at"] = new_next
+ save_jobs(jobs)
+ return True
+ return False
+ return False
+
+
def get_due_jobs() -> List[Dict[str, Any]]:
- """Get all jobs that are due to run now."""
+ """Get all jobs that are due to run now.
+
+ For recurring jobs (cron/interval), if the scheduled time is stale
+ (more than one period in the past, e.g. because the gateway was down),
+ the job is fast-forwarded to the next future run instead of firing
+ immediately. This prevents a burst of missed jobs on gateway restart.
+ """
now = _hermes_now()
- jobs = load_jobs()
+ raw_jobs = load_jobs()
+ jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
due = []
-
+ needs_save = False
+
for job in jobs:
if not job.get("enabled", True):
continue
-
+
next_run = job.get("next_run_at")
if not next_run:
- continue
-
+ recovered_next = _recoverable_oneshot_run_at(
+ job.get("schedule", {}),
+ now,
+ last_run_at=job.get("last_run_at"),
+ )
+ if not recovered_next:
+ continue
+
+ job["next_run_at"] = recovered_next
+ next_run = recovered_next
+ logger.info(
+ "Job '%s' had no next_run_at; recovering one-shot run at %s",
+ job.get("name", job["id"]),
+ recovered_next,
+ )
+ for rj in raw_jobs:
+ if rj["id"] == job["id"]:
+ rj["next_run_at"] = recovered_next
+ needs_save = True
+ break
+
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
if next_run_dt <= now:
+ schedule = job.get("schedule", {})
+ kind = schedule.get("kind")
+
+ # For recurring jobs, check if the scheduled time is stale
+ # (gateway was down and missed the window). Fast-forward to
+ # the next future occurrence instead of firing a stale run.
+ grace = _compute_grace_seconds(schedule)
+ if kind in ("cron", "interval") and (now - next_run_dt).total_seconds() > grace:
+ # Job is past its catch-up grace window โ this is a stale missed run.
+ # Grace scales with schedule period: daily=2h, hourly=30m, 10min=5m.
+ new_next = compute_next_run(schedule, now.isoformat())
+ if new_next:
+ logger.info(
+ "Job '%s' missed its scheduled time (%s, grace=%ds). "
+ "Fast-forwarding to next run: %s",
+ job.get("name", job["id"]),
+ next_run,
+ grace,
+ new_next,
+ )
+ # Update the job in storage
+ for rj in raw_jobs:
+ if rj["id"] == job["id"]:
+ rj["next_run_at"] = new_next
+ needs_save = True
+ break
+ continue # Skip this run
+
due.append(job)
-
+
+ if needs_save:
+ save_jobs(raw_jobs)
+
return due
@@ -431,8 +715,19 @@ def save_job_output(job_id: str, output: str):
timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S")
output_file = job_output_dir / f"{timestamp}.md"
- with open(output_file, 'w', encoding='utf-8') as f:
- f.write(output)
- _secure_file(output_file)
+ fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_')
+ try:
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
+ f.write(output)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, output_file)
+ _secure_file(output_file)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
return output_file
diff --git a/cron/scheduler.py b/cron/scheduler.py
index c80122ce836..b0bd00b832e 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -9,6 +9,7 @@
"""
import asyncio
+import json
import logging
import os
import sys
@@ -23,8 +24,8 @@
import msvcrt
except ImportError:
msvcrt = None
-from datetime import datetime
from pathlib import Path
+from hermes_constants import get_hermes_home
from typing import Optional
from hermes_time import now as _hermes_now
@@ -34,10 +35,15 @@
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
-from cron.jobs import get_due_jobs, mark_job_run, save_job_output
+from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
+
+# Sentinel: when a cron agent has nothing new to report, it can start its
+# response with this marker to suppress delivery. Output is still saved
+# locally for audit.
+SILENT_MARKER = "[SILENT]"
# Resolve Hermes home directory (respects HERMES_HOME override)
-_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+_hermes_home = get_hermes_home()
# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
_LOCK_DIR = _hermes_home / "cron"
@@ -56,6 +62,55 @@ def _resolve_origin(job: dict) -> Optional[dict]:
return None
+def _resolve_delivery_target(job: dict) -> Optional[dict]:
+ """Resolve the concrete auto-delivery target for a cron job, if any."""
+ deliver = job.get("deliver", "local")
+ origin = _resolve_origin(job)
+
+ if deliver == "local":
+ return None
+
+ if deliver == "origin":
+ if not origin:
+ return None
+ return {
+ "platform": origin["platform"],
+ "chat_id": str(origin["chat_id"]),
+ "thread_id": origin.get("thread_id"),
+ }
+
+ if ":" in deliver:
+ platform_name, rest = deliver.split(":", 1)
+ # Check for thread_id suffix (e.g. "telegram:-1003724596514:17")
+ if ":" in rest:
+ chat_id, thread_id = rest.split(":", 1)
+ else:
+ chat_id, thread_id = rest, None
+ return {
+ "platform": platform_name,
+ "chat_id": chat_id,
+ "thread_id": thread_id,
+ }
+
+ platform_name = deliver
+ if origin and origin.get("platform") == platform_name:
+ return {
+ "platform": platform_name,
+ "chat_id": str(origin["chat_id"]),
+ "thread_id": origin.get("thread_id"),
+ }
+
+ chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "")
+ if not chat_id:
+ return None
+
+ return {
+ "platform": platform_name,
+ "chat_id": chat_id,
+ "thread_id": None,
+ }
+
+
def _deliver_result(job: dict, content: str) -> None:
"""
Deliver job output to the configured target (origin chat, specific platform, etc.).
@@ -63,36 +118,19 @@ def _deliver_result(job: dict, content: str) -> None:
Uses the standalone platform send functions from send_message_tool so delivery
works whether or not the gateway is running.
"""
- deliver = job.get("deliver", "local")
- origin = _resolve_origin(job)
-
- if deliver == "local":
+ target = _resolve_delivery_target(job)
+ if not target:
+ if job.get("deliver", "local") != "local":
+ logger.warning(
+ "Job '%s' deliver=%s but no concrete delivery target could be resolved",
+ job["id"],
+ job.get("deliver", "local"),
+ )
return
- thread_id = None
-
- # Resolve target platform + chat_id
- if deliver == "origin":
- if not origin:
- logger.warning("Job '%s' deliver=origin but no origin stored, skipping delivery", job["id"])
- return
- platform_name = origin["platform"]
- chat_id = origin["chat_id"]
- thread_id = origin.get("thread_id")
- elif ":" in deliver:
- platform_name, chat_id = deliver.split(":", 1)
- else:
- # Bare platform name like "telegram" โ need to resolve to origin or home channel
- platform_name = deliver
- if origin and origin.get("platform") == platform_name:
- chat_id = origin["chat_id"]
- thread_id = origin.get("thread_id")
- else:
- # Fall back to home channel
- chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "")
- if not chat_id:
- logger.warning("Job '%s' deliver=%s but no chat_id or home channel. Set via: hermes config set %s_HOME_CHANNEL ", job["id"], deliver, platform_name.upper())
- return
+ platform_name = target["platform"]
+ chat_id = target["chat_id"]
+ thread_id = target.get("thread_id")
from tools.send_message_tool import _send_to_platform
from gateway.config import load_gateway_config, Platform
@@ -103,7 +141,12 @@ def _deliver_result(job: dict, content: str) -> None:
"slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
+ "matrix": Platform.MATRIX,
+ "mattermost": Platform.MATTERMOST,
+ "homeassistant": Platform.HOMEASSISTANT,
+ "dingtalk": Platform.DINGTALK,
"email": Platform.EMAIL,
+ "sms": Platform.SMS,
}
platform = platform_map.get(platform_name.lower())
if not platform:
@@ -121,15 +164,29 @@ def _deliver_result(job: dict, content: str) -> None:
logger.warning("Job '%s': platform '%s' not configured/enabled", job["id"], platform_name)
return
+ # Wrap the content so the user knows this is a cron delivery and that
+ # the interactive agent has no visibility into it.
+ task_name = job.get("name", job["id"])
+ wrapped = (
+ f"Cronjob Response: {task_name}\n"
+ f"-------------\n\n"
+ f"{content}\n\n"
+ f"Note: The agent cannot see this message, and therefore cannot respond to it."
+ )
+
# Run the async send in a fresh event loop (safe from any thread)
+ coro = _send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id)
try:
- result = asyncio.run(_send_to_platform(platform, pconfig, chat_id, content, thread_id=thread_id))
+ result = asyncio.run(coro)
except RuntimeError:
- # asyncio.run() fails if there's already a running loop in this thread;
- # spin up a new thread to avoid that.
+ # asyncio.run() checks for a running loop before awaiting the coroutine;
+ # when it raises, the original coro was never started โ close it to
+ # prevent "coroutine was never awaited" RuntimeWarning, then retry in a
+ # fresh thread that has no running loop.
+ coro.close()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
- future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, content, thread_id=thread_id))
+ future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id))
result = future.result(timeout=30)
except Exception as e:
logger.error("Job '%s': delivery to %s:%s failed: %s", job["id"], platform_name, chat_id, e)
@@ -139,12 +196,66 @@ def _deliver_result(job: dict, content: str) -> None:
logger.error("Job '%s': delivery error: %s", job["id"], result["error"])
else:
logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)
- # Mirror the delivered content into the target's gateway session
- try:
- from gateway.mirror import mirror_to_session
- mirror_to_session(platform_name, chat_id, content, source_label="cron", thread_id=thread_id)
- except Exception as e:
- logger.warning("Job '%s': mirror_to_session failed: %s", job["id"], e)
+
+
+def _build_job_prompt(job: dict) -> str:
+ """Build the effective prompt for a cron job, optionally loading one or more skills first."""
+ prompt = job.get("prompt", "")
+ skills = job.get("skills")
+
+ # 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 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:
+ legacy = job.get("skill")
+ skills = [legacy] if legacy else []
+
+ skill_names = [str(name).strip() for name in skills if str(name).strip()]
+ if not skill_names:
+ return prompt
+
+ from tools.skills_tool import skill_view
+
+ parts = []
+ skipped: list[str] = []
+ for skill_name in skill_names:
+ loaded = json.loads(skill_view(skill_name))
+ if not loaded.get("success"):
+ error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
+ logger.warning("Cron job '%s': skill not found, skipping โ %s", job.get("name", job.get("id")), error)
+ skipped.append(skill_name)
+ continue
+
+ content = str(loaded.get("content") or "").strip()
+ if parts:
+ parts.append("")
+ parts.extend(
+ [
+ f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
+ "",
+ content,
+ ]
+ )
+
+ if skipped:
+ notice = (
+ f"[SYSTEM: The following skill(s) were listed for this job but could not be found "
+ f"and were skipped: {', '.join(skipped)}. "
+ f"Start your response with a brief notice so the user is aware, e.g.: "
+ f"'โ ๏ธ Skill(s) not found and skipped: {', '.join(skipped)}']"
+ )
+ parts.insert(0, notice)
+
+ if prompt:
+ parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
+ return "\n".join(parts)
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
@@ -156,11 +267,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
from run_agent import AIAgent
+ # Initialize SQLite session store so cron job messages are persisted
+ # and discoverable via session_search (same pattern as gateway/run.py).
+ _session_db = None
+ try:
+ from hermes_state import SessionDB
+ _session_db = SessionDB()
+ except Exception as e:
+ logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)
+
job_id = job["id"]
job_name = job["name"]
- prompt = job["prompt"]
+ prompt = _build_job_prompt(job)
origin = _resolve_origin(job)
-
+ _cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
+
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
logger.info("Prompt: %s", prompt[:100])
@@ -180,7 +301,14 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except UnicodeDecodeError:
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1")
- model = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
+ delivery_target = _resolve_delivery_target(job)
+ if delivery_target:
+ os.environ["HERMES_CRON_AUTO_DELIVER_PLATFORM"] = delivery_target["platform"]
+ os.environ["HERMES_CRON_AUTO_DELIVER_CHAT_ID"] = str(delivery_target["chat_id"])
+ 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 "anthropic/claude-opus-4.6"
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
_cfg = {}
@@ -191,24 +319,20 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
with open(_cfg_path) as _f:
_cfg = yaml.safe_load(_f) or {}
_model_cfg = _cfg.get("model", {})
- if isinstance(_model_cfg, str):
- model = _model_cfg
- elif isinstance(_model_cfg, dict):
- model = _model_cfg.get("default", model)
+ if not job.get("model"):
+ if isinstance(_model_cfg, str):
+ model = _model_cfg
+ elif isinstance(_model_cfg, dict):
+ model = _model_cfg.get("default", model)
except Exception as e:
logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e)
# Reasoning config from env or config.yaml
- reasoning_config = None
+ from hermes_constants import parse_reasoning_effort
effort = os.getenv("HERMES_REASONING_EFFORT", "")
if not effort:
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
- if effort and effort.lower() != "none":
- valid = ("xhigh", "high", "medium", "low", "minimal")
- if effort.lower() in valid:
- reasoning_config = {"enabled": True, "effort": effort.lower()}
- elif effort.lower() == "none":
- reasoning_config = {"enabled": False}
+ reasoning_config = parse_reasoning_effort(effort)
# Prefill messages from env or config.yaml
prefill_messages = None
@@ -233,25 +357,52 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# Provider routing
pr = _cfg.get("provider_routing", {})
+ smart_routing = _cfg.get("smart_model_routing", {}) or {}
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
format_runtime_provider_error,
)
try:
- runtime = resolve_runtime_provider(
- requested=os.getenv("HERMES_INFERENCE_PROVIDER"),
- )
+ runtime_kwargs = {
+ "requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"),
+ }
+ if job.get("base_url"):
+ runtime_kwargs["explicit_base_url"] = job.get("base_url")
+ runtime = resolve_runtime_provider(**runtime_kwargs)
except Exception as exc:
message = format_runtime_provider_error(exc)
raise RuntimeError(message) from exc
+ from agent.smart_model_routing import resolve_turn_route
+ turn_route = resolve_turn_route(
+ prompt,
+ smart_routing,
+ {
+ "model": model,
+ "api_key": runtime.get("api_key"),
+ "base_url": runtime.get("base_url"),
+ "provider": runtime.get("provider"),
+ "api_mode": runtime.get("api_mode"),
+ "command": runtime.get("command"),
+ "args": list(runtime.get("args") or []),
+ "request_headers_resolver": runtime.get("request_headers_resolver"),
+ "payment_adapter": runtime.get("payment_adapter"),
+ "payment_config": runtime.get("payment_config"),
+ },
+ )
+
agent = AIAgent(
- model=model,
- api_key=runtime.get("api_key"),
- base_url=runtime.get("base_url"),
- provider=runtime.get("provider"),
- api_mode=runtime.get("api_mode"),
+ model=turn_route["model"],
+ api_key=turn_route["runtime"].get("api_key"),
+ base_url=turn_route["runtime"].get("base_url"),
+ provider=turn_route["runtime"].get("provider"),
+ api_mode=turn_route["runtime"].get("api_mode"),
+ acp_command=turn_route["runtime"].get("command"),
+ acp_args=turn_route["runtime"].get("args"),
+ request_headers_resolver=turn_route["runtime"].get("request_headers_resolver"),
+ payment_adapter=turn_route["runtime"].get("payment_adapter"),
+ payment_config=turn_route["runtime"].get("payment_config"),
max_iterations=max_iterations,
reasoning_config=reasoning_config,
prefill_messages=prefill_messages,
@@ -259,15 +410,19 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
providers_ignored=pr.get("ignore"),
providers_order=pr.get("order"),
provider_sort=pr.get("sort"),
+ disabled_toolsets=["cronjob", "messaging", "clarify"],
quiet_mode=True,
- session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
+ platform="cron",
+ session_id=_cron_session_id,
+ session_db=_session_db,
)
result = agent.run_conversation(prompt)
- final_response = result.get("final_response", "")
- if not final_response:
- final_response = "(No response generated)"
+ final_response = result.get("final_response", "") or ""
+ # Use a separate variable for log display; keep final_response clean
+ # for delivery logic (empty response = no delivery).
+ logged_response = final_response if final_response else "(No response generated)"
output = f"""# Cron Job: {job_name}
@@ -281,7 +436,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
## Response
-{final_response}
+{logged_response}
"""
logger.info("Job '%s' completed successfully", job_name)
@@ -313,8 +468,24 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
finally:
# Clean up injected env vars so they don't leak to other jobs
- for key in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME"):
+ for key in (
+ "HERMES_SESSION_PLATFORM",
+ "HERMES_SESSION_CHAT_ID",
+ "HERMES_SESSION_CHAT_NAME",
+ "HERMES_CRON_AUTO_DELIVER_PLATFORM",
+ "HERMES_CRON_AUTO_DELIVER_CHAT_ID",
+ "HERMES_CRON_AUTO_DELIVER_THREAD_ID",
+ ):
os.environ.pop(key, None)
+ if _session_db:
+ try:
+ _session_db.end_session(_cron_session_id, "cron_complete")
+ except (Exception, KeyboardInterrupt) as e:
+ logger.debug("Job '%s': failed to end session: %s", job_id, e)
+ try:
+ _session_db.close()
+ except (Exception, KeyboardInterrupt) as e:
+ logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
def tick(verbose: bool = True) -> int:
@@ -359,15 +530,28 @@ def tick(verbose: bool = True) -> int:
executed = 0
for job in due_jobs:
try:
+ # For recurring jobs (cron/interval), advance next_run_at to the
+ # next future occurrence BEFORE execution. This way, if the
+ # process crashes mid-run, the job won't re-fire on restart.
+ # One-shot jobs are left alone so they can retry on restart.
+ advance_next_run(job["id"])
+
success, output, final_response, error = run_job(job)
output_file = save_job_output(job["id"], output)
if verbose:
logger.info("Output saved to: %s", output_file)
- # Deliver the final response to the origin/target chat
+ # Deliver the final response to the origin/target chat.
+ # If the agent responded with [SILENT], skip delivery (but
+ # output is already saved above). Failed jobs always deliver.
deliver_content = final_response if success else f"โ ๏ธ Cron job '{job.get('name', job['id'])}' failed:\n{error}"
- if deliver_content:
+ should_deliver = bool(deliver_content)
+ if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER):
+ logger.info("Job '%s': agent returned %s โ skipping delivery", job["id"], SILENT_MARKER)
+ should_deliver = False
+
+ if should_deliver:
try:
_deliver_result(job, deliver_content)
except Exception as de:
diff --git a/docs/acp-setup.md b/docs/acp-setup.md
new file mode 100644
index 00000000000..c5f7fec1cce
--- /dev/null
+++ b/docs/acp-setup.md
@@ -0,0 +1,229 @@
+# Hermes Agent โ ACP (Agent Client Protocol) Setup Guide
+
+Hermes Agent supports the **Agent Client Protocol (ACP)**, allowing it to run as
+a coding agent inside your editor. ACP lets your IDE send tasks to Hermes, and
+Hermes responds with file edits, terminal commands, and explanations โ all shown
+natively in the editor UI.
+
+---
+
+## Prerequisites
+
+- Hermes Agent installed and configured (`hermes setup` completed)
+- An API key / provider set up in `~/.hermes/.env` or via `hermes login`
+- Python 3.11+
+
+Install the ACP extra:
+
+```bash
+pip install -e ".[acp]"
+```
+
+---
+
+## VS Code Setup
+
+### 1. Install the ACP Client extension
+
+Open VS Code and install **ACP Client** from the marketplace:
+
+- Press `Ctrl+Shift+X` (or `Cmd+Shift+X` on macOS)
+- Search for **"ACP Client"**
+- Click **Install**
+
+Or install from the command line:
+
+```bash
+code --install-extension anysphere.acp-client
+```
+
+### 2. Configure settings.json
+
+Open your VS Code settings (`Ctrl+,` โ click the `{}` icon for JSON) and add:
+
+```json
+{
+ "acpClient.agents": [
+ {
+ "name": "hermes-agent",
+ "registryDir": "/path/to/hermes-agent/acp_registry"
+ }
+ ]
+}
+```
+
+Replace `/path/to/hermes-agent` with the actual path to your Hermes Agent
+installation (e.g. `~/.hermes/hermes-agent`).
+
+Alternatively, if `hermes` is on your PATH, the ACP Client can discover it
+automatically via the registry directory.
+
+### 3. Restart VS Code
+
+After configuring, restart VS Code. You should see **Hermes Agent** appear in
+the ACP agent picker in the chat/agent panel.
+
+---
+
+## Zed Setup
+
+Zed has built-in ACP support.
+
+### 1. Configure Zed settings
+
+Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
+`settings.json`:
+
+```json
+{
+ "acp": {
+ "agents": [
+ {
+ "name": "hermes-agent",
+ "registry_dir": "/path/to/hermes-agent/acp_registry"
+ }
+ ]
+ }
+}
+```
+
+### 2. Restart Zed
+
+Hermes Agent will appear in the agent panel. Select it and start a conversation.
+
+---
+
+## JetBrains Setup (IntelliJ, PyCharm, WebStorm, etc.)
+
+### 1. Install the ACP plugin
+
+- Open **Settings** โ **Plugins** โ **Marketplace**
+- Search for **"ACP"** or **"Agent Client Protocol"**
+- Install and restart the IDE
+
+### 2. Configure the agent
+
+- Open **Settings** โ **Tools** โ **ACP Agents**
+- Click **+** to add a new agent
+- Set the registry directory to your `acp_registry/` folder:
+ `/path/to/hermes-agent/acp_registry`
+- Click **OK**
+
+### 3. Use the agent
+
+Open the ACP panel (usually in the right sidebar) and select **Hermes Agent**.
+
+---
+
+## What You Will See
+
+Once connected, your editor provides a native interface to Hermes Agent:
+
+### Chat Panel
+A conversational interface where you can describe tasks, ask questions, and
+give instructions. Hermes responds with explanations and actions.
+
+### File Diffs
+When Hermes edits files, you see standard diffs in the editor. You can:
+- **Accept** individual changes
+- **Reject** changes you don't want
+- **Review** the full diff before applying
+
+### Terminal Commands
+When Hermes needs to run shell commands (builds, tests, installs), the editor
+shows them in an integrated terminal. Depending on your settings:
+- Commands may run automatically
+- Or you may be prompted to **approve** each command
+
+### Approval Flow
+For potentially destructive operations, the editor will prompt you for
+approval before Hermes proceeds. This includes:
+- File deletions
+- Shell commands
+- Git operations
+
+---
+
+## Configuration
+
+Hermes Agent under ACP uses the **same configuration** as the CLI:
+
+- **API keys / providers**: `~/.hermes/.env`
+- **Agent config**: `~/.hermes/config.yaml`
+- **Skills**: `~/.hermes/skills/`
+- **Sessions**: `~/.hermes/state.db`
+
+You can run `hermes setup` to configure providers, or edit `~/.hermes/.env`
+directly.
+
+### Changing the model
+
+Edit `~/.hermes/config.yaml`:
+
+```yaml
+model: openrouter/nous/hermes-3-llama-3.1-70b
+```
+
+Or set the `HERMES_MODEL` environment variable.
+
+### Toolsets
+
+ACP sessions use the curated `hermes-acp` toolset by default. It is designed for editor workflows and intentionally excludes things like messaging delivery, cronjob management, and audio-first UX features.
+
+---
+
+## Troubleshooting
+
+### Agent doesn't appear in the editor
+
+1. **Check the registry path** โ make sure the `acp_registry/` directory path
+ in your editor settings is correct and contains `agent.json`.
+2. **Check `hermes` is on PATH** โ run `which hermes` in a terminal. If not
+ found, you may need to activate your virtualenv or add it to PATH.
+3. **Restart the editor** after changing settings.
+
+### Agent starts but errors immediately
+
+1. Run `hermes doctor` to check your configuration.
+2. Check that you have a valid API key: `hermes status`
+3. Try running `hermes acp` directly in a terminal to see error output.
+
+### "Module not found" errors
+
+Make sure you installed the ACP extra:
+
+```bash
+pip install -e ".[acp]"
+```
+
+### Slow responses
+
+- ACP streams responses, so you should see incremental output. If the agent
+ appears stuck, check your network connection and API provider status.
+- Some providers have rate limits. Try switching to a different model/provider.
+
+### Permission denied for terminal commands
+
+If the editor blocks terminal commands, check your ACP Client extension
+settings for auto-approval or manual-approval preferences.
+
+### Logs
+
+Hermes logs are written to stderr when running in ACP mode. Check:
+- VS Code: **Output** panel โ select **ACP Client** or **Hermes Agent**
+- Zed: **View** โ **Toggle Terminal** and check the process output
+- JetBrains: **Event Log** or the ACP tool window
+
+You can also enable verbose logging:
+
+```bash
+HERMES_LOG_LEVEL=DEBUG hermes acp
+```
+
+---
+
+## Further Reading
+
+- [ACP Specification](https://github.com/anysphere/acp)
+- [Hermes Agent Documentation](https://github.com/NousResearch/hermes-agent)
+- Run `hermes --help` for all CLI options
diff --git a/docs/honcho-integration-spec.html b/docs/honcho-integration-spec.html
new file mode 100644
index 00000000000..455fb84f237
--- /dev/null
+++ b/docs/honcho-integration-spec.html
@@ -0,0 +1,698 @@
+
+
+
+
+
+honcho-integration-spec
+
+
+
+
+
+
+
+
+
+
+
+
+ honcho-integration-spec
+ Comparison of Hermes Agent vs. openclaw-honcho โ and a porting spec for bringing Hermes patterns into other Honcho integrations.
+
+ hermes-agent / openclaw-honcho
+ Python + TypeScript
+ 2026-03-09
+
+
+
+
+ Contents
+
+ Overview
+ Architecture comparison
+ Diff table
+ Hermes patterns to port
+ Spec: async prefetch
+ Spec: dynamic reasoning level
+ Spec: per-peer memory modes
+ Spec: AI peer identity formation
+ Spec: session naming strategies
+ Spec: CLI surface injection
+ openclaw-honcho checklist
+ nanobot-honcho checklist
+
+
+
+
+
+ Overview
+
+ Two independent Honcho integrations have been built for two different agent runtimes: Hermes Agent (Python, baked into the runner) and openclaw-honcho (TypeScript plugin via hook/tool API). Both use the same Honcho peer paradigm โ dual peer model, session.context(), peer.chat() โ but they made different tradeoffs at every layer.
+
+ This document maps those tradeoffs and defines a porting spec: a set of Hermes-originated patterns, each stated as an integration-agnostic interface, that any Honcho integration can adopt regardless of runtime or language.
+
+
+ Scope Both integrations work correctly today. This spec is about the delta โ patterns in Hermes that are worth propagating and patterns in openclaw-honcho that Hermes should eventually adopt. The spec is additive, not prescriptive.
+
+
+
+
+
+ Architecture comparison
+
+ Hermes: baked-in runner
+ Honcho is initialised directly inside AIAgent.__init__. There is no plugin boundary. Session management, context injection, async prefetch, and CLI surface are all first-class concerns of the runner. Context is injected once per session (baked into _cached_system_prompt) and never re-fetched mid-session โ this maximises prefix cache hits at the LLM provider.
+
+
+%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f3150', 'primaryTextColor': '#c9d1d9', 'primaryBorderColor': '#3d6ea5', 'lineColor': '#3d6ea5', 'secondaryColor': '#162030', 'tertiaryColor': '#11151c' }}}%%
+flowchart TD
+ U["user message"] --> P["_honcho_prefetch() (reads cache โ no HTTP)"]
+ P --> SP["_build_system_prompt() (first turn only, cached)"]
+ SP --> LLM["LLM call"]
+ LLM --> R["response"]
+ R --> FP["_honcho_fire_prefetch() (daemon threads, turn end)"]
+ FP --> C1["prefetch_context() thread"]
+ FP --> C2["prefetch_dialectic() thread"]
+ C1 --> CACHE["_context_cache / _dialectic_cache"]
+ C2 --> CACHE
+
+ style U fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style P fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
+ style SP fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
+ style LLM fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style R fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style FP fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
+ style C1 fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
+ style C2 fill:#2a1a40,stroke:#bc8cff,color:#c9d1d9
+ style CACHE fill:#11151c,stroke:#484f58,color:#6e7681
+
+
+ openclaw-honcho: hook-based plugin
+ The plugin registers hooks against OpenClaw's event bus. Context is fetched synchronously inside before_prompt_build on every turn. Message capture happens in agent_end. The multi-agent hierarchy is tracked via subagent_spawned. This model is correct but every turn pays a blocking Honcho round-trip before the LLM call can begin.
+
+
+%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1f3150', 'primaryTextColor': '#c9d1d9', 'primaryBorderColor': '#3d6ea5', 'lineColor': '#3d6ea5', 'secondaryColor': '#162030', 'tertiaryColor': '#11151c' }}}%%
+flowchart TD
+ U2["user message"] --> BPB["before_prompt_build (BLOCKING HTTP โ every turn)"]
+ BPB --> CTX["session.context()"]
+ CTX --> SP2["system prompt assembled"]
+ SP2 --> LLM2["LLM call"]
+ LLM2 --> R2["response"]
+ R2 --> AE["agent_end hook"]
+ AE --> SAVE["session.addMessages() session.setMetadata()"]
+
+ style U2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style BPB fill:#3a1515,stroke:#f47067,color:#c9d1d9
+ style CTX fill:#3a1515,stroke:#f47067,color:#c9d1d9
+ style SP2 fill:#1f3150,stroke:#3d6ea5,color:#c9d1d9
+ style LLM2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style R2 fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style AE fill:#162030,stroke:#3d6ea5,color:#c9d1d9
+ style SAVE fill:#11151c,stroke:#484f58,color:#6e7681
+
+
+
+
+
+ Diff table
+
+
+
+
+
+ Dimension
+ Hermes Agent
+ openclaw-honcho
+
+
+
+
+ Context injection timing
+ Once per session (cached). Zero HTTP on response path after turn 1.
+ Every turn, blocking. Fresh context per turn but adds latency.
+
+
+ Prefetch strategy
+ Daemon threads fire at turn end; consumed next turn from cache.
+ None. Blocking call at prompt-build time.
+
+
+ Dialectic (peer.chat)
+ Prefetched async; result injected into system prompt next turn.
+ On-demand via honcho_recall / honcho_analyze tools.
+
+
+ Reasoning level
+ Dynamic: scales with message length. Floor = config default. Cap = "high".
+ Fixed per tool: recall=minimal, analyze=medium.
+
+
+ Memory modes
+ user_memory_mode / agent_memory_mode: hybrid / honcho / local.
+ None. Always writes to Honcho.
+
+
+ Write frequency
+ async (background queue), turn, session, N turns.
+ After every agent_end (no control).
+
+
+ AI peer identity
+ observe_me=True, seed_ai_identity(), get_ai_representation(), SOUL.md โ AI peer.
+ Agent files uploaded to agent peer at setup. No ongoing self-observation seeding.
+
+
+ Context scope
+ User peer + AI peer representation, both injected.
+ User peer (owner) representation + conversation summary. peerPerspective on context call.
+
+
+ Session naming
+ per-directory / global / manual map / title-based.
+ Derived from platform session key.
+
+
+ Multi-agent
+ Single-agent only.
+ Parent observer hierarchy via subagent_spawned.
+
+
+ Tool surface
+ Single query_user_context tool (on-demand dialectic).
+ 6 tools: session, profile, search, context (fast) + recall, analyze (LLM).
+
+
+ Platform metadata
+ Not stripped.
+ Explicitly stripped before Honcho storage.
+
+
+ Message dedup
+ None (sends on every save cycle).
+ lastSavedIndex in session metadata prevents re-sending.
+
+
+ CLI surface in prompt
+ Management commands injected into system prompt. Agent knows its own CLI.
+ Not injected.
+
+
+ AI peer name in identity
+ Replaces "Hermes Agent" in DEFAULT_AGENT_IDENTITY when configured.
+ Not implemented.
+
+
+ QMD / local file search
+ Not implemented.
+ Passthrough tools when QMD backend configured.
+
+
+ Workspace metadata
+ Not implemented.
+ agentPeerMap in workspace metadata tracks agent→peer ID.
+
+
+
+
+
+
+
+
+ Hermes patterns to port
+
+ Six patterns from Hermes are worth adopting in any Honcho integration. They are described below as integration-agnostic interfaces โ the implementation will differ per runtime, but the contract is the same.
+
+
+
+
Patterns Hermes contributes
+
+ Async prefetch (zero-latency)
+ Dynamic reasoning level
+ Per-peer memory modes
+ AI peer identity formation
+ Session naming strategies
+ CLI surface injection
+
+
+
+
Patterns openclaw contributes back
+
+ lastSavedIndex dedup
+ Platform metadata stripping
+ Multi-agent observer hierarchy
+ peerPerspective on context()
+ Tiered tool surface (fast/LLM)
+ Workspace agentPeerMap
+
+
+
+
+
+
+
+ Spec: async prefetch
+
+ Problem
+ Calling session.context() and peer.chat() synchronously before each LLM call adds 200โ800ms of Honcho round-trip latency to every turn. Users experience this as the agent "thinking slowly."
+
+ Pattern
+ Fire both calls as non-blocking background work at the end of each turn. Store results in a per-session cache keyed by session ID. At the start of the next turn, pop from cache โ the HTTP is already done. First turn is cold (empty cache); all subsequent turns are zero-latency on the response path.
+
+ Interface contract
+ // TypeScript (openclaw / nanobot plugin shape)
+
+interface AsyncPrefetch {
+ // Fire context + dialectic fetches at turn end. Non-blocking.
+ firePrefetch(sessionId: string , userMessage: string ): void ;
+
+ // Pop cached results at turn start. Returns empty if cache is cold.
+ popContextResult(sessionId: string ): ContextResult | null ;
+ popDialecticResult(sessionId: string ): string | null ;
+}
+
+type ContextResult = {
+ representation: string ;
+ card: string [];
+ aiRepresentation?: string ; // AI peer context if enabled
+ summary?: string ; // conversation summary if fetched
+};
+
+ Implementation notes
+
+ Python: threading.Thread(daemon=True). Write to dict[session_id, result] โ GIL makes this safe for simple writes.
+ TypeScript: Promise stored in Map<string, Promise<ContextResult>>. Await at pop time. If not resolved yet, skip (return null) โ do not block.
+ The pop is destructive: clears the cache entry after reading so stale data never accumulates.
+ Prefetch should also fire on first turn (even though it won't be consumed until turn 2) โ this ensures turn 2 is never cold.
+
+
+ openclaw-honcho adoption
+ Move session.context() from before_prompt_build to a post-agent_end background task. Store result in state.contextCache. In before_prompt_build, read from cache instead of calling Honcho. If cache is empty (turn 1), inject nothing โ the prompt is still valid without Honcho context on the first turn.
+
+
+
+
+ Spec: dynamic reasoning level
+
+ Problem
+ Honcho's dialectic endpoint supports reasoning levels from minimal to max. A fixed level per tool wastes budget on simple queries and under-serves complex ones.
+
+ Pattern
+ Select the reasoning level dynamically based on the user's message. Use the configured default as a floor. Bump by message length. Cap auto-selection at high โ never select max automatically.
+
+ Interface contract
+ // Shared helper โ identical logic in any language
+
+const LEVELS = ["minimal" , "low" , "medium" , "high" , "max" ];
+
+function dynamicReasoningLevel (
+ query: string ,
+ configDefault: string = "low"
+): string {
+ const baseIdx = Math.max(0 , LEVELS.indexOf(configDefault));
+ const n = query.length;
+ const bump = n < 120 ? 0 : n < 400 ? 1 : 2 ;
+ return LEVELS[Math.min(baseIdx + bump, 3 )]; // cap at "high" (idx 3)
+}
+
+ Config key
+ Add a dialecticReasoningLevel config field (string, default "low"). This sets the floor. Users can raise or lower it. The dynamic bump always applies on top.
+
+ openclaw-honcho adoption
+ Apply in honcho_recall and honcho_analyze: replace the fixed reasoningLevel with the dynamic selector. honcho_recall should use floor "minimal" and honcho_analyze floor "medium" โ both still bump with message length.
+
+
+
+
+ Spec: per-peer memory modes
+
+ Problem
+ Users want independent control over whether user context and agent context are written locally, to Honcho, or both. A single memoryMode shorthand is not granular enough.
+
+ Pattern
+ Three modes per peer: hybrid (write both local + Honcho), honcho (Honcho only, disable local files), local (local files only, skip Honcho sync for this peer). Two orthogonal axes: user peer and agent peer.
+
+ Config schema
+ // ~/.openclaw/openclaw.json (or ~/.nanobot/config.json)
+{
+ "plugins" : {
+ "openclaw-honcho" : {
+ "config" : {
+ "apiKey" : "..." ,
+ "memoryMode" : "hybrid" , // shorthand: both peers
+ "userMemoryMode" : "honcho" , // override for user peer
+ "agentMemoryMode" : "hybrid" // override for agent peer
+ }
+ }
+ }
+}
+
+ Resolution order
+
+ Per-peer field (userMemoryMode / agentMemoryMode) โ wins if present.
+ Shorthand memoryMode โ applies to both peers as default.
+ Hardcoded default: "hybrid".
+
+
+ Effect on Honcho sync
+
+ userMemoryMode=local: skip adding user peer messages to Honcho.
+ agentMemoryMode=local: skip adding assistant peer messages to Honcho.
+ Both local: skip session.addMessages() entirely.
+ userMemoryMode=honcho: disable local USER.md writes.
+ agentMemoryMode=honcho: disable local MEMORY.md / SOUL.md writes.
+
+
+
+
+
+ Spec: AI peer identity formation
+
+ Problem
+ Honcho builds the user's representation organically by observing what the user says. The same mechanism exists for the AI peer โ but only if observe_me=True is set for the agent peer. Without it, the agent peer accumulates nothing and Honcho's AI-side model never forms.
+
+ Additionally, existing persona files (SOUL.md, IDENTITY.md) should seed the AI peer's Honcho representation at first activation, rather than waiting for it to emerge from scratch.
+
+ Part A: observe_me=True for agent peer
+ // TypeScript โ in session.addPeers() call
+await session.addPeers([
+ [ownerPeer.id, { observeMe: true , observeOthers: false }],
+ [agentPeer.id, { observeMe: true , observeOthers: true }], // was false
+]);
+
+ This is a one-line change but foundational. Without it, Honcho's AI peer representation stays empty regardless of what the agent says.
+
+ Part B: seedAiIdentity()
+ async function seedAiIdentity (
+ session: HonchoSession,
+ agentPeer: Peer,
+ content: string ,
+ source: string
+): Promise<boolean > {
+ const wrapped = [
+ `<ai_identity_seed>` ,
+ `<source>${source}</source>` ,
+ `` ,
+ content.trim(),
+ `</ai_identity_seed>` ,
+ ].join("\n" );
+
+ await agentPeer.addMessage("assistant" , wrapped);
+ return true ;
+}
+
+ Part C: migrate agent files at setup
+ During openclaw honcho setup, upload agent-self files (SOUL.md, IDENTITY.md, AGENTS.md, BOOTSTRAP.md) to the agent peer using seedAiIdentity() instead of session.uploadFile(). This routes the content through Honcho's observation pipeline rather than the file store.
+
+ Part D: AI peer name in identity
+ When the agent has a configured name (non-default), inject it into the agent's self-identity prefix. In OpenClaw this means adding to the injected system prompt section:
+ // In context hook return value
+return {
+ systemPrompt: [
+ agentName ? `You are ${agentName}.` : "" ,
+ "## User Memory Context" ,
+ ...sections,
+ ].filter(Boolean).join("\n\n" )
+};
+
+ CLI surface: honcho identity subcommand
+ openclaw honcho identity <file> # seed from file
+openclaw honcho identity --show # show current AI peer representation
+
+
+
+
+ Spec: session naming strategies
+
+ Problem
+ When Honcho is used across multiple projects or directories, a single global session means every project shares the same context. Per-directory sessions provide isolation without requiring users to name sessions manually.
+
+ Strategies
+
+
+ Strategy Session key When to use
+
+ per-directorybasename of CWD Default. Each project gets its own session.
+ globalfixed string "global" Single cross-project session.
+ manual map user-configured per path sessions config map overrides directory basename.
+ title-based sanitized session title When agent supports named sessions; title set mid-conversation.
+
+
+
+
+ Config schema
+ {
+ "sessionStrategy" : "per-directory" , // "per-directory" | "global"
+ "sessionPeerPrefix" : false , // prepend peer name to session key
+ "sessions" : { // manual overrides
+ "/home/user/projects/foo" : "foo-project"
+ }
+}
+
+ CLI surface
+ openclaw honcho sessions # list all mappings
+openclaw honcho map <name> # map cwd to session name
+openclaw honcho map # no-arg = list mappings
+
+ Resolution order: manual map wins → session title → directory basename → platform key.
+
+
+
+
+ Spec: CLI surface injection
+
+ Problem
+ When a user asks "how do I change my memory settings?" or "what Honcho commands are available?" the agent either hallucinates or says it doesn't know. The agent should know its own management interface.
+
+ Pattern
+ When Honcho is active, append a compact command reference to the system prompt. The agent can cite these commands directly instead of guessing.
+
+ // In context hook, append to systemPrompt
+const honchoSection = [
+ "# Honcho memory integration" ,
+ `Active. Session: ${sessionKey}. Mode: ${mode}.` ,
+ "Management commands:" ,
+ " openclaw honcho status โ show config + connection" ,
+ " openclaw honcho mode [hybrid|honcho|local] โ show or set memory mode" ,
+ " openclaw honcho sessions โ list session mappings" ,
+ " openclaw honcho map <name> โ map directory to session" ,
+ " openclaw honcho identity [file] [--show] โ seed or show AI identity" ,
+ " openclaw honcho setup โ full interactive wizard" ,
+].join("\n" );
+
+
+ Keep it compact. This section is injected every turn. Keep it under 300 chars of context. List commands, not explanations โ the agent can explain them on request.
+
+
+
+
+
+ openclaw-honcho checklist
+
+ Ordered by impact. Each item maps to a spec section above.
+
+
+ Async prefetch โ move session.context() out of before_prompt_build into post-agent_end background Promise. Pop from cache at prompt build. (spec )
+ observe_me=True for agent peer โ one-line change in session.addPeers() config for agent peer. (spec )
+ Dynamic reasoning level โ add dynamicReasoningLevel() helper; apply in honcho_recall and honcho_analyze. Add dialecticReasoningLevel to config schema. (spec )
+ Per-peer memory modes โ add userMemoryMode / agentMemoryMode to config; gate Honcho sync and local writes accordingly. (spec )
+ seedAiIdentity() โ add helper; apply during setup migration for SOUL.md / IDENTITY.md instead of session.uploadFile(). (spec )
+ Session naming strategies โ add sessionStrategy, sessions map, sessionPeerPrefix to config; implement resolution function. (spec )
+ CLI surface injection โ append command reference to before_prompt_build return value when Honcho is active. (spec )
+ honcho identity subcommand โ add openclaw honcho identity CLI command. (spec )
+ AI peer name injection โ if aiPeer name configured, prepend to injected system prompt. (spec )
+ honcho mode / honcho sessions / honcho map โ CLI parity with Hermes. (spec )
+
+
+
+ Already done in openclaw-honcho (do not re-implement): lastSavedIndex dedup, platform metadata stripping, multi-agent parent observer hierarchy, peerPerspective on context(), tiered tool surface (fast/LLM), workspace agentPeerMap, QMD passthrough, self-hosted Honcho support.
+
+
+
+
+
+ nanobot-honcho checklist
+
+ nanobot-honcho is a greenfield integration. Start from openclaw-honcho's architecture (hook-based, dual peer) and apply all Hermes patterns from day one rather than retrofitting. Priority order:
+
+ Phase 1 โ core correctness
+
+ Dual peer model (owner + agent peer), both with observe_me=True
+ Message capture at turn end with lastSavedIndex dedup
+ Platform metadata stripping before Honcho storage
+ Async prefetch from day one โ do not implement blocking context injection
+ Legacy file migration at first activation (USER.md โ owner peer, SOUL.md โ seedAiIdentity())
+
+
+ Phase 2 โ configuration
+
+ Config schema: apiKey, workspaceId, baseUrl, memoryMode, userMemoryMode, agentMemoryMode, dialecticReasoningLevel, sessionStrategy, sessions
+ Per-peer memory mode gating
+ Dynamic reasoning level
+ Session naming strategies
+
+
+ Phase 3 โ tools and CLI
+
+ Tool surface: honcho_profile, honcho_recall, honcho_analyze, honcho_search, honcho_context
+ CLI: setup, status, sessions, map, mode, identity
+ CLI surface injection into system prompt
+ AI peer name wired into agent identity
+
+
+
+
+
+
+
+
+
diff --git a/docs/honcho-integration-spec.md b/docs/honcho-integration-spec.md
new file mode 100644
index 00000000000..7731a262d90
--- /dev/null
+++ b/docs/honcho-integration-spec.md
@@ -0,0 +1,377 @@
+# honcho-integration-spec
+
+Comparison of Hermes Agent vs. openclaw-honcho โ and a porting spec for bringing Hermes patterns into other Honcho integrations.
+
+---
+
+## Overview
+
+Two independent Honcho integrations have been built for two different agent runtimes: **Hermes Agent** (Python, baked into the runner) and **openclaw-honcho** (TypeScript plugin via hook/tool API). Both use the same Honcho peer paradigm โ dual peer model, `session.context()`, `peer.chat()` โ but they made different tradeoffs at every layer.
+
+This document maps those tradeoffs and defines a porting spec: a set of Hermes-originated patterns, each stated as an integration-agnostic interface, that any Honcho integration can adopt regardless of runtime or language.
+
+> **Scope** Both integrations work correctly today. This spec is about the delta โ patterns in Hermes that are worth propagating and patterns in openclaw-honcho that Hermes should eventually adopt. The spec is additive, not prescriptive.
+
+---
+
+## Architecture comparison
+
+### Hermes: baked-in runner
+
+Honcho is initialised directly inside `AIAgent.__init__`. There is no plugin boundary. Session management, context injection, async prefetch, and CLI surface are all first-class concerns of the runner. Context is injected once per session (baked into `_cached_system_prompt`) and never re-fetched mid-session โ this maximises prefix cache hits at the LLM provider.
+
+Turn flow:
+
+```
+user message
+ โ _honcho_prefetch() (reads cache โ no HTTP)
+ โ _build_system_prompt() (first turn only, cached)
+ โ LLM call
+ โ response
+ โ _honcho_fire_prefetch() (daemon threads, turn end)
+ โ prefetch_context() thread โโโ
+ โ prefetch_dialectic() thread โโดโ _context_cache / _dialectic_cache
+```
+
+### openclaw-honcho: hook-based plugin
+
+The plugin registers hooks against OpenClaw's event bus. Context is fetched synchronously inside `before_prompt_build` on every turn. Message capture happens in `agent_end`. The multi-agent hierarchy is tracked via `subagent_spawned`. This model is correct but every turn pays a blocking Honcho round-trip before the LLM call can begin.
+
+Turn flow:
+
+```
+user message
+ โ before_prompt_build (BLOCKING HTTP โ every turn)
+ โ session.context()
+ โ system prompt assembled
+ โ LLM call
+ โ response
+ โ agent_end hook
+ โ session.addMessages()
+ โ session.setMetadata()
+```
+
+---
+
+## Diff table
+
+| Dimension | Hermes Agent | openclaw-honcho |
+|---|---|---|
+| **Context injection timing** | Once per session (cached). Zero HTTP on response path after turn 1. | Every turn, blocking. Fresh context per turn but adds latency. |
+| **Prefetch strategy** | Daemon threads fire at turn end; consumed next turn from cache. | None. Blocking call at prompt-build time. |
+| **Dialectic (peer.chat)** | Prefetched async; result injected into system prompt next turn. | On-demand via `honcho_recall` / `honcho_analyze` tools. |
+| **Reasoning level** | Dynamic: scales with message length. Floor = config default. Cap = "high". | Fixed per tool: recall=minimal, analyze=medium. |
+| **Memory modes** | `user_memory_mode` / `agent_memory_mode`: hybrid / honcho / local. | None. Always writes to Honcho. |
+| **Write frequency** | async (background queue), turn, session, N turns. | After every agent_end (no control). |
+| **AI peer identity** | `observe_me=True`, `seed_ai_identity()`, `get_ai_representation()`, SOUL.md โ AI peer. | Agent files uploaded to agent peer at setup. No ongoing self-observation. |
+| **Context scope** | User peer + AI peer representation, both injected. | User peer (owner) representation + conversation summary. `peerPerspective` on context call. |
+| **Session naming** | per-directory / global / manual map / title-based. | Derived from platform session key. |
+| **Multi-agent** | Single-agent only. | Parent observer hierarchy via `subagent_spawned`. |
+| **Tool surface** | Single `query_user_context` tool (on-demand dialectic). | 6 tools: session, profile, search, context (fast) + recall, analyze (LLM). |
+| **Platform metadata** | Not stripped. | Explicitly stripped before Honcho storage. |
+| **Message dedup** | None. | `lastSavedIndex` in session metadata prevents re-sending. |
+| **CLI surface in prompt** | Management commands injected into system prompt. Agent knows its own CLI. | Not injected. |
+| **AI peer name in identity** | Replaces "Hermes Agent" in DEFAULT_AGENT_IDENTITY when configured. | Not implemented. |
+| **QMD / local file search** | Not implemented. | Passthrough tools when QMD backend configured. |
+| **Workspace metadata** | Not implemented. | `agentPeerMap` in workspace metadata tracks agentโpeer ID. |
+
+---
+
+## Patterns
+
+Six patterns from Hermes are worth adopting in any Honcho integration. Each is described as an integration-agnostic interface.
+
+**Hermes contributes:**
+- Async prefetch (zero-latency)
+- Dynamic reasoning level
+- Per-peer memory modes
+- AI peer identity formation
+- Session naming strategies
+- CLI surface injection
+
+**openclaw-honcho contributes back (Hermes should adopt):**
+- `lastSavedIndex` dedup
+- Platform metadata stripping
+- Multi-agent observer hierarchy
+- `peerPerspective` on `context()`
+- Tiered tool surface (fast/LLM)
+- Workspace `agentPeerMap`
+
+---
+
+## Spec: async prefetch
+
+### Problem
+
+Calling `session.context()` and `peer.chat()` synchronously before each LLM call adds 200โ800ms of Honcho round-trip latency to every turn.
+
+### Pattern
+
+Fire both calls as non-blocking background work at the **end** of each turn. Store results in a per-session cache keyed by session ID. At the **start** of the next turn, pop from cache โ the HTTP is already done. First turn is cold (empty cache); all subsequent turns are zero-latency on the response path.
+
+### Interface contract
+
+```typescript
+interface AsyncPrefetch {
+ // Fire context + dialectic fetches at turn end. Non-blocking.
+ firePrefetch(sessionId: string, userMessage: string): void;
+
+ // Pop cached results at turn start. Returns empty if cache is cold.
+ popContextResult(sessionId: string): ContextResult | null;
+ popDialecticResult(sessionId: string): string | null;
+}
+
+type ContextResult = {
+ representation: string;
+ card: string[];
+ aiRepresentation?: string; // AI peer context if enabled
+ summary?: string; // conversation summary if fetched
+};
+```
+
+### Implementation notes
+
+- **Python:** `threading.Thread(daemon=True)`. Write to `dict[session_id, result]` โ GIL makes this safe for simple writes.
+- **TypeScript:** `Promise` stored in `Map>`. Await at pop time. If not resolved yet, return null โ do not block.
+- The pop is destructive: clears the cache entry after reading so stale data never accumulates.
+- Prefetch should also fire on first turn (even though it won't be consumed until turn 2).
+
+### openclaw-honcho adoption
+
+Move `session.context()` from `before_prompt_build` to a post-`agent_end` background task. Store result in `state.contextCache`. In `before_prompt_build`, read from cache instead of calling Honcho. If cache is empty (turn 1), inject nothing โ the prompt is still valid without Honcho context on the first turn.
+
+---
+
+## Spec: dynamic reasoning level
+
+### Problem
+
+Honcho's dialectic endpoint supports reasoning levels from `minimal` to `max`. A fixed level per tool wastes budget on simple queries and under-serves complex ones.
+
+### Pattern
+
+Select the reasoning level dynamically based on the user's message. Use the configured default as a floor. Bump by message length. Cap auto-selection at `high` โ never select `max` automatically.
+
+### Logic
+
+```
+< 120 chars โ default (typically "low")
+120โ400 chars โ one level above default (cap at "high")
+> 400 chars โ two levels above default (cap at "high")
+```
+
+### Config key
+
+Add `dialecticReasoningLevel` (string, default `"low"`). This sets the floor. The dynamic bump always applies on top.
+
+### openclaw-honcho adoption
+
+Apply in `honcho_recall` and `honcho_analyze`: replace fixed `reasoningLevel` with the dynamic selector. `honcho_recall` uses floor `"minimal"`, `honcho_analyze` uses floor `"medium"` โ both still bump with message length.
+
+---
+
+## Spec: per-peer memory modes
+
+### Problem
+
+Users want independent control over whether user context and agent context are written locally, to Honcho, or both.
+
+### Modes
+
+| Mode | Effect |
+|---|---|
+| `hybrid` | Write to both local files and Honcho (default) |
+| `honcho` | Honcho only โ disable corresponding local file writes |
+| `local` | Local files only โ skip Honcho sync for this peer |
+
+### Config schema
+
+```json
+{
+ "memoryMode": "hybrid",
+ "userMemoryMode": "honcho",
+ "agentMemoryMode": "hybrid"
+}
+```
+
+Resolution order: per-peer field wins โ shorthand `memoryMode` โ default `"hybrid"`.
+
+### Effect on Honcho sync
+
+- `userMemoryMode=local`: skip adding user peer messages to Honcho
+- `agentMemoryMode=local`: skip adding assistant peer messages to Honcho
+- Both local: skip `session.addMessages()` entirely
+- `userMemoryMode=honcho`: disable local USER.md writes
+- `agentMemoryMode=honcho`: disable local MEMORY.md / SOUL.md writes
+
+---
+
+## Spec: AI peer identity formation
+
+### Problem
+
+Honcho builds the user's representation organically by observing what the user says. The same mechanism exists for the AI peer โ but only if `observe_me=True` is set for the agent peer. Without it, the agent peer accumulates nothing.
+
+Additionally, existing persona files (SOUL.md, IDENTITY.md) should seed the AI peer's Honcho representation at first activation.
+
+### Part A: observe_me=True for agent peer
+
+```typescript
+await session.addPeers([
+ [ownerPeer.id, { observeMe: true, observeOthers: false }],
+ [agentPeer.id, { observeMe: true, observeOthers: true }], // was false
+]);
+```
+
+One-line change. Foundational. Without it, the AI peer representation stays empty regardless of what the agent says.
+
+### Part B: seedAiIdentity()
+
+```typescript
+async function seedAiIdentity(
+ agentPeer: Peer,
+ content: string,
+ source: string
+): Promise {
+ const wrapped = [
+ ``,
+ `${source} `,
+ ``,
+ content.trim(),
+ ` `,
+ ].join("\n");
+
+ await agentPeer.addMessage("assistant", wrapped);
+ return true;
+}
+```
+
+### Part C: migrate agent files at setup
+
+During `honcho setup`, upload agent-self files (SOUL.md, IDENTITY.md, AGENTS.md) to the agent peer via `seedAiIdentity()` instead of `session.uploadFile()`. This routes content through Honcho's observation pipeline.
+
+### Part D: AI peer name in identity
+
+When the agent has a configured name, prepend it to the injected system prompt:
+
+```typescript
+const namePrefix = agentName ? `You are ${agentName}.\n\n` : "";
+return { systemPrompt: namePrefix + "## User Memory Context\n\n" + sections };
+```
+
+### CLI surface
+
+```
+honcho identity # seed from file
+honcho identity --show # show current AI peer representation
+```
+
+---
+
+## Spec: session naming strategies
+
+### Problem
+
+A single global session means every project shares the same Honcho context. Per-directory sessions provide isolation without requiring users to name sessions manually.
+
+### Strategies
+
+| Strategy | Session key | When to use |
+|---|---|---|
+| `per-directory` | basename of CWD | Default. Each project gets its own session. |
+| `global` | fixed string `"global"` | Single cross-project session. |
+| manual map | user-configured per path | `sessions` config map overrides directory basename. |
+| title-based | sanitized session title | When agent supports named sessions set mid-conversation. |
+
+### Config schema
+
+```json
+{
+ "sessionStrategy": "per-directory",
+ "sessionPeerPrefix": false,
+ "sessions": {
+ "/home/user/projects/foo": "foo-project"
+ }
+}
+```
+
+### CLI surface
+
+```
+honcho sessions # list all mappings
+honcho map # map cwd to session name
+honcho map # no-arg = list mappings
+```
+
+Resolution order: manual map โ session title โ directory basename โ platform key.
+
+---
+
+## Spec: CLI surface injection
+
+### Problem
+
+When a user asks "how do I change my memory settings?" the agent either hallucinates or says it doesn't know. The agent should know its own management interface.
+
+### Pattern
+
+When Honcho is active, append a compact command reference to the system prompt. Keep it under 300 chars.
+
+```
+# Honcho memory integration
+Active. Session: {sessionKey}. Mode: {mode}.
+Management commands:
+ honcho status โ show config + connection
+ honcho mode [hybrid|honcho|local] โ show or set memory mode
+ honcho sessions โ list session mappings
+ honcho map โ map directory to session
+ honcho identity [file] [--show] โ seed or show AI identity
+ honcho setup โ full interactive wizard
+```
+
+---
+
+## openclaw-honcho checklist
+
+Ordered by impact:
+
+- [ ] **Async prefetch** โ move `session.context()` out of `before_prompt_build` into post-`agent_end` background Promise
+- [ ] **observe_me=True for agent peer** โ one-line change in `session.addPeers()`
+- [ ] **Dynamic reasoning level** โ add helper; apply in `honcho_recall` and `honcho_analyze`; add `dialecticReasoningLevel` to config
+- [ ] **Per-peer memory modes** โ add `userMemoryMode` / `agentMemoryMode` to config; gate Honcho sync and local writes
+- [ ] **seedAiIdentity()** โ add helper; use during setup migration for SOUL.md / IDENTITY.md
+- [ ] **Session naming strategies** โ add `sessionStrategy`, `sessions` map, `sessionPeerPrefix`
+- [ ] **CLI surface injection** โ append command reference to `before_prompt_build` return value
+- [ ] **honcho identity subcommand** โ seed from file or `--show` current representation
+- [ ] **AI peer name injection** โ if `aiPeer` name configured, prepend to injected system prompt
+- [ ] **honcho mode / sessions / map** โ CLI parity with Hermes
+
+Already done in openclaw-honcho (do not re-implement): `lastSavedIndex` dedup, platform metadata stripping, multi-agent parent observer, `peerPerspective` on `context()`, tiered tool surface, workspace `agentPeerMap`, QMD passthrough, self-hosted Honcho.
+
+---
+
+## nanobot-honcho checklist
+
+Greenfield integration. Start from openclaw-honcho's architecture and apply all Hermes patterns from day one.
+
+### Phase 1 โ core correctness
+
+- [ ] Dual peer model (owner + agent peer), both with `observe_me=True`
+- [ ] Message capture at turn end with `lastSavedIndex` dedup
+- [ ] Platform metadata stripping before Honcho storage
+- [ ] Async prefetch from day one โ do not implement blocking context injection
+- [ ] Legacy file migration at first activation (USER.md โ owner peer, SOUL.md โ `seedAiIdentity()`)
+
+### Phase 2 โ configuration
+
+- [ ] Config schema: `apiKey`, `workspaceId`, `baseUrl`, `memoryMode`, `userMemoryMode`, `agentMemoryMode`, `dialecticReasoningLevel`, `sessionStrategy`, `sessions`
+- [ ] Per-peer memory mode gating
+- [ ] Dynamic reasoning level
+- [ ] Session naming strategies
+
+### Phase 3 โ tools and CLI
+
+- [ ] Tool surface: `honcho_profile`, `honcho_recall`, `honcho_analyze`, `honcho_search`, `honcho_context`
+- [ ] CLI: `setup`, `status`, `sessions`, `map`, `mode`, `identity`
+- [ ] CLI surface injection into system prompt
+- [ ] AI peer name wired into agent identity
diff --git a/docs/plans/2026-03-16-pricing-accuracy-architecture-design.md b/docs/plans/2026-03-16-pricing-accuracy-architecture-design.md
new file mode 100644
index 00000000000..a75f14ff5aa
--- /dev/null
+++ b/docs/plans/2026-03-16-pricing-accuracy-architecture-design.md
@@ -0,0 +1,608 @@
+# Pricing Accuracy Architecture
+
+Date: 2026-03-16
+
+## Goal
+
+Hermes should only show dollar costs when they are backed by an official source for the user's actual billing path.
+
+This design replaces the current static, heuristic pricing flow in:
+
+- `run_agent.py`
+- `agent/usage_pricing.py`
+- `agent/insights.py`
+- `cli.py`
+
+with a provider-aware pricing system that:
+
+- handles cache billing correctly
+- distinguishes `actual` vs `estimated` vs `included` vs `unknown`
+- reconciles post-hoc costs when providers expose authoritative billing data
+- supports direct providers, OpenRouter, subscriptions, enterprise pricing, and custom endpoints
+
+## Problems In The Current Design
+
+Current Hermes behavior has four structural issues:
+
+1. It stores only `prompt_tokens` and `completion_tokens`, which is insufficient for providers that bill cache reads and cache writes separately.
+2. It uses a static model price table and fuzzy heuristics, which can drift from current official pricing.
+3. It assumes public API list pricing matches the user's real billing path.
+4. It has no distinction between live estimates and reconciled billed cost.
+
+## Design Principles
+
+1. Normalize usage before pricing.
+2. Never fold cached tokens into plain input cost.
+3. Track certainty explicitly.
+4. Treat the billing path as part of the model identity.
+5. Prefer official machine-readable sources over scraped docs.
+6. Use post-hoc provider cost APIs when available.
+7. Show `n/a` rather than inventing precision.
+
+## High-Level Architecture
+
+The new system has four layers:
+
+1. `usage_normalization`
+ Converts raw provider usage into a canonical usage record.
+2. `pricing_source_resolution`
+ Determines the billing path, source of truth, and applicable pricing source.
+3. `cost_estimation_and_reconciliation`
+ Produces an immediate estimate when possible, then replaces or annotates it with actual billed cost later.
+4. `presentation`
+ `/usage`, `/insights`, and the status bar display cost with certainty metadata.
+
+## Canonical Usage Record
+
+Add a canonical usage model that every provider path maps into before any pricing math happens.
+
+Suggested structure:
+
+```python
+@dataclass
+class CanonicalUsage:
+ provider: str
+ billing_provider: str
+ model: str
+ billing_route: str
+
+ input_tokens: int = 0
+ output_tokens: int = 0
+ cache_read_tokens: int = 0
+ cache_write_tokens: int = 0
+ reasoning_tokens: int = 0
+ request_count: int = 1
+
+ raw_usage: dict[str, Any] | None = None
+ raw_usage_fields: dict[str, str] | None = None
+ computed_fields: set[str] | None = None
+
+ provider_request_id: str | None = None
+ provider_generation_id: str | None = None
+ provider_response_id: str | None = None
+```
+
+Rules:
+
+- `input_tokens` means non-cached input only.
+- `cache_read_tokens` and `cache_write_tokens` are never merged into `input_tokens`.
+- `output_tokens` excludes cache metrics.
+- `reasoning_tokens` is telemetry unless a provider officially bills it separately.
+
+This is the same normalization pattern used by `opencode`, extended with provenance and reconciliation ids.
+
+## Provider Normalization Rules
+
+### OpenAI Direct
+
+Source usage fields:
+
+- `prompt_tokens`
+- `completion_tokens`
+- `prompt_tokens_details.cached_tokens`
+
+Normalization:
+
+- `cache_read_tokens = cached_tokens`
+- `input_tokens = prompt_tokens - cached_tokens`
+- `cache_write_tokens = 0` unless OpenAI exposes it in the relevant route
+- `output_tokens = completion_tokens`
+
+### Anthropic Direct
+
+Source usage fields:
+
+- `input_tokens`
+- `output_tokens`
+- `cache_read_input_tokens`
+- `cache_creation_input_tokens`
+
+Normalization:
+
+- `input_tokens = input_tokens`
+- `output_tokens = output_tokens`
+- `cache_read_tokens = cache_read_input_tokens`
+- `cache_write_tokens = cache_creation_input_tokens`
+
+### OpenRouter
+
+Estimate-time usage normalization should use the response usage payload with the same rules as the underlying provider when possible.
+
+Reconciliation-time records should also store:
+
+- OpenRouter generation id
+- native token fields when available
+- `total_cost`
+- `cache_discount`
+- `upstream_inference_cost`
+- `is_byok`
+
+### Gemini / Vertex
+
+Use official Gemini or Vertex usage fields where available.
+
+If cached content tokens are exposed:
+
+- map them to `cache_read_tokens`
+
+If a route exposes no cache creation metric:
+
+- store `cache_write_tokens = 0`
+- preserve the raw usage payload for later extension
+
+### DeepSeek And Other Direct Providers
+
+Normalize only the fields that are officially exposed.
+
+If a provider does not expose cache buckets:
+
+- do not infer them unless the provider explicitly documents how to derive them
+
+### Subscription / Included-Cost Routes
+
+These still use the canonical usage model.
+
+Tokens are tracked normally. Cost depends on billing mode, not on whether usage exists.
+
+## Billing Route Model
+
+Hermes must stop keying pricing solely by `model`.
+
+Introduce a billing route descriptor:
+
+```python
+@dataclass
+class BillingRoute:
+ provider: str
+ base_url: str | None
+ model: str
+ billing_mode: str
+ organization_hint: str | None = None
+```
+
+`billing_mode` values:
+
+- `official_cost_api`
+- `official_generation_api`
+- `official_models_api`
+- `official_docs_snapshot`
+- `subscription_included`
+- `user_override`
+- `custom_contract`
+- `unknown`
+
+Examples:
+
+- OpenAI direct API with Costs API access: `official_cost_api`
+- Anthropic direct API with Usage & Cost API access: `official_cost_api`
+- OpenRouter request before reconciliation: `official_models_api`
+- OpenRouter request after generation lookup: `official_generation_api`
+- GitHub Copilot style subscription route: `subscription_included`
+- local OpenAI-compatible server: `unknown`
+- enterprise contract with configured rates: `custom_contract`
+
+## Cost Status Model
+
+Every displayed cost should have:
+
+```python
+@dataclass
+class CostResult:
+ amount_usd: Decimal | None
+ status: Literal["actual", "estimated", "included", "unknown"]
+ source: Literal[
+ "provider_cost_api",
+ "provider_generation_api",
+ "provider_models_api",
+ "official_docs_snapshot",
+ "user_override",
+ "custom_contract",
+ "none",
+ ]
+ label: str
+ fetched_at: datetime | None
+ pricing_version: str | None
+ notes: list[str]
+```
+
+Presentation rules:
+
+- `actual`: show dollar amount as final
+- `estimated`: show dollar amount with estimate labeling
+- `included`: show `included` or `$0.00 (included)` depending on UX choice
+- `unknown`: show `n/a`
+
+## Official Source Hierarchy
+
+Resolve cost using this order:
+
+1. Request-level or account-level official billed cost
+2. Official machine-readable model pricing
+3. Official docs snapshot
+4. User override or custom contract
+5. Unknown
+
+The system must never skip to a lower level if a higher-confidence source exists for the current billing route.
+
+## Provider-Specific Truth Rules
+
+### OpenAI Direct
+
+Preferred truth:
+
+1. Costs API for reconciled spend
+2. Official pricing page for live estimate
+
+### Anthropic Direct
+
+Preferred truth:
+
+1. Usage & Cost API for reconciled spend
+2. Official pricing docs for live estimate
+
+### OpenRouter
+
+Preferred truth:
+
+1. `GET /api/v1/generation` for reconciled `total_cost`
+2. `GET /api/v1/models` pricing for live estimate
+
+Do not use underlying provider public pricing as the source of truth for OpenRouter billing.
+
+### Gemini / Vertex
+
+Preferred truth:
+
+1. official billing export or billing API for reconciled spend when available for the route
+2. official pricing docs for estimate
+
+### DeepSeek
+
+Preferred truth:
+
+1. official machine-readable cost source if available in the future
+2. official pricing docs snapshot today
+
+### Subscription-Included Routes
+
+Preferred truth:
+
+1. explicit route config marking the model as included in subscription
+
+These should display `included`, not an API list-price estimate.
+
+### Custom Endpoint / Local Model
+
+Preferred truth:
+
+1. user override
+2. custom contract config
+3. unknown
+
+These should default to `unknown`.
+
+## Pricing Catalog
+
+Replace the current `MODEL_PRICING` dict with a richer pricing catalog.
+
+Suggested record:
+
+```python
+@dataclass
+class PricingEntry:
+ provider: str
+ route_pattern: str
+ model_pattern: str
+
+ input_cost_per_million: Decimal | None = None
+ output_cost_per_million: Decimal | None = None
+ cache_read_cost_per_million: Decimal | None = None
+ cache_write_cost_per_million: Decimal | None = None
+ request_cost: Decimal | None = None
+ image_cost: Decimal | None = None
+
+ source: str = "official_docs_snapshot"
+ source_url: str | None = None
+ fetched_at: datetime | None = None
+ pricing_version: str | None = None
+```
+
+The catalog should be route-aware:
+
+- `openai:gpt-5`
+- `anthropic:claude-opus-4-6`
+- `openrouter:anthropic/claude-opus-4.6`
+- `copilot:gpt-4o`
+
+This avoids conflating direct-provider billing with aggregator billing.
+
+## Pricing Sync Architecture
+
+Introduce a pricing sync subsystem instead of manually maintaining a single hardcoded table.
+
+Suggested modules:
+
+- `agent/pricing/catalog.py`
+- `agent/pricing/sources.py`
+- `agent/pricing/sync.py`
+- `agent/pricing/reconcile.py`
+- `agent/pricing/types.py`
+
+### Sync Sources
+
+- OpenRouter models API
+- official provider docs snapshots where no API exists
+- user overrides from config
+
+### Sync Output
+
+Cache pricing entries locally with:
+
+- source URL
+- fetch timestamp
+- version/hash
+- confidence/source type
+
+### Sync Frequency
+
+- startup warm cache
+- background refresh every 6 to 24 hours depending on source
+- manual `hermes pricing sync`
+
+## Reconciliation Architecture
+
+Live requests may produce only an estimate initially. Hermes should reconcile them later when a provider exposes actual billed cost.
+
+Suggested flow:
+
+1. Agent call completes.
+2. Hermes stores canonical usage plus reconciliation ids.
+3. Hermes computes an immediate estimate if a pricing source exists.
+4. A reconciliation worker fetches actual cost when supported.
+5. Session and message records are updated with `actual` cost.
+
+This can run:
+
+- inline for cheap lookups
+- asynchronously for delayed provider accounting
+
+## Persistence Changes
+
+Session storage should stop storing only aggregate prompt/completion totals.
+
+Add fields for both usage and cost certainty:
+
+- `input_tokens`
+- `output_tokens`
+- `cache_read_tokens`
+- `cache_write_tokens`
+- `reasoning_tokens`
+- `estimated_cost_usd`
+- `actual_cost_usd`
+- `cost_status`
+- `cost_source`
+- `pricing_version`
+- `billing_provider`
+- `billing_mode`
+
+If schema expansion is too large for one PR, add a new pricing events table:
+
+```text
+session_cost_events
+ id
+ session_id
+ request_id
+ provider
+ model
+ billing_mode
+ input_tokens
+ output_tokens
+ cache_read_tokens
+ cache_write_tokens
+ estimated_cost_usd
+ actual_cost_usd
+ cost_status
+ cost_source
+ pricing_version
+ created_at
+ updated_at
+```
+
+## Hermes Touchpoints
+
+### `run_agent.py`
+
+Current responsibility:
+
+- parse raw provider usage
+- update session token counters
+
+New responsibility:
+
+- build `CanonicalUsage`
+- update canonical counters
+- store reconciliation ids
+- emit usage event to pricing subsystem
+
+### `agent/usage_pricing.py`
+
+Current responsibility:
+
+- static lookup table
+- direct cost arithmetic
+
+New responsibility:
+
+- move or replace with pricing catalog facade
+- no fuzzy model-family heuristics
+- no direct pricing without billing-route context
+
+### `cli.py`
+
+Current responsibility:
+
+- compute session cost directly from prompt/completion totals
+
+New responsibility:
+
+- display `CostResult`
+- show status badges:
+ - `actual`
+ - `estimated`
+ - `included`
+ - `n/a`
+
+### `agent/insights.py`
+
+Current responsibility:
+
+- recompute historical estimates from static pricing
+
+New responsibility:
+
+- aggregate stored pricing events
+- prefer actual cost over estimate
+- surface estimates only when reconciliation is unavailable
+
+## UX Rules
+
+### Status Bar
+
+Show one of:
+
+- `$1.42`
+- `~$1.42`
+- `included`
+- `cost n/a`
+
+Where:
+
+- `$1.42` means `actual`
+- `~$1.42` means `estimated`
+- `included` means subscription-backed or explicitly zero-cost route
+- `cost n/a` means unknown
+
+### `/usage`
+
+Show:
+
+- token buckets
+- estimated cost
+- actual cost if available
+- cost status
+- pricing source
+
+### `/insights`
+
+Aggregate:
+
+- actual cost totals
+- estimated-only totals
+- unknown-cost sessions count
+- included-cost sessions count
+
+## Config And Overrides
+
+Add user-configurable pricing overrides in config:
+
+```yaml
+pricing:
+ mode: hybrid
+ sync_on_startup: true
+ sync_interval_hours: 12
+ overrides:
+ - provider: openrouter
+ model: anthropic/claude-opus-4.6
+ billing_mode: custom_contract
+ input_cost_per_million: 4.25
+ output_cost_per_million: 22.0
+ cache_read_cost_per_million: 0.5
+ cache_write_cost_per_million: 6.0
+ included_routes:
+ - provider: copilot
+ model: "*"
+ - provider: codex-subscription
+ model: "*"
+```
+
+Overrides must win over catalog defaults for the matching billing route.
+
+## Rollout Plan
+
+### Phase 1
+
+- add canonical usage model
+- split cache token buckets in `run_agent.py`
+- stop pricing cache-inflated prompt totals
+- preserve current UI with improved backend math
+
+### Phase 2
+
+- add route-aware pricing catalog
+- integrate OpenRouter models API sync
+- add `estimated` vs `included` vs `unknown`
+
+### Phase 3
+
+- add reconciliation for OpenRouter generation cost
+- add actual cost persistence
+- update `/insights` to prefer actual cost
+
+### Phase 4
+
+- add direct OpenAI and Anthropic reconciliation paths
+- add user overrides and contract pricing
+- add pricing sync CLI command
+
+## Testing Strategy
+
+Add tests for:
+
+- OpenAI cached token subtraction
+- Anthropic cache read/write separation
+- OpenRouter estimated vs actual reconciliation
+- subscription-backed models showing `included`
+- custom endpoints showing `n/a`
+- override precedence
+- stale catalog fallback behavior
+
+Current tests that assume heuristic pricing should be replaced with route-aware expectations.
+
+## Non-Goals
+
+- exact enterprise billing reconstruction without an official source or user override
+- backfilling perfect historical cost for old sessions that lack cache bucket data
+- scraping arbitrary provider web pages at request time
+
+## Recommendation
+
+Do not expand the existing `MODEL_PRICING` dict.
+
+That path cannot satisfy the product requirement. Hermes should instead migrate to:
+
+- canonical usage normalization
+- route-aware pricing sources
+- estimate-then-reconcile cost lifecycle
+- explicit certainty states in the UI
+
+This is the minimum architecture that makes the statement "Hermes pricing is backed by official sources where possible, and otherwise clearly labeled" defensible.
diff --git a/environments/README.md b/environments/README.md
index 7e5896f779e..f2d1a795604 100644
--- a/environments/README.md
+++ b/environments/README.md
@@ -101,7 +101,7 @@ Available methods:
### Patches (`patches.py`)
-**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., mini-swe-agent's Modal backend via SWE-ReX). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested.
+**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend via SWE-ReX). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested.
**Solution**: `patches.py` monkey-patches `SwerexModalEnvironment` to use a dedicated background thread (`_AsyncWorker`) with its own event loop. The calling code sees the same sync interface, but internally the async work happens on a separate thread that doesn't conflict with Atropos's loop.
diff --git a/environments/agent_loop.py b/environments/agent_loop.py
index ab8c0236e65..11a8a01f3a9 100644
--- a/environments/agent_loop.py
+++ b/environments/agent_loop.py
@@ -23,7 +23,7 @@
from model_tools import handle_function_call
# Thread pool for running sync tool calls that internally use asyncio.run()
-# (e.g., mini-swe-agent's modal/docker/daytona backends). Running them in a separate
+# (e.g., the Modal/Docker/Daytona terminal backends). Running them in a separate
# thread gives them a clean event loop so they don't deadlock inside Atropos's loop.
# Size must be large enough for concurrent eval tasks (e.g., 89 TB2 tasks all
# making tool calls). Too small = thread pool starvation, tasks queue for minutes.
@@ -39,7 +39,9 @@ def resize_tool_pool(max_workers: int):
Safe to call before any tasks are submitted.
"""
global _tool_executor
+ old_executor = _tool_executor
_tool_executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers)
+ old_executor.shutdown(wait=False)
logger.info("Tool thread pool resized to %d workers", max_workers)
logger = logging.getLogger(__name__)
@@ -344,79 +346,90 @@ def _tc_to_dict(tc):
tool_name, turn + 1,
)
else:
- # Parse arguments and dispatch
+ # Parse arguments
try:
args = json.loads(tool_args_raw)
- except json.JSONDecodeError:
- args = {}
- logger.warning(
- "Invalid JSON in tool call arguments for '%s': %s",
- tool_name, tool_args_raw[:200],
- )
-
- try:
- if tool_name == "terminal":
- backend = os.getenv("TERMINAL_ENV", "local")
- cmd_preview = args.get("command", "")[:80]
- logger.info(
- "[%s] $ %s", self.task_id[:8], cmd_preview,
- )
-
- tool_submit_time = _time.monotonic()
-
- # Todo tool -- handle locally (needs per-loop TodoStore)
- if tool_name == "todo":
- tool_result = _todo_tool(
- todos=args.get("todos"),
- merge=args.get("merge", False),
- store=_todo_store,
- )
- tool_elapsed = _time.monotonic() - tool_submit_time
- elif tool_name == "memory":
- tool_result = json.dumps({"error": "Memory is not available in RL environments."})
- tool_elapsed = _time.monotonic() - tool_submit_time
- elif tool_name == "session_search":
- tool_result = json.dumps({"error": "Session search is not available in RL environments."})
- tool_elapsed = _time.monotonic() - tool_submit_time
- else:
- # Run tool calls in a thread pool so backends that
- # use asyncio.run() internally (modal, docker, daytona) get
- # a clean event loop instead of deadlocking.
- loop = asyncio.get_event_loop()
- # Capture current tool_name/args for the lambda
- _tn, _ta, _tid = tool_name, args, self.task_id
- tool_result = await loop.run_in_executor(
- _tool_executor,
- lambda: handle_function_call(
- _tn, _ta, task_id=_tid,
- user_task=_user_task,
- ),
- )
- tool_elapsed = _time.monotonic() - tool_submit_time
-
- # Log slow tools and thread pool stats for debugging
- pool_active = _tool_executor._work_queue.qsize()
- if tool_elapsed > 30:
- logger.warning(
- "[%s] turn %d: %s took %.1fs (pool queue=%d)",
- self.task_id[:8], turn + 1, tool_name,
- tool_elapsed, pool_active,
- )
- except Exception as e:
+ except json.JSONDecodeError as e:
+ args = None
tool_result = json.dumps(
- {"error": f"Tool execution failed: {type(e).__name__}: {str(e)}"}
+ {"error": f"Invalid JSON in tool arguments: {e}. Please retry with valid JSON."}
)
tool_errors.append(ToolError(
turn=turn + 1, tool_name=tool_name,
arguments=tool_args_raw[:200],
- error=f"{type(e).__name__}: {str(e)}",
+ error=f"Invalid JSON: {e}",
tool_result=tool_result,
))
- logger.error(
- "Tool '%s' execution failed on turn %d: %s",
- tool_name, turn + 1, e,
+ logger.warning(
+ "Invalid JSON in tool call arguments for '%s': %s",
+ tool_name, tool_args_raw[:200],
)
+ # Dispatch tool only if arguments parsed successfully
+ if args is not None:
+ try:
+ if tool_name == "terminal":
+ backend = os.getenv("TERMINAL_ENV", "local")
+ cmd_preview = args.get("command", "")[:80]
+ logger.info(
+ "[%s] $ %s", self.task_id[:8], cmd_preview,
+ )
+
+ tool_submit_time = _time.monotonic()
+
+ # Todo tool -- handle locally (needs per-loop TodoStore)
+ if tool_name == "todo":
+ tool_result = _todo_tool(
+ todos=args.get("todos"),
+ merge=args.get("merge", False),
+ store=_todo_store,
+ )
+ tool_elapsed = _time.monotonic() - tool_submit_time
+ elif tool_name == "memory":
+ tool_result = json.dumps({"error": "Memory is not available in RL environments."})
+ tool_elapsed = _time.monotonic() - tool_submit_time
+ elif tool_name == "session_search":
+ tool_result = json.dumps({"error": "Session search is not available in RL environments."})
+ tool_elapsed = _time.monotonic() - tool_submit_time
+ else:
+ # Run tool calls in a thread pool so backends that
+ # use asyncio.run() internally (modal, docker, daytona) get
+ # a clean event loop instead of deadlocking.
+ loop = asyncio.get_event_loop()
+ # Capture current tool_name/args for the lambda
+ _tn, _ta, _tid = tool_name, args, self.task_id
+ tool_result = await loop.run_in_executor(
+ _tool_executor,
+ lambda: handle_function_call(
+ _tn, _ta, task_id=_tid,
+ user_task=_user_task,
+ ),
+ )
+ tool_elapsed = _time.monotonic() - tool_submit_time
+
+ # Log slow tools and thread pool stats for debugging
+ pool_active = _tool_executor._work_queue.qsize()
+ if tool_elapsed > 30:
+ logger.warning(
+ "[%s] turn %d: %s took %.1fs (pool queue=%d)",
+ self.task_id[:8], turn + 1, tool_name,
+ tool_elapsed, pool_active,
+ )
+ except Exception as e:
+ tool_result = json.dumps(
+ {"error": f"Tool execution failed: {type(e).__name__}: {str(e)}"}
+ )
+ tool_errors.append(ToolError(
+ turn=turn + 1, tool_name=tool_name,
+ arguments=tool_args_raw[:200],
+ error=f"{type(e).__name__}: {str(e)}",
+ tool_result=tool_result,
+ ))
+ logger.error(
+ "Tool '%s' execution failed on turn %d: %s",
+ tool_name, turn + 1, e,
+ )
+
# Also check if the tool returned an error in its JSON result
try:
result_data = json.loads(tool_result)
diff --git a/environments/agentic_opd_env.py b/environments/agentic_opd_env.py
new file mode 100644
index 00000000000..b9627123756
--- /dev/null
+++ b/environments/agentic_opd_env.py
@@ -0,0 +1,1213 @@
+"""
+AgenticOPDEnv โ On-Policy Distillation for Agentic Tool-Calling Tasks
+=====================================================================
+
+First Atropos environment to populate the distill_token_ids / distill_logprobs
+fields on ScoredDataGroup, enabling on-policy distillation (OPD) training.
+
+Key idea (from OpenClaw-RL, Princeton 2026):
+ Every time an agent receives a next-state signal (tool result, error trace,
+ test verdict), that signal contains hindsight information about how the
+ agent's PREVIOUS response could have been better. This environment:
+
+ 1. Runs standard agentic rollouts (tool-calling agent loop)
+ 2. Walks the conversation to find (assistant_turn, next_state) pairs
+ 3. Uses an LLM judge to extract "hints" from next-state signals
+ 4. Builds an enhanced prompt (original context + hint)
+ 5. Scores the student's response tokens under the enhanced distribution
+ using VLLM's prompt_logprobs (via Atropos's get_logprobs API)
+ 6. Packages the teacher's top-K predictions as distill_token_ids /
+ distill_logprobs on the ScoredDataGroup
+
+The trainer then computes per-token advantages:
+ A_t = teacher_logprob(token_t) - student_logprob(token_t)
+ Positive โ teacher approves this token (upweight)
+ Negative โ teacher disapproves (downweight)
+
+This gives dense, token-level training signal from every tool interaction,
+instead of just a scalar reward at the end of the trajectory.
+
+Task: Coding tasks with test verification (rich next-state signals from
+test results, error messages, terminal output). Falls back to built-in
+coding problems if no HuggingFace dataset is configured.
+
+Requirements:
+ - VLLM backend (server_type: vllm) โ needed for prompt logprob scoring
+ - Phase 2 mode (ManagedServer) โ needed for token-level tracking
+
+Usage:
+ # Process mode (offline data generation with OPD)
+ python environments/agentic_opd_env.py process \\
+ --env.total_steps 10 --env.group_size 2 \\
+ --env.data_path_to_save_groups output.jsonl \\
+ --openai.base_url http://localhost:8000/v1 \\
+ --openai.model_name Qwen/Qwen3-4B
+
+ # Serve mode (connected to Atropos trainer)
+ python environments/agentic_opd_env.py serve \\
+ --openai.base_url http://localhost:8000/v1 \\
+ --openai.model_name Qwen/Qwen3-4B
+
+ # Evaluate mode
+ python environments/agentic_opd_env.py evaluate \\
+ --env.eval_size 10 \\
+ --openai.base_url http://localhost:8000/v1 \\
+ --openai.model_name Qwen/Qwen3-4B
+
+Reference: Wang et al., "OpenClaw-RL: Train Any Agent Simply by Talking"
+ arXiv:2603.10165, March 2026
+"""
+
+from __future__ import annotations
+
+import asyncio
+import copy
+import json
+import logging
+import os
+import random
+import re
+import sys
+import time
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
+
+from pydantic import Field
+
+# Ensure hermes-agent root is on path
+_repo_root = Path(__file__).resolve().parent.parent
+if str(_repo_root) not in sys.path:
+ sys.path.insert(0, str(_repo_root))
+
+from atroposlib.envs.base import ScoredDataGroup, ScoredDataItem
+from atroposlib.envs.server_handling.server_manager import APIServerConfig
+from atroposlib.type_definitions import Item
+
+from environments.hermes_base_env import HermesAgentBaseEnv, HermesAgentEnvConfig
+from environments.agent_loop import AgentResult, HermesAgentLoop
+from environments.tool_context import ToolContext
+
+logger = logging.getLogger(__name__)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Built-in coding tasks (fallback when no HF dataset is configured)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+BUILTIN_CODING_TASKS = [
+ {
+ "task": "Write a Python function `fizzbuzz(n)` that returns a list of strings from 1 to n. "
+ "For multiples of 3 return 'Fizz', for multiples of 5 return 'Buzz', "
+ "for multiples of both return 'FizzBuzz', otherwise the number as a string.",
+ "test_code": (
+ "from solution import fizzbuzz\n"
+ "assert fizzbuzz(15) == ['1','2','Fizz','4','Buzz','Fizz','7','8','Fizz','Buzz','11','Fizz','13','14','FizzBuzz']\n"
+ "assert fizzbuzz(1) == ['1']\n"
+ "assert fizzbuzz(0) == []\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "easy",
+ },
+ {
+ "task": "Write a Python function `is_palindrome(s)` that checks if a string is a palindrome, "
+ "ignoring case and non-alphanumeric characters. Return True or False.",
+ "test_code": (
+ "from solution import is_palindrome\n"
+ "assert is_palindrome('A man, a plan, a canal: Panama') == True\n"
+ "assert is_palindrome('race a car') == False\n"
+ "assert is_palindrome('') == True\n"
+ "assert is_palindrome('Was it a car or a cat I saw?') == True\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "easy",
+ },
+ {
+ "task": "Write a Python function `two_sum(nums, target)` that returns the indices of the two "
+ "numbers in `nums` that add up to `target`. Assume exactly one solution exists. "
+ "Return a list of two indices [i, j] where i < j.",
+ "test_code": (
+ "from solution import two_sum\n"
+ "assert two_sum([2, 7, 11, 15], 9) == [0, 1]\n"
+ "assert two_sum([3, 2, 4], 6) == [1, 2]\n"
+ "assert two_sum([3, 3], 6) == [0, 1]\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "easy",
+ },
+ {
+ "task": "Write a Python function `flatten(lst)` that takes an arbitrarily nested list and "
+ "returns a flat list of all elements. For example, flatten([1, [2, [3, 4], 5]]) "
+ "should return [1, 2, 3, 4, 5].",
+ "test_code": (
+ "from solution import flatten\n"
+ "assert flatten([1, [2, [3, 4], 5]]) == [1, 2, 3, 4, 5]\n"
+ "assert flatten([]) == []\n"
+ "assert flatten([1, 2, 3]) == [1, 2, 3]\n"
+ "assert flatten([[[[1]]]]) == [1]\n"
+ "assert flatten([1, [2], [[3]], [[[4]]]]) == [1, 2, 3, 4]\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "medium",
+ },
+ {
+ "task": "Write a Python function `longest_common_prefix(strs)` that finds the longest "
+ "common prefix string amongst a list of strings. If there is no common prefix, "
+ "return an empty string.",
+ "test_code": (
+ "from solution import longest_common_prefix\n"
+ "assert longest_common_prefix(['flower', 'flow', 'flight']) == 'fl'\n"
+ "assert longest_common_prefix(['dog', 'racecar', 'car']) == ''\n"
+ "assert longest_common_prefix(['interspecies', 'interstellar', 'interstate']) == 'inters'\n"
+ "assert longest_common_prefix(['a']) == 'a'\n"
+ "assert longest_common_prefix([]) == ''\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "easy",
+ },
+ {
+ "task": "Write a Python function `group_anagrams(strs)` that groups anagrams together. "
+ "Return a list of lists, where each inner list contains strings that are anagrams of "
+ "each other. The order of groups and strings within groups does not matter.",
+ "test_code": (
+ "from solution import group_anagrams\n"
+ "result = group_anagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat'])\n"
+ "result_sorted = sorted([sorted(g) for g in result])\n"
+ "assert result_sorted == [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]\n"
+ "assert group_anagrams([]) == []\n"
+ "assert group_anagrams(['a']) == [['a']]\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "medium",
+ },
+ {
+ "task": "Write a Python function `valid_parentheses(s)` that determines if a string "
+ "containing just '(', ')', '{', '}', '[' and ']' is valid. A string is valid if "
+ "open brackets are closed by the same type and in the correct order.",
+ "test_code": (
+ "from solution import valid_parentheses\n"
+ "assert valid_parentheses('()') == True\n"
+ "assert valid_parentheses('()[]{}') == True\n"
+ "assert valid_parentheses('(]') == False\n"
+ "assert valid_parentheses('([)]') == False\n"
+ "assert valid_parentheses('{[]}') == True\n"
+ "assert valid_parentheses('') == True\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "easy",
+ },
+ {
+ "task": "Write a Python function `merge_intervals(intervals)` that merges overlapping "
+ "intervals. Each interval is a list [start, end]. Return the merged intervals sorted "
+ "by start time.",
+ "test_code": (
+ "from solution import merge_intervals\n"
+ "assert merge_intervals([[1,3],[2,6],[8,10],[15,18]]) == [[1,6],[8,10],[15,18]]\n"
+ "assert merge_intervals([[1,4],[4,5]]) == [[1,5]]\n"
+ "assert merge_intervals([[1,4],[0,4]]) == [[0,4]]\n"
+ "assert merge_intervals([]) == []\n"
+ "assert merge_intervals([[1,2]]) == [[1,2]]\n"
+ "print('All tests passed!')\n"
+ ),
+ "difficulty": "medium",
+ },
+]
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Hint extraction prompts (adapted from OpenClaw-RL)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_HINT_JUDGE_SYSTEM = (
+ "You are a process reward model used for hindsight hint extraction.\n"
+ "You are given:\n"
+ "1) The assistant response at turn t.\n"
+ "2) The next state at turn t+1, along with its **role**.\n\n"
+ "## Understanding the next state's role\n"
+ "- role='user': A reply from the user (follow-up, correction, new request, etc.).\n"
+ "- role='tool': The return value of a tool the assistant invoked. "
+ "This content was NOT available before the assistant's action โ "
+ "it exists BECAUSE the assistant called the tool. "
+ "A successful, non-error tool output generally means the assistant's "
+ "action was appropriate; do NOT treat it as information the assistant "
+ "should have already known.\n\n"
+ "Your goal is to decide whether the next state reveals useful hindsight information\n"
+ "that could have helped improve the assistant response at turn t.\n\n"
+ "Output format rules (strict):\n"
+ "- You MUST include exactly one final decision token: \\boxed{1} or \\boxed{-1}.\n"
+ "- If and only if decision is \\boxed{1}, provide a concise, information-dense hint in 1-3 sentences,\n"
+ " wrapped between [HINT_START] and [HINT_END].\n"
+ "- If decision is \\boxed{-1}, do not provide a hint block.\n"
+ "- Hint must be concrete and actionable for improving the previous response."
+)
+
+_BOXED_RE = re.compile(r"\\boxed\{(-?\d+)\}")
+_HINT_RE = re.compile(r"\[HINT_START\](.*?)\[HINT_END\]", re.DOTALL)
+
+
+def _build_hint_judge_messages(
+ response_text: str, next_state_text: str, next_state_role: str = "tool"
+) -> list[dict]:
+ """Build messages for the hint extraction judge."""
+ user = (
+ f"## Assistant response (turn t)\n{response_text}\n\n"
+ f"## Next state (turn t+1) [role: {next_state_role}]\n{next_state_text}\n\n"
+ "Now output your decision and (if positive) the hint in the required format."
+ )
+ return [
+ {"role": "system", "content": _HINT_JUDGE_SYSTEM},
+ {"role": "user", "content": user},
+ ]
+
+
+def _parse_hint_result(text: str) -> tuple[int | None, str]:
+ """Parse the judge's boxed decision and hint text."""
+ boxed = _BOXED_RE.findall(text)
+ score = int(boxed[-1]) if boxed else None
+ if score not in (1, -1):
+ score = None
+ hint_matches = _HINT_RE.findall(text)
+ hint = hint_matches[-1].strip() if hint_matches else ""
+ return score, hint
+
+
+def _select_best_hint(votes: list[dict]) -> dict | None:
+ """Select the best hint from majority-voted judge results."""
+ good = [
+ v
+ for v in votes
+ if v.get("score") == 1
+ and isinstance(v.get("hint"), str)
+ and len(v["hint"].strip()) > 10
+ ]
+ if not good:
+ return None
+ return max(good, key=lambda v: len(v["hint"].strip()))
+
+
+def _append_hint_to_messages(messages: list[dict], hint: str) -> list[dict]:
+ """Clone messages and append hint to the last user message."""
+ cloned = copy.deepcopy(messages)
+ if not cloned:
+ return [{"role": "user", "content": f"[user's hint / instruction]\n{hint}"}]
+
+ # Find last user message
+ target_idx = None
+ for i in range(len(cloned) - 1, -1, -1):
+ if cloned[i].get("role") == "user":
+ target_idx = i
+ break
+ if target_idx is None:
+ target_idx = len(cloned) - 1
+
+ content = cloned[target_idx].get("content", "")
+ if isinstance(content, list):
+ content = " ".join(
+ c.get("text", "") if isinstance(c, dict) else str(c) for c in content
+ )
+ suffix = f"\n\n[user's hint / instruction]\n{hint.strip()}"
+ cloned[target_idx]["content"] = (content + suffix).strip()
+ return cloned
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Configuration
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+class AgenticOPDConfig(HermesAgentEnvConfig):
+ """Configuration for the agentic OPD environment."""
+
+ # --- OPD settings ---
+ opd_enabled: bool = Field(
+ default=True,
+ description="Enable on-policy distillation pipeline. When disabled, "
+ "the environment behaves like a standard agentic env (no distill fields).",
+ )
+ distill_topk: int = Field(
+ default=50,
+ description="Number of top-K teacher logprobs per position for distillation.",
+ )
+ prm_votes: int = Field(
+ default=3,
+ description="Number of independent judge queries for majority-voted hint extraction.",
+ )
+ hint_max_next_state_chars: int = Field(
+ default=4000,
+ description="Maximum characters of next-state text to include in the hint judge prompt. "
+ "Tool results can be very long โ truncating prevents judge context overflow.",
+ )
+
+ # --- Reward settings ---
+ correctness_weight: float = Field(
+ default=0.7,
+ description="Weight for test pass/fail in reward.",
+ )
+ efficiency_weight: float = Field(
+ default=0.15,
+ description="Weight for efficiency (fewer turns = better).",
+ )
+ tool_usage_weight: float = Field(
+ default=0.15,
+ description="Weight for appropriate tool usage signal.",
+ )
+
+ # --- Dataset ---
+ dataset_name: Optional[str] = Field(
+ default=None,
+ description="HuggingFace dataset with coding tasks. "
+ "Expected fields: 'task' (problem description) and 'test_code' (pytest/assert tests). "
+ "Falls back to built-in tasks if not set or unavailable.",
+ )
+
+ # --- Eval ---
+ eval_size: int = Field(
+ default=10,
+ description="Number of held-out items for evaluation.",
+ )
+ eval_split_ratio: float = Field(
+ default=0.15,
+ description="Fraction of dataset to hold out for evaluation.",
+ )
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Environment
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+class AgenticOPDEnv(HermesAgentBaseEnv):
+ """
+ RL environment with on-policy distillation from next-state signals.
+
+ Runs coding tasks where the agent writes code and runs tests.
+ Tool results (test pass/fail, error traces) serve as next-state signals
+ for hint extraction and teacher logprob scoring.
+
+ This is the first Atropos environment to populate distill_token_ids
+ and distill_logprobs on ScoredDataGroup for OPD training.
+ """
+
+ name = "agentic-opd"
+ env_config_cls = AgenticOPDConfig
+
+ # Default toolsets: terminal for running code, file for writing it
+ default_toolsets = ["terminal", "file"]
+
+ @classmethod
+ def config_init(cls) -> Tuple[AgenticOPDConfig, List[APIServerConfig]]:
+ """Default configuration."""
+ env_config = AgenticOPDConfig(
+ # Toolsets
+ enabled_toolsets=["terminal", "file"],
+ # Agent loop
+ max_agent_turns=15,
+ agent_temperature=1.0,
+ system_prompt=(
+ "You are a skilled Python programmer. When given a coding task:\n"
+ "1. Write the solution to a file called 'solution.py'\n"
+ "2. Write the test code to a file called 'test_solution.py'\n"
+ "3. Run the tests with: python test_solution.py\n"
+ "4. If tests fail, read the error output carefully, fix your code, and re-run\n"
+ "5. Once all tests pass, report success\n\n"
+ "Be efficient โ write clean code and fix errors methodically."
+ ),
+ # OPD
+ opd_enabled=True,
+ distill_topk=50,
+ prm_votes=3,
+ # Training
+ group_size=4,
+ total_steps=500,
+ steps_per_eval=50,
+ use_wandb=True,
+ wandb_name="agentic-opd",
+ )
+
+ server_configs = [
+ APIServerConfig(
+ base_url="http://localhost:8000/v1",
+ model_name="Qwen/Qwen3-4B",
+ server_type="vllm",
+ )
+ ]
+
+ return env_config, server_configs
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._items: list[dict] = []
+ self._eval_items: list[dict] = []
+ self._index: int = 0
+
+ # Metric buffers
+ self._reward_buffer: list[float] = []
+ self._correctness_buffer: list[float] = []
+ self._efficiency_buffer: list[float] = []
+ self._tool_usage_buffer: list[float] = []
+ self._hints_extracted_buffer: list[int] = []
+ self._opd_turns_scored_buffer: list[int] = []
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 1. setup โ load dataset
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def setup(self) -> None:
+ """Load coding tasks from HuggingFace or use built-in set."""
+ if self.config.dataset_name:
+ try:
+ from datasets import load_dataset
+
+ logger.info(
+ "Loading dataset '%s'...", self.config.dataset_name
+ )
+ ds = load_dataset(
+ self.config.dataset_name, split=self.config.dataset_split
+ )
+ task_field = self.config.prompt_field
+ self._items = [
+ {
+ "task": row.get(task_field, row.get("task", "")),
+ "test_code": row.get("test_code", row.get("tests", "")),
+ "difficulty": row.get("difficulty", "unknown"),
+ }
+ for row in ds
+ if row.get(task_field, row.get("task", ""))
+ ]
+ if self._items:
+ random.shuffle(self._items)
+ eval_size = max(
+ self.config.eval_size,
+ int(len(self._items) * self.config.eval_split_ratio),
+ )
+ self._eval_items = self._items[:eval_size]
+ self._items = self._items[eval_size:]
+ logger.info(
+ "Loaded %d train / %d eval items from '%s'",
+ len(self._items),
+ len(self._eval_items),
+ self.config.dataset_name,
+ )
+ return
+ except Exception as e:
+ logger.warning(
+ "Could not load dataset '%s': %s. Using built-in tasks.",
+ self.config.dataset_name,
+ e,
+ )
+
+ # Fallback to built-in tasks
+ items = copy.deepcopy(BUILTIN_CODING_TASKS)
+ random.shuffle(items)
+ split = max(1, len(items) * 85 // 100)
+ self._items = items[:split]
+ self._eval_items = items[split:]
+ logger.info(
+ "Using built-in coding tasks: %d train / %d eval items",
+ len(self._items),
+ len(self._eval_items),
+ )
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 2. get_next_item
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def get_next_item(self) -> dict:
+ """Return the next coding task, cycling through the dataset."""
+ if not self._items:
+ raise RuntimeError("Dataset is empty. Did you call setup()?")
+ item = self._items[self._index % len(self._items)]
+ self._index += 1
+ return item
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 3. format_prompt
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ def format_prompt(self, item: dict) -> str:
+ """Format the coding task as a user prompt."""
+ prompt = (
+ f"Solve the following coding task.\n\n"
+ f"## Task\n{item['task']}\n\n"
+ )
+ if item.get("test_code"):
+ prompt += (
+ f"## Tests\nThe following test code will be used to verify your solution:\n"
+ f"```python\n{item['test_code']}```\n\n"
+ )
+ prompt += (
+ "## Instructions\n"
+ "1. Write your solution to `solution.py`\n"
+ "2. Write the test code to `test_solution.py`\n"
+ "3. Run `python test_solution.py` to verify\n"
+ "4. Fix any failures and re-run until all tests pass\n"
+ )
+ return prompt
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 4. compute_reward
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def compute_reward(
+ self,
+ item: dict,
+ result: AgentResult,
+ ctx: ToolContext,
+ ) -> float:
+ """
+ Multi-signal reward:
+ - correctness (0.7): Did the tests pass?
+ - efficiency (0.15): Fewer turns = better
+ - tool_usage (0.15): Did the agent actually write + run code?
+ """
+ cfg = self.config
+
+ # ---- Signal 1: Test correctness ----
+ # Check if test_solution.py exists and passes in the agent's sandbox
+ correctness = 0.0
+ try:
+ test_result = ctx.terminal("python test_solution.py 2>&1", timeout=30)
+ output = test_result.get("output", "")
+ exit_code = test_result.get("exit_code", 1)
+ if exit_code == 0 and "passed" in output.lower():
+ correctness = 1.0
+ elif exit_code == 0:
+ correctness = 0.8 # Ran without error but no explicit "passed"
+ elif "assert" in output.lower() and "error" in output.lower():
+ correctness = 0.2 # Partial โ code runs but assertions fail
+ else:
+ correctness = 0.1 # Code errors out entirely
+ except Exception as e:
+ logger.debug("Test execution failed in reward: %s", e)
+ correctness = 0.0
+
+ # ---- Signal 2: Efficiency ----
+ max_turns = cfg.max_agent_turns
+ turns_used = result.turns_used
+ if turns_used <= 3:
+ efficiency = 1.0
+ elif turns_used <= max_turns // 2:
+ efficiency = 0.8
+ elif turns_used <= max_turns * 3 // 4:
+ efficiency = 0.5
+ else:
+ efficiency = 0.2
+
+ # ---- Signal 3: Tool usage ----
+ tools_used = set()
+ for msg in result.messages:
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ for tc in msg["tool_calls"]:
+ fn = tc.get("function", {}) if isinstance(tc, dict) else {}
+ name = fn.get("name", "")
+ if name:
+ tools_used.add(name)
+
+ # Good: used both terminal and file tools
+ if "terminal" in tools_used and ("write_file" in tools_used or "patch" in tools_used):
+ tool_usage = 1.0
+ elif "terminal" in tools_used:
+ tool_usage = 0.6
+ elif tools_used:
+ tool_usage = 0.3
+ else:
+ tool_usage = 0.0
+
+ # ---- Combine ----
+ reward = (
+ cfg.correctness_weight * correctness
+ + cfg.efficiency_weight * efficiency
+ + cfg.tool_usage_weight * tool_usage
+ )
+ reward = min(1.0, max(0.0, reward))
+
+ # Track metrics
+ self._reward_buffer.append(reward)
+ self._correctness_buffer.append(correctness)
+ self._efficiency_buffer.append(efficiency)
+ self._tool_usage_buffer.append(tool_usage)
+
+ logger.debug(
+ "Reward: correctness=%.2f, efficiency=%.2f, tool_usage=%.2f โ %.3f",
+ correctness,
+ efficiency,
+ tool_usage,
+ reward,
+ )
+ return reward
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 5. collect_trajectories โ OPD pipeline
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def collect_trajectories(
+ self, item: Item
+ ) -> Tuple[
+ Union[Optional[ScoredDataGroup], List[Optional[ScoredDataGroup]]],
+ List[Item],
+ ]:
+ """
+ Override collect_trajectories to add the OPD pipeline.
+
+ 1. Run standard rollouts via super() โ ScoredDataGroup with tokens/masks/scores
+ 2. For each rollout, extract hints from next-state signals
+ 3. Score student tokens under enhanced (hint-augmented) distribution
+ 4. Add distill_token_ids / distill_logprobs to the ScoredDataGroup
+ """
+ # Step 1: Run standard rollouts
+ scored_group, backlog = await super().collect_trajectories(item)
+
+ # Step 2: OPD pipeline (only if enabled and we have VLLM server)
+ if (
+ self.config.opd_enabled
+ and scored_group is not None
+ and isinstance(scored_group, dict)
+ and self._use_managed_server()
+ ):
+ await self._apply_opd_pipeline(scored_group)
+
+ return scored_group, backlog
+
+ async def _apply_opd_pipeline(self, group: ScoredDataGroup) -> None:
+ """
+ Apply on-policy distillation to each rollout in the group.
+
+ For each rollout's messages:
+ 1. Find (assistant, next_state) turn pairs
+ 2. Extract hints via LLM judge with majority voting
+ 3. Build enhanced prompt (original + hint)
+ 4. Score student tokens under enhanced distribution via get_logprobs
+ 5. Add distill_token_ids / distill_logprobs to the group
+ """
+ messages_list = group.get("messages", [])
+ tokens_list = group.get("tokens", [])
+
+ if not messages_list or not tokens_list:
+ logger.debug("OPD: No messages or tokens to process")
+ return
+
+ all_distill_token_ids: List[Optional[List[List[int]]]] = []
+ all_distill_logprobs: List[Optional[List[List[float]]]] = []
+
+ for seq_idx, (messages, student_tokens) in enumerate(
+ zip(messages_list, tokens_list)
+ ):
+ try:
+ distill_ids, distill_lps = await self._opd_for_sequence(
+ messages, student_tokens
+ )
+ all_distill_token_ids.append(distill_ids)
+ all_distill_logprobs.append(distill_lps)
+ except Exception as e:
+ logger.warning(
+ "OPD failed for sequence %d: %s", seq_idx, e
+ )
+ all_distill_token_ids.append(None)
+ all_distill_logprobs.append(None)
+
+ # Only set distill fields if at least one sequence succeeded
+ any_succeeded = any(d is not None for d in all_distill_token_ids)
+ if any_succeeded:
+ # Replace None entries with zero-padded arrays matching token length
+ for i in range(len(all_distill_token_ids)):
+ if all_distill_token_ids[i] is None and i < len(tokens_list):
+ seq_len = len(tokens_list[i])
+ k = self.config.distill_topk
+ all_distill_token_ids[i] = [[0] * k] * seq_len
+ all_distill_logprobs[i] = [[0.0] * k] * seq_len
+
+ group["distill_token_ids"] = all_distill_token_ids
+ group["distill_logprobs"] = all_distill_logprobs
+ logger.info(
+ "OPD: Set distill fields on %d/%d sequences",
+ sum(1 for d in all_distill_token_ids if d is not None),
+ len(all_distill_token_ids),
+ )
+
+ async def _opd_for_sequence(
+ self, messages: List[Dict], student_tokens: List[int]
+ ) -> Tuple[List[List[int]], List[List[float]]]:
+ """
+ Run OPD for a single rollout sequence.
+
+ 1. Walk conversation to find (assistant, next_state) pairs
+ 2. Extract hints from next-state signals
+ 3. For each hint-augmented turn, score student tokens via get_logprobs
+ 4. Merge per-turn teacher logprobs into a full-sequence distill array
+
+ Returns:
+ (distill_token_ids, distill_logprobs) each of shape [seq_len][top_k]
+ """
+ k = self.config.distill_topk
+ seq_len = len(student_tokens)
+
+ # Initialize with zeros (no distill info = neutral)
+ distill_token_ids: List[List[int]] = [[0] * k for _ in range(seq_len)]
+ distill_logprobs: List[List[float]] = [[0.0] * k for _ in range(seq_len)]
+
+ # Find (assistant, next_state) turn pairs
+ turn_pairs = self._extract_turn_pairs(messages)
+ if not turn_pairs:
+ return distill_token_ids, distill_logprobs
+
+ hints_extracted = 0
+ turns_scored = 0
+
+ for pair in turn_pairs:
+ try:
+ hint = await self._extract_hint(
+ pair["assistant_text"],
+ pair["next_state_text"],
+ pair["next_state_role"],
+ )
+ if not hint:
+ continue
+
+ hints_extracted += 1
+
+ # Build enhanced prompt with hint
+ enhanced_messages = _append_hint_to_messages(
+ pair["context_messages"], hint
+ )
+
+ # Tokenize the enhanced prompt
+ if not self.tokenizer:
+ logger.warning("OPD: No tokenizer available, skipping scoring")
+ continue
+
+ enhanced_prompt = self.tokenizer.apply_chat_template(
+ enhanced_messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ )
+
+ # Tokenize the assistant response to score
+ response_text = pair["assistant_text"]
+ enhanced_full_text = enhanced_prompt + response_text
+ enhanced_ids = self.tokenizer(
+ enhanced_full_text, add_special_tokens=False
+ )["input_ids"]
+
+ response_ids = self.tokenizer(
+ response_text, add_special_tokens=False
+ )["input_ids"]
+ response_len = len(response_ids)
+
+ if response_len == 0:
+ continue
+
+ # Score via get_logprobs โ teacher scoring the student's tokens
+ # under the enhanced (hint-augmented) distribution
+ try:
+ logprob_result = await self.server.get_logprobs(
+ input_ids=enhanced_ids,
+ top_k=k,
+ split="eval", # Use eval semaphore to not block training
+ )
+ except Exception as e:
+ logger.debug("get_logprobs failed: %s", e)
+ continue
+
+ teacher_topk_ids = logprob_result.get("prompt_topk_token_ids", [])
+ teacher_topk_lps = logprob_result.get("prompt_topk_logprobs", [])
+
+ if not teacher_topk_ids:
+ continue
+
+ # Extract only the response positions (last response_len entries)
+ if len(teacher_topk_ids) >= response_len:
+ resp_topk_ids = teacher_topk_ids[-response_len:]
+ resp_topk_lps = teacher_topk_lps[-response_len:]
+ else:
+ # Pad from the left if the response was shorter than expected
+ pad_len = response_len - len(teacher_topk_ids)
+ resp_topk_ids = [[0] * k] * pad_len + teacher_topk_ids
+ resp_topk_lps = [[0.0] * k] * pad_len + teacher_topk_lps
+
+ # Map these back to the student's full sequence positions
+ # Find where this assistant turn's tokens appear in the full sequence
+ turn_start = self._find_token_span(
+ student_tokens, response_ids
+ )
+ if turn_start is not None:
+ for j in range(min(response_len, seq_len - turn_start)):
+ pos = turn_start + j
+ if pos < seq_len and j < len(resp_topk_ids):
+ # Pad/truncate to exactly k entries
+ ids = resp_topk_ids[j][:k]
+ lps = resp_topk_lps[j][:k]
+ while len(ids) < k:
+ ids.append(0)
+ lps.append(0.0)
+ distill_token_ids[pos] = ids
+ distill_logprobs[pos] = lps
+ turns_scored += 1
+
+ except Exception as e:
+ logger.debug("OPD turn processing failed: %s", e)
+ continue
+
+ # Track OPD metrics
+ self._hints_extracted_buffer.append(hints_extracted)
+ self._opd_turns_scored_buffer.append(turns_scored)
+
+ logger.debug(
+ "OPD sequence: %d turn pairs, %d hints extracted, %d turns scored",
+ len(turn_pairs),
+ hints_extracted,
+ turns_scored,
+ )
+ return distill_token_ids, distill_logprobs
+
+ def _extract_turn_pairs(
+ self, messages: List[Dict]
+ ) -> List[Dict[str, Any]]:
+ """
+ Walk conversation messages to find (assistant, next_state) pairs.
+
+ A "turn pair" is an assistant message with content (the response)
+ followed by one or more tool results or a user reply (the next state).
+
+ Returns list of dicts:
+ {
+ "context_messages": messages up to (not including) the assistant turn,
+ "assistant_text": the assistant's response text,
+ "next_state_text": the next state content (tool result or user reply),
+ "next_state_role": "tool" or "user",
+ }
+ """
+ pairs = []
+ i = 0
+ while i < len(messages):
+ msg = messages[i]
+ if msg.get("role") == "assistant" and msg.get("content"):
+ # Found an assistant message with content
+ assistant_text = msg["content"]
+ context = messages[:i] # Everything before this turn
+
+ # Look ahead for next state
+ j = i + 1
+ # Skip tool_calls-only assistant messages and collect tool results
+ next_states = []
+ while j < len(messages):
+ next_msg = messages[j]
+ if next_msg.get("role") == "tool":
+ next_states.append(next_msg)
+ j += 1
+ elif next_msg.get("role") == "user":
+ next_states.append(next_msg)
+ break
+ else:
+ break
+
+ if next_states:
+ # Combine all next-state content
+ next_text_parts = []
+ next_role = next_states[0].get("role", "tool")
+ for ns in next_states:
+ content = ns.get("content", "")
+ if content:
+ # Truncate very long tool outputs
+ max_chars = self.config.hint_max_next_state_chars
+ if len(content) > max_chars:
+ content = content[:max_chars] + "\n...[truncated]"
+ next_text_parts.append(content)
+
+ next_text = "\n---\n".join(next_text_parts)
+ if next_text.strip():
+ pairs.append(
+ {
+ "context_messages": context,
+ "assistant_text": assistant_text,
+ "next_state_text": next_text,
+ "next_state_role": next_role,
+ }
+ )
+ i += 1
+ return pairs
+
+ async def _extract_hint(
+ self,
+ assistant_text: str,
+ next_state_text: str,
+ next_state_role: str,
+ ) -> Optional[str]:
+ """
+ Extract a hindsight hint from a next-state signal using majority-voted LLM judge.
+
+ Returns the hint string if the judge votes positively, None otherwise.
+ """
+ judge_messages = _build_hint_judge_messages(
+ response_text=assistant_text,
+ next_state_text=next_state_text,
+ next_state_role=next_state_role,
+ )
+
+ # Majority voting across multiple judge queries
+ votes = []
+ tasks = []
+ for _ in range(self.config.prm_votes):
+ tasks.append(
+ self.server.chat_completion(
+ messages=judge_messages,
+ n=1,
+ max_tokens=500,
+ temperature=0.7,
+ split="eval",
+ )
+ )
+
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ for result in results:
+ if isinstance(result, Exception):
+ logger.debug("Hint judge call failed: %s", result)
+ votes.append({"score": None, "hint": ""})
+ continue
+ try:
+ text = result.choices[0].message.content or ""
+ score, hint = _parse_hint_result(text)
+ votes.append({"score": score, "hint": hint})
+ except Exception as e:
+ logger.debug("Hint parse failed: %s", e)
+ votes.append({"score": None, "hint": ""})
+
+ selected = _select_best_hint(votes)
+ if selected is None:
+ return None
+ return selected["hint"]
+
+ @staticmethod
+ def _find_token_span(
+ full_tokens: List[int], sub_tokens: List[int]
+ ) -> Optional[int]:
+ """
+ Find where sub_tokens appears in full_tokens.
+ Returns the start index, or None if not found.
+
+ Uses a sliding window search. For long sequences, searches
+ from the end since assistant responses are typically at the end.
+ """
+ if not sub_tokens or not full_tokens:
+ return None
+ sub_len = len(sub_tokens)
+ full_len = len(full_tokens)
+ if sub_len > full_len:
+ return None
+
+ # Search backwards (assistant responses are usually near the end)
+ for i in range(full_len - sub_len, -1, -1):
+ if full_tokens[i : i + sub_len] == sub_tokens:
+ return i
+ return None
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 6. evaluate
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def evaluate(self, *args, **kwargs) -> None:
+ """
+ Evaluate on held-out coding tasks using the full agent loop.
+ No OPD during eval โ just standard agentic evaluation.
+ """
+ if not self._eval_items:
+ logger.warning("No eval items available.")
+ return
+
+ eval_size = min(self.config.eval_size, len(self._eval_items))
+ eval_items = self._eval_items[:eval_size]
+
+ logger.info("Running eval on %d coding tasks...", len(eval_items))
+ start_time = time.time()
+ samples = []
+
+ tools, valid_names = self._resolve_tools_for_group()
+
+ for i, item in enumerate(eval_items):
+ task_id = str(uuid.uuid4())
+ logger.info(
+ "Eval [%d/%d]: %s...", i + 1, len(eval_items), item["task"][:60]
+ )
+
+ try:
+ messages: List[Dict[str, Any]] = []
+ if self.config.system_prompt:
+ messages.append(
+ {"role": "system", "content": self.config.system_prompt}
+ )
+ messages.append(
+ {"role": "user", "content": self.format_prompt(item)}
+ )
+
+ agent = HermesAgentLoop(
+ server=self.server,
+ tool_schemas=tools,
+ valid_tool_names=valid_names,
+ max_turns=self.config.max_agent_turns,
+ task_id=task_id,
+ temperature=0.0,
+ max_tokens=self.config.max_token_length,
+ extra_body=self.config.extra_body,
+ )
+ result = await agent.run(messages)
+
+ # Compute reward (track buffer lengths to rollback eval pollution)
+ buf_len = len(self._correctness_buffer)
+ ctx = ToolContext(task_id)
+ try:
+ reward = await self.compute_reward(item, result, ctx)
+ finally:
+ ctx.cleanup()
+
+ # Extract correctness and rollback training buffers
+ correctness = (
+ self._correctness_buffer[buf_len]
+ if len(self._correctness_buffer) > buf_len
+ else 0.0
+ )
+ for buf in (
+ self._reward_buffer,
+ self._correctness_buffer,
+ self._efficiency_buffer,
+ self._tool_usage_buffer,
+ ):
+ if len(buf) > buf_len:
+ buf.pop()
+
+ # Also rollback OPD buffers if they were touched
+ for buf in (
+ self._hints_extracted_buffer,
+ self._opd_turns_scored_buffer,
+ ):
+ if len(buf) > buf_len:
+ buf.pop()
+
+ # Extract final response
+ final_response = ""
+ for msg in reversed(result.messages):
+ if (
+ msg.get("role") == "assistant"
+ and msg.get("content")
+ and not final_response
+ ):
+ final_response = msg["content"]
+ break
+
+ samples.append(
+ {
+ "prompt": item["task"][:200],
+ "response": final_response[:500],
+ "correctness": correctness,
+ "reward": reward,
+ "turns": result.turns_used,
+ }
+ )
+
+ logger.info(
+ " โ correctness=%.2f, reward=%.3f, turns=%d",
+ correctness,
+ reward,
+ result.turns_used,
+ )
+
+ except Exception as e:
+ logger.error("Eval error: %s", e)
+ samples.append(
+ {
+ "prompt": item["task"][:200],
+ "response": f"ERROR: {e}",
+ "correctness": 0.0,
+ "reward": 0.0,
+ "turns": 0,
+ }
+ )
+
+ end_time = time.time()
+
+ correctness_scores = [s["correctness"] for s in samples]
+ rewards = [s["reward"] for s in samples]
+ n = len(samples)
+
+ eval_metrics = {
+ "eval/mean_correctness": sum(correctness_scores) / n if n else 0.0,
+ "eval/mean_reward": sum(rewards) / n if n else 0.0,
+ "eval/pass_rate": (
+ sum(1 for c in correctness_scores if c >= 0.8) / n if n else 0.0
+ ),
+ "eval/n_items": n,
+ }
+
+ logger.info(
+ "Eval complete โ correctness=%.3f, reward=%.3f, pass_rate=%.0f%%",
+ eval_metrics["eval/mean_correctness"],
+ eval_metrics["eval/mean_reward"],
+ eval_metrics["eval/pass_rate"] * 100,
+ )
+
+ await self.evaluate_log(
+ metrics=eval_metrics,
+ samples=samples,
+ start_time=start_time,
+ end_time=end_time,
+ )
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 7. wandb_log โ custom OPD metrics
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async def wandb_log(self, wandb_metrics: Optional[Dict] = None) -> None:
+ """Log reward breakdown and OPD-specific metrics to wandb."""
+ if wandb_metrics is None:
+ wandb_metrics = {}
+
+ if self._reward_buffer:
+ n = len(self._reward_buffer)
+ wandb_metrics["train/mean_reward"] = sum(self._reward_buffer) / n
+ wandb_metrics["train/mean_correctness"] = (
+ sum(self._correctness_buffer) / n
+ )
+ wandb_metrics["train/mean_efficiency"] = (
+ sum(self._efficiency_buffer) / n
+ )
+ wandb_metrics["train/mean_tool_usage"] = (
+ sum(self._tool_usage_buffer) / n
+ )
+ wandb_metrics["train/pass_rate"] = (
+ sum(1 for c in self._correctness_buffer if c >= 0.8) / n
+ )
+ wandb_metrics["train/total_rollouts"] = n
+
+ self._reward_buffer.clear()
+ self._correctness_buffer.clear()
+ self._efficiency_buffer.clear()
+ self._tool_usage_buffer.clear()
+
+ # OPD-specific metrics
+ if self._hints_extracted_buffer:
+ n = len(self._hints_extracted_buffer)
+ wandb_metrics["opd/mean_hints_per_rollout"] = (
+ sum(self._hints_extracted_buffer) / n
+ )
+ wandb_metrics["opd/mean_turns_scored"] = (
+ sum(self._opd_turns_scored_buffer) / n
+ )
+ wandb_metrics["opd/hint_rate"] = (
+ sum(1 for h in self._hints_extracted_buffer if h > 0) / n
+ )
+ wandb_metrics["opd/total_hints"] = sum(self._hints_extracted_buffer)
+ wandb_metrics["opd/total_scored_turns"] = sum(
+ self._opd_turns_scored_buffer
+ )
+
+ self._hints_extracted_buffer.clear()
+ self._opd_turns_scored_buffer.clear()
+
+ await super().wandb_log(wandb_metrics)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Entry point
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+if __name__ == "__main__":
+ AgenticOPDEnv.cli()
diff --git a/environments/patches.py b/environments/patches.py
index 3c5ed2cd1bb..aed78da6e7e 100644
--- a/environments/patches.py
+++ b/environments/patches.py
@@ -2,203 +2,41 @@
Monkey patches for making hermes-agent tools work inside async frameworks (Atropos).
Problem:
- Some tools use asyncio.run() internally (e.g., mini-swe-agent's Modal backend,
+ Some tools use asyncio.run() internally (e.g., Modal backend via SWE-ReX,
web_extract). This crashes when called from inside Atropos's event loop because
asyncio.run() can't be nested.
Solution:
- Replace the problematic methods with versions that use a dedicated background
- thread with its own event loop. The calling code sees the same sync interface --
- call a function, get a result -- but internally the async work happens on a
- separate thread that doesn't conflict with Atropos's loop.
+ The Modal environment (tools/environments/modal.py) now uses a dedicated
+ _AsyncWorker thread internally, making it safe for both CLI and Atropos use.
+ No monkey-patching is required.
- These patches are safe for normal CLI use too: when there's no running event
- loop, the behavior is identical (the background thread approach works regardless).
-
-What gets patched:
- - SwerexModalEnvironment.__init__ -- creates Modal deployment on a background thread
- - SwerexModalEnvironment.execute -- runs commands on the same background thread
- - SwerexModalEnvironment.stop -- stops deployment on the background thread
+ This module is kept for backward compatibility โ apply_patches() is now a no-op.
Usage:
Call apply_patches() once at import time (done automatically by hermes_base_env.py).
- This is idempotent -- calling it multiple times is safe.
+ This is idempotent โ calling it multiple times is safe.
"""
-import asyncio
import logging
-import threading
-from typing import Any
logger = logging.getLogger(__name__)
_patches_applied = False
-class _AsyncWorker:
- """
- A dedicated background thread with its own event loop.
-
- Allows sync code to submit async coroutines and block for results,
- even when called from inside another running event loop. Used to
- bridge sync tool interfaces with async backends (Modal, SWE-ReX).
- """
-
- def __init__(self):
- self._loop: asyncio.AbstractEventLoop = None
- self._thread: threading.Thread = None
- self._started = threading.Event()
-
- def start(self):
- """Start the background event loop thread."""
- self._thread = threading.Thread(target=self._run_loop, daemon=True)
- self._thread.start()
- self._started.wait(timeout=30)
-
- def _run_loop(self):
- """Background thread entry point -- runs the event loop forever."""
- self._loop = asyncio.new_event_loop()
- asyncio.set_event_loop(self._loop)
- self._started.set()
- self._loop.run_forever()
-
- def run_coroutine(self, coro, timeout=600):
- """
- Submit a coroutine to the background loop and block until it completes.
-
- Safe to call from any thread, including threads that already have
- a running event loop.
- """
- if self._loop is None or self._loop.is_closed():
- raise RuntimeError("AsyncWorker loop is not running")
- future = asyncio.run_coroutine_threadsafe(coro, self._loop)
- return future.result(timeout=timeout)
-
- def stop(self):
- """Stop the background event loop and join the thread."""
- if self._loop and self._loop.is_running():
- self._loop.call_soon_threadsafe(self._loop.stop)
- if self._thread:
- self._thread.join(timeout=10)
-
-
-def _patch_swerex_modal():
- """
- Monkey patch SwerexModalEnvironment to use a background thread event loop
- instead of asyncio.run(). This makes it safe to call from inside Atropos's
- async event loop.
-
- The patched methods have the exact same interface and behavior -- the only
- difference is HOW the async work is executed internally.
- """
- try:
- from minisweagent.environments.extra.swerex_modal import (
- SwerexModalEnvironment,
- SwerexModalEnvironmentConfig,
- )
- from swerex.deployment.modal import ModalDeployment
- from swerex.runtime.abstract import Command as RexCommand
- except ImportError:
- # mini-swe-agent or swe-rex not installed -- nothing to patch
- logger.debug("mini-swe-agent Modal backend not available, skipping patch")
- return
-
- # Save original methods so we can refer to config handling
- _original_init = SwerexModalEnvironment.__init__
-
- def _patched_init(self, **kwargs):
- """Patched __init__: creates Modal deployment on a background thread."""
- self.config = SwerexModalEnvironmentConfig(**kwargs)
-
- # Start a dedicated event loop thread for all Modal async operations
- self._worker = _AsyncWorker()
- self._worker.start()
-
- # Pre-build a modal.Image with pip fix for Modal's legacy image builder.
- # Modal requires `python -m pip` to work during image build, but some
- # task images (e.g., TBLite's broken-python) have intentionally broken pip.
- # Fix: remove stale pip dist-info and reinstall via ensurepip before Modal
- # tries to use it. This is a no-op for images where pip already works.
- import modal as _modal
- image_spec = self.config.image
- if isinstance(image_spec, str):
- image_spec = _modal.Image.from_registry(
- image_spec,
- setup_dockerfile_commands=[
- "RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; "
- "python -m ensurepip --upgrade --default-pip 2>/dev/null || true",
- ],
- )
-
- # Create AND start the deployment entirely on the worker's loop/thread
- # so all gRPC channels and async state are bound to that loop
- async def _create_and_start():
- deployment = ModalDeployment(
- image=image_spec,
- startup_timeout=self.config.startup_timeout,
- runtime_timeout=self.config.runtime_timeout,
- deployment_timeout=self.config.deployment_timeout,
- install_pipx=self.config.install_pipx,
- modal_sandbox_kwargs=self.config.modal_sandbox_kwargs,
- )
- await deployment.start()
- return deployment
-
- self.deployment = self._worker.run_coroutine(_create_and_start())
-
- def _patched_execute(self, command: str, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
- """Patched execute: runs commands on the background thread's loop."""
- async def _do_execute():
- return await self.deployment.runtime.execute(
- RexCommand(
- command=command,
- shell=True,
- check=False,
- cwd=cwd or self.config.cwd,
- timeout=timeout or self.config.timeout,
- merge_output_streams=True,
- env=self.config.env if self.config.env else None,
- )
- )
-
- output = self._worker.run_coroutine(_do_execute())
- return {
- "output": output.stdout,
- "returncode": output.exit_code,
- }
-
- def _patched_stop(self):
- """Patched stop: stops deployment on the background thread, then stops the thread."""
- try:
- self._worker.run_coroutine(
- asyncio.wait_for(self.deployment.stop(), timeout=10),
- timeout=15,
- )
- except Exception:
- pass
- finally:
- self._worker.stop()
-
- # Apply the patches
- SwerexModalEnvironment.__init__ = _patched_init
- SwerexModalEnvironment.execute = _patched_execute
- SwerexModalEnvironment.stop = _patched_stop
-
- logger.debug("Patched SwerexModalEnvironment for async-safe operation")
-
-
def apply_patches():
- """
- Apply all monkey patches needed for Atropos compatibility.
+ """Apply all monkey patches needed for Atropos compatibility.
- Safe to call multiple times -- patches are only applied once.
- Safe for normal CLI use -- patched code works identically when
- there is no running event loop.
+ Now a no-op โ Modal async safety is built directly into ModalEnvironment.
+ Safe to call multiple times.
"""
global _patches_applied
if _patches_applied:
return
- _patch_swerex_modal()
+ # Modal async-safety is now built into tools/environments/modal.py
+ # via the _AsyncWorker class. No monkey-patching needed.
+ logger.debug("apply_patches() called โ no patches needed (async safety is built-in)")
_patches_applied = True
diff --git a/environments/tool_call_parsers/deepseek_v3_parser.py b/environments/tool_call_parsers/deepseek_v3_parser.py
index 2d24ed3309e..61d23d5fecc 100644
--- a/environments/tool_call_parsers/deepseek_v3_parser.py
+++ b/environments/tool_call_parsers/deepseek_v3_parser.py
@@ -10,12 +10,13 @@
<๏ฝtoolโcallโend๏ฝ>
<๏ฝtoolโcallsโend๏ฝ>
-Based on VLLM's DeepSeekV3ToolParser.extract_tool_calls()
+Fixes Issue #989: Support for multiple simultaneous tool calls.
"""
import re
import uuid
-from typing import List, Optional
+import logging
+from typing import List, Optional, Tuple
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
@@ -24,6 +25,7 @@
from environments.tool_call_parsers import ParseResult, ToolCallParser, register_parser
+logger = logging.getLogger(__name__)
@register_parser("deepseek_v3")
class DeepSeekV3ToolCallParser(ToolCallParser):
@@ -32,45 +34,56 @@ class DeepSeekV3ToolCallParser(ToolCallParser):
Uses special unicode tokens with fullwidth angle brackets and block elements.
Extracts type, function name, and JSON arguments from the structured format.
+ Ensures all tool calls are captured when the model executes multiple actions.
"""
START_TOKEN = "<๏ฝtoolโcallsโbegin๏ฝ>"
- # Regex captures: type, function_name, function_arguments
+ # Updated PATTERN: Using \s* instead of literal \n for increased robustness
+ # against variations in model formatting (Issue #989).
PATTERN = re.compile(
- r"<๏ฝtoolโcallโbegin๏ฝ>(?P.*)<๏ฝtoolโsep๏ฝ>(?P.*)\n```json\n(?P.*)\n```<๏ฝtoolโcallโend๏ฝ>",
+ r"<๏ฝtoolโcallโbegin๏ฝ>(?P.*?)<๏ฝtoolโsep๏ฝ>(?P.*?)\s*```json\s*(?P.*?)\s*```\s*<๏ฝtoolโcallโend๏ฝ>",
re.DOTALL,
)
def parse(self, text: str) -> ParseResult:
+ """
+ Parses the input text and extracts all available tool calls.
+ """
if self.START_TOKEN not in text:
return text, None
try:
- matches = self.PATTERN.findall(text)
+ # Using finditer to capture ALL tool calls in the sequence
+ matches = list(self.PATTERN.finditer(text))
if not matches:
return text, None
tool_calls: List[ChatCompletionMessageToolCall] = []
+
for match in matches:
- tc_type, func_name, func_args = match
+ func_name = match.group("function_name").strip()
+ func_args = match.group("function_arguments").strip()
+
tool_calls.append(
ChatCompletionMessageToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
type="function",
function=Function(
- name=func_name.strip(),
- arguments=func_args.strip(),
+ name=func_name,
+ arguments=func_args,
),
)
)
- if not tool_calls:
- return text, None
+ if tool_calls:
+ # Content is text before the first tool call block
+ content_index = text.find(self.START_TOKEN)
+ content = text[:content_index].strip()
+ return content if content else None, tool_calls
- # Content is everything before the tool calls section
- content = text[: text.find(self.START_TOKEN)].strip()
- return content if content else None, tool_calls
+ return text, None
- except Exception:
+ except Exception as e:
+ logger.error(f"Error parsing DeepSeek V3 tool calls: {e}")
return text, None
diff --git a/environments/tool_call_parsers/mistral_parser.py b/environments/tool_call_parsers/mistral_parser.py
index 5526bdd0107..50e98a6f864 100644
--- a/environments/tool_call_parsers/mistral_parser.py
+++ b/environments/tool_call_parsers/mistral_parser.py
@@ -10,7 +10,6 @@
"""
import json
-import re
import uuid
from typing import List, Optional
@@ -42,9 +41,6 @@ class MistralToolCallParser(ToolCallParser):
# The [TOOL_CALLS] token -- may appear as different strings depending on tokenizer
BOT_TOKEN = "[TOOL_CALLS]"
- # Fallback regex for pre-v11 format when JSON parsing fails
- TOOL_CALL_REGEX = re.compile(r"\[?\s*(\{.*?\})\s*\]?", re.DOTALL)
-
def parse(self, text: str) -> ParseResult:
if self.BOT_TOKEN not in text:
return text, None
@@ -71,6 +67,13 @@ def parse(self, text: str) -> ParseResult:
tool_name = raw[:brace_idx].strip()
args_str = raw[brace_idx:]
+ # Validate and clean the JSON arguments
+ try:
+ parsed_args = json.loads(args_str)
+ args_str = json.dumps(parsed_args, ensure_ascii=False)
+ except json.JSONDecodeError:
+ pass # Keep raw if parsing fails
+
tool_calls.append(
ChatCompletionMessageToolCall(
id=_generate_mistral_id(),
@@ -100,13 +103,14 @@ def parse(self, text: str) -> ParseResult:
)
)
except json.JSONDecodeError:
- # Fallback regex extraction
- match = self.TOOL_CALL_REGEX.findall(first_raw)
- if match:
- for raw_json in match:
- try:
- tc = json.loads(raw_json)
- args = tc.get("arguments", {})
+ # Fallback: extract JSON objects using raw_decode
+ decoder = json.JSONDecoder()
+ idx = 0
+ while idx < len(first_raw):
+ try:
+ obj, end_idx = decoder.raw_decode(first_raw, idx)
+ if isinstance(obj, dict) and "name" in obj:
+ args = obj.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
tool_calls.append(
@@ -114,12 +118,13 @@ def parse(self, text: str) -> ParseResult:
id=_generate_mistral_id(),
type="function",
function=Function(
- name=tc["name"], arguments=args
+ name=obj["name"], arguments=args
),
)
)
- except (json.JSONDecodeError, KeyError):
- continue
+ idx = end_idx
+ except json.JSONDecodeError:
+ idx += 1
if not tool_calls:
return text, None
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000000..628e492f65f
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,181 @@
+{
+ "nodes": {
+ "flake-parts": {
+ "inputs": {
+ "nixpkgs-lib": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772408722,
+ "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1751274312,
+ "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-24.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-build-systems": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": "pyproject-nix",
+ "uv2nix": "uv2nix"
+ },
+ "locked": {
+ "lastModified": 1772555609,
+ "narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "rev": "c37f66a953535c394244888598947679af231863",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "build-system-pkgs",
+ "type": "github"
+ }
+ },
+ "pyproject-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "pyproject-build-systems",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1769936401,
+ "narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=",
+ "owner": "nix-community",
+ "repo": "pyproject.nix",
+ "rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "pyproject-nix_2": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1772865871,
+ "narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "pyproject-nix_3": {
+ "inputs": {
+ "nixpkgs": [
+ "uv2nix",
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1771518446,
+ "narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=",
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "pyproject.nix",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-parts": "flake-parts",
+ "nixpkgs": "nixpkgs",
+ "pyproject-build-systems": "pyproject-build-systems",
+ "pyproject-nix": "pyproject-nix_2",
+ "uv2nix": "uv2nix_2"
+ }
+ },
+ "uv2nix": {
+ "inputs": {
+ "nixpkgs": [
+ "pyproject-build-systems",
+ "nixpkgs"
+ ],
+ "pyproject-nix": [
+ "pyproject-build-systems",
+ "pyproject-nix"
+ ]
+ },
+ "locked": {
+ "lastModified": 1770770348,
+ "narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=",
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "type": "github"
+ }
+ },
+ "uv2nix_2": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "pyproject-nix": "pyproject-nix_3"
+ },
+ "locked": {
+ "lastModified": 1773039484,
+ "narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "pyproject-nix",
+ "repo": "uv2nix",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000000..87be89c85c3
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,35 @@
+{
+ description = "Hermes Agent - AI agent framework by Nous Research";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+ flake-parts = {
+ url = "github:hercules-ci/flake-parts";
+ inputs.nixpkgs-lib.follows = "nixpkgs";
+ };
+ pyproject-nix = {
+ url = "github:pyproject-nix/pyproject.nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ uv2nix = {
+ url = "github:pyproject-nix/uv2nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ pyproject-build-systems = {
+ url = "github:pyproject-nix/build-system-pkgs";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = inputs:
+ inputs.flake-parts.lib.mkFlake { inherit inputs; } {
+ systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
+
+ imports = [
+ ./nix/packages.nix
+ ./nix/nixosModules.nix
+ ./nix/checks.nix
+ ./nix/devShell.nix
+ ];
+ };
+}
diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py
index 4d11c3a91e2..235f11f59fd 100644
--- a/gateway/channel_directory.py
+++ b/gateway/channel_directory.py
@@ -9,12 +9,13 @@
import json
import logging
from datetime import datetime
-from pathlib import Path
from typing import Any, Dict, List, Optional
+from hermes_cli.config import get_hermes_home
+
logger = logging.getLogger(__name__)
-DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json"
+DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
@@ -61,7 +62,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
- for plat_name in ("telegram", "whatsapp", "signal", "email"):
+ for plat_name in ("telegram", "whatsapp", "signal", "email", "sms"):
if plat_name not in platforms:
platforms[plat_name] = _build_from_sessions(plat_name)
@@ -88,7 +89,7 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
return channels
try:
- import discord as _discord
+ import discord as _discord # noqa: F401 โ SDK presence check
except ImportError:
return channels
@@ -117,7 +118,6 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
return _build_from_sessions("slack")
try:
- import asyncio
from tools.send_message_tool import _send_slack # noqa: F401
# Use the Slack Web API directly if available
except Exception:
@@ -129,7 +129,7 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
"""Pull known channels/contacts from sessions.json origin data."""
- sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json"
+ sessions_path = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_path.exists():
return []
diff --git a/gateway/config.py b/gateway/config.py
index 5d3dfa9f59f..f93c6905a6b 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -16,9 +16,31 @@
from typing import Dict, List, Optional, Any
from enum import Enum
+from hermes_cli.config import get_hermes_home
+
logger = logging.getLogger(__name__)
+def _coerce_bool(value: Any, default: bool = True) -> bool:
+ """Coerce bool-ish config values, preserving a caller-provided default."""
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in ("true", "1", "yes", "on")
+ return bool(value)
+
+
+def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
+ """Normalize unauthorized DM behavior to a supported value."""
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"pair", "ignore"}:
+ return normalized
+ return default
+
+
class Platform(Enum):
"""Supported messaging platforms."""
LOCAL = "local"
@@ -27,8 +49,14 @@ class Platform(Enum):
WHATSAPP = "whatsapp"
SLACK = "slack"
SIGNAL = "signal"
+ MATTERMOST = "mattermost"
+ MATRIX = "matrix"
HOMEASSISTANT = "homeassistant"
EMAIL = "email"
+ SMS = "sms"
+ DINGTALK = "dingtalk"
+ API_SERVER = "api_server"
+ WEBHOOK = "webhook"
@dataclass
@@ -73,20 +101,32 @@ class SessionResetPolicy:
mode: str = "both" # "daily", "idle", "both", or "none"
at_hour: int = 4 # Hour for daily reset (0-23, local time)
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
+ notify: bool = True # Send a notification to the user when auto-reset occurs
+ notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications
def to_dict(self) -> Dict[str, Any]:
return {
"mode": self.mode,
"at_hour": self.at_hour,
"idle_minutes": self.idle_minutes,
+ "notify": self.notify,
+ "notify_exclude_platforms": list(self.notify_exclude_platforms),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
+ # Handle both missing keys and explicit null values (YAML null โ None)
+ mode = data.get("mode")
+ at_hour = data.get("at_hour")
+ idle_minutes = data.get("idle_minutes")
+ notify = data.get("notify")
+ exclude = data.get("notify_exclude_platforms")
return cls(
- mode=data.get("mode", "both"),
- at_hour=data.get("at_hour", 4),
- idle_minutes=data.get("idle_minutes", 1440),
+ mode=mode if mode is not None else "both",
+ at_hour=at_hour if at_hour is not None else 4,
+ idle_minutes=idle_minutes if idle_minutes is not None else 1440,
+ notify=notify if notify is not None else True,
+ notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
)
@@ -98,6 +138,12 @@ class PlatformConfig:
api_key: Optional[str] = None # API key if different from token
home_channel: Optional[HomeChannel] = None
+ # Reply threading mode (Telegram/Slack)
+ # - "off": Never thread replies to original message
+ # - "first": Only first chunk threads to user's message (default)
+ # - "all": All chunks in multi-part replies thread to user's message
+ reply_to_mode: str = "first"
+
# Platform-specific settings
extra: Dict[str, Any] = field(default_factory=dict)
@@ -105,6 +151,7 @@ def to_dict(self) -> Dict[str, Any]:
result = {
"enabled": self.enabled,
"extra": self.extra,
+ "reply_to_mode": self.reply_to_mode,
}
if self.token:
result["token"] = self.token
@@ -125,10 +172,42 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
token=data.get("token"),
api_key=data.get("api_key"),
home_channel=home_channel,
+ reply_to_mode=data.get("reply_to_mode", "first"),
extra=data.get("extra", {}),
)
+@dataclass
+class StreamingConfig:
+ """Configuration for real-time token streaming to messaging platforms."""
+ enabled: bool = False
+ transport: str = "edit" # "edit" (progressive editMessageText) or "off"
+ edit_interval: float = 0.3 # Seconds between message edits
+ buffer_threshold: int = 40 # Chars before forcing an edit
+ cursor: str = " โ" # Cursor shown during streaming
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "enabled": self.enabled,
+ "transport": self.transport,
+ "edit_interval": self.edit_interval,
+ "buffer_threshold": self.buffer_threshold,
+ "cursor": self.cursor,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
+ if not data:
+ return cls()
+ return cls(
+ enabled=data.get("enabled", False),
+ transport=data.get("transport", "edit"),
+ edit_interval=float(data.get("edit_interval", 0.3)),
+ buffer_threshold=int(data.get("buffer_threshold", 40)),
+ cursor=data.get("cursor", " โ"),
+ )
+
+
@dataclass
class GatewayConfig:
"""
@@ -146,13 +225,28 @@ class GatewayConfig:
# Reset trigger commands
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
+
+ # User-defined quick commands (slash commands that bypass the agent loop)
+ quick_commands: Dict[str, Any] = field(default_factory=dict)
# Storage paths
- sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
+ sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
# Delivery settings
always_log_local: bool = True # Always save cron outputs to local files
-
+
+ # STT settings
+ stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
+
+ # Session isolation in shared chats
+ group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
+
+ # Unauthorized DM policy
+ unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
+
+ # Streaming configuration
+ streaming: StreamingConfig = field(default_factory=StreamingConfig)
+
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@@ -171,6 +265,15 @@ def get_connected_platforms(self) -> List[Platform]:
# Email uses extra dict for config (address + imap_host + smtp_host)
elif platform == Platform.EMAIL and config.extra.get("address"):
connected.append(platform)
+ # SMS uses api_key (Twilio auth token) โ SID checked via env
+ elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
+ connected.append(platform)
+ # API Server uses enabled flag only (no token needed)
+ elif platform == Platform.API_SERVER:
+ connected.append(platform)
+ # Webhook uses enabled flag only (secrets are per-route)
+ elif platform == Platform.WEBHOOK:
+ connected.append(platform)
return connected
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
@@ -213,8 +316,13 @@ def to_dict(self) -> Dict[str, Any]:
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
},
"reset_triggers": self.reset_triggers,
+ "quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local,
+ "stt_enabled": self.stt_enabled,
+ "group_sessions_per_user": self.group_sessions_per_user,
+ "unauthorized_dm_behavior": self.unauthorized_dm_behavior,
+ "streaming": self.streaming.to_dict(),
}
@classmethod
@@ -243,58 +351,175 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
if "default_reset_policy" in data:
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
- sessions_dir = Path.home() / ".hermes" / "sessions"
+ sessions_dir = get_hermes_home() / "sessions"
if "sessions_dir" in data:
sessions_dir = Path(data["sessions_dir"])
+ quick_commands = data.get("quick_commands", {})
+ if not isinstance(quick_commands, dict):
+ quick_commands = {}
+
+ stt_enabled = data.get("stt_enabled")
+ if stt_enabled is None:
+ stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None
+
+ group_sessions_per_user = data.get("group_sessions_per_user")
+ unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
+ data.get("unauthorized_dm_behavior"),
+ "pair",
+ )
+
return cls(
platforms=platforms,
default_reset_policy=default_policy,
reset_by_type=reset_by_type,
reset_by_platform=reset_by_platform,
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
+ quick_commands=quick_commands,
sessions_dir=sessions_dir,
always_log_local=data.get("always_log_local", True),
+ stt_enabled=_coerce_bool(stt_enabled, True),
+ group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
+ unauthorized_dm_behavior=unauthorized_dm_behavior,
+ streaming=StreamingConfig.from_dict(data.get("streaming", {})),
)
+ def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
+ """Return the effective unauthorized-DM behavior for a platform."""
+ if platform:
+ platform_cfg = self.platforms.get(platform)
+ if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra:
+ return _normalize_unauthorized_dm_behavior(
+ platform_cfg.extra.get("unauthorized_dm_behavior"),
+ self.unauthorized_dm_behavior,
+ )
+ return self.unauthorized_dm_behavior
+
def load_gateway_config() -> GatewayConfig:
"""
Load gateway configuration from multiple sources.
-
+
Priority (highest to lowest):
1. Environment variables
- 2. ~/.hermes/gateway.json
- 3. cli-config.yaml gateway section
- 4. Defaults
+ 2. ~/.hermes/config.yaml (primary user-facing config)
+ 3. ~/.hermes/gateway.json (legacy โ provides defaults under config.yaml)
+ 4. Built-in defaults
"""
- config = GatewayConfig()
-
- # Try loading from ~/.hermes/gateway.json
- gateway_config_path = Path.home() / ".hermes" / "gateway.json"
- if gateway_config_path.exists():
+ _home = get_hermes_home()
+ gw_data: dict = {}
+
+ # Legacy fallback: gateway.json provides the base layer.
+ # config.yaml keys always win when both specify the same setting.
+ gateway_json_path = _home / "gateway.json"
+ if gateway_json_path.exists():
try:
- with open(gateway_config_path, "r", encoding="utf-8") as f:
- data = json.load(f)
- config = GatewayConfig.from_dict(data)
+ with open(gateway_json_path, "r", encoding="utf-8") as f:
+ gw_data = json.load(f) or {}
+ logger.info(
+ "Loaded legacy %s โ consider moving settings to config.yaml",
+ gateway_json_path,
+ )
except Exception as e:
- print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
-
- # Bridge session_reset from config.yaml (the user-facing config file)
- # into the gateway config. config.yaml takes precedence over gateway.json
- # for session reset policy since that's where hermes setup writes it.
+ logger.warning("Failed to load %s: %s", gateway_json_path, e)
+
+ # Primary source: config.yaml
try:
import yaml
- config_yaml_path = Path.home() / ".hermes" / "config.yaml"
+ config_yaml_path = _home / "config.yaml"
if config_yaml_path.exists():
with open(config_yaml_path, encoding="utf-8") as f:
yaml_cfg = yaml.safe_load(f) or {}
+
+ # Map config.yaml keys โ GatewayConfig.from_dict() schema.
+ # Each key overwrites whatever gateway.json may have set.
sr = yaml_cfg.get("session_reset")
if sr and isinstance(sr, dict):
- config.default_reset_policy = SessionResetPolicy.from_dict(sr)
+ gw_data["default_reset_policy"] = sr
+
+ qc = yaml_cfg.get("quick_commands")
+ if qc is not None:
+ if isinstance(qc, dict):
+ gw_data["quick_commands"] = qc
+ else:
+ logger.warning(
+ "Ignoring invalid quick_commands in config.yaml "
+ "(expected mapping, got %s)",
+ type(qc).__name__,
+ )
+
+ stt_cfg = yaml_cfg.get("stt")
+ if isinstance(stt_cfg, dict):
+ gw_data["stt"] = stt_cfg
+
+ if "group_sessions_per_user" in yaml_cfg:
+ gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"]
+
+ streaming_cfg = yaml_cfg.get("streaming")
+ if isinstance(streaming_cfg, dict):
+ gw_data["streaming"] = streaming_cfg
+
+ if "reset_triggers" in yaml_cfg:
+ gw_data["reset_triggers"] = yaml_cfg["reset_triggers"]
- # Bridge discord settings from config.yaml to env vars
- # (env vars take precedence โ only set if not already defined)
+ if "always_log_local" in yaml_cfg:
+ gw_data["always_log_local"] = yaml_cfg["always_log_local"]
+
+ if "unauthorized_dm_behavior" in yaml_cfg:
+ gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
+ yaml_cfg.get("unauthorized_dm_behavior"),
+ "pair",
+ )
+
+ # Merge platforms section from config.yaml into gw_data so that
+ # nested keys like platforms.webhook.extra.routes are loaded.
+ yaml_platforms = yaml_cfg.get("platforms")
+ platforms_data = gw_data.setdefault("platforms", {})
+ if not isinstance(platforms_data, dict):
+ platforms_data = {}
+ gw_data["platforms"] = platforms_data
+ if isinstance(yaml_platforms, dict):
+ for plat_name, plat_block in yaml_platforms.items():
+ if not isinstance(plat_block, dict):
+ continue
+ existing = platforms_data.get(plat_name, {})
+ if not isinstance(existing, dict):
+ existing = {}
+ # Deep-merge extra dicts so gateway.json defaults survive
+ merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
+ merged = {**existing, **plat_block}
+ if merged_extra:
+ merged["extra"] = merged_extra
+ platforms_data[plat_name] = merged
+ gw_data["platforms"] = platforms_data
+ for plat in Platform:
+ if plat == Platform.LOCAL:
+ continue
+ platform_cfg = yaml_cfg.get(plat.value)
+ if not isinstance(platform_cfg, dict):
+ continue
+ # Collect bridgeable keys from this platform section
+ bridged = {}
+ if "unauthorized_dm_behavior" in platform_cfg:
+ bridged["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
+ platform_cfg.get("unauthorized_dm_behavior"),
+ gw_data.get("unauthorized_dm_behavior", "pair"),
+ )
+ if "reply_prefix" in platform_cfg:
+ bridged["reply_prefix"] = platform_cfg["reply_prefix"]
+ if not bridged:
+ continue
+ plat_data = platforms_data.setdefault(plat.value, {})
+ if not isinstance(plat_data, dict):
+ plat_data = {}
+ platforms_data[plat.value] = plat_data
+ extra = plat_data.setdefault("extra", {})
+ if not isinstance(extra, dict):
+ extra = {}
+ plat_data["extra"] = extra
+ extra.update(bridged)
+
+ # Discord settings โ env vars (env vars take precedence)
discord_cfg = yaml_cfg.get("discord", {})
if isinstance(discord_cfg, dict):
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
@@ -304,8 +529,17 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
- except Exception:
- pass
+ if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
+ os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
+ except Exception as e:
+ logger.warning(
+ "Failed to process config.yaml โ falling back to .env / gateway.json values. "
+ "Check %s for syntax errors. Error: %s",
+ _home / "config.yaml",
+ e,
+ )
+
+ config = GatewayConfig.from_dict(gw_data)
# Override with environment variables
_apply_env_overrides(config)
@@ -332,6 +566,8 @@ def load_gateway_config() -> GatewayConfig:
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
Platform.DISCORD: "DISCORD_BOT_TOKEN",
Platform.SLACK: "SLACK_BOT_TOKEN",
+ Platform.MATTERMOST: "MATTERMOST_TOKEN",
+ Platform.MATRIX: "MATRIX_ACCESS_TOKEN",
}
for platform, pconfig in config.platforms.items():
if not pconfig.enabled:
@@ -358,6 +594,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.TELEGRAM].enabled = True
config.platforms[Platform.TELEGRAM].token = telegram_token
+ # Reply threading mode for Telegram (off/first/all)
+ telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower()
+ if telegram_reply_mode in ("off", "first", "all"):
+ if Platform.TELEGRAM not in config.platforms:
+ config.platforms[Platform.TELEGRAM] = PlatformConfig()
+ config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode
+
+ telegram_fallback_ips = os.getenv("TELEGRAM_FALLBACK_IPS", "")
+ if telegram_fallback_ips:
+ if Platform.TELEGRAM not in config.platforms:
+ config.platforms[Platform.TELEGRAM] = PlatformConfig()
+ config.platforms[Platform.TELEGRAM].extra["fallback_ips"] = [
+ ip.strip() for ip in telegram_fallback_ips.split(",") if ip.strip()
+ ]
+
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
if telegram_home and Platform.TELEGRAM in config.platforms:
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
@@ -425,6 +676,53 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
)
+ # Mattermost
+ mattermost_token = os.getenv("MATTERMOST_TOKEN")
+ if mattermost_token:
+ mattermost_url = os.getenv("MATTERMOST_URL", "")
+ if not mattermost_url:
+ logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing")
+ if Platform.MATTERMOST not in config.platforms:
+ config.platforms[Platform.MATTERMOST] = PlatformConfig()
+ config.platforms[Platform.MATTERMOST].enabled = True
+ config.platforms[Platform.MATTERMOST].token = mattermost_token
+ config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url
+ mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
+ if mattermost_home:
+ config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
+ platform=Platform.MATTERMOST,
+ chat_id=mattermost_home,
+ name=os.getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"),
+ )
+
+ # Matrix
+ matrix_token = os.getenv("MATRIX_ACCESS_TOKEN")
+ matrix_homeserver = os.getenv("MATRIX_HOMESERVER", "")
+ if matrix_token or os.getenv("MATRIX_PASSWORD"):
+ if not matrix_homeserver:
+ logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing")
+ if Platform.MATRIX not in config.platforms:
+ config.platforms[Platform.MATRIX] = PlatformConfig()
+ config.platforms[Platform.MATRIX].enabled = True
+ if matrix_token:
+ config.platforms[Platform.MATRIX].token = matrix_token
+ config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver
+ matrix_user = os.getenv("MATRIX_USER_ID", "")
+ if matrix_user:
+ config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user
+ matrix_password = os.getenv("MATRIX_PASSWORD", "")
+ if matrix_password:
+ config.platforms[Platform.MATRIX].extra["password"] = matrix_password
+ matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
+ config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
+ matrix_home = os.getenv("MATRIX_HOME_ROOM")
+ if matrix_home:
+ config.platforms[Platform.MATRIX].home_channel = HomeChannel(
+ platform=Platform.MATRIX,
+ chat_id=matrix_home,
+ name=os.getenv("MATRIX_HOME_ROOM_NAME", "Home"),
+ )
+
# Home Assistant
hass_token = os.getenv("HASS_TOKEN")
if hass_token:
@@ -458,6 +756,61 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
)
+ # SMS (Twilio)
+ twilio_sid = os.getenv("TWILIO_ACCOUNT_SID")
+ if twilio_sid:
+ if Platform.SMS not in config.platforms:
+ config.platforms[Platform.SMS] = PlatformConfig()
+ config.platforms[Platform.SMS].enabled = True
+ config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "")
+ sms_home = os.getenv("SMS_HOME_CHANNEL")
+ if sms_home:
+ config.platforms[Platform.SMS].home_channel = HomeChannel(
+ platform=Platform.SMS,
+ chat_id=sms_home,
+ name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
+ )
+
+ # API Server
+ api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes")
+ api_server_key = os.getenv("API_SERVER_KEY", "")
+ api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "")
+ api_server_port = os.getenv("API_SERVER_PORT")
+ api_server_host = os.getenv("API_SERVER_HOST")
+ if api_server_enabled or api_server_key:
+ if Platform.API_SERVER not in config.platforms:
+ config.platforms[Platform.API_SERVER] = PlatformConfig()
+ config.platforms[Platform.API_SERVER].enabled = True
+ if api_server_key:
+ config.platforms[Platform.API_SERVER].extra["key"] = api_server_key
+ if api_server_cors_origins:
+ origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()]
+ if origins:
+ config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins
+ if api_server_port:
+ try:
+ config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port)
+ except ValueError:
+ pass
+ if api_server_host:
+ config.platforms[Platform.API_SERVER].extra["host"] = api_server_host
+
+ # Webhook platform
+ webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes")
+ webhook_port = os.getenv("WEBHOOK_PORT")
+ webhook_secret = os.getenv("WEBHOOK_SECRET", "")
+ if webhook_enabled:
+ if Platform.WEBHOOK not in config.platforms:
+ config.platforms[Platform.WEBHOOK] = PlatformConfig()
+ config.platforms[Platform.WEBHOOK].enabled = True
+ if webhook_port:
+ try:
+ config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port)
+ except ValueError:
+ pass
+ if webhook_secret:
+ config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
+
# Session settings
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
if idle_minutes:
@@ -474,10 +827,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
pass
-def save_gateway_config(config: GatewayConfig) -> None:
- """Save gateway configuration to ~/.hermes/gateway.json."""
- gateway_config_path = Path.home() / ".hermes" / "gateway.json"
- gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
-
- with open(gateway_config_path, "w", encoding="utf-8") as f:
- json.dump(config.to_dict(), f, indent=2)
diff --git a/gateway/delivery.py b/gateway/delivery.py
index 5bcd58f4c4a..5adb3c2c129 100644
--- a/gateway/delivery.py
+++ b/gateway/delivery.py
@@ -13,7 +13,8 @@
from datetime import datetime
from dataclasses import dataclass
from typing import Dict, List, Optional, Any, Union
-from enum import Enum
+
+from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
@@ -116,7 +117,7 @@ def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None):
"""
self.config = config
self.adapters = adapters or {}
- self.output_dir = Path.home() / ".hermes" / "cron" / "output"
+ self.output_dir = get_hermes_home() / "cron" / "output"
def resolve_targets(
self,
@@ -159,7 +160,7 @@ def resolve_targets(
# Always include local if configured
if self.config.always_log_local:
- local_key = (Platform.LOCAL, None)
+ local_key = (Platform.LOCAL, None, None)
if local_key not in seen_platforms:
targets.append(DeliveryTarget(platform=Platform.LOCAL))
@@ -256,7 +257,7 @@ def _deliver_local(
def _save_full_output(self, content: str, job_id: str) -> Path:
"""Save full cron output to disk and return the file path."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- out_dir = Path.home() / ".hermes" / "cron" / "output"
+ out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path.write_text(content)
@@ -313,7 +314,7 @@ def build_delivery_context_for_tool(
origin: Optional[SessionSource] = None
) -> Dict[str, Any]:
"""
- Build context for the schedule_cronjob tool to understand delivery options.
+ Build context for the unified cronjob tool to understand delivery options.
This is passed to the tool so it can validate and explain delivery targets.
"""
diff --git a/gateway/hooks.py b/gateway/hooks.py
index d2face15c57..15ecd3fee65 100644
--- a/gateway/hooks.py
+++ b/gateway/hooks.py
@@ -8,8 +8,9 @@
Events:
- gateway:startup -- Gateway process starts
- - session:start -- New session created
- - session:reset -- User ran /new or /reset
+ - session:start -- New session created (first message of a new session)
+ - session:end -- Session ends (user ran /new or /reset)
+ - session:reset -- Session reset completed (new session entry created)
- agent:start -- Agent begins processing a message
- agent:step -- Each turn in the tool-calling loop
- agent:end -- Agent finishes processing
@@ -20,14 +21,14 @@
import asyncio
import importlib.util
-import os
-from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import yaml
+from hermes_cli.config import get_hermes_home
-HOOKS_DIR = Path(os.path.expanduser("~/.hermes/hooks"))
+
+HOOKS_DIR = get_hermes_home() / "hooks"
class HookRegistry:
diff --git a/gateway/mirror.py b/gateway/mirror.py
index f54e6e1a3f9..0312424f183 100644
--- a/gateway/mirror.py
+++ b/gateway/mirror.py
@@ -12,12 +12,13 @@
import json
import logging
from datetime import datetime
-from pathlib import Path
from typing import Optional
+from hermes_cli.config import get_hermes_home
+
logger = logging.getLogger(__name__)
-_SESSIONS_DIR = Path.home() / ".hermes" / "sessions"
+_SESSIONS_DIR = get_hermes_home() / "sessions"
_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json"
diff --git a/gateway/pairing.py b/gateway/pairing.py
index b1e066ffe1f..20b64b01311 100644
--- a/gateway/pairing.py
+++ b/gateway/pairing.py
@@ -25,6 +25,8 @@
from pathlib import Path
from typing import Optional
+from hermes_cli.config import get_hermes_home
+
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -39,7 +41,7 @@
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
-PAIRING_DIR = Path(os.path.expanduser("~/.hermes/pairing"))
+PAIRING_DIR = get_hermes_home() / "pairing"
def _secure_write(path: Path, data: str) -> None:
diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md
index dadd9890d96..f773f8c8f89 100644
--- a/gateway/platforms/ADDING_A_PLATFORM.md
+++ b/gateway/platforms/ADDING_A_PLATFORM.md
@@ -173,7 +173,7 @@ platform_map = {
}
```
-Without this, `schedule_cronjob(deliver="your_platform")` silently fails.
+Without this, `cronjob(action="create", deliver="your_platform", ...)` silently fails.
---
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
new file mode 100644
index 00000000000..0641aca28d8
--- /dev/null
+++ b/gateway/platforms/api_server.py
@@ -0,0 +1,1287 @@
+"""
+OpenAI-compatible API server platform adapter.
+
+Exposes an HTTP server with endpoints:
+- POST /v1/chat/completions โ OpenAI Chat Completions format (stateless)
+- POST /v1/responses โ OpenAI Responses API format (stateful via previous_response_id)
+- GET /v1/responses/{response_id} โ Retrieve a stored response
+- DELETE /v1/responses/{response_id} โ Delete a stored response
+- GET /v1/models โ lists hermes-agent as an available model
+- GET /health โ health check
+
+Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
+AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
+through this adapter by pointing at http://localhost:8642/v1.
+
+Requires:
+- aiohttp (already available in the gateway)
+"""
+
+import asyncio
+import json
+import logging
+import os
+import sqlite3
+import time
+import uuid
+from typing import Any, Dict, List, Optional
+
+try:
+ from aiohttp import web
+ AIOHTTP_AVAILABLE = True
+except ImportError:
+ AIOHTTP_AVAILABLE = False
+ web = None # type: ignore[assignment]
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+# Default settings
+DEFAULT_HOST = "127.0.0.1"
+DEFAULT_PORT = 8642
+MAX_STORED_RESPONSES = 100
+MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
+
+
+def check_api_server_requirements() -> bool:
+ """Check if API server dependencies are available."""
+ return AIOHTTP_AVAILABLE
+
+
+class ResponseStore:
+ """
+ SQLite-backed LRU store for Responses API state.
+
+ Each stored response includes the full internal conversation history
+ (with tool calls and results) so it can be reconstructed on subsequent
+ requests via previous_response_id.
+
+ Persists across gateway restarts. Falls back to in-memory SQLite
+ if the on-disk path is unavailable.
+ """
+
+ def __init__(self, max_size: int = MAX_STORED_RESPONSES, db_path: str = None):
+ self._max_size = max_size
+ if db_path is None:
+ try:
+ from hermes_cli.config import get_hermes_home
+ db_path = str(get_hermes_home() / "response_store.db")
+ except Exception:
+ db_path = ":memory:"
+ try:
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
+ except Exception:
+ self._conn = sqlite3.connect(":memory:", check_same_thread=False)
+ self._conn.execute("PRAGMA journal_mode=WAL")
+ self._conn.execute(
+ """CREATE TABLE IF NOT EXISTS responses (
+ response_id TEXT PRIMARY KEY,
+ data TEXT NOT NULL,
+ accessed_at REAL NOT NULL
+ )"""
+ )
+ self._conn.execute(
+ """CREATE TABLE IF NOT EXISTS conversations (
+ name TEXT PRIMARY KEY,
+ response_id TEXT NOT NULL
+ )"""
+ )
+ self._conn.commit()
+
+ def get(self, response_id: str) -> Optional[Dict[str, Any]]:
+ """Retrieve a stored response by ID (updates access time for LRU)."""
+ row = self._conn.execute(
+ "SELECT data FROM responses WHERE response_id = ?", (response_id,)
+ ).fetchone()
+ if row is None:
+ return None
+ import time
+ self._conn.execute(
+ "UPDATE responses SET accessed_at = ? WHERE response_id = ?",
+ (time.time(), response_id),
+ )
+ self._conn.commit()
+ return json.loads(row[0])
+
+ def put(self, response_id: str, data: Dict[str, Any]) -> None:
+ """Store a response, evicting the oldest if at capacity."""
+ import time
+ self._conn.execute(
+ "INSERT OR REPLACE INTO responses (response_id, data, accessed_at) VALUES (?, ?, ?)",
+ (response_id, json.dumps(data, default=str), time.time()),
+ )
+ # Evict oldest entries beyond max_size
+ count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
+ if count > self._max_size:
+ self._conn.execute(
+ "DELETE FROM responses WHERE response_id IN "
+ "(SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?)",
+ (count - self._max_size,),
+ )
+ self._conn.commit()
+
+ def delete(self, response_id: str) -> bool:
+ """Remove a response from the store. Returns True if found and deleted."""
+ cursor = self._conn.execute(
+ "DELETE FROM responses WHERE response_id = ?", (response_id,)
+ )
+ self._conn.commit()
+ return cursor.rowcount > 0
+
+ def get_conversation(self, name: str) -> Optional[str]:
+ """Get the latest response_id for a conversation name."""
+ row = self._conn.execute(
+ "SELECT response_id FROM conversations WHERE name = ?", (name,)
+ ).fetchone()
+ return row[0] if row else None
+
+ def set_conversation(self, name: str, response_id: str) -> None:
+ """Map a conversation name to its latest response_id."""
+ self._conn.execute(
+ "INSERT OR REPLACE INTO conversations (name, response_id) VALUES (?, ?)",
+ (name, response_id),
+ )
+ self._conn.commit()
+
+ def close(self) -> None:
+ """Close the database connection."""
+ try:
+ self._conn.close()
+ except Exception:
+ pass
+
+ def __len__(self) -> int:
+ row = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()
+ return row[0] if row else 0
+
+
+# ---------------------------------------------------------------------------
+# CORS middleware
+# ---------------------------------------------------------------------------
+
+_CORS_HEADERS = {
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
+}
+
+
+if AIOHTTP_AVAILABLE:
+ @web.middleware
+ async def cors_middleware(request, handler):
+ """Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
+ adapter = request.app.get("api_server_adapter")
+ origin = request.headers.get("Origin", "")
+ cors_headers = None
+ if adapter is not None:
+ if not adapter._origin_allowed(origin):
+ return web.Response(status=403)
+ cors_headers = adapter._cors_headers_for_origin(origin)
+
+ if request.method == "OPTIONS":
+ if cors_headers is None:
+ return web.Response(status=403)
+ return web.Response(status=200, headers=cors_headers)
+
+ response = await handler(request)
+ if cors_headers is not None:
+ response.headers.update(cors_headers)
+ return response
+else:
+ cors_middleware = None # type: ignore[assignment]
+
+
+def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
+ """OpenAI-style error envelope."""
+ return {
+ "error": {
+ "message": message,
+ "type": err_type,
+ "param": param,
+ "code": code,
+ }
+ }
+
+
+if AIOHTTP_AVAILABLE:
+ @web.middleware
+ async def body_limit_middleware(request, handler):
+ """Reject overly large request bodies early based on Content-Length."""
+ if request.method in ("POST", "PUT", "PATCH"):
+ cl = request.headers.get("Content-Length")
+ if cl is not None:
+ try:
+ if int(cl) > MAX_REQUEST_BYTES:
+ return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
+ except ValueError:
+ return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
+ return await handler(request)
+else:
+ body_limit_middleware = None # type: ignore[assignment]
+
+
+class _IdempotencyCache:
+ """In-memory idempotency cache with TTL and basic LRU semantics."""
+ def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
+ from collections import OrderedDict
+ self._store = OrderedDict()
+ self._ttl = ttl_seconds
+ self._max = max_items
+
+ def _purge(self):
+ import time as _t
+ now = _t.time()
+ expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl]
+ for k in expired:
+ self._store.pop(k, None)
+ while len(self._store) > self._max:
+ self._store.popitem(last=False)
+
+ async def get_or_set(self, key: str, fingerprint: str, compute_coro):
+ self._purge()
+ item = self._store.get(key)
+ if item and item["fp"] == fingerprint:
+ return item["resp"]
+ resp = await compute_coro()
+ import time as _t
+ self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()}
+ self._purge()
+ return resp
+
+
+_idem_cache = _IdempotencyCache()
+
+
+def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
+ from hashlib import sha256
+ subset = {k: body.get(k) for k in keys}
+ return sha256(repr(subset).encode("utf-8")).hexdigest()
+
+
+class APIServerAdapter(BasePlatformAdapter):
+ """
+ OpenAI-compatible HTTP API server adapter.
+
+ Runs an aiohttp web server that accepts OpenAI-format requests
+ and routes them through hermes-agent's AIAgent.
+ """
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.API_SERVER)
+ extra = config.extra or {}
+ self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
+ self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))))
+ self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
+ self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
+ extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
+ )
+ self._app: Optional["web.Application"] = None
+ self._runner: Optional["web.AppRunner"] = None
+ self._site: Optional["web.TCPSite"] = None
+ self._response_store = ResponseStore()
+
+ @staticmethod
+ def _parse_cors_origins(value: Any) -> tuple[str, ...]:
+ """Normalize configured CORS origins into a stable tuple."""
+ if not value:
+ return ()
+
+ if isinstance(value, str):
+ items = value.split(",")
+ elif isinstance(value, (list, tuple, set)):
+ items = value
+ else:
+ items = [str(value)]
+
+ return tuple(str(item).strip() for item in items if str(item).strip())
+
+ def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
+ """Return CORS headers for an allowed browser origin."""
+ if not origin or not self._cors_origins:
+ return None
+
+ if "*" in self._cors_origins:
+ headers = dict(_CORS_HEADERS)
+ headers["Access-Control-Allow-Origin"] = "*"
+ return headers
+
+ if origin not in self._cors_origins:
+ return None
+
+ headers = dict(_CORS_HEADERS)
+ headers["Access-Control-Allow-Origin"] = origin
+ headers["Vary"] = "Origin"
+ return headers
+
+ def _origin_allowed(self, origin: str) -> bool:
+ """Allow non-browser clients and explicitly configured browser origins."""
+ if not origin:
+ return True
+
+ if not self._cors_origins:
+ return False
+
+ return "*" in self._cors_origins or origin in self._cors_origins
+
+ # ------------------------------------------------------------------
+ # Auth helper
+ # ------------------------------------------------------------------
+
+ def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
+ """
+ Validate Bearer token from Authorization header.
+
+ Returns None if auth is OK, or a 401 web.Response on failure.
+ If no API key is configured, all requests are allowed.
+ """
+ if not self._api_key:
+ return None # No key configured โ allow all (local-only use)
+
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:].strip()
+ if token == self._api_key:
+ return None # Auth OK
+
+ return web.json_response(
+ {"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}},
+ status=401,
+ )
+
+ # ------------------------------------------------------------------
+ # Agent creation helper
+ # ------------------------------------------------------------------
+
+ def _create_agent(
+ self,
+ ephemeral_system_prompt: Optional[str] = None,
+ session_id: Optional[str] = None,
+ stream_delta_callback=None,
+ ) -> Any:
+ """
+ Create an AIAgent instance using the gateway's runtime config.
+
+ Uses _resolve_runtime_agent_kwargs() to pick up model, api_key,
+ base_url, etc. from config.yaml / env vars. Toolsets are resolved
+ from config.yaml platform_toolsets.api_server (same as all other
+ gateway platforms), falling back to the hermes-api-server default.
+ """
+ from run_agent import AIAgent
+ from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config
+ from hermes_cli.tools_config import _get_platform_tools
+
+ runtime_kwargs = _resolve_runtime_agent_kwargs()
+ model = _resolve_gateway_model()
+
+ user_config = _load_gateway_config()
+ enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))
+
+ max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
+
+ agent = AIAgent(
+ model=model,
+ **runtime_kwargs,
+ max_iterations=max_iterations,
+ quiet_mode=True,
+ verbose_logging=False,
+ ephemeral_system_prompt=ephemeral_system_prompt or None,
+ enabled_toolsets=enabled_toolsets,
+ session_id=session_id,
+ platform="api_server",
+ stream_delta_callback=stream_delta_callback,
+ )
+ return agent
+
+ # ------------------------------------------------------------------
+ # HTTP Handlers
+ # ------------------------------------------------------------------
+
+ async def _handle_health(self, request: "web.Request") -> "web.Response":
+ """GET /health โ simple health check."""
+ return web.json_response({"status": "ok", "platform": "hermes-agent"})
+
+ async def _handle_models(self, request: "web.Request") -> "web.Response":
+ """GET /v1/models โ return hermes-agent as an available model."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+
+ return web.json_response({
+ "object": "list",
+ "data": [
+ {
+ "id": "hermes-agent",
+ "object": "model",
+ "created": int(time.time()),
+ "owned_by": "hermes",
+ "permission": [],
+ "root": "hermes-agent",
+ "parent": None,
+ }
+ ],
+ })
+
+ async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
+ """POST /v1/chat/completions โ OpenAI Chat Completions format."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+
+ # Parse request body
+ try:
+ body = await request.json()
+ except (json.JSONDecodeError, Exception):
+ return web.json_response(_openai_error("Invalid JSON in request body"), status=400)
+
+ messages = body.get("messages")
+ if not messages or not isinstance(messages, list):
+ return web.json_response(
+ {"error": {"message": "Missing or invalid 'messages' field", "type": "invalid_request_error"}},
+ status=400,
+ )
+
+ stream = body.get("stream", False)
+
+ # Extract system message (becomes ephemeral system prompt layered ON TOP of core)
+ system_prompt = None
+ conversation_messages: List[Dict[str, str]] = []
+
+ for msg in messages:
+ role = msg.get("role", "")
+ content = msg.get("content", "")
+ if role == "system":
+ # Accumulate system messages
+ if system_prompt is None:
+ system_prompt = content
+ else:
+ system_prompt = system_prompt + "\n" + content
+ elif role in ("user", "assistant"):
+ conversation_messages.append({"role": role, "content": content})
+
+ # Extract the last user message as the primary input
+ user_message = ""
+ history = []
+ if conversation_messages:
+ user_message = conversation_messages[-1].get("content", "")
+ history = conversation_messages[:-1]
+
+ if not user_message:
+ return web.json_response(
+ {"error": {"message": "No user message found in messages", "type": "invalid_request_error"}},
+ status=400,
+ )
+
+ session_id = str(uuid.uuid4())
+ completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
+ model_name = body.get("model", "hermes-agent")
+ created = int(time.time())
+
+ if stream:
+ import queue as _q
+ _stream_q: _q.Queue = _q.Queue()
+
+ def _on_delta(delta):
+ # Filter out None โ the agent fires stream_delta_callback(None)
+ # to signal the CLI display to close its response box before
+ # tool execution, but the SSE writer uses None as end-of-stream
+ # sentinel. Forwarding it would prematurely close the HTTP
+ # response, causing Open WebUI (and similar frontends) to miss
+ # the final answer after tool calls. The SSE loop detects
+ # completion via agent_task.done() instead.
+ if delta is not None:
+ _stream_q.put(delta)
+
+ # Start agent in background. agent_ref is a mutable container
+ # so the SSE writer can interrupt the agent on client disconnect.
+ agent_ref = [None]
+ agent_task = asyncio.ensure_future(self._run_agent(
+ user_message=user_message,
+ conversation_history=history,
+ ephemeral_system_prompt=system_prompt,
+ session_id=session_id,
+ stream_delta_callback=_on_delta,
+ agent_ref=agent_ref,
+ ))
+
+ return await self._write_sse_chat_completion(
+ request, completion_id, model_name, created, _stream_q,
+ agent_task, agent_ref,
+ )
+
+ # Non-streaming: run the agent (with optional Idempotency-Key)
+ async def _compute_completion():
+ return await self._run_agent(
+ user_message=user_message,
+ conversation_history=history,
+ ephemeral_system_prompt=system_prompt,
+ session_id=session_id,
+ )
+
+ idempotency_key = request.headers.get("Idempotency-Key")
+ if idempotency_key:
+ fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"])
+ try:
+ result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion)
+ except Exception as e:
+ logger.error("Error running agent for chat completions: %s", e, exc_info=True)
+ return web.json_response(
+ _openai_error(f"Internal server error: {e}", err_type="server_error"),
+ status=500,
+ )
+ else:
+ try:
+ result, usage = await _compute_completion()
+ except Exception as e:
+ logger.error("Error running agent for chat completions: %s", e, exc_info=True)
+ return web.json_response(
+ _openai_error(f"Internal server error: {e}", err_type="server_error"),
+ status=500,
+ )
+
+ final_response = result.get("final_response", "")
+ if not final_response:
+ final_response = result.get("error", "(No response generated)")
+
+ response_data = {
+ "id": completion_id,
+ "object": "chat.completion",
+ "created": created,
+ "model": model_name,
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": final_response,
+ },
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": usage.get("input_tokens", 0),
+ "completion_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ },
+ }
+
+ return web.json_response(response_data)
+
+ async def _write_sse_chat_completion(
+ self, request: "web.Request", completion_id: str, model: str,
+ created: int, stream_q, agent_task, agent_ref=None,
+ ) -> "web.StreamResponse":
+ """Write real streaming SSE from agent's stream_delta_callback queue.
+
+ If the client disconnects mid-stream (network drop, browser tab close),
+ the agent is interrupted via ``agent.interrupt()`` so it stops making
+ LLM API calls, and the asyncio task wrapper is cancelled.
+ """
+ import queue as _q
+
+ response = web.StreamResponse(
+ status=200,
+ headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
+ )
+ await response.prepare(request)
+
+ try:
+ # Role chunk
+ role_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
+ }
+ await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
+
+ # Stream content chunks as they arrive from the agent
+ loop = asyncio.get_event_loop()
+ while True:
+ try:
+ delta = await loop.run_in_executor(None, lambda: stream_q.get(timeout=0.5))
+ except _q.Empty:
+ if agent_task.done():
+ # Drain any remaining items
+ while True:
+ try:
+ delta = stream_q.get_nowait()
+ if delta is None:
+ break
+ content_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
+ }
+ await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
+ except _q.Empty:
+ break
+ break
+ continue
+
+ if delta is None: # End of stream sentinel
+ break
+
+ content_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
+ }
+ await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
+
+ # Get usage from completed agent
+ usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
+ try:
+ result, agent_usage = await agent_task
+ usage = agent_usage or usage
+ except Exception:
+ pass
+
+ # Finish chunk
+ finish_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
+ "usage": {
+ "prompt_tokens": usage.get("input_tokens", 0),
+ "completion_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ },
+ }
+ await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
+ await response.write(b"data: [DONE]\n\n")
+ except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
+ # Client disconnected mid-stream. Interrupt the agent so it
+ # stops making LLM API calls at the next loop iteration, then
+ # cancel the asyncio task wrapper.
+ agent = agent_ref[0] if agent_ref else None
+ if agent is not None:
+ try:
+ agent.interrupt("SSE client disconnected")
+ except Exception:
+ pass
+ if not agent_task.done():
+ agent_task.cancel()
+ try:
+ await agent_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
+
+ return response
+
+ async def _handle_responses(self, request: "web.Request") -> "web.Response":
+ """POST /v1/responses โ OpenAI Responses API format."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+
+ # Parse request body
+ try:
+ body = await request.json()
+ except (json.JSONDecodeError, Exception):
+ return web.json_response(
+ {"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}},
+ status=400,
+ )
+
+ raw_input = body.get("input")
+ if raw_input is None:
+ return web.json_response(_openai_error("Missing 'input' field"), status=400)
+
+ instructions = body.get("instructions")
+ previous_response_id = body.get("previous_response_id")
+ conversation = body.get("conversation")
+ store = body.get("store", True)
+
+ # conversation and previous_response_id are mutually exclusive
+ if conversation and previous_response_id:
+ return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400)
+
+ # Resolve conversation name to latest response_id
+ if conversation:
+ previous_response_id = self._response_store.get_conversation(conversation)
+ # No error if conversation doesn't exist yet โ it's a new conversation
+
+ # Normalize input to message list
+ input_messages: List[Dict[str, str]] = []
+ if isinstance(raw_input, str):
+ input_messages = [{"role": "user", "content": raw_input}]
+ elif isinstance(raw_input, list):
+ for item in raw_input:
+ if isinstance(item, str):
+ input_messages.append({"role": "user", "content": item})
+ elif isinstance(item, dict):
+ role = item.get("role", "user")
+ content = item.get("content", "")
+ # Handle content that may be a list of content parts
+ if isinstance(content, list):
+ text_parts = []
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "input_text":
+ text_parts.append(part.get("text", ""))
+ elif isinstance(part, dict) and part.get("type") == "output_text":
+ text_parts.append(part.get("text", ""))
+ elif isinstance(part, str):
+ text_parts.append(part)
+ content = "\n".join(text_parts)
+ input_messages.append({"role": role, "content": content})
+ else:
+ return web.json_response(_openai_error("'input' must be a string or array"), status=400)
+
+ # Reconstruct conversation history from previous_response_id
+ conversation_history: List[Dict[str, str]] = []
+ if previous_response_id:
+ stored = self._response_store.get(previous_response_id)
+ if stored is None:
+ return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
+ conversation_history = list(stored.get("conversation_history", []))
+ # If no instructions provided, carry forward from previous
+ if instructions is None:
+ instructions = stored.get("instructions")
+
+ # Append new input messages to history (all but the last become history)
+ for msg in input_messages[:-1]:
+ conversation_history.append(msg)
+
+ # Last input message is the user_message
+ user_message = input_messages[-1].get("content", "") if input_messages else ""
+ if not user_message:
+ return web.json_response(_openai_error("No user message found in input"), status=400)
+
+ # Truncation support
+ if body.get("truncation") == "auto" and len(conversation_history) > 100:
+ conversation_history = conversation_history[-100:]
+
+ # Run the agent (with Idempotency-Key support)
+ session_id = str(uuid.uuid4())
+
+ async def _compute_response():
+ return await self._run_agent(
+ user_message=user_message,
+ conversation_history=conversation_history,
+ ephemeral_system_prompt=instructions,
+ session_id=session_id,
+ )
+
+ idempotency_key = request.headers.get("Idempotency-Key")
+ if idempotency_key:
+ fp = _make_request_fingerprint(
+ body,
+ keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"],
+ )
+ try:
+ result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response)
+ except Exception as e:
+ logger.error("Error running agent for responses: %s", e, exc_info=True)
+ return web.json_response(
+ _openai_error(f"Internal server error: {e}", err_type="server_error"),
+ status=500,
+ )
+ else:
+ try:
+ result, usage = await _compute_response()
+ except Exception as e:
+ logger.error("Error running agent for responses: %s", e, exc_info=True)
+ return web.json_response(
+ _openai_error(f"Internal server error: {e}", err_type="server_error"),
+ status=500,
+ )
+
+ final_response = result.get("final_response", "")
+ if not final_response:
+ final_response = result.get("error", "(No response generated)")
+
+ response_id = f"resp_{uuid.uuid4().hex[:28]}"
+ created_at = int(time.time())
+
+ # Build the full conversation history for storage
+ # (includes tool calls from the agent run)
+ full_history = list(conversation_history)
+ full_history.append({"role": "user", "content": user_message})
+ # Add agent's internal messages if available
+ agent_messages = result.get("messages", [])
+ if agent_messages:
+ full_history.extend(agent_messages)
+ else:
+ full_history.append({"role": "assistant", "content": final_response})
+
+ # Build output items (includes tool calls + final message)
+ output_items = self._extract_output_items(result)
+
+ response_data = {
+ "id": response_id,
+ "object": "response",
+ "status": "completed",
+ "created_at": created_at,
+ "model": body.get("model", "hermes-agent"),
+ "output": output_items,
+ "usage": {
+ "input_tokens": usage.get("input_tokens", 0),
+ "output_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ },
+ }
+
+ # Store the complete response object for future chaining / GET retrieval
+ if store:
+ self._response_store.put(response_id, {
+ "response": response_data,
+ "conversation_history": full_history,
+ "instructions": instructions,
+ })
+ # Update conversation mapping so the next request with the same
+ # conversation name automatically chains to this response
+ if conversation:
+ self._response_store.set_conversation(conversation, response_id)
+
+ return web.json_response(response_data)
+
+ # ------------------------------------------------------------------
+ # GET / DELETE response endpoints
+ # ------------------------------------------------------------------
+
+ async def _handle_get_response(self, request: "web.Request") -> "web.Response":
+ """GET /v1/responses/{response_id} โ retrieve a stored response."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+
+ response_id = request.match_info["response_id"]
+ stored = self._response_store.get(response_id)
+ if stored is None:
+ return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
+
+ return web.json_response(stored["response"])
+
+ async def _handle_delete_response(self, request: "web.Request") -> "web.Response":
+ """DELETE /v1/responses/{response_id} โ delete a stored response."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+
+ response_id = request.match_info["response_id"]
+ deleted = self._response_store.delete(response_id)
+ if not deleted:
+ return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
+
+ return web.json_response({
+ "id": response_id,
+ "object": "response",
+ "deleted": True,
+ })
+
+ # ------------------------------------------------------------------
+ # Cron jobs API
+ # ------------------------------------------------------------------
+
+ # Check cron module availability once (not per-request)
+ _CRON_AVAILABLE = False
+ try:
+ from cron.jobs import (
+ list_jobs as _cron_list,
+ get_job as _cron_get,
+ create_job as _cron_create,
+ update_job as _cron_update,
+ remove_job as _cron_remove,
+ pause_job as _cron_pause,
+ resume_job as _cron_resume,
+ trigger_job as _cron_trigger,
+ )
+ _CRON_AVAILABLE = True
+ except ImportError:
+ pass
+
+ _JOB_ID_RE = __import__("re").compile(r"[a-f0-9]{12}")
+ # Allowed fields for update โ prevents clients injecting arbitrary keys
+ _UPDATE_ALLOWED_FIELDS = {"name", "schedule", "prompt", "deliver", "skills", "skill", "repeat", "enabled"}
+ _MAX_NAME_LENGTH = 200
+ _MAX_PROMPT_LENGTH = 5000
+
+ def _check_jobs_available(self) -> Optional["web.Response"]:
+ """Return error response if cron module isn't available."""
+ if not self._CRON_AVAILABLE:
+ return web.json_response(
+ {"error": "Cron module not available"}, status=501,
+ )
+ return None
+
+ def _check_job_id(self, request: "web.Request") -> tuple:
+ """Validate and extract job_id. Returns (job_id, error_response)."""
+ job_id = request.match_info["job_id"]
+ if not self._JOB_ID_RE.fullmatch(job_id):
+ return job_id, web.json_response(
+ {"error": "Invalid job ID format"}, status=400,
+ )
+ return job_id, None
+
+ async def _handle_list_jobs(self, request: "web.Request") -> "web.Response":
+ """GET /api/jobs โ list all cron jobs."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ try:
+ include_disabled = request.query.get("include_disabled", "").lower() in ("true", "1")
+ jobs = self._cron_list(include_disabled=include_disabled)
+ return web.json_response({"jobs": jobs})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_create_job(self, request: "web.Request") -> "web.Response":
+ """POST /api/jobs โ create a new cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ try:
+ body = await request.json()
+ name = (body.get("name") or "").strip()
+ schedule = (body.get("schedule") or "").strip()
+ prompt = body.get("prompt", "")
+ deliver = body.get("deliver", "local")
+ skills = body.get("skills")
+ repeat = body.get("repeat")
+
+ if not name:
+ return web.json_response({"error": "Name is required"}, status=400)
+ if len(name) > self._MAX_NAME_LENGTH:
+ return web.json_response(
+ {"error": f"Name must be โค {self._MAX_NAME_LENGTH} characters"}, status=400,
+ )
+ if not schedule:
+ return web.json_response({"error": "Schedule is required"}, status=400)
+ if len(prompt) > self._MAX_PROMPT_LENGTH:
+ return web.json_response(
+ {"error": f"Prompt must be โค {self._MAX_PROMPT_LENGTH} characters"}, status=400,
+ )
+ if repeat is not None and (not isinstance(repeat, int) or repeat < 1):
+ return web.json_response({"error": "Repeat must be a positive integer"}, status=400)
+
+ kwargs = {
+ "prompt": prompt,
+ "schedule": schedule,
+ "name": name,
+ "deliver": deliver,
+ }
+ if skills:
+ kwargs["skills"] = skills
+ if repeat is not None:
+ kwargs["repeat"] = repeat
+
+ job = self._cron_create(**kwargs)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_get_job(self, request: "web.Request") -> "web.Response":
+ """GET /api/jobs/{job_id} โ get a single cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ job = self._cron_get(job_id)
+ if not job:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_update_job(self, request: "web.Request") -> "web.Response":
+ """PATCH /api/jobs/{job_id} โ update a cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ body = await request.json()
+ # Whitelist allowed fields to prevent arbitrary key injection
+ sanitized = {k: v for k, v in body.items() if k in self._UPDATE_ALLOWED_FIELDS}
+ if not sanitized:
+ return web.json_response({"error": "No valid fields to update"}, status=400)
+ # Validate lengths if present
+ if "name" in sanitized and len(sanitized["name"]) > self._MAX_NAME_LENGTH:
+ return web.json_response(
+ {"error": f"Name must be โค {self._MAX_NAME_LENGTH} characters"}, status=400,
+ )
+ if "prompt" in sanitized and len(sanitized["prompt"]) > self._MAX_PROMPT_LENGTH:
+ return web.json_response(
+ {"error": f"Prompt must be โค {self._MAX_PROMPT_LENGTH} characters"}, status=400,
+ )
+ job = self._cron_update(job_id, sanitized)
+ if not job:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_delete_job(self, request: "web.Request") -> "web.Response":
+ """DELETE /api/jobs/{job_id} โ delete a cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ success = self._cron_remove(job_id)
+ if not success:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"ok": True})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_pause_job(self, request: "web.Request") -> "web.Response":
+ """POST /api/jobs/{job_id}/pause โ pause a cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ job = self._cron_pause(job_id)
+ if not job:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_resume_job(self, request: "web.Request") -> "web.Response":
+ """POST /api/jobs/{job_id}/resume โ resume a paused cron job."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ job = self._cron_resume(job_id)
+ if not job:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ async def _handle_run_job(self, request: "web.Request") -> "web.Response":
+ """POST /api/jobs/{job_id}/run โ trigger immediate execution."""
+ auth_err = self._check_auth(request)
+ if auth_err:
+ return auth_err
+ cron_err = self._check_jobs_available()
+ if cron_err:
+ return cron_err
+ job_id, id_err = self._check_job_id(request)
+ if id_err:
+ return id_err
+ try:
+ job = self._cron_trigger(job_id)
+ if not job:
+ return web.json_response({"error": "Job not found"}, status=404)
+ return web.json_response({"job": job})
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
+ # ------------------------------------------------------------------
+ # Output extraction helper
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _extract_output_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """
+ Build the full output item array from the agent's messages.
+
+ Walks *result["messages"]* and emits:
+ - ``function_call`` items for each tool_call on assistant messages
+ - ``function_call_output`` items for each tool-role message
+ - a final ``message`` item with the assistant's text reply
+ """
+ items: List[Dict[str, Any]] = []
+ messages = result.get("messages", [])
+
+ for msg in messages:
+ role = msg.get("role")
+ if role == "assistant" and msg.get("tool_calls"):
+ for tc in msg["tool_calls"]:
+ func = tc.get("function", {})
+ items.append({
+ "type": "function_call",
+ "name": func.get("name", ""),
+ "arguments": func.get("arguments", ""),
+ "call_id": tc.get("id", ""),
+ })
+ elif role == "tool":
+ items.append({
+ "type": "function_call_output",
+ "call_id": msg.get("tool_call_id", ""),
+ "output": msg.get("content", ""),
+ })
+
+ # Final assistant message
+ final = result.get("final_response", "")
+ if not final:
+ final = result.get("error", "(No response generated)")
+
+ items.append({
+ "type": "message",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": final,
+ }
+ ],
+ })
+ return items
+
+ # ------------------------------------------------------------------
+ # Agent execution
+ # ------------------------------------------------------------------
+
+ async def _run_agent(
+ self,
+ user_message: str,
+ conversation_history: List[Dict[str, str]],
+ ephemeral_system_prompt: Optional[str] = None,
+ session_id: Optional[str] = None,
+ stream_delta_callback=None,
+ agent_ref: Optional[list] = None,
+ ) -> tuple:
+ """
+ Create an agent and run a conversation in a thread executor.
+
+ Returns ``(result_dict, usage_dict)`` where *usage_dict* contains
+ ``input_tokens``, ``output_tokens`` and ``total_tokens``.
+
+ If *agent_ref* is a one-element list, the AIAgent instance is stored
+ at ``agent_ref[0]`` before ``run_conversation`` begins. This allows
+ callers (e.g. the SSE writer) to call ``agent.interrupt()`` from
+ another thread to stop in-progress LLM calls.
+ """
+ loop = asyncio.get_event_loop()
+
+ def _run():
+ agent = self._create_agent(
+ ephemeral_system_prompt=ephemeral_system_prompt,
+ session_id=session_id,
+ stream_delta_callback=stream_delta_callback,
+ )
+ if agent_ref is not None:
+ agent_ref[0] = agent
+ result = agent.run_conversation(
+ user_message=user_message,
+ conversation_history=conversation_history,
+ )
+ usage = {
+ "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
+ "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
+ "total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
+ }
+ return result, usage
+
+ return await loop.run_in_executor(None, _run)
+
+ # ------------------------------------------------------------------
+ # BasePlatformAdapter interface
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ """Start the aiohttp web server."""
+ if not AIOHTTP_AVAILABLE:
+ logger.warning("[%s] aiohttp not installed", self.name)
+ return False
+
+ try:
+ mws = [mw for mw in (cors_middleware, body_limit_middleware) if mw is not None]
+ self._app = web.Application(middlewares=mws)
+ self._app["api_server_adapter"] = self
+ self._app.router.add_get("/health", self._handle_health)
+ self._app.router.add_get("/v1/models", self._handle_models)
+ self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
+ self._app.router.add_post("/v1/responses", self._handle_responses)
+ self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
+ self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
+ # Cron jobs management API
+ self._app.router.add_get("/api/jobs", self._handle_list_jobs)
+ self._app.router.add_post("/api/jobs", self._handle_create_job)
+ self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
+ self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
+ self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
+ self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
+ self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
+ self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
+
+ self._runner = web.AppRunner(self._app)
+ await self._runner.setup()
+ self._site = web.TCPSite(self._runner, self._host, self._port)
+ await self._site.start()
+
+ self._mark_connected()
+ logger.info(
+ "[%s] API server listening on http://%s:%d",
+ self.name, self._host, self._port,
+ )
+ return True
+
+ except Exception as e:
+ logger.error("[%s] Failed to start API server: %s", self.name, e)
+ return False
+
+ async def disconnect(self) -> None:
+ """Stop the aiohttp web server."""
+ self._mark_disconnected()
+ if self._site:
+ await self._site.stop()
+ self._site = None
+ if self._runner:
+ await self._runner.cleanup()
+ self._runner = None
+ self._app = None
+ logger.info("[%s] API server stopped", self.name)
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """
+ Not used โ HTTP request/response cycle handles delivery directly.
+ """
+ return SendResult(success=False, error="API server uses HTTP request/response, not send()")
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return basic info about the API server."""
+ return {
+ "name": "API Server",
+ "type": "api",
+ "host": self._host,
+ "port": self._port,
+ }
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index ba8d763ce6a..7f72635b6d2 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -8,6 +8,7 @@
import asyncio
import logging
import os
+import random
import re
import uuid
from abc import ABC, abstractmethod
@@ -25,6 +26,13 @@
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
+from hermes_cli.config import get_hermes_home
+
+
+GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
+ "Secure secret entry is not supported over messaging. "
+ "Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually."
+)
# ---------------------------------------------------------------------------
@@ -36,8 +44,8 @@
# (e.g. Telegram file URLs expire after ~1 hour).
# ---------------------------------------------------------------------------
-# Default location: ~/.hermes/image_cache/
-IMAGE_CACHE_DIR = Path(os.path.expanduser("~/.hermes/image_cache"))
+# Default location: {HERMES_HOME}/image_cache/
+IMAGE_CACHE_DIR = get_hermes_home() / "image_cache"
def get_image_cache_dir() -> Path:
@@ -64,31 +72,51 @@ def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
return str(filepath)
-async def cache_image_from_url(url: str, ext: str = ".jpg") -> str:
+async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> str:
"""
Download an image from a URL and save it to the local cache.
- Uses httpx for async download with a reasonable timeout.
+ Retries on transient failures (timeouts, 429, 5xx) with exponential
+ backoff so a single slow CDN response doesn't lose the media.
Args:
url: The HTTP/HTTPS URL to download from.
ext: File extension including the dot (e.g. ".jpg", ".png").
+ retries: Number of retry attempts on transient failures.
Returns:
Absolute path to the cached image file as a string.
"""
+ import asyncio
import httpx
+ import logging as _logging
+ _log = _logging.getLogger(__name__)
+ last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
- response = await client.get(
- url,
- headers={
- "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
- "Accept": "image/*,*/*;q=0.8",
- },
- )
- response.raise_for_status()
- return cache_image_from_bytes(response.content, ext)
+ for attempt in range(retries + 1):
+ try:
+ response = await client.get(
+ url,
+ headers={
+ "User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)",
+ "Accept": "image/*,*/*;q=0.8",
+ },
+ )
+ response.raise_for_status()
+ return cache_image_from_bytes(response.content, ext)
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
+ last_exc = exc
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
+ raise
+ if attempt < retries:
+ wait = 1.5 * (attempt + 1)
+ _log.debug("Media cache retry %d/%d for %s (%.1fs): %s",
+ attempt + 1, retries, url[:80], wait, exc)
+ await asyncio.sleep(wait)
+ continue
+ raise
+ raise last_exc
def cleanup_image_cache(max_age_hours: int = 24) -> int:
@@ -119,7 +147,7 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int:
# here so the STT tool (OpenAI Whisper) can transcribe them from local files.
# ---------------------------------------------------------------------------
-AUDIO_CACHE_DIR = Path(os.path.expanduser("~/.hermes/audio_cache"))
+AUDIO_CACHE_DIR = get_hermes_home() / "audio_cache"
def get_audio_cache_dir() -> Path:
@@ -178,7 +206,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg") -> str:
# here so the agent can reference them by local file path.
# ---------------------------------------------------------------------------
-DOCUMENT_CACHE_DIR = Path(os.path.expanduser("~/.hermes/document_cache"))
+DOCUMENT_CACHE_DIR = get_hermes_home() / "document_cache"
SUPPORTED_DOCUMENT_TYPES = {
".pdf": "application/pdf",
@@ -281,11 +309,16 @@ class MessageEvent:
message_id: Optional[str] = None
# Media attachments
+ # media_urls: local file paths (for vision tool access)
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)
# Reply context
reply_to_message_id: Optional[str] = None
+ reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
+
+ # Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics)
+ auto_skill: Optional[str] = None
# Timestamps
timestamp: datetime = field(default_factory=datetime.now)
@@ -317,6 +350,24 @@ class SendResult:
message_id: Optional[str] = None
error: Optional[str] = None
raw_response: Any = None
+ retryable: bool = False # True for transient errors (network, timeout) โ base will retry automatically
+
+
+# Error substrings that indicate a transient network failure worth retrying
+_RETRYABLE_ERROR_PATTERNS = (
+ "connecterror",
+ "connectionerror",
+ "connectionreset",
+ "connectionrefused",
+ "timeout",
+ "timed out",
+ "network",
+ "broken pipe",
+ "remotedisconnected",
+ "eoferror",
+ "readtimeout",
+ "writetimeout",
+)
# Type for message handlers
@@ -339,11 +390,85 @@ def __init__(self, config: PlatformConfig, platform: Platform):
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._running = False
+ self._fatal_error_code: Optional[str] = None
+ self._fatal_error_message: Optional[str] = None
+ self._fatal_error_retryable = True
+ self._fatal_error_handler: Optional[Callable[["BasePlatformAdapter"], Awaitable[None] | None]] = None
# Track active message handlers per session for interrupt support
# Key: session_key (e.g., chat_id), Value: (event, asyncio.Event for interrupt)
self._active_sessions: Dict[str, asyncio.Event] = {}
self._pending_messages: Dict[str, MessageEvent] = {}
+ # Background message-processing tasks spawned by handle_message().
+ # Gateway shutdown cancels these so an old gateway instance doesn't keep
+ # working on a task after --replace or manual restarts.
+ self._background_tasks: set[asyncio.Task] = set()
+ # Chats where auto-TTS on voice input is disabled (set by /voice off)
+ self._auto_tts_disabled_chats: set = set()
+
+ @property
+ def has_fatal_error(self) -> bool:
+ return self._fatal_error_message is not None
+
+ @property
+ def fatal_error_message(self) -> Optional[str]:
+ return self._fatal_error_message
+
+ @property
+ def fatal_error_code(self) -> Optional[str]:
+ return self._fatal_error_code
+
+ @property
+ def fatal_error_retryable(self) -> bool:
+ return self._fatal_error_retryable
+
+ def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None:
+ self._fatal_error_handler = handler
+
+ def _mark_connected(self) -> None:
+ self._running = True
+ self._fatal_error_code = None
+ self._fatal_error_message = None
+ self._fatal_error_retryable = True
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(platform=self.platform.value, platform_state="connected", error_code=None, error_message=None)
+ except Exception:
+ pass
+
+ def _mark_disconnected(self) -> None:
+ self._running = False
+ if self.has_fatal_error:
+ return
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(platform=self.platform.value, platform_state="disconnected", error_code=None, error_message=None)
+ except Exception:
+ pass
+
+ def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None:
+ self._running = False
+ self._fatal_error_code = code
+ self._fatal_error_message = message
+ self._fatal_error_retryable = retryable
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(
+ platform=self.platform.value,
+ platform_state="fatal",
+ error_code=code,
+ error_message=message,
+ )
+ except Exception:
+ pass
+
+ async def _notify_fatal_error(self) -> None:
+ handler = self._fatal_error_handler
+ if not handler:
+ return
+ result = handler(self)
+ if asyncio.iscoroutine(result):
+ await result
@property
def name(self) -> str:
@@ -421,6 +546,14 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
metadata: optional dict with platform-specific context (e.g. thread_id for Slack).
"""
pass
+
+ async def stop_typing(self, chat_id: str) -> None:
+ """Stop a persistent typing indicator (if the platform uses one).
+
+ Override in subclasses that start background typing loops.
+ Default is a no-op for platforms with one-shot typing indicators.
+ """
+ pass
async def send_image(
self,
@@ -428,6 +561,7 @@ async def send_image(
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""
Send an image natively via the platform API.
@@ -446,6 +580,7 @@ async def send_animation(
animation_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""
Send an animated GIF natively via the platform API.
@@ -454,7 +589,7 @@ async def send_animation(
(e.g., Telegram send_animation) so they auto-play inline.
Default falls back to send_image.
"""
- return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to)
+ return await self.send_image(chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to, metadata=metadata)
@staticmethod
def _is_animation_url(url: str) -> bool:
@@ -530,6 +665,20 @@ async def send_voice(
text = f"{caption}\n{text}"
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to)
+ async def play_tts(
+ self,
+ chat_id: str,
+ audio_path: str,
+ **kwargs,
+ ) -> SendResult:
+ """
+ Play auto-TTS audio for voice replies.
+
+ Override in subclasses for invisible playback (e.g. Web UI).
+ Default falls back to send_voice (shows audio player).
+ """
+ return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)
+
async def send_video(
self,
chat_id: str,
@@ -611,20 +760,94 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
has_voice_tag = "[[audio_as_voice]]" in content
cleaned = cleaned.replace("[[audio_as_voice]]", "")
- # Extract MEDIA: tags (path may contain spaces)
- media_pattern = r'MEDIA:(\S+)'
- for match in re.finditer(media_pattern, content):
- path = match.group(1).strip()
+ # Extract MEDIA: tags, allowing optional whitespace after the colon
+ # and quoted/backticked paths for LLM-formatted outputs.
+ media_pattern = re.compile(
+ r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
+ )
+ for match in media_pattern.finditer(content):
+ path = match.group("path").strip()
+ if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
+ path = path[1:-1].strip()
+ path = path.lstrip("`\"'").rstrip("`\"',.;:)}]")
if path:
media.append((path, has_voice_tag))
-
- # Remove MEDIA tags from content
+
+ # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
if media:
- cleaned = re.sub(media_pattern, '', cleaned)
+ cleaned = media_pattern.sub('', cleaned)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
return media, cleaned
-
+
+ @staticmethod
+ def extract_local_files(content: str) -> Tuple[List[str], str]:
+ """
+ Detect bare local file paths in response text for native media delivery.
+
+ Matches absolute paths (/...) and tilde paths (~/) ending in common
+ image or video extensions. Validates each candidate with
+ ``os.path.isfile()`` to avoid false positives from URLs or
+ non-existent paths.
+
+ Paths inside fenced code blocks (``` ... ```) and inline code
+ (`...`) are ignored so that code samples are never mutilated.
+
+ Returns:
+ Tuple of (list of expanded file paths, cleaned text with the
+ raw path strings removed).
+ """
+ _LOCAL_MEDIA_EXTS = (
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
+ '.mp4', '.mov', '.avi', '.mkv', '.webm',
+ )
+ ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
+
+ # (? bool:
+ return any(s <= pos < e for s, e in code_spans)
+
+ found: list = [] # (raw_match_text, expanded_path)
+ for match in path_re.finditer(content):
+ if _in_code(match.start()):
+ continue
+ raw = match.group(0)
+ expanded = os.path.expanduser(raw)
+ if os.path.isfile(expanded):
+ found.append((raw, expanded))
+
+ # Deduplicate by expanded path, preserving discovery order
+ seen: set = set()
+ unique: list = []
+ for raw, expanded in found:
+ if expanded not in seen:
+ seen.add(expanded)
+ unique.append((raw, expanded))
+
+ paths = [expanded for _, expanded in unique]
+
+ cleaned = content
+ if unique:
+ for raw, _exp in unique:
+ cleaned = cleaned.replace(raw, '')
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
+
+ return paths, cleaned
+
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None:
"""
Continuously send typing indicator until cancelled.
@@ -638,7 +861,102 @@ async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None)
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
+ finally:
+ # Ensure the underlying platform typing loop is stopped.
+ # _keep_typing may have called send_typing() after an outer
+ # stop_typing() cleared the task dict, recreating the loop.
+ # Cancelling _keep_typing alone won't clean that up.
+ if hasattr(self, "stop_typing"):
+ try:
+ await self.stop_typing(chat_id)
+ except Exception:
+ pass
+ @staticmethod
+ def _is_retryable_error(error: Optional[str]) -> bool:
+ """Return True if the error string looks like a transient network failure."""
+ if not error:
+ return False
+ lowered = error.lower()
+ return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS)
+
+ async def _send_with_retry(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Any = None,
+ max_retries: int = 2,
+ base_delay: float = 2.0,
+ ) -> "SendResult":
+ """
+ Send a message with automatic retry for transient network errors.
+
+ On permanent failures (e.g. formatting / permission errors) falls back
+ to a plain-text version before giving up. If all attempts fail due to
+ network errors, sends the user a brief delivery-failure notice so they
+ know to retry rather than waiting indefinitely.
+ """
+
+ result = await self.send(
+ chat_id=chat_id,
+ content=content,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+
+ if result.success:
+ return result
+
+ error_str = result.error or ""
+ is_network = result.retryable or self._is_retryable_error(error_str)
+
+ if is_network:
+ # Retry with exponential backoff for transient errors
+ for attempt in range(1, max_retries + 1):
+ delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
+ logger.warning(
+ "[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s",
+ self.name, attempt, max_retries, delay, error_str,
+ )
+ await asyncio.sleep(delay)
+ result = await self.send(
+ chat_id=chat_id,
+ content=content,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if result.success:
+ logger.info("[%s] Send succeeded on retry %d", self.name, attempt)
+ return result
+ error_str = result.error or ""
+ if not (result.retryable or self._is_retryable_error(error_str)):
+ break # error switched to non-transient โ fall through to plain-text fallback
+ else:
+ # All retries exhausted (loop completed without break) โ notify user
+ logger.error("[%s] Failed to deliver response after %d retries: %s", self.name, max_retries, error_str)
+ notice = (
+ "\u26a0\ufe0f Message delivery failed after multiple attempts. "
+ "Please try again \u2014 your request was processed but the response could not be sent."
+ )
+ try:
+ await self.send(chat_id=chat_id, content=notice, reply_to=reply_to, metadata=metadata)
+ except Exception as notify_err:
+ logger.debug("[%s] Could not send delivery-failure notice: %s", self.name, notify_err)
+ return result
+
+ # Non-network / post-retry formatting failure: try plain text as fallback
+ logger.warning("[%s] Send failed: %s โ trying plain-text fallback", self.name, error_str)
+ fallback_result = await self.send(
+ chat_id=chat_id,
+ content=f"(Response formatting failed, plain text:)\n\n{content[:3500]}",
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if not fallback_result.success:
+ logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error)
+ return fallback_result
+
async def handle_message(self, event: MessageEvent) -> None:
"""
Process an incoming message.
@@ -650,11 +968,32 @@ async def handle_message(self, event: MessageEvent) -> None:
if not self._message_handler:
return
- session_key = build_session_key(event.source)
+ session_key = build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ )
# Check if there's already an active handler for this session
if session_key in self._active_sessions:
- # Store this as a pending message - it will interrupt the running agent
+ # Special case: photo bursts/albums frequently arrive as multiple near-
+ # simultaneous messages. Queue them without interrupting the active run,
+ # then process them immediately after the current task finishes.
+ if event.message_type == MessageType.PHOTO:
+ print(f"[{self.name}] ๐ผ๏ธ Queuing photo follow-up for session {session_key} without interrupt")
+ existing = self._pending_messages.get(session_key)
+ if existing and existing.message_type == MessageType.PHOTO:
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ if not existing.text:
+ existing.text = event.text
+ elif event.text not in existing.text:
+ existing.text = f"{existing.text}\n\n{event.text}".strip()
+ else:
+ self._pending_messages[session_key] = event
+ return # Don't interrupt now - will run after current task completes
+
+ # Default behavior for non-photo follow-ups: interrupt the running agent
print(f"[{self.name}] โก New message while session {session_key} is active - triggering interrupt")
self._pending_messages[session_key] = event
# Signal the interrupt (the processing task checks this)
@@ -662,7 +1001,15 @@ async def handle_message(self, event: MessageEvent) -> None:
return # Don't process now - will be handled after current task finishes
# Spawn background task to process this message
- asyncio.create_task(self._process_message_background(event, session_key))
+ task = asyncio.create_task(self._process_message_background(event, session_key))
+ try:
+ self._background_tasks.add(task)
+ except TypeError:
+ # Some tests stub create_task() with lightweight sentinels that are not
+ # hashable and do not support lifecycle callbacks.
+ return
+ if hasattr(task, "add_done_callback"):
+ task.add_done_callback(self._background_tasks.discard)
@staticmethod
def _get_human_delay() -> float:
@@ -708,35 +1055,67 @@ async def _process_message_background(self, event: MessageEvent, session_key: st
# Extract image URLs and send them as native platform attachments
images, text_content = self.extract_images(response)
+ # Strip any remaining internal directives from message body (fixes #1561)
+ text_content = text_content.replace("[[audio_as_voice]]", "").strip()
+ text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
+
+ # Auto-detect bare local file paths for native media delivery
+ # (helps small models that don't use MEDIA: syntax)
+ local_files, text_content = self.extract_local_files(text_content)
+ if local_files:
+ logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
- # Send the text portion first (if any remains after extractions)
+ # Auto-TTS: if voice message, generate audio FIRST (before sending text)
+ # Skipped when the chat has voice mode disabled (/voice off)
+ _tts_path = None
+ if (event.message_type == MessageType.VOICE
+ and text_content
+ and not media_files
+ and event.source.chat_id not in self._auto_tts_disabled_chats):
+ try:
+ from tools.tts_tool import text_to_speech_tool, check_tts_requirements
+ if check_tts_requirements():
+ import json as _json
+ speech_text = re.sub(r'[*_`#\[\]()]', '', text_content)[:4000].strip()
+ if not speech_text:
+ raise ValueError("Empty text after markdown cleanup")
+ tts_result_str = await asyncio.to_thread(
+ text_to_speech_tool, text=speech_text
+ )
+ tts_data = _json.loads(tts_result_str)
+ _tts_path = tts_data.get("file_path")
+ except Exception as tts_err:
+ logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err)
+
+ # Play TTS audio before text (voice-first experience)
+ if _tts_path and Path(_tts_path).exists():
+ try:
+ await self.play_tts(
+ chat_id=event.source.chat_id,
+ audio_path=_tts_path,
+ metadata=_thread_metadata,
+ )
+ finally:
+ try:
+ os.remove(_tts_path)
+ except OSError:
+ pass
+
+ # Send the text portion
if text_content:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
- result = await self.send(
+ result = await self._send_with_retry(
chat_id=event.source.chat_id,
content=text_content,
reply_to=event.message_id,
metadata=_thread_metadata,
)
-
- # Log send failures (don't raise - user already saw tool progress)
- if not result.success:
- print(f"[{self.name}] Failed to send response: {result.error}")
- # Try sending without markdown as fallback
- fallback_result = await self.send(
- chat_id=event.source.chat_id,
- content=f"(Response formatting failed, plain text:)\n\n{text_content[:3500]}",
- reply_to=event.message_id,
- metadata=_thread_metadata,
- )
- if not fallback_result.success:
- print(f"[{self.name}] Fallback send also failed: {fallback_result.error}")
-
+
# Human-like pacing delay between text and media
human_delay = self._get_human_delay()
-
+
# Send extracted images as native attachments
if images:
logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
@@ -764,10 +1143,10 @@ async def _process_message_background(self, event: MessageEvent, session_key: st
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
except Exception as img_err:
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
-
+
# Send extracted media files โ route by file type
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
- _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.3gp'}
+ _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
for media_path, is_voice in media_files:
@@ -804,7 +1183,34 @@ async def _process_message_background(self, event: MessageEvent, session_key: st
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
except Exception as media_err:
print(f"[{self.name}] Error sending media: {media_err}")
-
+
+ # Send auto-detected local files as native attachments
+ for file_path in local_files:
+ if human_delay > 0:
+ await asyncio.sleep(human_delay)
+ try:
+ ext = Path(file_path).suffix.lower()
+ if ext in _IMAGE_EXTS:
+ await self.send_image_file(
+ chat_id=event.source.chat_id,
+ image_path=file_path,
+ metadata=_thread_metadata,
+ )
+ elif ext in _VIDEO_EXTS:
+ await self.send_video(
+ chat_id=event.source.chat_id,
+ video_path=file_path,
+ metadata=_thread_metadata,
+ )
+ else:
+ await self.send_document(
+ chat_id=event.source.chat_id,
+ file_path=file_path,
+ metadata=_thread_metadata,
+ )
+ except Exception as file_err:
+ logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
+
# 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)
@@ -825,6 +1231,22 @@ async def _process_message_background(self, event: MessageEvent, session_key: st
print(f"[{self.name}] Error handling message: {e}")
import traceback
traceback.print_exc()
+ # Send the error to the user so they aren't left with radio silence
+ try:
+ error_type = type(e).__name__
+ error_detail = str(e)[:300] if str(e) else "no details available"
+ _thread_metadata = {"thread_id": event.source.thread_id} if event.source.thread_id else None
+ await self.send(
+ chat_id=event.source.chat_id,
+ content=(
+ f"Sorry, I encountered an error ({error_type}).\n"
+ f"{error_detail}\n"
+ "Try again or use /reset to start a fresh session."
+ ),
+ metadata=_thread_metadata,
+ )
+ except Exception:
+ pass # Last resort โ don't let error reporting crash the handler
finally:
# Stop typing indicator
typing_task.cancel()
@@ -832,10 +1254,32 @@ async def _process_message_background(self, event: MessageEvent, session_key: st
await typing_task
except asyncio.CancelledError:
pass
+ # Also cancel any platform-level persistent typing tasks (e.g. Discord)
+ # that may have been recreated by _keep_typing after the last stop_typing()
+ try:
+ if hasattr(self, "stop_typing"):
+ await self.stop_typing(event.source.chat_id)
+ except Exception:
+ pass
# Clean up session tracking
if session_key in self._active_sessions:
del self._active_sessions[session_key]
+ async def cancel_background_tasks(self) -> None:
+ """Cancel any in-flight background message-processing tasks.
+
+ Used during gateway shutdown/replacement so active sessions from the old
+ process do not keep running after adapters are being torn down.
+ """
+ tasks = [task for task in self._background_tasks if not task.done()]
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ self._background_tasks.clear()
+ self._pending_messages.clear()
+ self._active_sessions.clear()
+
def has_pending_interrupt(self, session_key: str) -> bool:
"""Check if there's a pending interrupt for a session."""
return session_key in self._active_sessions and self._active_sessions[session_key].is_set()
@@ -895,7 +1339,8 @@ def format_message(self, content: str) -> str:
"""
return content
- def truncate_message(self, content: str, max_length: int = 4096) -> List[str]:
+ @staticmethod
+ def truncate_message(content: str, max_length: int = 4096) -> List[str]:
"""
Split a long message into chunks, preserving code block boundaries.
@@ -947,6 +1392,27 @@ def truncate_message(self, content: str, max_length: int = 4096) -> List[str]:
if split_at < 1:
split_at = headroom
+ # Avoid splitting inside an inline code span (`...`).
+ # If the text before split_at has an odd number of unescaped
+ # backticks, the split falls inside inline code โ the resulting
+ # chunk would have an unpaired backtick and any special characters
+ # (like parentheses) inside the broken span would be unescaped,
+ # causing MarkdownV2 parse errors on Telegram.
+ candidate = remaining[:split_at]
+ backtick_count = candidate.count("`") - candidate.count("\\`")
+ if backtick_count % 2 == 1:
+ # Find the last unescaped backtick and split before it
+ last_bt = candidate.rfind("`")
+ while last_bt > 0 and candidate[last_bt - 1] == "\\":
+ last_bt = candidate.rfind("`", 0, last_bt)
+ if last_bt > 0:
+ # Try to find a space or newline just before the backtick
+ safe_split = candidate.rfind(" ", 0, last_bt)
+ nl_split = candidate.rfind("\n", 0, last_bt)
+ safe_split = max(safe_split, nl_split)
+ if safe_split > headroom // 4:
+ split_at = safe_split
+
chunk_body = remaining[:split_at]
remaining = remaining[split_at:].lstrip()
diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py
new file mode 100644
index 00000000000..8ed3769624a
--- /dev/null
+++ b/gateway/platforms/dingtalk.py
@@ -0,0 +1,340 @@
+"""
+DingTalk platform adapter using Stream Mode.
+
+Uses dingtalk-stream SDK for real-time message reception without webhooks.
+Responses are sent via DingTalk's session webhook (markdown format).
+
+Requires:
+ pip install dingtalk-stream httpx
+ DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET env vars
+
+Configuration in config.yaml:
+ platforms:
+ dingtalk:
+ enabled: true
+ extra:
+ client_id: "your-app-key" # or DINGTALK_CLIENT_ID env var
+ client_secret: "your-secret" # or DINGTALK_CLIENT_SECRET env var
+"""
+
+import asyncio
+import logging
+import os
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional
+
+try:
+ import dingtalk_stream
+ from dingtalk_stream import ChatbotHandler, ChatbotMessage
+ DINGTALK_STREAM_AVAILABLE = True
+except ImportError:
+ DINGTALK_STREAM_AVAILABLE = False
+ dingtalk_stream = None # type: ignore[assignment]
+
+try:
+ import httpx
+ HTTPX_AVAILABLE = True
+except ImportError:
+ HTTPX_AVAILABLE = False
+ httpx = None # type: ignore[assignment]
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+MAX_MESSAGE_LENGTH = 20000
+DEDUP_WINDOW_SECONDS = 300
+DEDUP_MAX_SIZE = 1000
+RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
+
+
+def check_dingtalk_requirements() -> bool:
+ """Check if DingTalk dependencies are available and configured."""
+ if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE:
+ return False
+ if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"):
+ return False
+ return True
+
+
+class DingTalkAdapter(BasePlatformAdapter):
+ """DingTalk chatbot adapter using Stream Mode.
+
+ The dingtalk-stream SDK maintains a long-lived WebSocket connection.
+ Incoming messages arrive via a ChatbotHandler callback. Replies are
+ sent via the incoming message's session_webhook URL using httpx.
+ """
+
+ MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.DINGTALK)
+
+ extra = config.extra or {}
+ self._client_id: str = extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", "")
+ self._client_secret: str = extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET", "")
+
+ self._stream_client: Any = None
+ self._stream_task: Optional[asyncio.Task] = None
+ self._http_client: Optional["httpx.AsyncClient"] = None
+
+ # Message deduplication: msg_id -> timestamp
+ self._seen_messages: Dict[str, float] = {}
+ # Map chat_id -> session_webhook for reply routing
+ self._session_webhooks: Dict[str, str] = {}
+
+ # -- Connection lifecycle -----------------------------------------------
+
+ async def connect(self) -> bool:
+ """Connect to DingTalk via Stream Mode."""
+ if not DINGTALK_STREAM_AVAILABLE:
+ logger.warning("[%s] dingtalk-stream not installed. Run: pip install dingtalk-stream", self.name)
+ return False
+ if not HTTPX_AVAILABLE:
+ logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name)
+ return False
+ if not self._client_id or not self._client_secret:
+ logger.warning("[%s] DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required", self.name)
+ return False
+
+ try:
+ self._http_client = httpx.AsyncClient(timeout=30.0)
+
+ credential = dingtalk_stream.Credential(self._client_id, self._client_secret)
+ self._stream_client = dingtalk_stream.DingTalkStreamClient(credential)
+
+ # Capture the current event loop for cross-thread dispatch
+ loop = asyncio.get_running_loop()
+ handler = _IncomingHandler(self, loop)
+ self._stream_client.register_callback_handler(
+ dingtalk_stream.ChatbotMessage.TOPIC, handler
+ )
+
+ self._stream_task = asyncio.create_task(self._run_stream())
+ self._mark_connected()
+ logger.info("[%s] Connected via Stream Mode", self.name)
+ return True
+ except Exception as e:
+ logger.error("[%s] Failed to connect: %s", self.name, e)
+ return False
+
+ async def _run_stream(self) -> None:
+ """Run the blocking stream client with auto-reconnection."""
+ backoff_idx = 0
+ while self._running:
+ try:
+ logger.debug("[%s] Starting stream client...", self.name)
+ await asyncio.to_thread(self._stream_client.start)
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ if not self._running:
+ return
+ logger.warning("[%s] Stream client error: %s", self.name, e)
+
+ if not self._running:
+ return
+
+ delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
+ logger.info("[%s] Reconnecting in %ds...", self.name, delay)
+ await asyncio.sleep(delay)
+ backoff_idx += 1
+
+ async def disconnect(self) -> None:
+ """Disconnect from DingTalk."""
+ self._running = False
+ self._mark_disconnected()
+
+ if self._stream_task:
+ self._stream_task.cancel()
+ try:
+ await self._stream_task
+ except asyncio.CancelledError:
+ pass
+ self._stream_task = None
+
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+
+ self._stream_client = None
+ self._session_webhooks.clear()
+ self._seen_messages.clear()
+ logger.info("[%s] Disconnected", self.name)
+
+ # -- Inbound message processing -----------------------------------------
+
+ async def _on_message(self, message: "ChatbotMessage") -> None:
+ """Process an incoming DingTalk chatbot message."""
+ msg_id = getattr(message, "message_id", None) or uuid.uuid4().hex
+ if self._is_duplicate(msg_id):
+ logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
+ return
+
+ text = self._extract_text(message)
+ if not text:
+ logger.debug("[%s] Empty message, skipping", self.name)
+ return
+
+ # Chat context
+ conversation_id = getattr(message, "conversation_id", "") or ""
+ conversation_type = getattr(message, "conversation_type", "1")
+ is_group = str(conversation_type) == "2"
+ sender_id = getattr(message, "sender_id", "") or ""
+ sender_nick = getattr(message, "sender_nick", "") or sender_id
+ sender_staff_id = getattr(message, "sender_staff_id", "") or ""
+
+ chat_id = conversation_id or sender_id
+ chat_type = "group" if is_group else "dm"
+
+ # Store session webhook for reply routing
+ session_webhook = getattr(message, "session_webhook", None) or ""
+ if session_webhook and chat_id:
+ self._session_webhooks[chat_id] = session_webhook
+
+ source = self.build_source(
+ chat_id=chat_id,
+ chat_name=getattr(message, "conversation_title", None),
+ chat_type=chat_type,
+ user_id=sender_id,
+ user_name=sender_nick,
+ user_id_alt=sender_staff_id if sender_staff_id else None,
+ )
+
+ # Parse timestamp
+ create_at = getattr(message, "create_at", None)
+ try:
+ timestamp = datetime.fromtimestamp(int(create_at) / 1000, tz=timezone.utc) if create_at else datetime.now(tz=timezone.utc)
+ except (ValueError, OSError, TypeError):
+ timestamp = datetime.now(tz=timezone.utc)
+
+ event = MessageEvent(
+ text=text,
+ message_type=MessageType.TEXT,
+ source=source,
+ message_id=msg_id,
+ raw_message=message,
+ timestamp=timestamp,
+ )
+
+ logger.debug("[%s] Message from %s in %s: %s",
+ self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:50])
+ await self.handle_message(event)
+
+ @staticmethod
+ def _extract_text(message: "ChatbotMessage") -> str:
+ """Extract plain text from a DingTalk chatbot message."""
+ text = getattr(message, "text", None) or ""
+ if isinstance(text, dict):
+ content = text.get("content", "").strip()
+ else:
+ content = str(text).strip()
+
+ # Fall back to rich text if present
+ if not content:
+ rich_text = getattr(message, "rich_text", None)
+ if rich_text and isinstance(rich_text, list):
+ parts = [item["text"] for item in rich_text
+ if isinstance(item, dict) and item.get("text")]
+ content = " ".join(parts).strip()
+ return content
+
+ # -- Deduplication ------------------------------------------------------
+
+ def _is_duplicate(self, msg_id: str) -> bool:
+ """Check and record a message ID. Returns True if already seen."""
+ now = time.time()
+ if len(self._seen_messages) > DEDUP_MAX_SIZE:
+ cutoff = now - DEDUP_WINDOW_SECONDS
+ self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
+
+ if msg_id in self._seen_messages:
+ return True
+ self._seen_messages[msg_id] = now
+ return False
+
+ # -- Outbound messaging -------------------------------------------------
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a markdown reply via DingTalk session webhook."""
+ metadata = metadata or {}
+
+ session_webhook = metadata.get("session_webhook") or self._session_webhooks.get(chat_id)
+ if not session_webhook:
+ return SendResult(success=False,
+ error="No session_webhook available. Reply must follow an incoming message.")
+
+ if not self._http_client:
+ return SendResult(success=False, error="HTTP client not initialized")
+
+ payload = {
+ "msgtype": "markdown",
+ "markdown": {"title": "Hermes", "text": content[:self.MAX_MESSAGE_LENGTH]},
+ }
+
+ try:
+ resp = await self._http_client.post(session_webhook, json=payload, timeout=15.0)
+ if resp.status_code < 300:
+ return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
+ body = resp.text
+ logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200])
+ return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}")
+ except httpx.TimeoutException:
+ return SendResult(success=False, error="Timeout sending message to DingTalk")
+ except Exception as e:
+ logger.error("[%s] Send error: %s", self.name, e)
+ return SendResult(success=False, error=str(e))
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ """DingTalk does not support typing indicators."""
+ pass
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return basic info about a DingTalk conversation."""
+ return {"name": chat_id, "type": "group" if "group" in chat_id.lower() else "dm"}
+
+
+# ---------------------------------------------------------------------------
+# Internal stream handler
+# ---------------------------------------------------------------------------
+
+class _IncomingHandler(ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object):
+ """dingtalk-stream ChatbotHandler that forwards messages to the adapter."""
+
+ def __init__(self, adapter: DingTalkAdapter, loop: asyncio.AbstractEventLoop):
+ if DINGTALK_STREAM_AVAILABLE:
+ super().__init__()
+ self._adapter = adapter
+ self._loop = loop
+
+ def process(self, message: "ChatbotMessage"):
+ """Called by dingtalk-stream in its thread when a message arrives.
+
+ Schedules the async handler on the main event loop.
+ """
+ loop = self._loop
+ if loop is None or loop.is_closed():
+ logger.error("[DingTalk] Event loop unavailable, cannot dispatch message")
+ return dingtalk_stream.AckMessage.STATUS_OK, "OK"
+
+ future = asyncio.run_coroutine_threadsafe(self._adapter._on_message(message), loop)
+ try:
+ future.result(timeout=60)
+ except Exception:
+ logger.exception("[DingTalk] Error processing incoming message")
+
+ return dingtalk_stream.AckMessage.STATUS_OK, "OK"
diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py
index c7ae2ada5db..7ee1d3d79da 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
"""
Discord platform adapter.
@@ -8,12 +10,22 @@
"""
import asyncio
+import json
import logging
import os
-from typing import Dict, List, Optional, Any
+import struct
+import subprocess
+import tempfile
+import threading
+import time
+from collections import defaultdict
+from pathlib import Path
+from typing import Callable, Dict, Optional, Any
logger = logging.getLogger(__name__)
+VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
+
try:
import discord
from discord import Message as DiscordMessage, Intents
@@ -31,6 +43,8 @@
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
+import re
+
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@@ -38,14 +52,359 @@
SendResult,
cache_image_from_url,
cache_audio_from_url,
+ cache_document_from_bytes,
+ SUPPORTED_DOCUMENT_TYPES,
)
+def _clean_discord_id(entry: str) -> str:
+ """Strip common prefixes from a Discord user ID or username entry.
+
+ Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``,
+ or ``<@!123>`` from Discord's UI or other tools. This normalises the
+ entry to just the bare ID or username.
+ """
+ entry = entry.strip()
+ # Strip Discord mention syntax: <@123> or <@!123>
+ if entry.startswith("<@") and entry.endswith(">"):
+ entry = entry.lstrip("<@!").rstrip(">")
+ # Strip "user:" prefix (seen in some Discord tools / onboarding pastes)
+ if entry.lower().startswith("user:"):
+ entry = entry[5:]
+ return entry.strip()
+
+
def check_discord_requirements() -> bool:
"""Check if Discord dependencies are available."""
return DISCORD_AVAILABLE
+class VoiceReceiver:
+ """Captures and decodes voice audio from a Discord voice channel.
+
+ Attaches to a VoiceClient's socket listener, decrypts RTP packets
+ (NaCl transport + DAVE E2EE), decodes Opus to PCM, and buffers
+ per-user audio. A polling loop detects silence and delivers
+ completed utterances via a callback.
+ """
+
+ SILENCE_THRESHOLD = 1.5 # seconds of silence โ end of utterance
+ MIN_SPEECH_DURATION = 0.5 # minimum seconds to process (skip noise)
+ SAMPLE_RATE = 48000 # Discord native rate
+ CHANNELS = 2 # Discord sends stereo
+
+ def __init__(self, voice_client, allowed_user_ids: set = None):
+ self._vc = voice_client
+ self._allowed_user_ids = allowed_user_ids or set()
+ self._running = False
+
+ # Decryption
+ self._secret_key: Optional[bytes] = None
+ self._dave_session = None
+ self._bot_ssrc: int = 0
+
+ # SSRC -> user_id mapping (populated from SPEAKING events)
+ self._ssrc_to_user: Dict[int, int] = {}
+ self._lock = threading.Lock()
+
+ # Per-user audio buffers
+ self._buffers: Dict[int, bytearray] = defaultdict(bytearray)
+ self._last_packet_time: Dict[int, float] = {}
+
+ # Opus decoder per SSRC (each user needs own decoder state)
+ self._decoders: Dict[int, object] = {}
+
+ # Pause flag: don't capture while bot is playing TTS
+ self._paused = False
+
+ # Debug logging counter (instance-level to avoid cross-instance races)
+ self._packet_debug_count = 0
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def start(self):
+ """Start listening for voice packets."""
+ conn = self._vc._connection
+ self._secret_key = bytes(conn.secret_key)
+ self._dave_session = conn.dave_session
+ self._bot_ssrc = conn.ssrc
+
+ self._install_speaking_hook(conn)
+ conn.add_socket_listener(self._on_packet)
+ self._running = True
+ logger.info("VoiceReceiver started (bot_ssrc=%d)", self._bot_ssrc)
+
+ def stop(self):
+ """Stop listening and clean up."""
+ self._running = False
+ try:
+ self._vc._connection.remove_socket_listener(self._on_packet)
+ except Exception:
+ pass
+ with self._lock:
+ self._buffers.clear()
+ self._last_packet_time.clear()
+ self._decoders.clear()
+ self._ssrc_to_user.clear()
+ logger.info("VoiceReceiver stopped")
+
+ def pause(self):
+ self._paused = True
+
+ def resume(self):
+ self._paused = False
+
+ # ------------------------------------------------------------------
+ # SSRC -> user_id mapping via SPEAKING opcode hook
+ # ------------------------------------------------------------------
+
+ def map_ssrc(self, ssrc: int, user_id: int):
+ with self._lock:
+ self._ssrc_to_user[ssrc] = user_id
+
+ def _install_speaking_hook(self, conn):
+ """Wrap the voice websocket hook to capture SPEAKING events (op 5).
+
+ VoiceConnectionState stores the hook as ``conn.hook`` (public attr).
+ It is passed to DiscordVoiceWebSocket on each (re)connect, so we
+ must wrap it on the VoiceConnectionState level AND on the current
+ live websocket instance.
+ """
+ original_hook = conn.hook
+ receiver_self = self
+
+ async def wrapped_hook(ws, msg):
+ if isinstance(msg, dict) and msg.get("op") == 5:
+ data = msg.get("d", {})
+ ssrc = data.get("ssrc")
+ user_id = data.get("user_id")
+ if ssrc and user_id:
+ logger.info("SPEAKING event: ssrc=%d -> user=%s", ssrc, user_id)
+ receiver_self.map_ssrc(int(ssrc), int(user_id))
+ if original_hook:
+ await original_hook(ws, msg)
+
+ # Set on connection state (for future reconnects)
+ conn.hook = wrapped_hook
+ # Set on the current live websocket (for immediate effect)
+ try:
+ from discord.utils import MISSING
+ if hasattr(conn, 'ws') and conn.ws is not MISSING:
+ conn.ws._hook = wrapped_hook
+ logger.info("Speaking hook installed on live websocket")
+ except Exception as e:
+ logger.warning("Could not install hook on live ws: %s", e)
+
+ # ------------------------------------------------------------------
+ # Packet handler (called from SocketReader thread)
+ # ------------------------------------------------------------------
+
+ def _on_packet(self, data: bytes):
+ if not self._running or self._paused:
+ return
+
+ # Log first few raw packets for debugging
+ self._packet_debug_count += 1
+ if self._packet_debug_count <= 5:
+ logger.debug(
+ "Raw UDP packet: len=%d, first_bytes=%s",
+ len(data), data[:4].hex() if len(data) >= 4 else "short",
+ )
+
+ if len(data) < 16:
+ return
+
+ # RTP version check: top 2 bits must be 10 (version 2).
+ # Lower bits may vary (padding, extension, CSRC count).
+ # Payload type (byte 1 lower 7 bits) = 0x78 (120) for voice.
+ if (data[0] >> 6) != 2 or (data[1] & 0x7F) != 0x78:
+ if self._packet_debug_count <= 5:
+ logger.debug("Skipped non-RTP: byte0=0x%02x byte1=0x%02x", data[0], data[1])
+ return
+
+ first_byte = data[0]
+ _, _, seq, timestamp, ssrc = struct.unpack_from(">BBHII", data, 0)
+
+ # Skip bot's own audio
+ if ssrc == self._bot_ssrc:
+ return
+
+ # Calculate dynamic RTP header size (RFC 9335 / rtpsize mode)
+ cc = first_byte & 0x0F # CSRC count
+ has_extension = bool(first_byte & 0x10) # extension bit
+ header_size = 12 + (4 * cc) + (4 if has_extension else 0)
+
+ if len(data) < header_size + 4: # need at least header + nonce
+ return
+
+ # Read extension length from preamble (for skipping after decrypt)
+ ext_data_len = 0
+ if has_extension:
+ ext_preamble_offset = 12 + (4 * cc)
+ ext_words = struct.unpack_from(">H", data, ext_preamble_offset + 2)[0]
+ ext_data_len = ext_words * 4
+
+ if self._packet_debug_count <= 10:
+ with self._lock:
+ known_user = self._ssrc_to_user.get(ssrc, "unknown")
+ logger.debug(
+ "RTP packet: ssrc=%d, seq=%d, user=%s, hdr=%d, ext_data=%d",
+ ssrc, seq, known_user, header_size, ext_data_len,
+ )
+
+ header = bytes(data[:header_size])
+ payload_with_nonce = data[header_size:]
+
+ # --- NaCl transport decrypt (aead_xchacha20_poly1305_rtpsize) ---
+ if len(payload_with_nonce) < 4:
+ return
+ nonce = bytearray(24)
+ nonce[:4] = payload_with_nonce[-4:]
+ encrypted = bytes(payload_with_nonce[:-4])
+
+ try:
+ import nacl.secret # noqa: delayed import โ only in voice path
+ box = nacl.secret.Aead(self._secret_key)
+ decrypted = box.decrypt(encrypted, header, bytes(nonce))
+ except Exception as e:
+ if self._packet_debug_count <= 10:
+ logger.warning("NaCl decrypt failed: %s (hdr=%d, enc=%d)", e, header_size, len(encrypted))
+ return
+
+ # Skip encrypted extension data to get the actual opus payload
+ if ext_data_len and len(decrypted) > ext_data_len:
+ decrypted = decrypted[ext_data_len:]
+
+ # --- DAVE E2EE decrypt ---
+ if self._dave_session:
+ with self._lock:
+ user_id = self._ssrc_to_user.get(ssrc, 0)
+ if user_id:
+ try:
+ import davey
+ decrypted = self._dave_session.decrypt(
+ user_id, davey.MediaType.audio, decrypted
+ )
+ except Exception as e:
+ # Unencrypted passthrough โ use NaCl-decrypted data as-is
+ if "Unencrypted" not in str(e):
+ if self._packet_debug_count <= 10:
+ logger.warning("DAVE decrypt failed for ssrc=%d: %s", ssrc, e)
+ return
+ # If SSRC unknown (no SPEAKING event yet), skip DAVE and try
+ # Opus decode directly โ audio may be in passthrough mode.
+ # Buffer will get a user_id when SPEAKING event arrives later.
+
+ # --- Opus decode -> PCM ---
+ try:
+ if ssrc not in self._decoders:
+ self._decoders[ssrc] = discord.opus.Decoder()
+ pcm = self._decoders[ssrc].decode(decrypted)
+ with self._lock:
+ self._buffers[ssrc].extend(pcm)
+ self._last_packet_time[ssrc] = time.monotonic()
+ except Exception as e:
+ logger.debug("Opus decode error for SSRC %s: %s", ssrc, e)
+ return
+
+ # ------------------------------------------------------------------
+ # Silence detection
+ # ------------------------------------------------------------------
+
+ def _infer_user_for_ssrc(self, ssrc: int) -> int:
+ """Try to infer user_id for an unmapped SSRC.
+
+ When the bot rejoins a voice channel, Discord may not resend
+ SPEAKING events for users already speaking. If exactly one
+ allowed user is in the channel, map the SSRC to them.
+ """
+ try:
+ channel = self._vc.channel
+ if not channel:
+ return 0
+ bot_id = self._vc.user.id if self._vc.user else 0
+ allowed = self._allowed_user_ids
+ candidates = [
+ m.id for m in channel.members
+ if m.id != bot_id and (not allowed or str(m.id) in allowed)
+ ]
+ if len(candidates) == 1:
+ uid = candidates[0]
+ self._ssrc_to_user[ssrc] = uid
+ logger.info("Auto-mapped ssrc=%d -> user=%d (sole allowed member)", ssrc, uid)
+ return uid
+ except Exception:
+ pass
+ return 0
+
+ def check_silence(self) -> list:
+ """Return list of (user_id, pcm_bytes) for completed utterances."""
+ now = time.monotonic()
+ completed = []
+
+ with self._lock:
+ ssrc_user_map = dict(self._ssrc_to_user)
+ ssrc_list = list(self._buffers.keys())
+
+ for ssrc in ssrc_list:
+ last_time = self._last_packet_time.get(ssrc, now)
+ silence_duration = now - last_time
+ buf = self._buffers[ssrc]
+ # 48kHz, 16-bit, stereo = 192000 bytes/sec
+ buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2)
+
+ if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION:
+ user_id = ssrc_user_map.get(ssrc, 0)
+ if not user_id:
+ # SSRC not mapped (SPEAKING event missing after bot rejoin).
+ # Infer from allowed users in the voice channel.
+ user_id = self._infer_user_for_ssrc(ssrc)
+ if user_id:
+ completed.append((user_id, bytes(buf)))
+ self._buffers[ssrc] = bytearray()
+ self._last_packet_time.pop(ssrc, None)
+ elif silence_duration >= self.SILENCE_THRESHOLD * 2:
+ # Stale buffer with no valid user โ discard
+ self._buffers.pop(ssrc, None)
+ self._last_packet_time.pop(ssrc, None)
+
+ return completed
+
+ # ------------------------------------------------------------------
+ # PCM -> WAV conversion (for Whisper STT)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def pcm_to_wav(pcm_data: bytes, output_path: str,
+ src_rate: int = 48000, src_channels: int = 2):
+ """Convert raw PCM to 16kHz mono WAV via ffmpeg."""
+ with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as f:
+ f.write(pcm_data)
+ pcm_path = f.name
+ try:
+ subprocess.run(
+ [
+ "ffmpeg", "-y", "-loglevel", "error",
+ "-f", "s16le",
+ "-ar", str(src_rate),
+ "-ac", str(src_channels),
+ "-i", pcm_path,
+ "-ar", "16000",
+ "-ac", "1",
+ output_path,
+ ],
+ check=True,
+ timeout=10,
+ )
+ finally:
+ try:
+ os.unlink(pcm_path)
+ except OSError:
+ pass
+
+
class DiscordAdapter(BasePlatformAdapter):
"""
Discord bot adapter.
@@ -63,17 +422,64 @@ class DiscordAdapter(BasePlatformAdapter):
# Discord message limits
MAX_MESSAGE_LENGTH = 2000
+ # Auto-disconnect from voice channel after this many seconds of inactivity
+ VOICE_TIMEOUT = 300
+
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.DISCORD)
self._client: Optional[commands.Bot] = None
self._ready_event = asyncio.Event()
self._allowed_user_ids: set = set() # For button approval authorization
+ # Voice channel state (per-guild)
+ self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
+ self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id
+ self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task
+ # Phase 2: voice listening
+ self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver
+ self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
+ self._voice_input_callback: Optional[Callable] = None # set by run.py
+ self._on_voice_disconnect: Optional[Callable] = None # set by run.py
+ # Track threads where the bot has participated so follow-up messages
+ # in those threads don't require @mention. Persisted to disk so the
+ # set survives gateway restarts.
+ self._bot_participated_threads: set = self._load_participated_threads()
+ # Persistent typing indicator loops per channel (DMs don't reliably
+ # show the standard typing gateway event for bots)
+ self._typing_tasks: Dict[str, asyncio.Task] = {}
+ self._bot_task: Optional[asyncio.Task] = None
+ # Cap to prevent unbounded growth (Discord threads get archived).
+ self._MAX_TRACKED_THREADS = 500
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
if not DISCORD_AVAILABLE:
logger.error("[%s] discord.py not installed. Run: pip install discord.py", self.name)
return False
+
+ # Load opus codec for voice channel support
+ if not discord.opus.is_loaded():
+ import ctypes.util
+ opus_path = ctypes.util.find_library("opus")
+ # ctypes.util.find_library fails on macOS with Homebrew-installed libs,
+ # so fall back to known Homebrew paths if needed.
+ if not opus_path:
+ import sys
+ _homebrew_paths = (
+ "/opt/homebrew/lib/libopus.dylib", # Apple Silicon
+ "/usr/local/lib/libopus.dylib", # Intel Mac
+ )
+ if sys.platform == "darwin":
+ for _hp in _homebrew_paths:
+ if os.path.isfile(_hp):
+ opus_path = _hp
+ break
+ if opus_path:
+ try:
+ discord.opus.load_opus(opus_path)
+ except Exception:
+ logger.warning("Opus codec found at %s but failed to load", opus_path)
+ if not discord.opus.is_loaded():
+ logger.warning("Opus codec not found โ voice channel playback disabled")
if not self.config.token:
logger.error("[%s] No bot token configured", self.name)
@@ -86,6 +492,7 @@ async def connect(self) -> bool:
intents.dm_messages = True
intents.guild_messages = True
intents.members = True
+ intents.voice_states = True
# Create bot
self._client = commands.Bot(
@@ -97,7 +504,8 @@ async def connect(self) -> bool:
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed_env:
self._allowed_user_ids = {
- uid.strip() for uid in allowed_env.split(",") if uid.strip()
+ _clean_discord_id(uid) for uid in allowed_env.split(",")
+ if uid.strip()
}
adapter_self = self # capture for closure
@@ -124,6 +532,11 @@ async def on_message(message: DiscordMessage):
if message.author == self._client.user:
return
+ # Ignore Discord system messages (thread renames, pins, member joins, etc.)
+ # Allow both default and reply types โ replies have a distinct MessageType.
+ if message.type not in (discord.MessageType.default, discord.MessageType.reply):
+ return
+
# Bot message filtering (DISCORD_ALLOW_BOTS):
# "none" โ ignore all other bots (default)
# "mentions" โ accept bot messages only when they @mention us
@@ -138,12 +551,45 @@ async def on_message(message: DiscordMessage):
# "all" falls through to handle_message
await self._handle_message(message)
-
+
+ @self._client.event
+ async def on_voice_state_update(member, before, after):
+ """Track voice channel join/leave events."""
+ # Only track channels where the bot is connected
+ bot_guild_ids = set(adapter_self._voice_clients.keys())
+ if not bot_guild_ids:
+ return
+ guild_id = member.guild.id
+ if guild_id not in bot_guild_ids:
+ return
+ # Ignore the bot itself
+ if member == adapter_self._client.user:
+ return
+
+ joined = before.channel is None and after.channel is not None
+ left = before.channel is not None and after.channel is None
+ switched = (
+ before.channel is not None
+ and after.channel is not None
+ and before.channel != after.channel
+ )
+
+ if joined or left or switched:
+ logger.info(
+ "Voice state: %s (%d) %s (guild %d)",
+ member.display_name,
+ member.id,
+ "joined " + after.channel.name if joined
+ else "left " + before.channel.name if left
+ else f"moved {before.channel.name} -> {after.channel.name}",
+ guild_id,
+ )
+
# Register slash commands
self._register_slash_commands()
# Start the bot in background
- asyncio.create_task(self._client.start(self.config.token))
+ self._bot_task = asyncio.create_task(self._client.start(self.config.token))
# Wait for ready
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
@@ -160,12 +606,19 @@ async def on_message(message: DiscordMessage):
async def disconnect(self) -> None:
"""Disconnect from Discord."""
+ # Clean up all active voice connections before closing the client
+ for guild_id in list(self._voice_clients.keys()):
+ try:
+ await self.leave_voice_channel(guild_id)
+ except Exception as e: # pragma: no cover - defensive logging
+ logger.debug("[%s] Error leaving voice channel %s: %s", self.name, guild_id, e)
+
if self._client:
try:
await self._client.close()
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[%s] Error during disconnect: %s", self.name, e, exc_info=True)
-
+
self._running = False
self._client = None
self._ready_event.clear()
@@ -181,7 +634,7 @@ async def send(
"""Send a message to a Discord channel."""
if not self._client:
return SendResult(success=False, error="Not connected")
-
+
try:
# Get the channel
channel = self._client.get_channel(int(chat_id))
@@ -206,10 +659,30 @@ async def send(
logger.debug("Could not fetch reply-to message: %s", e)
for i, chunk in enumerate(chunks):
- msg = await channel.send(
- content=chunk,
- reference=reference if i == 0 else None,
- )
+ chunk_reference = reference if i == 0 else None
+ try:
+ msg = await channel.send(
+ content=chunk,
+ reference=chunk_reference,
+ )
+ except Exception as e:
+ err_text = str(e)
+ if (
+ chunk_reference is not None
+ and "error code: 50035" in err_text
+ and "Cannot reply to a system message" in err_text
+ ):
+ logger.warning(
+ "[%s] Reply target %s is a Discord system message; retrying send without reply reference",
+ self.name,
+ reply_to,
+ )
+ msg = await channel.send(
+ content=chunk,
+ reference=None,
+ )
+ else:
+ raise
message_ids.append(str(msg.id))
return SendResult(
@@ -245,80 +718,438 @@ async def edit_message(
logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True)
return SendResult(success=False, error=str(e))
+ async def _send_file_attachment(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ ) -> SendResult:
+ """Send a local file as a Discord attachment."""
+ if not self._client:
+ return SendResult(success=False, error="Not connected")
+
+ channel = self._client.get_channel(int(chat_id))
+ if not channel:
+ channel = await self._client.fetch_channel(int(chat_id))
+ if not channel:
+ return SendResult(success=False, error=f"Channel {chat_id} not found")
+
+ filename = file_name or os.path.basename(file_path)
+ with open(file_path, "rb") as fh:
+ file = discord.File(fh, filename=filename)
+ msg = await channel.send(content=caption if caption else None, file=file)
+ return SendResult(success=True, message_id=str(msg.id))
+
+ async def play_tts(
+ self,
+ chat_id: str,
+ audio_path: str,
+ **kwargs,
+ ) -> SendResult:
+ """Play auto-TTS audio.
+
+ When the bot is in a voice channel for this chat's guild, play
+ directly in the VC instead of sending as a file attachment.
+ """
+ for gid, text_ch_id in self._voice_text_channels.items():
+ if str(text_ch_id) == str(chat_id) and self.is_in_voice_channel(gid):
+ logger.info("[%s] Playing TTS in voice channel (guild=%d)", self.name, gid)
+ success = await self.play_in_voice_channel(gid, audio_path)
+ return SendResult(success=success)
+ return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)
+
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
) -> SendResult:
"""Send audio as a Discord file attachment."""
- if not self._client:
- return SendResult(success=False, error="Not connected")
-
try:
import io
-
+
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
-
+
if not os.path.exists(audio_path):
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
-
- # Determine filename from path
+
filename = os.path.basename(audio_path)
-
+
with open(audio_path, "rb") as f:
- file = discord.File(io.BytesIO(f.read()), filename=filename)
- msg = await channel.send(
- content=caption if caption else None,
- file=file,
+ file_data = f.read()
+
+ # Try sending as a native voice message via raw API (flags=8192).
+ try:
+ import base64
+
+ duration_secs = 5.0
+ try:
+ from mutagen.oggopus import OggOpus
+ info = OggOpus(audio_path)
+ duration_secs = info.info.length
+ except Exception:
+ duration_secs = max(1.0, len(file_data) / 2000.0)
+
+ waveform_bytes = bytes([128] * 256)
+ waveform_b64 = base64.b64encode(waveform_bytes).decode()
+
+ import json as _json
+ payload = _json.dumps({
+ "flags": 8192,
+ "attachments": [{
+ "id": "0",
+ "filename": "voice-message.ogg",
+ "duration_secs": round(duration_secs, 2),
+ "waveform": waveform_b64,
+ }],
+ })
+ form = [
+ {"name": "payload_json", "value": payload},
+ {
+ "name": "files[0]",
+ "value": file_data,
+ "filename": "voice-message.ogg",
+ "content_type": "audio/ogg",
+ },
+ ]
+ msg_data = await self._client.http.request(
+ discord.http.Route("POST", "/channels/{channel_id}/messages", channel_id=channel.id),
+ form=form,
)
+ return SendResult(success=True, message_id=str(msg_data["id"]))
+ except Exception as voice_err:
+ logger.debug("Voice message flag failed, falling back to file: %s", voice_err)
+ file = discord.File(io.BytesIO(file_data), filename=filename)
+ msg = await channel.send(file=file)
return SendResult(success=True, message_id=str(msg.id))
-
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True)
- return await super().send_voice(chat_id, audio_path, caption, reply_to)
-
+ return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
+
+ # ------------------------------------------------------------------
+ # Voice channel methods (join / leave / play)
+ # ------------------------------------------------------------------
+
+ async def join_voice_channel(self, channel) -> bool:
+ """Join a Discord voice channel. Returns True on success."""
+ if not self._client or not DISCORD_AVAILABLE:
+ return False
+ guild_id = channel.guild.id
+
+ # Already connected in this guild?
+ existing = self._voice_clients.get(guild_id)
+ if existing and existing.is_connected():
+ if existing.channel.id == channel.id:
+ self._reset_voice_timeout(guild_id)
+ return True
+ await existing.move_to(channel)
+ self._reset_voice_timeout(guild_id)
+ return True
+
+ vc = await channel.connect()
+ self._voice_clients[guild_id] = vc
+ self._reset_voice_timeout(guild_id)
+
+ # Start voice receiver (Phase 2: listen to users)
+ try:
+ receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids)
+ receiver.start()
+ self._voice_receivers[guild_id] = receiver
+ self._voice_listen_tasks[guild_id] = asyncio.ensure_future(
+ self._voice_listen_loop(guild_id)
+ )
+ except Exception as e:
+ logger.warning("Voice receiver failed to start: %s", e)
+
+ return True
+
+ async def leave_voice_channel(self, guild_id: int) -> None:
+ """Disconnect from the voice channel in a guild."""
+ # Stop voice receiver first
+ receiver = self._voice_receivers.pop(guild_id, None)
+ if receiver:
+ receiver.stop()
+ listen_task = self._voice_listen_tasks.pop(guild_id, None)
+ if listen_task:
+ listen_task.cancel()
+
+ vc = self._voice_clients.pop(guild_id, None)
+ if vc and vc.is_connected():
+ await vc.disconnect()
+ task = self._voice_timeout_tasks.pop(guild_id, None)
+ if task:
+ task.cancel()
+ self._voice_text_channels.pop(guild_id, None)
+
+ # Maximum seconds to wait for voice playback before giving up
+ PLAYBACK_TIMEOUT = 120
+
+ async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool:
+ """Play an audio file in the connected voice channel."""
+ vc = self._voice_clients.get(guild_id)
+ if not vc or not vc.is_connected():
+ return False
+
+ # Pause voice receiver while playing (echo prevention)
+ receiver = self._voice_receivers.get(guild_id)
+ if receiver:
+ receiver.pause()
+
+ try:
+ # Wait for current playback to finish (with timeout)
+ wait_start = time.monotonic()
+ while vc.is_playing():
+ if time.monotonic() - wait_start > self.PLAYBACK_TIMEOUT:
+ logger.warning("Timed out waiting for previous playback to finish")
+ vc.stop()
+ break
+ await asyncio.sleep(0.1)
+
+ done = asyncio.Event()
+ loop = asyncio.get_running_loop()
+
+ def _after(error):
+ if error:
+ logger.error("Voice playback error: %s", error)
+ loop.call_soon_threadsafe(done.set)
+
+ source = discord.FFmpegPCMAudio(audio_path)
+ source = discord.PCMVolumeTransformer(source, volume=1.0)
+ vc.play(source, after=_after)
+ try:
+ await asyncio.wait_for(done.wait(), timeout=self.PLAYBACK_TIMEOUT)
+ except asyncio.TimeoutError:
+ logger.warning("Voice playback timed out after %ds", self.PLAYBACK_TIMEOUT)
+ vc.stop()
+ self._reset_voice_timeout(guild_id)
+ return True
+ finally:
+ if receiver:
+ receiver.resume()
+
+ async def get_user_voice_channel(self, guild_id: int, user_id: str):
+ """Return the voice channel the user is currently in, or None."""
+ if not self._client:
+ return None
+ guild = self._client.get_guild(guild_id)
+ if not guild:
+ return None
+ member = guild.get_member(int(user_id))
+ if not member or not member.voice:
+ return None
+ return member.voice.channel
+
+ def _reset_voice_timeout(self, guild_id: int) -> None:
+ """Reset the auto-disconnect inactivity timer."""
+ task = self._voice_timeout_tasks.pop(guild_id, None)
+ if task:
+ task.cancel()
+ self._voice_timeout_tasks[guild_id] = asyncio.ensure_future(
+ self._voice_timeout_handler(guild_id)
+ )
+
+ async def _voice_timeout_handler(self, guild_id: int) -> None:
+ """Auto-disconnect after VOICE_TIMEOUT seconds of inactivity."""
+ try:
+ await asyncio.sleep(self.VOICE_TIMEOUT)
+ except asyncio.CancelledError:
+ return
+ text_ch_id = self._voice_text_channels.get(guild_id)
+ await self.leave_voice_channel(guild_id)
+ # Notify the runner so it can clean up voice_mode state
+ if self._on_voice_disconnect and text_ch_id:
+ try:
+ self._on_voice_disconnect(str(text_ch_id))
+ except Exception:
+ pass
+ if text_ch_id and self._client:
+ ch = self._client.get_channel(text_ch_id)
+ if ch:
+ try:
+ await ch.send("Left voice channel (inactivity timeout).")
+ except Exception:
+ pass
+
+ def is_in_voice_channel(self, guild_id: int) -> bool:
+ """Check if the bot is connected to a voice channel in this guild."""
+ vc = self._voice_clients.get(guild_id)
+ return vc is not None and vc.is_connected()
+
+ def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]:
+ """Return voice channel awareness info for the given guild.
+
+ Returns None if the bot is not in a voice channel. Otherwise
+ returns a dict with channel name, member list, count, and
+ currently-speaking user IDs (from SSRC mapping).
+ """
+ vc = self._voice_clients.get(guild_id)
+ if not vc or not vc.is_connected():
+ return None
+
+ channel = vc.channel
+ if not channel:
+ return None
+
+ # Members currently in the voice channel (includes bot)
+ members_info = []
+ bot_user = self._client.user if self._client else None
+ for m in channel.members:
+ if bot_user and m.id == bot_user.id:
+ continue # skip the bot itself
+ members_info.append({
+ "user_id": m.id,
+ "display_name": m.display_name,
+ "is_bot": m.bot,
+ })
+
+ # Currently speaking users (from SSRC mapping + active buffers)
+ speaking_user_ids: set = set()
+ receiver = self._voice_receivers.get(guild_id)
+ if receiver:
+ import time as _time
+ now = _time.monotonic()
+ with receiver._lock:
+ for ssrc, last_t in receiver._last_packet_time.items():
+ # Consider "speaking" if audio received within last 2 seconds
+ if now - last_t < 2.0:
+ uid = receiver._ssrc_to_user.get(ssrc)
+ if uid:
+ speaking_user_ids.add(uid)
+
+ # Tag speaking status on members
+ for info in members_info:
+ info["is_speaking"] = info["user_id"] in speaking_user_ids
+
+ return {
+ "channel_name": channel.name,
+ "member_count": len(members_info),
+ "members": members_info,
+ "speaking_count": len(speaking_user_ids),
+ }
+
+ def get_voice_channel_context(self, guild_id: int) -> str:
+ """Return a human-readable voice channel context string.
+
+ Suitable for injection into the system/ephemeral prompt so the
+ agent is always aware of voice channel state.
+ """
+ info = self.get_voice_channel_info(guild_id)
+ if not info:
+ return ""
+
+ parts = [f"[Voice channel: #{info['channel_name']} โ {info['member_count']} participant(s)]"]
+ for m in info["members"]:
+ status = " (speaking)" if m["is_speaking"] else ""
+ parts.append(f" - {m['display_name']}{status}")
+
+ return "\n".join(parts)
+
+ # ------------------------------------------------------------------
+ # Voice listening (Phase 2)
+ # ------------------------------------------------------------------
+
+ # UDP keepalive interval in seconds โ prevents Discord from dropping
+ # the UDP route after ~60s of silence.
+ _KEEPALIVE_INTERVAL = 15
+
+ async def _voice_listen_loop(self, guild_id: int):
+ """Periodically check for completed utterances and process them."""
+ receiver = self._voice_receivers.get(guild_id)
+ if not receiver:
+ return
+ last_keepalive = time.monotonic()
+ try:
+ while receiver._running:
+ await asyncio.sleep(0.2)
+
+ # Send periodic UDP keepalive to prevent Discord from
+ # dropping the UDP session after ~60s of silence.
+ now = time.monotonic()
+ if now - last_keepalive >= self._KEEPALIVE_INTERVAL:
+ last_keepalive = now
+ try:
+ vc = self._voice_clients.get(guild_id)
+ if vc and vc.is_connected():
+ vc._connection.send_packet(b'\xf8\xff\xfe')
+ except Exception:
+ pass
+
+ completed = receiver.check_silence()
+ for user_id, pcm_data in completed:
+ if not self._is_allowed_user(str(user_id)):
+ continue
+ await self._process_voice_input(guild_id, user_id, pcm_data)
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logger.error("Voice listen loop error: %s", e, exc_info=True)
+
+ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes):
+ """Convert PCM -> WAV -> STT -> callback."""
+ from tools.voice_mode import is_whisper_hallucination
+
+ tmp_f = tempfile.NamedTemporaryFile(suffix=".wav", prefix="vc_listen_", delete=False)
+ wav_path = tmp_f.name
+ tmp_f.close()
+ try:
+ await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path)
+
+ from tools.transcription_tools import transcribe_audio, get_stt_model_from_config
+ stt_model = get_stt_model_from_config()
+ result = await asyncio.to_thread(transcribe_audio, wav_path, model=stt_model)
+
+ if not result.get("success"):
+ return
+ transcript = result.get("transcript", "").strip()
+ if not transcript or is_whisper_hallucination(transcript):
+ return
+
+ logger.info("Voice input from user %d: %s", user_id, transcript[:100])
+
+ if self._voice_input_callback:
+ await self._voice_input_callback(
+ guild_id=guild_id,
+ user_id=user_id,
+ transcript=transcript,
+ )
+ except Exception as e:
+ logger.warning("Voice input processing failed: %s", e, exc_info=True)
+ finally:
+ try:
+ os.unlink(wav_path)
+ except OSError:
+ pass
+
+ def _is_allowed_user(self, user_id: str) -> bool:
+ """Check if user is in DISCORD_ALLOWED_USERS."""
+ if not self._allowed_user_ids:
+ return True
+ return user_id in self._allowed_user_ids
+
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file natively as a Discord file attachment."""
- if not self._client:
- return SendResult(success=False, error="Not connected")
-
try:
- import io
-
- channel = self._client.get_channel(int(chat_id))
- if not channel:
- channel = await self._client.fetch_channel(int(chat_id))
- if not channel:
- return SendResult(success=False, error=f"Channel {chat_id} not found")
-
- if not os.path.exists(image_path):
- return SendResult(success=False, error=f"Image file not found: {image_path}")
-
- filename = os.path.basename(image_path)
-
- with open(image_path, "rb") as f:
- file = discord.File(io.BytesIO(f.read()), filename=filename)
- msg = await channel.send(
- content=caption if caption else None,
- file=file,
- )
- return SendResult(success=True, message_id=str(msg.id))
-
+ return await self._send_file_attachment(chat_id, image_path, caption)
+ except FileNotFoundError:
+ return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True)
- return await super().send_image_file(chat_id, image_path, caption, reply_to)
+ return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
async def send_image(
self,
@@ -326,6 +1157,7 @@ async def send_image(
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image natively as a Discord file attachment."""
if not self._client:
@@ -383,16 +1215,85 @@ async def send_image(
exc_info=True,
)
return await super().send_image(chat_id, image_url, caption, reply_to)
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a local video file natively as a Discord attachment."""
+ try:
+ return await self._send_file_attachment(chat_id, video_path, caption)
+ except FileNotFoundError:
+ return SendResult(success=False, error=f"Video file not found: {video_path}")
+ except Exception as e: # pragma: no cover - defensive logging
+ logger.error("[%s] Failed to send local video, falling back to base adapter: %s", self.name, e, exc_info=True)
+ return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send an arbitrary file natively as a Discord attachment."""
+ try:
+ return await self._send_file_attachment(chat_id, file_path, caption, file_name=file_name)
+ except FileNotFoundError:
+ return SendResult(success=False, error=f"File not found: {file_path}")
+ except Exception as e: # pragma: no cover - defensive logging
+ logger.error("[%s] Failed to send document, falling back to base adapter: %s", self.name, e, exc_info=True)
+ return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
async def send_typing(self, chat_id: str, metadata=None) -> None:
- """Send typing indicator."""
- if self._client:
+ """Start a persistent typing indicator for a channel.
+
+ Discord's TYPING_START gateway event is unreliable in DMs for bots.
+ Instead, start a background loop that hits the typing endpoint every
+ 8 seconds (typing indicator lasts ~10s). The loop is cancelled when
+ stop_typing() is called (after the response is sent).
+ """
+ if not self._client:
+ return
+ # Don't start a duplicate loop
+ if chat_id in self._typing_tasks:
+ return
+
+ async def _typing_loop() -> None:
try:
- channel = self._client.get_channel(int(chat_id))
- if channel:
- await channel.typing()
- except Exception:
- pass # Ignore typing indicator failures
+ while True:
+ try:
+ route = discord.http.Route(
+ "POST", "/channels/{channel_id}/typing",
+ channel_id=chat_id,
+ )
+ await self._client.http.request(route)
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ logger.debug("Discord typing indicator failed for %s: %s", chat_id, e)
+ return
+ await asyncio.sleep(8)
+ except asyncio.CancelledError:
+ pass
+
+ self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
+
+ async def stop_typing(self, chat_id: str) -> None:
+ """Stop the persistent typing indicator for a channel."""
+ task = self._typing_tasks.pop(chat_id, None)
+ if task:
+ task.cancel()
+ try:
+ await task
+ except (asyncio.CancelledError, Exception):
+ pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a Discord channel."""
@@ -505,7 +1406,23 @@ def format_message(self, content: str) -> str:
"""
# Discord markdown is fairly standard, no special escaping needed
return content
-
+
+ async def _run_simple_slash(
+ self,
+ interaction: discord.Interaction,
+ command_text: str,
+ followup_msg: str | None = None,
+ ) -> None:
+ """Common handler for simple slash commands that dispatch a command string."""
+ await interaction.response.defer(ephemeral=True)
+ event = self._build_slash_event(interaction, command_text)
+ await self.handle_message(event)
+ if followup_msg:
+ try:
+ await interaction.followup.send(followup_msg, ephemeral=True)
+ except Exception as e:
+ logger.debug("Discord followup failed: %s", e)
+
def _register_slash_commands(self) -> None:
"""Register Discord slash commands on the command tree."""
if not self._client:
@@ -513,208 +1430,134 @@ def _register_slash_commands(self) -> None:
tree = self._client.tree
- @tree.command(name="ask", description="Ask Hermes a question")
- @discord.app_commands.describe(question="Your question for Hermes")
- async def slash_ask(interaction: discord.Interaction, question: str):
- await interaction.response.defer()
- event = self._build_slash_event(interaction, question)
- await self.handle_message(event)
- # The response is sent via the normal send() flow
- # Send a followup to close the interaction if needed
- try:
- await interaction.followup.send("Processing complete~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
-
@tree.command(name="new", description="Start a new conversation")
async def slash_new(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/reset")
- await self.handle_message(event)
- try:
- await interaction.followup.send("New conversation started~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/reset", "New conversation started~")
@tree.command(name="reset", description="Reset your Hermes session")
async def slash_reset(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/reset")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Session reset~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/reset", "Session reset~")
@tree.command(name="model", description="Show or change the model")
@discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.")
async def slash_model(interaction: discord.Interaction, name: str = ""):
+ await self._run_simple_slash(interaction, f"/model {name}".strip())
+
+ @tree.command(name="reasoning", description="Show or change reasoning effort")
+ @discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
+ async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/model {name}".strip())
+ event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
@tree.command(name="personality", description="Set a personality")
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
async def slash_personality(interaction: discord.Interaction, name: str = ""):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/personality {name}".strip())
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, f"/personality {name}".strip())
@tree.command(name="retry", description="Retry your last message")
async def slash_retry(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/retry")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Retrying~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/retry", "Retrying~")
@tree.command(name="undo", description="Remove the last exchange")
async def slash_undo(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/undo")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/undo")
@tree.command(name="status", description="Show Hermes session status")
async def slash_status(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/status")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Status sent~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/status", "Status sent~")
@tree.command(name="sethome", description="Set this chat as the home channel")
async def slash_sethome(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/sethome")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/sethome")
@tree.command(name="stop", description="Stop the running Hermes agent")
async def slash_stop(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/stop")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Stop requested~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/stop", "Stop requested~")
@tree.command(name="compress", description="Compress conversation context")
async def slash_compress(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/compress")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/compress")
@tree.command(name="title", description="Set or show the session title")
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
async def slash_title(interaction: discord.Interaction, name: str = ""):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/title {name}".strip())
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, f"/title {name}".strip())
@tree.command(name="resume", description="Resume a previously-named session")
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
async def slash_resume(interaction: discord.Interaction, name: str = ""):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/resume {name}".strip())
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, f"/resume {name}".strip())
@tree.command(name="usage", description="Show token usage for this session")
async def slash_usage(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/usage")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/usage")
@tree.command(name="provider", description="Show available providers")
async def slash_provider(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/provider")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/provider")
@tree.command(name="help", description="Show available commands")
async def slash_help(interaction: discord.Interaction):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/help")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, "/help")
@tree.command(name="insights", description="Show usage insights and analytics")
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
async def slash_insights(interaction: discord.Interaction, days: int = 7):
- await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, f"/insights {days}")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._run_simple_slash(interaction, f"/insights {days}")
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
async def slash_reload_mcp(interaction: discord.Interaction):
+ await self._run_simple_slash(interaction, "/reload-mcp")
+
+ @tree.command(name="voice", description="Toggle voice reply mode")
+ @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
+ @discord.app_commands.choices(mode=[
+ discord.app_commands.Choice(name="channel โ join your voice channel", value="channel"),
+ discord.app_commands.Choice(name="leave โ leave voice channel", value="leave"),
+ discord.app_commands.Choice(name="on โ voice reply to voice messages", value="on"),
+ discord.app_commands.Choice(name="tts โ voice reply to all messages", value="tts"),
+ discord.app_commands.Choice(name="off โ text only", value="off"),
+ discord.app_commands.Choice(name="status โ show current mode", value="status"),
+ ])
+ async def slash_voice(interaction: discord.Interaction, mode: str = ""):
await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/reload-mcp")
+ event = self._build_slash_event(interaction, f"/voice {mode}".strip())
await self.handle_message(event)
- try:
- await interaction.followup.send("Done~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
@tree.command(name="update", description="Update Hermes Agent to the latest version")
async def slash_update(interaction: discord.Interaction):
+ await self._run_simple_slash(interaction, "/update", "Update initiated~")
+
+ @tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
+ @discord.app_commands.describe(
+ name="Thread name",
+ message="Optional first message to send to Hermes in the thread",
+ auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
+ )
+ async def slash_thread(
+ interaction: discord.Interaction,
+ name: str,
+ message: str = "",
+ auto_archive_duration: int = 1440,
+ ):
await interaction.response.defer(ephemeral=True)
- event = self._build_slash_event(interaction, "/update")
- await self.handle_message(event)
- try:
- await interaction.followup.send("Update initiated~", ephemeral=True)
- except Exception as e:
- logger.debug("Discord followup failed: %s", e)
+ await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
is_dm = isinstance(interaction.channel, discord.DMChannel)
- chat_type = "dm" if is_dm else "group"
+ is_thread = isinstance(interaction.channel, discord.Thread)
+ thread_id = None
+
+ if is_dm:
+ chat_type = "dm"
+ elif is_thread:
+ chat_type = "thread"
+ thread_id = str(interaction.channel_id)
+ else:
+ chat_type = "group"
+
chat_name = ""
if not is_dm and hasattr(interaction.channel, "name"):
chat_name = interaction.channel.name
@@ -730,6 +1573,7 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes
chat_type=chat_type,
user_id=str(interaction.user.id),
user_name=interaction.user.display_name,
+ thread_id=thread_id,
chat_topic=chat_topic,
)
@@ -741,6 +1585,192 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes
raw_message=interaction,
)
+ # ------------------------------------------------------------------
+ # Thread creation helpers
+ # ------------------------------------------------------------------
+
+ async def _handle_thread_create_slash(
+ self,
+ interaction: discord.Interaction,
+ name: str,
+ message: str = "",
+ auto_archive_duration: int = 1440,
+ ) -> None:
+ """Create a Discord thread from a slash command and start a session in it."""
+ result = await self._create_thread(
+ interaction,
+ name=name,
+ message=message,
+ auto_archive_duration=auto_archive_duration,
+ )
+
+ if not result.get("success"):
+ error = result.get("error", "unknown error")
+ await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
+ return
+
+ thread_id = result.get("thread_id")
+ thread_name = result.get("thread_name") or name
+
+ # Tell the user where the thread is
+ link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
+ await interaction.followup.send(f"Created thread {link}", ephemeral=True)
+
+ # Track thread participation so follow-ups don't require @mention
+ if thread_id:
+ self._track_thread(thread_id)
+
+ # If a message was provided, kick off a new Hermes session in the thread
+ starter = (message or "").strip()
+ if starter and thread_id:
+ await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
+
+ async def _dispatch_thread_session(
+ self,
+ interaction: discord.Interaction,
+ thread_id: str,
+ thread_name: str,
+ text: str,
+ ) -> None:
+ """Build a MessageEvent pointing at a thread and send it through handle_message."""
+ guild_name = ""
+ if hasattr(interaction, "guild") and interaction.guild:
+ guild_name = interaction.guild.name
+
+ chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
+
+ source = self.build_source(
+ chat_id=thread_id,
+ chat_name=chat_name,
+ chat_type="thread",
+ user_id=str(interaction.user.id),
+ user_name=interaction.user.display_name,
+ thread_id=thread_id,
+ )
+
+ event = MessageEvent(
+ text=text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=interaction,
+ )
+ await self.handle_message(event)
+
+ def _thread_parent_channel(self, channel: Any) -> Any:
+ """Return the parent text channel when invoked from a thread."""
+ return getattr(channel, "parent", None) or channel
+
+ async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
+ """Return the interaction channel, fetching it if the payload is partial."""
+ channel = getattr(interaction, "channel", None)
+ if channel is not None:
+ return channel
+ if not self._client:
+ return None
+ channel_id = getattr(interaction, "channel_id", None)
+ if channel_id is None:
+ return None
+ channel = self._client.get_channel(int(channel_id))
+ if channel is not None:
+ return channel
+ try:
+ return await self._client.fetch_channel(int(channel_id))
+ except Exception:
+ return None
+
+ async def _create_thread(
+ self,
+ interaction: discord.Interaction,
+ *,
+ name: str,
+ message: str = "",
+ auto_archive_duration: int = 1440,
+ ) -> Dict[str, Any]:
+ """Create a thread in the current Discord channel.
+
+ Tries ``parent_channel.create_thread()`` first. If Discord rejects
+ that (e.g. permission issues), falls back to sending a seed message
+ and creating the thread from it.
+ """
+ name = (name or "").strip()
+ if not name:
+ return {"error": "Thread name is required."}
+
+ if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
+ allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
+ return {"error": f"auto_archive_duration must be one of: {allowed}."}
+
+ channel = await self._resolve_interaction_channel(interaction)
+ if channel is None:
+ return {"error": "Could not resolve the current Discord channel."}
+ if isinstance(channel, discord.DMChannel):
+ return {"error": "Discord threads can only be created inside server text channels, not DMs."}
+
+ parent_channel = self._thread_parent_channel(channel)
+ if parent_channel is None:
+ return {"error": "Could not determine a parent text channel for the new thread."}
+
+ display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
+ reason = f"Requested by {display_name} via /thread"
+ starter_message = (message or "").strip()
+
+ try:
+ thread = await parent_channel.create_thread(
+ name=name,
+ auto_archive_duration=auto_archive_duration,
+ reason=reason,
+ )
+ if starter_message:
+ await thread.send(starter_message)
+ return {
+ "success": True,
+ "thread_id": str(thread.id),
+ "thread_name": getattr(thread, "name", None) or name,
+ }
+ except Exception as direct_error:
+ try:
+ seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
+ seed_msg = await parent_channel.send(seed_content)
+ thread = await seed_msg.create_thread(
+ name=name,
+ auto_archive_duration=auto_archive_duration,
+ reason=reason,
+ )
+ return {
+ "success": True,
+ "thread_id": str(thread.id),
+ "thread_name": getattr(thread, "name", None) or name,
+ }
+ except Exception as fallback_error:
+ return {
+ "error": (
+ "Discord rejected direct thread creation and the fallback also failed. "
+ f"Direct error: {direct_error}. Fallback error: {fallback_error}"
+ )
+ }
+
+ # ------------------------------------------------------------------
+ # Auto-thread helpers
+ # ------------------------------------------------------------------
+
+ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
+ """Create a thread from a user message for auto-threading.
+
+ Returns the created thread object, or ``None`` on failure.
+ """
+ # Build a short thread name from the message
+ content = (message.content or "").strip()
+ thread_name = content[:80] if content else "Hermes"
+ if len(content) > 80:
+ thread_name = thread_name[:77] + "..."
+
+ try:
+ thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
+ return thread
+ except Exception as e:
+ logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
+ return None
+
async def send_exec_approval(
self, chat_id: str, command: str, approval_id: str
) -> SendResult:
@@ -757,9 +1787,12 @@ async def send_exec_approval(
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
+ # Discord embed description limit is 4096; show full command up to that
+ max_desc = 4088
+ cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..."
embed = discord.Embed(
title="Command Approval Required",
- description=f"```\n{command[:500]}\n```",
+ description=f"```\n{cmd_display}\n```",
color=discord.Color.orange(),
)
embed.set_footer(text=f"Approval ID: {approval_id}")
@@ -815,17 +1848,59 @@ def _format_thread_chat_name(self, thread: Any) -> str:
return f"{parent_name} / {thread_name}"
return thread_name
+ # ------------------------------------------------------------------
+ # Thread participation persistence
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _thread_state_path() -> Path:
+ """Path to the persisted thread participation set."""
+ from hermes_cli.config import get_hermes_home
+ return get_hermes_home() / "discord_threads.json"
+
+ @classmethod
+ def _load_participated_threads(cls) -> set:
+ """Load persisted thread IDs from disk."""
+ path = cls._thread_state_path()
+ try:
+ if path.exists():
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return set(data)
+ except Exception as e:
+ logger.debug("Could not load discord thread state: %s", e)
+ return set()
+
+ def _save_participated_threads(self) -> None:
+ """Persist the current thread set to disk (best-effort)."""
+ path = self._thread_state_path()
+ try:
+ # Trim to most recent entries if over cap
+ thread_list = list(self._bot_participated_threads)
+ if len(thread_list) > self._MAX_TRACKED_THREADS:
+ thread_list = thread_list[-self._MAX_TRACKED_THREADS:]
+ self._bot_participated_threads = set(thread_list)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(thread_list), encoding="utf-8")
+ except Exception as e:
+ logger.debug("Could not save discord thread state: %s", e)
+
+ def _track_thread(self, thread_id: str) -> None:
+ """Add a thread to the participation set and persist."""
+ if thread_id not in self._bot_participated_threads:
+ self._bot_participated_threads.add(thread_id)
+ self._save_participated_threads()
+
async def _handle_message(self, message: DiscordMessage) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
- # UNLESS the channel is in the free-response list.
+ # UNLESS the channel is in the free-response list or the message is
+ # in a thread where the bot has already participated.
#
- # Config:
- # DISCORD_FREE_RESPONSE_CHANNELS: Comma-separated channel IDs where the
- # bot responds to every message without needing a mention.
- # DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement
- # globally (all channels become free-response). Default: "true".
- # Can also be set via discord.require_mention in config.yaml.
+ # Config (all settable via discord.* in config.yaml):
+ # discord.require_mention: Require @mention in server channels (default: true)
+ # discord.free_response_channels: Channel IDs where bot responds without mention
+ # discord.auto_thread: Auto-create thread on @mention in channels (default: true)
thread_id = None
parent_channel_id = None
@@ -844,7 +1919,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
is_free_channel = bool(channel_ids & free_channels)
- if require_mention and not is_free_channel:
+ # Skip the mention check if the message is in a thread where
+ # the bot has previously participated (auto-created or replied in).
+ in_bot_thread = is_thread and thread_id in self._bot_participated_threads
+
+ if require_mention and not is_free_channel and not in_bot_thread:
if self._client.user not in message.mentions:
return
@@ -852,6 +1931,20 @@ async def _handle_message(self, message: DiscordMessage) -> None:
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
+ # Auto-thread: when enabled, automatically create a thread for every
+ # @mention in a text channel so each conversation is isolated (like Slack).
+ # Messages already inside threads or DMs are unaffected.
+ auto_threaded_channel = None
+ if not is_thread and not isinstance(message.channel, discord.DMChannel):
+ auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
+ if auto_thread:
+ thread = await self._auto_create_thread(message)
+ if thread:
+ is_thread = True
+ thread_id = str(thread.id)
+ auto_threaded_channel = thread
+ self._track_thread(thread_id)
+
# Determine message type
msg_type = MessageType.TEXT
if message.content.startswith("/"):
@@ -867,16 +1960,24 @@ async def _handle_message(self, message: DiscordMessage) -> None:
elif att.content_type.startswith("audio/"):
msg_type = MessageType.AUDIO
else:
- msg_type = MessageType.DOCUMENT
+ doc_ext = ""
+ if att.filename:
+ _, doc_ext = os.path.splitext(att.filename)
+ doc_ext = doc_ext.lower()
+ if doc_ext in SUPPORTED_DOCUMENT_TYPES:
+ msg_type = MessageType.DOCUMENT
break
+ # When auto-threading kicked in, route responses to the new thread
+ effective_channel = auto_threaded_channel or message.channel
+
# Determine chat type
if isinstance(message.channel, discord.DMChannel):
chat_type = "dm"
chat_name = message.author.name
elif is_thread:
chat_type = "thread"
- chat_name = self._format_thread_chat_name(message.channel)
+ chat_name = self._format_thread_chat_name(effective_channel)
else:
chat_type = "group"
chat_name = getattr(message.channel, "name", str(message.channel.id))
@@ -888,7 +1989,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
# Build source
source = self.build_source(
- chat_id=str(message.channel.id),
+ chat_id=str(effective_channel.id),
chat_name=chat_name,
chat_type=chat_type,
user_id=str(message.author.id),
@@ -901,6 +2002,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
# vision tool can access them reliably (Discord CDN URLs can expire).
media_urls = []
media_types = []
+ pending_text_injection: Optional[str] = None
for att in message.attachments:
content_type = att.content_type or "unknown"
if content_type.startswith("image/"):
@@ -932,12 +2034,75 @@ async def _handle_message(self, message: DiscordMessage) -> None:
media_urls.append(att.url)
media_types.append(content_type)
else:
- # Other attachments: keep the original URL
- media_urls.append(att.url)
- media_types.append(content_type)
+ # Document attachments: download, cache, and optionally inject text
+ ext = ""
+ if att.filename:
+ _, ext = os.path.splitext(att.filename)
+ ext = ext.lower()
+ if not ext and content_type:
+ mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
+ ext = mime_to_ext.get(content_type, "")
+ if ext not in SUPPORTED_DOCUMENT_TYPES:
+ logger.warning(
+ "[Discord] Unsupported document type '%s' (%s), skipping",
+ ext or "unknown", content_type,
+ )
+ else:
+ MAX_DOC_BYTES = 20 * 1024 * 1024
+ if att.size and att.size > MAX_DOC_BYTES:
+ logger.warning(
+ "[Discord] Document too large (%s bytes), skipping: %s",
+ att.size, att.filename,
+ )
+ else:
+ try:
+ import aiohttp
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ att.url,
+ timeout=aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status != 200:
+ raise Exception(f"HTTP {resp.status}")
+ raw_bytes = await resp.read()
+ cached_path = cache_document_from_bytes(
+ raw_bytes, att.filename or f"document{ext}"
+ )
+ doc_mime = SUPPORTED_DOCUMENT_TYPES[ext]
+ media_urls.append(cached_path)
+ media_types.append(doc_mime)
+ logger.info("[Discord] Cached user document: %s", cached_path)
+ # Inject text content for .txt/.md files (capped at 100 KB)
+ MAX_TEXT_INJECT_BYTES = 100 * 1024
+ if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
+ try:
+ text_content = raw_bytes.decode("utf-8")
+ display_name = att.filename or f"document{ext}"
+ display_name = re.sub(r'[^\w.\- ]', '_', display_name)
+ injection = f"[Content of {display_name}]:\n{text_content}"
+ if pending_text_injection:
+ pending_text_injection = f"{pending_text_injection}\n\n{injection}"
+ else:
+ pending_text_injection = injection
+ except UnicodeDecodeError:
+ pass
+ except Exception as e:
+ logger.warning(
+ "[Discord] Failed to cache document %s: %s",
+ att.filename, e, exc_info=True,
+ )
+ event_text = message.content
+ if pending_text_injection:
+ event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection
+
+ # Defense-in-depth: prevent empty user messages from entering session
+ # (can happen when user sends @mention-only with no other text)
+ if not event_text or not event_text.strip():
+ event_text = "(The user sent a message with no text content)"
+
event = MessageEvent(
- text=message.content,
+ text=event_text,
message_type=msg_type,
source=source,
raw_message=message,
@@ -947,7 +2112,12 @@ async def _handle_message(self, message: DiscordMessage) -> None:
reply_to_message_id=str(message.reference.message_id) if message.reference else None,
timestamp=message.created_at,
)
-
+
+ # Track thread participation so the bot won't require @mention for
+ # follow-up messages in threads it has already engaged in.
+ if thread_id:
+ self._track_thread(thread_id)
+
await self.handle_message(event)
diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py
index 3b2db3f6fd3..f1a0ef07b6b 100644
--- a/gateway/platforms/email.py
+++ b/gateway/platforms/email.py
@@ -22,8 +22,8 @@
import os
import re
import smtplib
+import ssl
import uuid
-from datetime import datetime
from email.header import decode_header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -134,14 +134,23 @@ def _extract_email_address(raw: str) -> str:
return raw.strip().lower()
-def _extract_attachments(msg: email_lib.message.Message) -> List[Dict[str, Any]]:
- """Extract attachment metadata and cache files locally."""
+def _extract_attachments(
+ msg: email_lib.message.Message,
+ skip_attachments: bool = False,
+) -> List[Dict[str, Any]]:
+ """Extract attachment metadata and cache files locally.
+
+ When *skip_attachments* is True, all attachment/inline parts are ignored
+ (useful for malware protection or bandwidth savings).
+ """
attachments = []
if not msg.is_multipart():
return attachments
for part in msg.walk():
disposition = str(part.get("Content-Disposition", ""))
+ if skip_attachments and ("attachment" in disposition or "inline" in disposition):
+ continue
if "attachment" not in disposition and "inline" not in disposition:
continue
# Skip text/plain and text/html body parts
@@ -195,8 +204,16 @@ def __init__(self, config: PlatformConfig):
self._smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
self._poll_interval = int(os.getenv("EMAIL_POLL_INTERVAL", "15"))
+ # Skip attachments โ configured via config.yaml:
+ # platforms:
+ # email:
+ # skip_attachments: true
+ extra = config.extra or {}
+ self._skip_attachments = extra.get("skip_attachments", False)
+
# Track message IDs we've already processed to avoid duplicates
self._seen_uids: set = set()
+ self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth
self._poll_task: Optional[asyncio.Task] = None
# Map chat_id (sender email) -> last subject + message-id for threading
@@ -204,18 +221,40 @@ def __init__(self, config: PlatformConfig):
logger.info("[Email] Adapter initialized for %s", self._address)
+ def _trim_seen_uids(self) -> None:
+ """Keep only the most recent UIDs to prevent unbounded memory growth.
+
+ IMAP UIDs are monotonically increasing integers. When the set grows
+ beyond the cap, we keep only the highest half โ old UIDs are safe to
+ drop because new messages always have higher UIDs and IMAP's UNSEEN
+ flag prevents re-delivery regardless.
+ """
+ if len(self._seen_uids) <= self._seen_uids_max:
+ return
+ try:
+ # UIDs are bytes like b'1234' โ sort numerically and keep top half
+ sorted_uids = sorted(self._seen_uids, key=lambda u: int(u))
+ keep = self._seen_uids_max // 2
+ self._seen_uids = set(sorted_uids[-keep:])
+ logger.debug("[Email] Trimmed seen UIDs to %d entries", len(self._seen_uids))
+ except (ValueError, TypeError):
+ # Fallback: just clear old entries if sort fails
+ self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])
+
async def connect(self) -> bool:
"""Connect to the IMAP server and start polling for new messages."""
try:
# Test IMAP connection
- imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port)
+ imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
imap.login(self._address, self._password)
# Mark all existing messages as seen so we only process new ones
imap.select("INBOX")
- status, data = imap.search(None, "ALL")
- if status == "OK" and data[0]:
+ status, data = imap.uid("search", None, "ALL")
+ if status == "OK" and data and data[0]:
for uid in data[0].split():
self._seen_uids.add(uid)
+ # Keep only the most recent UIDs to prevent unbounded growth
+ self._trim_seen_uids()
imap.logout()
logger.info("[Email] IMAP connection test passed. %d existing messages skipped.", len(self._seen_uids))
except Exception as e:
@@ -224,8 +263,8 @@ async def connect(self) -> bool:
try:
# Test SMTP connection
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
- smtp.starttls()
+ smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.quit()
logger.info("[Email] SMTP connection test passed.")
@@ -273,12 +312,12 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
"""Fetch new (unseen) messages from IMAP. Runs in executor thread."""
results = []
try:
- imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port)
+ imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
imap.login(self._address, self._password)
imap.select("INBOX")
- status, data = imap.search(None, "UNSEEN")
- if status != "OK" or not data[0]:
+ status, data = imap.uid("search", None, "UNSEEN")
+ if status != "OK" or not data or not data[0]:
imap.logout()
return results
@@ -286,8 +325,11 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
if uid in self._seen_uids:
continue
self._seen_uids.add(uid)
+ # Trim periodically to prevent unbounded memory growth
+ if len(self._seen_uids) > self._seen_uids_max:
+ self._trim_seen_uids()
- status, msg_data = imap.fetch(uid, "(RFC822)")
+ status, msg_data = imap.uid("fetch", uid, "(RFC822)")
if status != "OK":
continue
@@ -305,7 +347,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
body = _extract_text_body(msg)
- attachments = _extract_attachments(msg)
+ attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments)
results.append({
"uid": uid,
@@ -426,8 +468,8 @@ def _send_email(
msg.attach(MIMEText(body, "plain", "utf-8"))
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
- smtp.starttls()
+ smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
smtp.quit()
@@ -435,9 +477,8 @@ def _send_email(
logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject)
return msg_id
- async def send_typing(self, chat_id: str) -> None:
+ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Email has no typing indicator โ no-op."""
- pass
async def send_image(
self,
@@ -514,8 +555,8 @@ def _send_email_with_attachment(
part.add_header("Content-Disposition", f"attachment; filename={fname}")
msg.attach(part)
- smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
- smtp.starttls()
+ smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
+ smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
smtp.quit()
diff --git a/gateway/platforms/homeassistant.py b/gateway/platforms/homeassistant.py
index 930470608e1..746465594ce 100644
--- a/gateway/platforms/homeassistant.py
+++ b/gateway/platforms/homeassistant.py
@@ -19,7 +19,7 @@
import time
import uuid
from datetime import datetime
-from typing import Any, Dict, List, Optional, Set
+from typing import Any, Dict, Optional, Set
try:
import aiohttp
@@ -83,6 +83,7 @@ def __init__(self, config: PlatformConfig):
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
+ self._watch_all: bool = bool(extra.get("watch_all", False))
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
# Cooldown tracking: entity_id -> last_event_timestamp
@@ -113,7 +114,18 @@ async def connect(self) -> bool:
return False
# Dedicated REST session for send() calls
- self._rest_session = aiohttp.ClientSession()
+ self._rest_session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30)
+ )
+
+ # Warn if no event filters are configured
+ if not self._watch_domains and not self._watch_entities and not self._watch_all:
+ logger.warning(
+ "[%s] No watch_domains, watch_entities, or watch_all configured. "
+ "All state_changed events will be dropped. Configure filters in "
+ "your HA platform config to receive events.",
+ self.name,
+ )
# Start background listener
self._listen_task = asyncio.create_task(self._listen_loop())
@@ -130,8 +142,10 @@ async def _ws_connect(self) -> bool:
ws_url = self._hass_url.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_url}/api/websocket"
- self._session = aiohttp.ClientSession()
- self._ws = await self._session.ws_connect(ws_url, heartbeat=30)
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30)
+ )
+ self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30)
# Step 1: Receive auth_required
msg = await self._ws.receive_json()
@@ -257,13 +271,17 @@ async def _handle_ha_event(self, event: Dict[str, Any]) -> None:
if entity_id in self._ignore_entities:
return
- # Apply domain/entity watch filters
+ # Apply domain/entity watch filters (closed by default โ require
+ # explicit watch_domains, watch_entities, or watch_all to forward)
domain = entity_id.split(".")[0] if "." in entity_id else ""
if self._watch_domains or self._watch_entities:
domain_match = domain in self._watch_domains if self._watch_domains else False
entity_match = entity_id in self._watch_entities if self._watch_entities else False
if not domain_match and not entity_match:
return
+ elif not self._watch_all:
+ # No filters configured and watch_all is off โ drop the event
+ return
# Apply cooldown
now = time.time()
@@ -421,7 +439,6 @@ async def send(
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""No typing indicator for Home Assistant."""
- pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return basic info about the HA event channel."""
diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py
new file mode 100644
index 00000000000..79ac8239632
--- /dev/null
+++ b/gateway/platforms/matrix.py
@@ -0,0 +1,905 @@
+"""Matrix gateway adapter.
+
+Connects to any Matrix homeserver (self-hosted or matrix.org) via the
+matrix-nio Python SDK. Supports optional end-to-end encryption (E2EE)
+when installed with ``pip install "matrix-nio[e2e]"``.
+
+Environment variables:
+ MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org)
+ MATRIX_ACCESS_TOKEN Access token (preferred auth method)
+ MATRIX_USER_ID Full user ID (@bot:server) โ required for password login
+ MATRIX_PASSWORD Password (alternative to access token)
+ MATRIX_ENCRYPTION Set "true" to enable E2EE
+ MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
+ MATRIX_HOME_ROOM Room ID for cron/notification delivery
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import mimetypes
+import os
+import re
+import time
+from pathlib import Path
+from typing import Any, Dict, Optional, Set
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+# Matrix message size limit (4000 chars practical, spec has no hard limit
+# but clients render poorly above this).
+MAX_MESSAGE_LENGTH = 4000
+
+# Store directory for E2EE keys and sync state.
+_STORE_DIR = Path.home() / ".hermes" / "matrix" / "store"
+
+# Grace period: ignore messages older than this many seconds before startup.
+_STARTUP_GRACE_SECONDS = 5
+
+
+def check_matrix_requirements() -> bool:
+ """Return True if the Matrix adapter can be used."""
+ token = os.getenv("MATRIX_ACCESS_TOKEN", "")
+ password = os.getenv("MATRIX_PASSWORD", "")
+ homeserver = os.getenv("MATRIX_HOMESERVER", "")
+
+ if not token and not password:
+ logger.debug("Matrix: neither MATRIX_ACCESS_TOKEN nor MATRIX_PASSWORD set")
+ return False
+ if not homeserver:
+ logger.warning("Matrix: MATRIX_HOMESERVER not set")
+ return False
+ try:
+ import nio # noqa: F401
+ return True
+ except ImportError:
+ logger.warning(
+ "Matrix: matrix-nio not installed. "
+ "Run: pip install 'matrix-nio[e2e]'"
+ )
+ return False
+
+
+class MatrixAdapter(BasePlatformAdapter):
+ """Gateway adapter for Matrix (any homeserver)."""
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.MATRIX)
+
+ self._homeserver: str = (
+ config.extra.get("homeserver", "")
+ or os.getenv("MATRIX_HOMESERVER", "")
+ ).rstrip("/")
+ self._access_token: str = config.token or os.getenv("MATRIX_ACCESS_TOKEN", "")
+ self._user_id: str = (
+ config.extra.get("user_id", "")
+ or os.getenv("MATRIX_USER_ID", "")
+ )
+ self._password: str = (
+ config.extra.get("password", "")
+ or os.getenv("MATRIX_PASSWORD", "")
+ )
+ self._encryption: bool = config.extra.get(
+ "encryption",
+ os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"),
+ )
+
+ self._client: Any = None # nio.AsyncClient
+ self._sync_task: Optional[asyncio.Task] = None
+ self._closing = False
+ self._startup_ts: float = 0.0
+
+ # Cache: room_id โ bool (is DM)
+ self._dm_rooms: Dict[str, bool] = {}
+ # Set of room IDs we've joined
+ self._joined_rooms: Set[str] = set()
+ # Event deduplication (bounded deque keeps newest entries)
+ from collections import deque
+ self._processed_events: deque = deque(maxlen=1000)
+ self._processed_events_set: set = set()
+
+ def _is_duplicate_event(self, event_id) -> bool:
+ """Return True if this event was already processed. Tracks the ID otherwise."""
+ if not event_id:
+ return False
+ if event_id in self._processed_events_set:
+ return True
+ if len(self._processed_events) == self._processed_events.maxlen:
+ evicted = self._processed_events[0]
+ self._processed_events_set.discard(evicted)
+ self._processed_events.append(event_id)
+ self._processed_events_set.add(event_id)
+ return False
+
+ # ------------------------------------------------------------------
+ # Required overrides
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ """Connect to the Matrix homeserver and start syncing."""
+ import nio
+
+ if not self._homeserver:
+ logger.error("Matrix: homeserver URL not configured")
+ return False
+
+ # Determine store path and ensure it exists.
+ store_path = str(_STORE_DIR)
+ _STORE_DIR.mkdir(parents=True, exist_ok=True)
+
+ # Create the client.
+ if self._encryption:
+ try:
+ client = nio.AsyncClient(
+ self._homeserver,
+ self._user_id or "",
+ store_path=store_path,
+ )
+ logger.info("Matrix: E2EE enabled (store: %s)", store_path)
+ except Exception as exc:
+ logger.warning(
+ "Matrix: failed to create E2EE client (%s), "
+ "falling back to plain client. Install: "
+ "pip install 'matrix-nio[e2e]'",
+ exc,
+ )
+ client = nio.AsyncClient(self._homeserver, self._user_id or "")
+ else:
+ client = nio.AsyncClient(self._homeserver, self._user_id or "")
+
+ self._client = client
+
+ # Authenticate.
+ if self._access_token:
+ client.access_token = self._access_token
+ # Resolve user_id if not set.
+ if not self._user_id:
+ resp = await client.whoami()
+ if isinstance(resp, nio.WhoamiResponse):
+ self._user_id = resp.user_id
+ client.user_id = resp.user_id
+ logger.info("Matrix: authenticated as %s", self._user_id)
+ else:
+ logger.error(
+ "Matrix: whoami failed โ check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER"
+ )
+ await client.close()
+ return False
+ else:
+ client.user_id = self._user_id
+ logger.info("Matrix: using access token for %s", self._user_id)
+ elif self._password and self._user_id:
+ resp = await client.login(
+ self._password,
+ device_name="Hermes Agent",
+ )
+ if isinstance(resp, nio.LoginResponse):
+ logger.info("Matrix: logged in as %s", self._user_id)
+ else:
+ logger.error("Matrix: login failed โ %s", getattr(resp, "message", resp))
+ await client.close()
+ return False
+ else:
+ logger.error("Matrix: need MATRIX_ACCESS_TOKEN or MATRIX_USER_ID + MATRIX_PASSWORD")
+ await client.close()
+ return False
+
+ # If E2EE is enabled, load the crypto store.
+ if self._encryption and hasattr(client, "olm"):
+ try:
+ if client.should_upload_keys:
+ await client.keys_upload()
+ logger.info("Matrix: E2EE crypto initialized")
+ except Exception as exc:
+ logger.warning("Matrix: crypto init issue: %s", exc)
+
+ # Register event callbacks.
+ client.add_event_callback(self._on_room_message, nio.RoomMessageText)
+ client.add_event_callback(self._on_room_message_media, nio.RoomMessageImage)
+ client.add_event_callback(self._on_room_message_media, nio.RoomMessageAudio)
+ client.add_event_callback(self._on_room_message_media, nio.RoomMessageVideo)
+ client.add_event_callback(self._on_room_message_media, nio.RoomMessageFile)
+ client.add_event_callback(self._on_invite, nio.InviteMemberEvent)
+
+ # If E2EE: handle encrypted events.
+ if self._encryption and hasattr(client, "olm"):
+ client.add_event_callback(
+ self._on_room_message, nio.MegolmEvent
+ )
+
+ # Initial sync to catch up, then start background sync.
+ self._startup_ts = time.time()
+ self._closing = False
+
+ # Do an initial sync to populate room state.
+ resp = await client.sync(timeout=10000, full_state=True)
+ if isinstance(resp, nio.SyncResponse):
+ self._joined_rooms = set(resp.rooms.join.keys())
+ logger.info(
+ "Matrix: initial sync complete, joined %d rooms",
+ len(self._joined_rooms),
+ )
+ # Build DM room cache from m.direct account data.
+ await self._refresh_dm_cache()
+ else:
+ logger.warning("Matrix: initial sync returned %s", type(resp).__name__)
+
+ # Start the sync loop.
+ self._sync_task = asyncio.create_task(self._sync_loop())
+ self._mark_connected()
+ return True
+
+ async def disconnect(self) -> None:
+ """Disconnect from Matrix."""
+ self._closing = True
+
+ if self._sync_task and not self._sync_task.done():
+ self._sync_task.cancel()
+ try:
+ await self._sync_task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ if self._client:
+ await self._client.close()
+ self._client = None
+
+ logger.info("Matrix: disconnected")
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a message to a Matrix room."""
+ import nio
+
+ if not content:
+ return SendResult(success=True)
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
+
+ last_event_id = None
+ for chunk in chunks:
+ msg_content: Dict[str, Any] = {
+ "msgtype": "m.text",
+ "body": chunk,
+ }
+
+ # Convert markdown to HTML for rich rendering.
+ html = self._markdown_to_html(chunk)
+ if html and html != chunk:
+ msg_content["format"] = "org.matrix.custom.html"
+ msg_content["formatted_body"] = html
+
+ # Reply-to support.
+ if reply_to:
+ msg_content["m.relates_to"] = {
+ "m.in_reply_to": {"event_id": reply_to}
+ }
+
+ # Thread support: if metadata has thread_id, send as threaded reply.
+ thread_id = (metadata or {}).get("thread_id")
+ if thread_id:
+ relates_to = msg_content.get("m.relates_to", {})
+ relates_to["rel_type"] = "m.thread"
+ relates_to["event_id"] = thread_id
+ relates_to["is_falling_back"] = True
+ if reply_to and "m.in_reply_to" not in relates_to:
+ relates_to["m.in_reply_to"] = {"event_id": reply_to}
+ msg_content["m.relates_to"] = relates_to
+
+ resp = await self._client.room_send(
+ chat_id,
+ "m.room.message",
+ msg_content,
+ )
+ if isinstance(resp, nio.RoomSendResponse):
+ last_event_id = resp.event_id
+ else:
+ err = getattr(resp, "message", str(resp))
+ logger.error("Matrix: failed to send to %s: %s", chat_id, err)
+ return SendResult(success=False, error=err)
+
+ return SendResult(success=True, message_id=last_event_id)
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return room name and type (dm/group)."""
+ name = chat_id
+ chat_type = "group"
+
+ if self._client:
+ room = self._client.rooms.get(chat_id)
+ if room:
+ name = room.display_name or room.canonical_alias or chat_id
+ # Use DM cache.
+ if self._dm_rooms.get(chat_id, False):
+ chat_type = "dm"
+ elif room.member_count == 2:
+ chat_type = "dm"
+
+ return {"name": name, "type": chat_type}
+
+ # ------------------------------------------------------------------
+ # Optional overrides
+ # ------------------------------------------------------------------
+
+ async def send_typing(
+ self, chat_id: str, metadata: Optional[Dict[str, Any]] = None
+ ) -> None:
+ """Send a typing indicator."""
+ if self._client:
+ try:
+ await self._client.room_typing(chat_id, typing_state=True, timeout=30000)
+ except Exception:
+ pass
+
+ async def edit_message(
+ self, chat_id: str, message_id: str, content: str
+ ) -> SendResult:
+ """Edit an existing message (via m.replace)."""
+ import nio
+
+ formatted = self.format_message(content)
+ msg_content: Dict[str, Any] = {
+ "msgtype": "m.text",
+ "body": f"* {formatted}",
+ "m.new_content": {
+ "msgtype": "m.text",
+ "body": formatted,
+ },
+ "m.relates_to": {
+ "rel_type": "m.replace",
+ "event_id": message_id,
+ },
+ }
+
+ html = self._markdown_to_html(formatted)
+ if html and html != formatted:
+ msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
+ msg_content["m.new_content"]["formatted_body"] = html
+ msg_content["format"] = "org.matrix.custom.html"
+ msg_content["formatted_body"] = f"* {html}"
+
+ resp = await self._client.room_send(chat_id, "m.room.message", msg_content)
+ if isinstance(resp, nio.RoomSendResponse):
+ return SendResult(success=True, message_id=resp.event_id)
+ return SendResult(success=False, error=getattr(resp, "message", str(resp)))
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Download an image URL and upload it to Matrix."""
+ try:
+ # Try aiohttp first (always available), fall back to httpx
+ try:
+ import aiohttp as _aiohttp
+ async with _aiohttp.ClientSession() as http:
+ async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp:
+ resp.raise_for_status()
+ data = await resp.read()
+ ct = resp.content_type or "image/png"
+ fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
+ except ImportError:
+ import httpx
+ async with httpx.AsyncClient() as http:
+ resp = await http.get(image_url, follow_redirects=True, timeout=30)
+ resp.raise_for_status()
+ data = resp.content
+ ct = resp.headers.get("content-type", "image/png")
+ fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png"
+ except Exception as exc:
+ logger.warning("Matrix: failed to download image %s: %s", image_url, exc)
+ return await self.send(chat_id, f"{caption or ''}\n{image_url}".strip(), reply_to)
+
+ return await self._upload_and_send(chat_id, data, fname, ct, "m.image", caption, reply_to, metadata)
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a local image file to Matrix."""
+ return await self._send_local_file(chat_id, image_path, "m.image", caption, reply_to, metadata=metadata)
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a local file as a document."""
+ return await self._send_local_file(chat_id, file_path, "m.file", caption, reply_to, file_name, metadata)
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """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,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a video file."""
+ return await self._send_local_file(chat_id, video_path, "m.video", caption, reply_to, metadata=metadata)
+
+ def format_message(self, content: str) -> str:
+ """Pass-through โ Matrix supports standard Markdown natively."""
+ # Strip image markdown; media is uploaded separately.
+ content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content)
+ return content
+
+ # ------------------------------------------------------------------
+ # File helpers
+ # ------------------------------------------------------------------
+
+ async def _upload_and_send(
+ self,
+ room_id: str,
+ data: bytes,
+ filename: str,
+ content_type: str,
+ msgtype: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload bytes to Matrix and send as a media message."""
+ import nio
+
+ # Upload to homeserver.
+ resp = await self._client.upload(
+ data,
+ content_type=content_type,
+ filename=filename,
+ )
+ if not isinstance(resp, nio.UploadResponse):
+ err = getattr(resp, "message", str(resp))
+ logger.error("Matrix: upload failed: %s", err)
+ return SendResult(success=False, error=err)
+
+ mxc_url = resp.content_uri
+
+ # Build media message content.
+ msg_content: Dict[str, Any] = {
+ "msgtype": msgtype,
+ "body": caption or filename,
+ "url": mxc_url,
+ "info": {
+ "mimetype": content_type,
+ "size": len(data),
+ },
+ }
+
+ if reply_to:
+ msg_content["m.relates_to"] = {
+ "m.in_reply_to": {"event_id": reply_to}
+ }
+
+ thread_id = (metadata or {}).get("thread_id")
+ if thread_id:
+ relates_to = msg_content.get("m.relates_to", {})
+ relates_to["rel_type"] = "m.thread"
+ relates_to["event_id"] = thread_id
+ relates_to["is_falling_back"] = True
+ msg_content["m.relates_to"] = relates_to
+
+ resp2 = await self._client.room_send(room_id, "m.room.message", msg_content)
+ if isinstance(resp2, nio.RoomSendResponse):
+ return SendResult(success=True, message_id=resp2.event_id)
+ return SendResult(success=False, error=getattr(resp2, "message", str(resp2)))
+
+ async def _send_local_file(
+ self,
+ room_id: str,
+ file_path: str,
+ msgtype: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ file_name: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Read a local file and upload it."""
+ p = Path(file_path)
+ if not p.exists():
+ return await self.send(
+ room_id, f"{caption or ''}\n(file not found: {file_path})", reply_to
+ )
+
+ fname = file_name or p.name
+ 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)
+
+ # ------------------------------------------------------------------
+ # Sync loop
+ # ------------------------------------------------------------------
+
+ async def _sync_loop(self) -> None:
+ """Continuously sync with the homeserver."""
+ import nio
+
+ while not self._closing:
+ try:
+ resp = await self._client.sync(timeout=30000)
+ if isinstance(resp, nio.SyncError):
+ if self._closing:
+ return
+ logger.warning(
+ "Matrix: sync returned %s: %s โ retrying in 5s",
+ type(resp).__name__,
+ getattr(resp, "message", resp),
+ )
+ await asyncio.sleep(5)
+ except asyncio.CancelledError:
+ return
+ except Exception as exc:
+ if self._closing:
+ return
+ logger.warning("Matrix: sync error: %s โ retrying in 5s", exc)
+ await asyncio.sleep(5)
+
+ # ------------------------------------------------------------------
+ # Event callbacks
+ # ------------------------------------------------------------------
+
+ async def _on_room_message(self, room: Any, event: Any) -> None:
+ """Handle incoming text messages (and decrypted megolm events)."""
+ import nio
+
+ # Ignore own messages.
+ if event.sender == self._user_id:
+ return
+
+ # Deduplicate by event ID (nio can fire the same event more than once).
+ if self._is_duplicate_event(getattr(event, "event_id", None)):
+ return
+
+ # Startup grace: ignore old messages from initial sync.
+ event_ts = getattr(event, "server_timestamp", 0) / 1000.0
+ if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
+ return
+
+ # 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",
+ event.event_id, room.room_id,
+ )
+ return
+
+ # Skip edits (m.replace relation).
+ source_content = getattr(event, "source", {}).get("content", {})
+ relates_to = source_content.get("m.relates_to", {})
+ if relates_to.get("rel_type") == "m.replace":
+ return
+
+ body = getattr(event, "body", "") or ""
+ if not body:
+ return
+
+ # Determine chat type.
+ is_dm = self._dm_rooms.get(room.room_id, False)
+ if not is_dm and room.member_count == 2:
+ is_dm = True
+ chat_type = "dm" if is_dm else "group"
+
+ # Thread support.
+ thread_id = None
+ if relates_to.get("rel_type") == "m.thread":
+ thread_id = relates_to.get("event_id")
+
+ # Reply-to detection.
+ reply_to = None
+ in_reply_to = relates_to.get("m.in_reply_to", {})
+ if in_reply_to:
+ reply_to = in_reply_to.get("event_id")
+
+ # Strip reply fallback from body (Matrix prepends "> ..." lines).
+ if reply_to and body.startswith("> "):
+ lines = body.split("\n")
+ stripped = []
+ past_fallback = False
+ for line in lines:
+ if not past_fallback:
+ if line.startswith("> ") or line == ">":
+ continue
+ if line == "":
+ past_fallback = True
+ continue
+ past_fallback = True
+ stripped.append(line)
+ body = "\n".join(stripped) if stripped else body
+
+ # Message type.
+ msg_type = MessageType.TEXT
+ if body.startswith("!") or body.startswith("/"):
+ msg_type = MessageType.COMMAND
+
+ source = self.build_source(
+ chat_id=room.room_id,
+ chat_type=chat_type,
+ user_id=event.sender,
+ user_name=self._get_display_name(room, event.sender),
+ thread_id=thread_id,
+ )
+
+ msg_event = MessageEvent(
+ text=body,
+ message_type=msg_type,
+ source=source,
+ raw_message=getattr(event, "source", {}),
+ message_id=event.event_id,
+ reply_to_message_id=reply_to,
+ )
+
+ await self.handle_message(msg_event)
+
+ async def _on_room_message_media(self, room: Any, event: Any) -> None:
+ """Handle incoming media messages (images, audio, video, files)."""
+ import nio
+
+ # Ignore own messages.
+ if event.sender == self._user_id:
+ return
+
+ # Deduplicate by event ID.
+ if self._is_duplicate_event(getattr(event, "event_id", None)):
+ return
+
+ # Startup grace.
+ event_ts = getattr(event, "server_timestamp", 0) / 1000.0
+ if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
+ return
+
+ body = getattr(event, "body", "") or ""
+ url = getattr(event, "url", "")
+
+ # Convert mxc:// to HTTP URL for downstream processing.
+ http_url = ""
+ if url and url.startswith("mxc://"):
+ http_url = self._mxc_to_http(url)
+
+ # Determine message type from event class.
+ # Use the MIME type from the event's content info when available,
+ # falling back to category-level MIME types for downstream matching
+ # (gateway/run.py checks startswith("image/"), startswith("audio/"), etc.)
+ content_info = getattr(event, "content", {}) if isinstance(getattr(event, "content", None), dict) else {}
+ event_mimetype = (content_info.get("info") or {}).get("mimetype", "")
+ media_type = "application/octet-stream"
+ msg_type = MessageType.DOCUMENT
+ if isinstance(event, nio.RoomMessageImage):
+ msg_type = MessageType.PHOTO
+ media_type = event_mimetype or "image/png"
+ elif isinstance(event, nio.RoomMessageAudio):
+ msg_type = MessageType.AUDIO
+ media_type = event_mimetype or "audio/ogg"
+ elif isinstance(event, nio.RoomMessageVideo):
+ msg_type = MessageType.VIDEO
+ media_type = event_mimetype or "video/mp4"
+ elif event_mimetype:
+ media_type = event_mimetype
+
+ # For images, download and cache locally so vision tools can access them.
+ # Matrix MXC URLs require authentication, so direct URL access fails.
+ cached_path = None
+ if msg_type == MessageType.PHOTO and url:
+ try:
+ ext_map = {
+ "image/jpeg": ".jpg", "image/png": ".png",
+ "image/gif": ".gif", "image/webp": ".webp",
+ }
+ ext = ext_map.get(event_mimetype, ".jpg")
+ download_resp = await self._client.download(url)
+ if isinstance(download_resp, nio.DownloadResponse):
+ from gateway.platforms.base import cache_image_from_bytes
+ cached_path = cache_image_from_bytes(download_resp.body, ext=ext)
+ logger.info("[Matrix] Cached user image at %s", cached_path)
+ except Exception as e:
+ logger.warning("[Matrix] Failed to cache image: %s", e)
+
+ is_dm = self._dm_rooms.get(room.room_id, False)
+ if not is_dm and room.member_count == 2:
+ is_dm = True
+ chat_type = "dm" if is_dm else "group"
+
+ # Thread/reply detection.
+ source_content = getattr(event, "source", {}).get("content", {})
+ relates_to = source_content.get("m.relates_to", {})
+ thread_id = None
+ if relates_to.get("rel_type") == "m.thread":
+ thread_id = relates_to.get("event_id")
+
+ source = self.build_source(
+ chat_id=room.room_id,
+ chat_type=chat_type,
+ user_id=event.sender,
+ user_name=self._get_display_name(room, event.sender),
+ thread_id=thread_id,
+ )
+
+ # 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(
+ text=body,
+ message_type=msg_type,
+ source=source,
+ raw_message=getattr(event, "source", {}),
+ message_id=event.event_id,
+ media_urls=media_urls,
+ media_types=media_types,
+ )
+
+ await self.handle_message(msg_event)
+
+ async def _on_invite(self, room: Any, event: Any) -> None:
+ """Auto-join rooms when invited."""
+ import nio
+
+ if not isinstance(event, nio.InviteMemberEvent):
+ return
+
+ # Only process invites directed at us.
+ if event.state_key != self._user_id:
+ return
+
+ if event.membership != "invite":
+ return
+
+ logger.info(
+ "Matrix: invited to %s by %s โ joining",
+ room.room_id, event.sender,
+ )
+ try:
+ resp = await self._client.join(room.room_id)
+ if isinstance(resp, nio.JoinResponse):
+ self._joined_rooms.add(room.room_id)
+ logger.info("Matrix: joined %s", room.room_id)
+ # Refresh DM cache since new room may be a DM.
+ await self._refresh_dm_cache()
+ else:
+ logger.warning(
+ "Matrix: failed to join %s: %s",
+ room.room_id, getattr(resp, "message", resp),
+ )
+ except Exception as exc:
+ logger.warning("Matrix: error joining %s: %s", room.room_id, exc)
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ async def _refresh_dm_cache(self) -> None:
+ """Refresh the DM room cache from m.direct account data.
+
+ Tries the account_data API first, then falls back to parsing
+ the sync response's account_data for robustness.
+ """
+ if not self._client:
+ return
+
+ dm_data: Optional[Dict] = None
+
+ # Primary: try the dedicated account data endpoint.
+ try:
+ resp = await self._client.get_account_data("m.direct")
+ if hasattr(resp, "content"):
+ dm_data = resp.content
+ elif isinstance(resp, dict):
+ dm_data = resp
+ except Exception as exc:
+ logger.debug("Matrix: get_account_data('m.direct') failed: %s โ trying sync fallback", exc)
+
+ # Fallback: parse from the client's account_data store (populated by sync).
+ if dm_data is None:
+ try:
+ # matrix-nio stores account data events on the client object
+ ad = getattr(self._client, "account_data", None)
+ if ad and isinstance(ad, dict) and "m.direct" in ad:
+ event = ad["m.direct"]
+ if hasattr(event, "content"):
+ dm_data = event.content
+ elif isinstance(event, dict):
+ dm_data = event
+ except Exception:
+ pass
+
+ if dm_data is None:
+ return
+
+ dm_room_ids: Set[str] = set()
+ for user_id, rooms in dm_data.items():
+ if isinstance(rooms, list):
+ dm_room_ids.update(rooms)
+
+ self._dm_rooms = {
+ rid: (rid in dm_room_ids)
+ for rid in self._joined_rooms
+ }
+
+ def _get_display_name(self, room: Any, user_id: str) -> str:
+ """Get a user's display name in a room, falling back to user_id."""
+ if room and hasattr(room, "users"):
+ user = room.users.get(user_id)
+ if user and getattr(user, "display_name", None):
+ return user.display_name
+ # Strip the @...:server format to just the localpart.
+ if user_id.startswith("@") and ":" in user_id:
+ return user_id[1:].split(":")[0]
+ return user_id
+
+ def _mxc_to_http(self, mxc_url: str) -> str:
+ """Convert mxc://server/media_id to an HTTP download URL."""
+ # mxc://matrix.org/abc123 โ https://matrix.org/_matrix/client/v1/media/download/matrix.org/abc123
+ # Uses the authenticated client endpoint (spec v1.11+) instead of the
+ # deprecated /_matrix/media/v3/download/ path.
+ if not mxc_url.startswith("mxc://"):
+ return mxc_url
+ parts = mxc_url[6:] # strip mxc://
+ # Use our homeserver for download (federation handles the rest).
+ return f"{self._homeserver}/_matrix/client/v1/media/download/{parts}"
+
+ def _markdown_to_html(self, text: str) -> str:
+ """Convert Markdown to Matrix-compatible HTML.
+
+ Uses a simple conversion for common patterns. For full fidelity
+ a markdown-it style library could be used, but this covers the
+ common cases without an extra dependency.
+ """
+ try:
+ import markdown
+ html = markdown.markdown(
+ text,
+ extensions=["fenced_code", "tables", "nl2br"],
+ )
+ # Strip wrapping tags for single-paragraph messages.
+ if html.count("
") == 1:
+ html = html.replace("
", "").replace("
", "")
+ return html
+ except ImportError:
+ pass
+
+ # Minimal fallback: just handle bold, italic, code.
+ html = text
+ html = re.sub(r"\*\*(.+?)\*\*", r"\1 ", html)
+ html = re.sub(r"\*(.+?)\*", r"\1 ", html)
+ html = re.sub(r"`([^`]+)`", r"\1", html)
+ html = re.sub(r"\n", r" ", html)
+ return html
diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py
new file mode 100644
index 00000000000..8e8cd4db0f8
--- /dev/null
+++ b/gateway/platforms/mattermost.py
@@ -0,0 +1,705 @@
+"""Mattermost gateway adapter.
+
+Connects to a self-hosted (or cloud) Mattermost instance via its REST API
+(v4) and WebSocket for real-time events. No external Mattermost library
+required โ uses aiohttp which is already a Hermes dependency.
+
+Environment variables:
+ MATTERMOST_URL Server URL (e.g. https://mm.example.com)
+ MATTERMOST_TOKEN Bot token or personal-access token
+ MATTERMOST_ALLOWED_USERS Comma-separated user IDs
+ MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import re
+import time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+# Mattermost post size limit (server default is 16383, but 4000 is the
+# practical limit for readable messages โ matching OpenClaw's choice).
+MAX_POST_LENGTH = 4000
+
+# Channel type codes returned by the Mattermost API.
+_CHANNEL_TYPE_MAP = {
+ "D": "dm",
+ "G": "group",
+ "P": "group", # private channel โ treat as group
+ "O": "channel",
+}
+
+# Reconnect parameters (exponential backoff).
+_RECONNECT_BASE_DELAY = 2.0
+_RECONNECT_MAX_DELAY = 60.0
+_RECONNECT_JITTER = 0.2
+
+
+def check_mattermost_requirements() -> bool:
+ """Return True if the Mattermost adapter can be used."""
+ token = os.getenv("MATTERMOST_TOKEN", "")
+ url = os.getenv("MATTERMOST_URL", "")
+ if not token:
+ logger.debug("Mattermost: MATTERMOST_TOKEN not set")
+ return False
+ if not url:
+ logger.warning("Mattermost: MATTERMOST_URL not set")
+ return False
+ try:
+ import aiohttp # noqa: F401
+ return True
+ except ImportError:
+ logger.warning("Mattermost: aiohttp not installed")
+ return False
+
+
+class MattermostAdapter(BasePlatformAdapter):
+ """Gateway adapter for Mattermost (self-hosted or cloud)."""
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.MATTERMOST)
+
+ self._base_url: str = (
+ config.extra.get("url", "")
+ or os.getenv("MATTERMOST_URL", "")
+ ).rstrip("/")
+ self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "")
+
+ self._bot_user_id: str = ""
+ self._bot_username: str = ""
+
+ # aiohttp session + websocket handle
+ self._session: Any = None # aiohttp.ClientSession
+ self._ws: Any = None # aiohttp.ClientWebSocketResponse
+ self._ws_task: Optional[asyncio.Task] = None
+ self._reconnect_task: Optional[asyncio.Task] = None
+ self._closing = False
+
+ # Reply mode: "thread" to nest replies, "off" for flat messages.
+ self._reply_mode: str = (
+ config.extra.get("reply_mode", "")
+ or os.getenv("MATTERMOST_REPLY_MODE", "off")
+ ).lower()
+
+ # Dedup cache: post_id โ timestamp (prevent reprocessing)
+ self._seen_posts: Dict[str, float] = {}
+ self._SEEN_MAX = 2000
+ self._SEEN_TTL = 300 # 5 minutes
+
+ # ------------------------------------------------------------------
+ # HTTP helpers
+ # ------------------------------------------------------------------
+
+ def _headers(self) -> Dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self._token}",
+ "Content-Type": "application/json",
+ }
+
+ async def _api_get(self, path: str) -> Dict[str, Any]:
+ """GET /api/v4/{path}."""
+ import aiohttp
+ url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
+ try:
+ async with self._session.get(url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ logger.error("MM API GET %s โ %s: %s", path, resp.status, body[:200])
+ return {}
+ return await resp.json()
+ except aiohttp.ClientError as exc:
+ logger.error("MM API GET %s network error: %s", path, exc)
+ return {}
+
+ async def _api_post(
+ self, path: str, payload: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """POST /api/v4/{path} with JSON body."""
+ import aiohttp
+ url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
+ try:
+ async with self._session.post(
+ url, headers=self._headers(), json=payload,
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ logger.error("MM API POST %s โ %s: %s", path, resp.status, body[:200])
+ return {}
+ return await resp.json()
+ except aiohttp.ClientError as exc:
+ logger.error("MM API POST %s network error: %s", path, exc)
+ return {}
+
+ async def _api_put(
+ self, path: str, payload: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ """PUT /api/v4/{path} with JSON body."""
+ import aiohttp
+ url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
+ try:
+ async with self._session.put(
+ url, headers=self._headers(), json=payload
+ ) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ logger.error("MM API PUT %s โ %s: %s", path, resp.status, body[:200])
+ return {}
+ return await resp.json()
+ except aiohttp.ClientError as exc:
+ logger.error("MM API PUT %s network error: %s", path, exc)
+ return {}
+
+ async def _upload_file(
+ self, channel_id: str, file_data: bytes, filename: str, content_type: str = "application/octet-stream"
+ ) -> Optional[str]:
+ """Upload a file and return its file ID, or None on failure."""
+ import aiohttp
+
+ url = f"{self._base_url}/api/v4/files"
+ form = aiohttp.FormData()
+ form.add_field("channel_id", channel_id)
+ form.add_field(
+ "files",
+ file_data,
+ filename=filename,
+ content_type=content_type,
+ )
+ headers = {"Authorization": f"Bearer {self._token}"}
+ async with self._session.post(url, headers=headers, data=form, timeout=aiohttp.ClientTimeout(total=60)) as resp:
+ if resp.status >= 400:
+ body = await resp.text()
+ logger.error("MM file upload โ %s: %s", resp.status, body[:200])
+ return None
+ data = await resp.json()
+ infos = data.get("file_infos", [])
+ return infos[0]["id"] if infos else None
+
+ # ------------------------------------------------------------------
+ # Required overrides
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ """Connect to Mattermost and start the WebSocket listener."""
+ import aiohttp
+
+ if not self._base_url or not self._token:
+ logger.error("Mattermost: URL or token not configured")
+ return False
+
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30)
+ )
+ self._closing = False
+
+ # Verify credentials and fetch bot identity.
+ me = await self._api_get("users/me")
+ if not me or "id" not in me:
+ logger.error("Mattermost: failed to authenticate โ check MATTERMOST_TOKEN and MATTERMOST_URL")
+ await self._session.close()
+ return False
+
+ self._bot_user_id = me["id"]
+ self._bot_username = me.get("username", "")
+ logger.info(
+ "Mattermost: authenticated as @%s (%s) on %s",
+ self._bot_username,
+ self._bot_user_id,
+ self._base_url,
+ )
+
+ # Start WebSocket in background.
+ self._ws_task = asyncio.create_task(self._ws_loop())
+ self._mark_connected()
+ return True
+
+ async def disconnect(self) -> None:
+ """Disconnect from Mattermost."""
+ self._closing = True
+
+ if self._ws_task and not self._ws_task.done():
+ self._ws_task.cancel()
+ try:
+ await self._ws_task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ if self._reconnect_task and not self._reconnect_task.done():
+ self._reconnect_task.cancel()
+
+ if self._ws:
+ await self._ws.close()
+ self._ws = None
+
+ if self._session and not self._session.closed:
+ await self._session.close()
+
+ logger.info("Mattermost: disconnected")
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Send a message (or multiple chunks) to a channel."""
+ if not content:
+ return SendResult(success=True)
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted, MAX_POST_LENGTH)
+
+ last_id = None
+ for chunk in chunks:
+ payload: Dict[str, Any] = {
+ "channel_id": chat_id,
+ "message": chunk,
+ }
+ # Thread support: reply_to is the root post ID.
+ if reply_to and self._reply_mode == "thread":
+ payload["root_id"] = reply_to
+
+ data = await self._api_post("posts", payload)
+ if not data or "id" not in data:
+ return SendResult(success=False, error="Failed to create post")
+ last_id = data["id"]
+
+ return SendResult(success=True, message_id=last_id)
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return channel name and type."""
+ data = await self._api_get(f"channels/{chat_id}")
+ if not data:
+ return {"name": chat_id, "type": "channel"}
+
+ ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel")
+ display_name = data.get("display_name") or data.get("name") or chat_id
+ return {"name": display_name, "type": ch_type}
+
+ # ------------------------------------------------------------------
+ # Optional overrides
+ # ------------------------------------------------------------------
+
+ async def send_typing(
+ self, chat_id: str, metadata: Optional[Dict[str, Any]] = None
+ ) -> None:
+ """Send a typing indicator."""
+ await self._api_post(
+ f"users/{self._bot_user_id}/typing",
+ {"channel_id": chat_id},
+ )
+
+ async def edit_message(
+ self, chat_id: str, message_id: str, content: str
+ ) -> SendResult:
+ """Edit an existing post."""
+ formatted = self.format_message(content)
+ data = await self._api_put(
+ f"posts/{message_id}/patch",
+ {"message": formatted},
+ )
+ if not data or "id" not in data:
+ return SendResult(success=False, error="Failed to edit post")
+ return SendResult(success=True, message_id=data["id"])
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Download an image and upload it as a file attachment."""
+ return await self._send_url_as_file(
+ chat_id, image_url, caption, reply_to, "image"
+ )
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a local image file."""
+ return await self._send_local_file(
+ chat_id, image_path, caption, reply_to
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a local file as a document."""
+ return await self._send_local_file(
+ chat_id, file_path, caption, reply_to, file_name
+ )
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload an audio file."""
+ return await self._send_local_file(
+ chat_id, audio_path, caption, reply_to
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a video file."""
+ return await self._send_local_file(
+ chat_id, video_path, caption, reply_to
+ )
+
+ def format_message(self, content: str) -> str:
+ """Mattermost uses standard Markdown โ mostly pass through.
+
+ Strip image markdown into plain links (files are uploaded separately).
+ """
+ # Convert  to just the URL โ Mattermost renders
+ # image URLs as inline previews automatically.
+ content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content)
+ return content
+
+ # ------------------------------------------------------------------
+ # File helpers
+ # ------------------------------------------------------------------
+
+ async def _send_url_as_file(
+ self,
+ chat_id: str,
+ url: str,
+ caption: Optional[str],
+ reply_to: Optional[str],
+ kind: str = "file",
+ ) -> SendResult:
+ """Download a URL and upload it as a file attachment."""
+ import asyncio
+ import aiohttp
+
+ last_exc = None
+ file_data = None
+ ct = "application/octet-stream"
+ fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png"
+
+ for attempt in range(3):
+ try:
+ async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status >= 500 or resp.status == 429:
+ if attempt < 2:
+ logger.debug("Mattermost download retry %d/2 for %s (status %d)",
+ attempt + 1, url[:80], resp.status)
+ await asyncio.sleep(1.5 * (attempt + 1))
+ continue
+ if resp.status >= 400:
+ return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
+ file_data = await resp.read()
+ ct = resp.content_type or "application/octet-stream"
+ break
+ except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
+ last_exc = exc
+ if attempt < 2:
+ await asyncio.sleep(1.5 * (attempt + 1))
+ continue
+ logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
+ return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
+
+ if file_data is None:
+ logger.warning("Mattermost: download returned no data for %s", url)
+ return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
+
+ file_id = await self._upload_file(chat_id, file_data, fname, ct)
+ if not file_id:
+ return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
+
+ payload: Dict[str, Any] = {
+ "channel_id": chat_id,
+ "message": caption or "",
+ "file_ids": [file_id],
+ }
+ if reply_to and self._reply_mode == "thread":
+ payload["root_id"] = reply_to
+
+ data = await self._api_post("posts", payload)
+ if not data or "id" not in data:
+ return SendResult(success=False, error="Failed to post with file")
+ return SendResult(success=True, message_id=data["id"])
+
+ async def _send_local_file(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str],
+ reply_to: Optional[str],
+ file_name: Optional[str] = None,
+ ) -> SendResult:
+ """Upload a local file and attach it to a post."""
+ import mimetypes
+
+ p = Path(file_path)
+ if not p.exists():
+ return await self.send(
+ chat_id, f"{caption or ''}\n(file not found: {file_path})", reply_to
+ )
+
+ fname = file_name or p.name
+ ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
+ file_data = p.read_bytes()
+
+ file_id = await self._upload_file(chat_id, file_data, fname, ct)
+ if not file_id:
+ return SendResult(success=False, error="File upload failed")
+
+ payload: Dict[str, Any] = {
+ "channel_id": chat_id,
+ "message": caption or "",
+ "file_ids": [file_id],
+ }
+ if reply_to and self._reply_mode == "thread":
+ payload["root_id"] = reply_to
+
+ data = await self._api_post("posts", payload)
+ if not data or "id" not in data:
+ return SendResult(success=False, error="Failed to post with file")
+ return SendResult(success=True, message_id=data["id"])
+
+ # ------------------------------------------------------------------
+ # WebSocket
+ # ------------------------------------------------------------------
+
+ async def _ws_loop(self) -> None:
+ """Connect to the WebSocket and listen for events, reconnecting on failure."""
+ delay = _RECONNECT_BASE_DELAY
+ while not self._closing:
+ try:
+ await self._ws_connect_and_listen()
+ # Clean disconnect โ reset delay.
+ delay = _RECONNECT_BASE_DELAY
+ except asyncio.CancelledError:
+ return
+ except Exception as exc:
+ if self._closing:
+ return
+ logger.warning("Mattermost WS error: %s โ reconnecting in %.0fs", exc, delay)
+
+ if self._closing:
+ return
+
+ # Exponential backoff with jitter.
+ import random
+ jitter = delay * _RECONNECT_JITTER * random.random()
+ await asyncio.sleep(delay + jitter)
+ delay = min(delay * 2, _RECONNECT_MAX_DELAY)
+
+ async def _ws_connect_and_listen(self) -> None:
+ """Single WebSocket session: connect, authenticate, process events."""
+ # Build WS URL: https:// โ wss://, http:// โ ws://
+ ws_url = re.sub(r"^http", "ws", self._base_url) + "/api/v4/websocket"
+ logger.info("Mattermost: connecting to %s", ws_url)
+
+ self._ws = await self._session.ws_connect(ws_url, heartbeat=30.0)
+
+ # Authenticate via the WebSocket.
+ auth_msg = {
+ "seq": 1,
+ "action": "authentication_challenge",
+ "data": {"token": self._token},
+ }
+ await self._ws.send_json(auth_msg)
+ logger.info("Mattermost: WebSocket connected and authenticated")
+
+ async for raw_msg in self._ws:
+ if self._closing:
+ return
+
+ if raw_msg.type in (
+ raw_msg.type.TEXT,
+ raw_msg.type.BINARY,
+ ):
+ try:
+ event = json.loads(raw_msg.data)
+ except (json.JSONDecodeError, TypeError):
+ continue
+ await self._handle_ws_event(event)
+ elif raw_msg.type in (
+ raw_msg.type.ERROR,
+ raw_msg.type.CLOSE,
+ raw_msg.type.CLOSING,
+ raw_msg.type.CLOSED,
+ ):
+ logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type)
+ break
+
+ async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
+ """Process a single WebSocket event."""
+ event_type = event.get("event")
+ if event_type != "posted":
+ return
+
+ data = event.get("data", {})
+ raw_post_str = data.get("post")
+ if not raw_post_str:
+ return
+
+ try:
+ post = json.loads(raw_post_str)
+ except (json.JSONDecodeError, TypeError):
+ return
+
+ # Ignore own messages.
+ if post.get("user_id") == self._bot_user_id:
+ return
+
+ # Ignore system posts.
+ if post.get("type"):
+ return
+
+ post_id = post.get("id", "")
+
+ # Dedup.
+ self._prune_seen()
+ if post_id in self._seen_posts:
+ return
+ self._seen_posts[post_id] = time.time()
+
+ # Build message event.
+ channel_id = post.get("channel_id", "")
+ channel_type_raw = data.get("channel_type", "O")
+ chat_type = _CHANNEL_TYPE_MAP.get(channel_type_raw, "channel")
+
+ # For DMs, user_id is sufficient. For channels, check for @mention.
+ message_text = post.get("message", "")
+
+ # Mention-only mode: skip channel messages that don't @mention the bot.
+ # DMs (type "D") are always processed.
+ if channel_type_raw != "D":
+ mention_patterns = [
+ f"@{self._bot_username}",
+ f"@{self._bot_user_id}",
+ ]
+ has_mention = any(
+ pattern.lower() in message_text.lower()
+ for pattern in mention_patterns
+ )
+ if not has_mention:
+ logger.debug(
+ "Mattermost: skipping non-DM message without @mention (channel=%s)",
+ channel_id,
+ )
+ return
+
+ # Resolve sender info.
+ sender_id = post.get("user_id", "")
+ sender_name = data.get("sender_name", "").lstrip("@") or sender_id
+
+ # Thread support: if the post is in a thread, use root_id.
+ thread_id = post.get("root_id") or None
+
+ # Determine message type.
+ file_ids = post.get("file_ids") or []
+ msg_type = MessageType.TEXT
+ if message_text.startswith("/"):
+ msg_type = MessageType.COMMAND
+
+ # Download file attachments immediately (URLs require auth headers
+ # that downstream tools won't have).
+ media_urls: List[str] = []
+ media_types: List[str] = []
+ for fid in file_ids:
+ try:
+ file_info = await self._api_get(f"files/{fid}/info")
+ fname = file_info.get("name", f"file_{fid}")
+ ext = Path(fname).suffix or ""
+ mime = file_info.get("mime_type", "application/octet-stream")
+
+ import aiohttp
+ dl_url = f"{self._base_url}/api/v4/files/{fid}"
+ async with self._session.get(
+ dl_url,
+ headers={"Authorization": f"Bearer {self._token}"},
+ timeout=aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status < 400:
+ file_data = await resp.read()
+ from gateway.platforms.base import cache_image_from_bytes, cache_document_from_bytes
+ if mime.startswith("image/"):
+ local_path = cache_image_from_bytes(file_data, ext or ".png")
+ media_urls.append(local_path)
+ media_types.append(mime)
+ elif mime.startswith("audio/"):
+ from gateway.platforms.base import cache_audio_from_bytes
+ local_path = cache_audio_from_bytes(file_data, ext or ".ogg")
+ media_urls.append(local_path)
+ media_types.append(mime)
+ else:
+ local_path = cache_document_from_bytes(file_data, fname)
+ media_urls.append(local_path)
+ media_types.append(mime)
+ else:
+ logger.warning("Mattermost: failed to download file %s: HTTP %s", fid, resp.status)
+ except Exception as exc:
+ logger.warning("Mattermost: error downloading file %s: %s", fid, exc)
+
+ source = self.build_source(
+ chat_id=channel_id,
+ chat_type=chat_type,
+ user_id=sender_id,
+ user_name=sender_name,
+ thread_id=thread_id,
+ )
+
+ msg_event = MessageEvent(
+ text=message_text,
+ message_type=msg_type,
+ source=source,
+ raw_message=post,
+ message_id=post_id,
+ media_urls=media_urls if media_urls else None,
+ media_types=media_types if media_types else None,
+ )
+
+ await self.handle_message(msg_event)
+
+ def _prune_seen(self) -> None:
+ """Remove expired entries from the dedup cache."""
+ if len(self._seen_posts) < self._SEEN_MAX:
+ return
+ now = time.time()
+ self._seen_posts = {
+ pid: ts
+ for pid, ts in self._seen_posts.items()
+ if now - ts < self._SEEN_TTL
+ }
diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py
index 2ce072ae3c3..cbe12a87cf2 100644
--- a/gateway/platforms/signal.py
+++ b/gateway/platforms/signal.py
@@ -179,6 +179,11 @@ def __init__(self, config: PlatformConfig):
# Normalize account for self-message filtering
self._account_normalized = self.account.strip()
+ # Track recently sent message timestamps to prevent echo-back loops
+ # in Note to Self / self-chat mode (mirrors WhatsApp recentlySentIds)
+ self._recent_sent_timestamps: set = set()
+ self._max_recent_timestamps = 50
+
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
self.http_url, _redact_phone(self.account),
"enabled" if self.group_allow_from else "disabled")
@@ -274,6 +279,12 @@ async def _sse_listener(self) -> None:
line = line.strip()
if not line:
continue
+ # SSE keepalive comments (":") prove the connection
+ # is alive โ update activity so the health monitor
+ # doesn't report false idle warnings.
+ if line.startswith(":"):
+ self._last_sse_activity = time.time()
+ continue
# Parse SSE data lines
if line.startswith("data:"):
data_str = line[5:].strip()
@@ -339,7 +350,9 @@ def _force_reconnect(self) -> None:
"""Force SSE reconnection by closing the current response."""
if self._sse_response and not self._sse_response.is_stream_consumed:
try:
- asyncio.create_task(self._sse_response.aclose())
+ task = asyncio.create_task(self._sse_response.aclose())
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
except Exception:
pass
self._sse_response = None
@@ -353,10 +366,26 @@ async def _handle_envelope(self, envelope: dict) -> None:
# Unwrap nested envelope if present
envelope_data = envelope.get("envelope", envelope)
- # Filter syncMessage envelopes (sent transcripts, read receipts, etc.)
- # signal-cli may set syncMessage to null vs omitting it, so check key existence
+ # Handle syncMessage: extract "Note to Self" messages (sent to own account)
+ # while still filtering other sync events (read receipts, typing, etc.)
+ is_note_to_self = False
if "syncMessage" in envelope_data:
- return
+ sync_msg = envelope_data.get("syncMessage")
+ if sync_msg and isinstance(sync_msg, dict):
+ sent_msg = sync_msg.get("sentMessage")
+ if sent_msg and isinstance(sent_msg, dict):
+ dest = sent_msg.get("destinationNumber") or sent_msg.get("destination")
+ sent_ts = sent_msg.get("timestamp")
+ if dest == self._account_normalized:
+ # Check if this is an echo of our own outbound reply
+ if sent_ts and sent_ts in self._recent_sent_timestamps:
+ self._recent_sent_timestamps.discard(sent_ts)
+ return
+ # Genuine user Note to Self โ promote to dataMessage
+ is_note_to_self = True
+ envelope_data = {**envelope_data, "dataMessage": sent_msg}
+ if not is_note_to_self:
+ return
# Extract sender info
sender = (
@@ -371,8 +400,8 @@ async def _handle_envelope(self, envelope: dict) -> None:
logger.debug("Signal: ignoring envelope with no sender")
return
- # Self-message filtering โ prevent reply loops
- if self._account_normalized and sender == self._account_normalized:
+ # Self-message filtering โ prevent reply loops (but allow Note to Self)
+ if self._account_normalized and sender == self._account_normalized and not is_note_to_self:
return
# Filter stories
@@ -457,7 +486,7 @@ async def _handle_envelope(self, envelope: dict) -> None:
if any(mt.startswith("audio/") for mt in media_types):
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
- msg_type = MessageType.IMAGE
+ msg_type = MessageType.PHOTO
# Parse timestamp from envelope data (milliseconds since epoch)
ts_ms = envelope_data.get("timestamp", 0)
@@ -498,6 +527,13 @@ async def _fetch_attachment(self, attachment_id: str) -> tuple:
if not result:
return None, ""
+ # Handle dict response (signal-cli returns {"data": "base64..."})
+ if isinstance(result, dict):
+ result = result.get("data")
+ if not result:
+ logger.warning("Signal: attachment response missing 'data' key")
+ return None, ""
+
# Result is base64-encoded file content
raw_data = base64.b64decode(result)
ext = _guess_extension(raw_data)
@@ -577,9 +613,18 @@ async def send(
result = await self._rpc("send", params)
if result is not None:
+ self._track_sent_timestamp(result)
return SendResult(success=True)
return SendResult(success=False, error="RPC send failed")
+ def _track_sent_timestamp(self, rpc_result) -> None:
+ """Record outbound message timestamp for echo-back filtering."""
+ ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None
+ if ts:
+ self._recent_sent_timestamps.add(ts)
+ if len(self._recent_sent_timestamps) > self._max_recent_timestamps:
+ self._recent_sent_timestamps.pop()
+
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send a typing indicator."""
params: Dict[str, Any] = {
@@ -635,6 +680,7 @@ async def send_image(
result = await self._rpc("send", params)
if result is not None:
+ self._track_sent_timestamp(result)
return SendResult(success=True)
return SendResult(success=False, error="RPC send with attachment failed")
@@ -665,6 +711,7 @@ async def send_document(
result = await self._rpc("send", params)
if result is not None:
+ self._track_sent_timestamp(result)
return SendResult(success=True)
return SendResult(success=False, error="RPC send document failed")
diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py
index b57dc854169..3fae98ae6c7 100644
--- a/gateway/platforms/slack.py
+++ b/gateway/platforms/slack.py
@@ -12,7 +12,7 @@
import logging
import os
import re
-from typing import Dict, List, Optional, Any
+from typing import Dict, Optional, Any
try:
from slack_bolt.async_app import AsyncApp
@@ -37,8 +37,6 @@
SendResult,
SUPPORTED_DOCUMENT_TYPES,
cache_document_from_bytes,
- cache_image_from_url,
- cache_audio_from_url,
)
@@ -74,6 +72,7 @@ def __init__(self, config: PlatformConfig):
self._handler: Optional[AsyncSocketModeHandler] = None
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
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -121,7 +120,7 @@ async def handle_hermes_command(ack, command):
# Start Socket Mode handler in background
self._handler = AsyncSocketModeHandler(self._app, app_token)
- asyncio.create_task(self._handler.start_async())
+ self._socket_mode_task = asyncio.create_task(self._handler.start_async())
self._running = True
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
@@ -260,6 +259,30 @@ def _resolve_thread_ts(
return metadata["thread_ts"]
return reply_to
+ async def _upload_file(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Upload a local file to Slack."""
+ if not self._app:
+ return SendResult(success=False, error="Not connected")
+
+ if not os.path.exists(file_path):
+ raise FileNotFoundError(f"File not found: {file_path}")
+
+ result = await self._app.client.files_upload_v2(
+ channel=chat_id,
+ file=file_path,
+ filename=os.path.basename(file_path),
+ initial_comment=caption or "",
+ thread_ts=self._resolve_thread_ts(reply_to, metadata),
+ )
+ return SendResult(success=True, raw_response=result)
+
# ----- Markdown โ mrkdwn conversion -----
def format_message(self, content: str) -> str:
@@ -417,23 +440,10 @@ async def send_image_file(
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file to Slack by uploading it."""
- if not self._app:
- return SendResult(success=False, error="Not connected")
-
try:
- import os
- if not os.path.exists(image_path):
- return SendResult(success=False, error=f"Image file not found: {image_path}")
-
- result = await self._app.client.files_upload_v2(
- channel=chat_id,
- file=image_path,
- filename=os.path.basename(image_path),
- initial_comment=caption or "",
- thread_ts=self._resolve_thread_ts(reply_to, metadata),
- )
- return SendResult(success=True, raw_response=result)
-
+ return await self._upload_file(chat_id, image_path, caption, reply_to, metadata)
+ except FileNotFoundError:
+ return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[%s] Failed to send local Slack image %s: %s",
@@ -442,7 +452,10 @@ async def send_image_file(
e,
exc_info=True,
)
- return await super().send_image_file(chat_id, image_path, caption, reply_to)
+ text = f"๐ผ๏ธ Image: {image_path}"
+ if caption:
+ text = f"{caption}\n{text}"
+ return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
async def send_image(
self,
@@ -492,21 +505,13 @@ async def send_voice(
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
) -> SendResult:
"""Send an audio file to Slack."""
- if not self._app:
- return SendResult(success=False, error="Not connected")
-
try:
- result = await self._app.client.files_upload_v2(
- channel=chat_id,
- file=audio_path,
- filename=os.path.basename(audio_path),
- initial_comment=caption or "",
- thread_ts=self._resolve_thread_ts(reply_to, metadata),
- )
- return SendResult(success=True, raw_response=result)
-
+ return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata)
+ except FileNotFoundError:
+ return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[Slack] Failed to send audio file %s: %s",
@@ -549,7 +554,10 @@ async def send_video(
e,
exc_info=True,
)
- return await super().send_video(chat_id, video_path, caption, reply_to)
+ text = f"๐ฌ Video: {video_path}"
+ if caption:
+ text = f"{caption}\n{text}"
+ return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
async def send_document(
self,
@@ -587,7 +595,10 @@ async def send_document(
e,
exc_info=True,
)
- return await super().send_document(chat_id, file_path, caption, file_name, reply_to)
+ text = f"๐ File: {file_path}"
+ if caption:
+ text = f"{caption}\n{text}"
+ return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a Slack channel."""
@@ -777,23 +788,11 @@ async def _handle_slash_command(self, command: dict) -> None:
user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "")
- # Map subcommands to gateway commands
- subcommand_map = {
- "new": "/reset", "reset": "/reset",
- "status": "/status", "stop": "/stop",
- "help": "/help",
- "model": "/model", "personality": "/personality",
- "retry": "/retry", "undo": "/undo",
- "compact": "/compress", "compress": "/compress",
- "resume": "/resume",
- "background": "/background",
- "usage": "/usage",
- "insights": "/insights",
- "title": "/title",
- "reasoning": "/reasoning",
- "provider": "/provider",
- "rollback": "/rollback",
- }
+ # Map subcommands to gateway commands โ derived from central registry.
+ # Also keep "compact" as a Slack-specific alias for /compress.
+ from hermes_cli.commands import slack_subcommand_map
+ subcommand_map = slack_subcommand_map()
+ subcommand_map["compact"] = "/compress"
first_word = text.split()[0] if text else ""
if first_word in subcommand_map:
# Preserve arguments after the subcommand
@@ -820,33 +819,65 @@ async def _handle_slash_command(self, command: dict) -> None:
await self.handle_message(event)
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
- """Download a Slack file using the bot token for auth."""
+ """Download a Slack file using the bot token for auth, with retry."""
+ import asyncio
import httpx
bot_token = self.config.token
- async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
- response = await client.get(
- url,
- headers={"Authorization": f"Bearer {bot_token}"},
- )
- response.raise_for_status()
+ last_exc = None
- if audio:
- from gateway.platforms.base import cache_audio_from_bytes
- return cache_audio_from_bytes(response.content, ext)
- else:
- from gateway.platforms.base import cache_image_from_bytes
- return cache_image_from_bytes(response.content, ext)
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+ for attempt in range(3):
+ try:
+ response = await client.get(
+ url,
+ headers={"Authorization": f"Bearer {bot_token}"},
+ )
+ response.raise_for_status()
+
+ if audio:
+ from gateway.platforms.base import cache_audio_from_bytes
+ return cache_audio_from_bytes(response.content, ext)
+ else:
+ from gateway.platforms.base import cache_image_from_bytes
+ return cache_image_from_bytes(response.content, ext)
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
+ last_exc = exc
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
+ raise
+ if attempt < 2:
+ logger.debug("Slack file download retry %d/2 for %s: %s",
+ attempt + 1, url[:80], exc)
+ await asyncio.sleep(1.5 * (attempt + 1))
+ continue
+ raise
+ raise last_exc
async def _download_slack_file_bytes(self, url: str) -> bytes:
- """Download a Slack file and return raw bytes."""
+ """Download a Slack file and return raw bytes, with retry."""
+ import asyncio
import httpx
bot_token = self.config.token
+ last_exc = None
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
- response = await client.get(
- url,
- headers={"Authorization": f"Bearer {bot_token}"},
- )
- response.raise_for_status()
- return response.content
+ for attempt in range(3):
+ try:
+ response = await client.get(
+ url,
+ headers={"Authorization": f"Bearer {bot_token}"},
+ )
+ response.raise_for_status()
+ return response.content
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
+ last_exc = exc
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
+ raise
+ if attempt < 2:
+ logger.debug("Slack file download retry %d/2 for %s: %s",
+ attempt + 1, url[:80], exc)
+ await asyncio.sleep(1.5 * (attempt + 1))
+ continue
+ raise
+ raise last_exc
diff --git a/gateway/platforms/sms.py b/gateway/platforms/sms.py
new file mode 100644
index 00000000000..a0760199ba8
--- /dev/null
+++ b/gateway/platforms/sms.py
@@ -0,0 +1,276 @@
+"""SMS (Twilio) platform adapter.
+
+Connects to the Twilio REST API for outbound SMS and runs an aiohttp
+webhook server to receive inbound messages.
+
+Shares credentials with the optional telephony skill โ same env vars:
+ - TWILIO_ACCOUNT_SID
+ - TWILIO_AUTH_TOKEN
+ - TWILIO_PHONE_NUMBER (E.164 from-number, e.g. +15551234567)
+
+Gateway-specific env vars:
+ - SMS_WEBHOOK_PORT (default 8080)
+ - SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
+ - SMS_ALLOW_ALL_USERS (true/false)
+ - SMS_HOME_CHANNEL (phone number for cron delivery)
+"""
+
+import asyncio
+import base64
+import logging
+import os
+import re
+import urllib.parse
+from typing import Any, Dict, Optional
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
+MAX_SMS_LENGTH = 1600 # ~10 SMS segments
+DEFAULT_WEBHOOK_PORT = 8080
+
+# E.164 phone number pattern for redaction
+_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}")
+
+
+def _redact_phone(phone: str) -> str:
+ """Redact a phone number for logging: +15551234567 -> +1555***4567."""
+ if not phone:
+ return ""
+ if len(phone) <= 8:
+ return phone[:2] + "***" + phone[-2:] if len(phone) > 4 else "****"
+ return phone[:5] + "***" + phone[-4:]
+
+
+def check_sms_requirements() -> bool:
+ """Check if SMS adapter dependencies are available."""
+ try:
+ import aiohttp # noqa: F401
+ except ImportError:
+ return False
+ return bool(os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"))
+
+
+class SmsAdapter(BasePlatformAdapter):
+ """
+ Twilio SMS <-> Hermes gateway adapter.
+
+ Each inbound phone number gets its own Hermes session (multi-tenant).
+ Replies are always sent from the configured TWILIO_PHONE_NUMBER.
+ """
+
+ MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.SMS)
+ self._account_sid: str = os.environ["TWILIO_ACCOUNT_SID"]
+ self._auth_token: str = os.environ["TWILIO_AUTH_TOKEN"]
+ self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
+ self._webhook_port: int = int(
+ os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
+ )
+ self._runner = None
+ self._http_session: Optional["aiohttp.ClientSession"] = None
+
+ def _basic_auth_header(self) -> str:
+ """Build HTTP Basic auth header value for Twilio."""
+ creds = f"{self._account_sid}:{self._auth_token}"
+ encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
+ return f"Basic {encoded}"
+
+ # ------------------------------------------------------------------
+ # Required abstract methods
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ import aiohttp
+ from aiohttp import web
+
+ if not self._from_number:
+ logger.error("[sms] TWILIO_PHONE_NUMBER not set โ cannot send replies")
+ return False
+
+ app = web.Application()
+ app.router.add_post("/webhooks/twilio", self._handle_webhook)
+ app.router.add_get("/health", lambda _: web.Response(text="ok"))
+
+ self._runner = web.AppRunner(app)
+ await self._runner.setup()
+ site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port)
+ await site.start()
+ self._http_session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30),
+ )
+ self._running = True
+
+ logger.info(
+ "[sms] Twilio webhook server listening on port %d, from: %s",
+ self._webhook_port,
+ _redact_phone(self._from_number),
+ )
+ return True
+
+ async def disconnect(self) -> None:
+ if self._http_session:
+ await self._http_session.close()
+ self._http_session = None
+ if self._runner:
+ await self._runner.cleanup()
+ self._runner = None
+ self._running = False
+ logger.info("[sms] Disconnected")
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ import aiohttp
+
+ formatted = self.format_message(content)
+ chunks = self.truncate_message(formatted)
+ last_result = SendResult(success=True)
+
+ url = f"{TWILIO_API_BASE}/{self._account_sid}/Messages.json"
+ headers = {
+ "Authorization": self._basic_auth_header(),
+ }
+
+ session = self._http_session or aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30),
+ )
+ try:
+ for chunk in chunks:
+ form_data = aiohttp.FormData()
+ form_data.add_field("From", self._from_number)
+ form_data.add_field("To", chat_id)
+ form_data.add_field("Body", chunk)
+
+ try:
+ async with session.post(url, data=form_data, headers=headers) as resp:
+ body = await resp.json()
+ if resp.status >= 400:
+ error_msg = body.get("message", str(body))
+ logger.error(
+ "[sms] send failed to %s: %s %s",
+ _redact_phone(chat_id),
+ resp.status,
+ error_msg,
+ )
+ return SendResult(
+ success=False,
+ error=f"Twilio {resp.status}: {error_msg}",
+ )
+ msg_sid = body.get("sid", "")
+ last_result = SendResult(success=True, message_id=msg_sid)
+ except Exception as e:
+ logger.error("[sms] send error to %s: %s", _redact_phone(chat_id), e)
+ return SendResult(success=False, error=str(e))
+ finally:
+ # Close session only if we created a fallback (no persistent session)
+ if not self._http_session and session:
+ await session.close()
+
+ return last_result
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ return {"name": chat_id, "type": "dm"}
+
+ # ------------------------------------------------------------------
+ # SMS-specific formatting
+ # ------------------------------------------------------------------
+
+ def format_message(self, content: str) -> str:
+ """Strip markdown โ SMS renders it as literal characters."""
+ content = re.sub(r"\*\*(.+?)\*\*", r"\1", content, flags=re.DOTALL)
+ content = re.sub(r"\*(.+?)\*", r"\1", content, flags=re.DOTALL)
+ content = re.sub(r"__(.+?)__", r"\1", content, flags=re.DOTALL)
+ content = re.sub(r"_(.+?)_", r"\1", content, flags=re.DOTALL)
+ content = re.sub(r"```[a-z]*\n?", "", content)
+ content = re.sub(r"`(.+?)`", r"\1", content)
+ content = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE)
+ content = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content)
+ content = re.sub(r"\n{3,}", "\n\n", content)
+ return content.strip()
+
+ # ------------------------------------------------------------------
+ # Twilio webhook handler
+ # ------------------------------------------------------------------
+
+ async def _handle_webhook(self, request) -> "aiohttp.web.Response":
+ from aiohttp import web
+
+ try:
+ raw = await request.read()
+ # Twilio sends form-encoded data, not JSON
+ form = urllib.parse.parse_qs(raw.decode("utf-8"))
+ except Exception as e:
+ logger.error("[sms] webhook parse error: %s", e)
+ return web.Response(
+ text=' ',
+ content_type="application/xml",
+ status=400,
+ )
+
+ # Extract fields (parse_qs returns lists)
+ from_number = (form.get("From", [""]))[0].strip()
+ to_number = (form.get("To", [""]))[0].strip()
+ text = (form.get("Body", [""]))[0].strip()
+ message_sid = (form.get("MessageSid", [""]))[0].strip()
+
+ if not from_number or not text:
+ return web.Response(
+ text=' ',
+ content_type="application/xml",
+ )
+
+ # Ignore messages from our own number (echo prevention)
+ if from_number == self._from_number:
+ logger.debug("[sms] ignoring echo from own number %s", _redact_phone(from_number))
+ return web.Response(
+ text=' ',
+ content_type="application/xml",
+ )
+
+ logger.info(
+ "[sms] inbound from %s -> %s: %s",
+ _redact_phone(from_number),
+ _redact_phone(to_number),
+ text[:80],
+ )
+
+ source = self.build_source(
+ chat_id=from_number,
+ chat_name=from_number,
+ chat_type="dm",
+ user_id=from_number,
+ user_name=from_number,
+ )
+ event = MessageEvent(
+ text=text,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=form,
+ message_id=message_sid,
+ )
+
+ # Non-blocking: Twilio expects a fast response
+ task = asyncio.create_task(self.handle_message(event))
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+
+ # Return empty TwiML โ we send replies via the REST API, not inline TwiML
+ return web.Response(
+ text=' ',
+ content_type="application/xml",
+ )
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index 5243d30210d..83753096f5f 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -25,6 +25,7 @@
filters,
)
from telegram.constants import ParseMode, ChatType
+ from telegram.request import HTTPXRequest
TELEGRAM_AVAILABLE = True
except ImportError:
TELEGRAM_AVAILABLE = False
@@ -34,6 +35,7 @@
Application = Any
CommandHandler = Any
TelegramMessageHandler = Any
+ HTTPXRequest = Any
filters = None
ParseMode = None
ChatType = None
@@ -59,6 +61,11 @@ class _MockContextTypes:
cache_document_from_bytes,
SUPPORTED_DOCUMENT_TYPES,
)
+from gateway.platforms.telegram_network import (
+ TelegramFallbackTransport,
+ discover_fallback_ips,
+ parse_fallback_ip_env,
+)
def check_telegram_requirements() -> bool:
@@ -79,8 +86,8 @@ def _escape_mdv2(text: str) -> str:
def _strip_mdv2(text: str) -> str:
"""Strip MarkdownV2 escape backslashes to produce clean plain text.
- Also removes MarkdownV2 bold markers (*text* -> text) so the fallback
- doesn't show stray asterisks from header/bold conversion.
+ Also removes MarkdownV2 formatting markers so the fallback
+ doesn't show stray syntax characters from format_message conversion.
"""
# Remove escape backslashes before special characters
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
@@ -89,6 +96,10 @@ def _strip_mdv2(text: str) -> str:
# Remove MarkdownV2 italic markers that format_message converted from *italic*
# Use word boundary (\b) to avoid breaking snake_case like my_variable_name
cleaned = re.sub(r'(? message_thread_id (populated at startup)
+ self._dm_topics: Dict[str, int] = {}
+ # DM Topics config from extra.dm_topics
+ self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
+
+ def _fallback_ips(self) -> list[str]:
+ """Return validated fallback IPs from config (populated by _apply_env_overrides)."""
+ configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else []
+ if isinstance(configured, str):
+ configured = configured.split(",")
+ return parse_fallback_ip_env(",".join(str(v) for v in configured) if configured else None)
+
+ @staticmethod
+ def _looks_like_polling_conflict(error: Exception) -> bool:
+ text = str(error).lower()
+ return (
+ error.__class__.__name__.lower() == "conflict"
+ or "terminated by other getupdates request" in text
+ or "another bot instance is running" in text
+ )
+
+ @staticmethod
+ def _looks_like_network_error(error: Exception) -> bool:
+ """Return True for transient network errors that warrant a reconnect attempt."""
+ name = error.__class__.__name__.lower()
+ if name in ("networkerror", "timedout", "connectionerror"):
+ return True
+ try:
+ from telegram.error import NetworkError, TimedOut
+ if isinstance(error, (NetworkError, TimedOut)):
+ return True
+ except ImportError:
+ pass
+ return isinstance(error, OSError)
+
+ async def _handle_polling_network_error(self, error: Exception) -> None:
+ """Reconnect polling after a transient network interruption.
+
+ Triggered by NetworkError/TimedOut in the polling error callback, which
+ happen when the host loses connectivity (Mac sleep, WiFi switch, VPN
+ reconnect, etc.). The gateway process stays alive but the long-poll
+ connection silently dies; without this handler the bot never recovers.
+
+ Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to
+ MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so
+ the supervisor restarts the gateway process.
+ """
+ if self.has_fatal_error:
+ return
+
+ MAX_NETWORK_RETRIES = 10
+ BASE_DELAY = 5
+ MAX_DELAY = 60
+
+ self._polling_network_error_count += 1
+ attempt = self._polling_network_error_count
+
+ if attempt > MAX_NETWORK_RETRIES:
+ message = (
+ "Telegram polling could not reconnect after %d network error retries. "
+ "Restarting gateway." % MAX_NETWORK_RETRIES
+ )
+ logger.error("[%s] %s Last error: %s", self.name, message, error)
+ self._set_fatal_error("telegram_network_error", message, retryable=True)
+ await self._notify_fatal_error()
+ return
+
+ delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
+ logger.warning(
+ "[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s",
+ self.name, attempt, MAX_NETWORK_RETRIES, delay, error,
+ )
+ await asyncio.sleep(delay)
+
+ try:
+ if self._app and self._app.updater and self._app.updater.running:
+ await self._app.updater.stop()
+ except Exception:
+ pass
+
+ try:
+ await self._app.updater.start_polling(
+ allowed_updates=Update.ALL_TYPES,
+ drop_pending_updates=False,
+ error_callback=self._polling_error_callback_ref,
+ )
+ logger.info(
+ "[%s] Telegram polling resumed after network error (attempt %d)",
+ self.name, attempt,
+ )
+ self._polling_network_error_count = 0
+ except Exception as retry_err:
+ logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err)
+ # start_polling failed โ polling is dead and no further error
+ # callbacks will fire, so schedule the next retry ourselves.
+ if not self.has_fatal_error:
+ task = asyncio.ensure_future(
+ self._handle_polling_network_error(retry_err)
+ )
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+
+ async def _handle_polling_conflict(self, error: Exception) -> None:
+ if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
+ return
+ # Track consecutive conflicts โ transient 409s can occur when a
+ # previous gateway instance hasn't fully released its long-poll
+ # session on Telegram's server (e.g. during --replace handoffs or
+ # systemd Restart=on-failure respawns). Retry a few times before
+ # giving up, so the old session has time to expire.
+ self._polling_conflict_count += 1
+
+ MAX_CONFLICT_RETRIES = 3
+ RETRY_DELAY = 10 # seconds
+
+ if self._polling_conflict_count <= MAX_CONFLICT_RETRIES:
+ logger.warning(
+ "[%s] Telegram polling conflict (%d/%d), will retry in %ds. Error: %s",
+ self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
+ RETRY_DELAY, error,
+ )
+ try:
+ if self._app and self._app.updater and self._app.updater.running:
+ await self._app.updater.stop()
+ except Exception:
+ pass
+ await asyncio.sleep(RETRY_DELAY)
+ try:
+ await self._app.updater.start_polling(
+ allowed_updates=Update.ALL_TYPES,
+ drop_pending_updates=False,
+ error_callback=self._polling_error_callback_ref,
+ )
+ logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count)
+ self._polling_conflict_count = 0 # reset on success
+ return
+ except Exception as retry_err:
+ logger.warning("[%s] Telegram polling retry failed: %s", self.name, retry_err)
+ # Don't fall through to fatal yet โ wait for the next conflict
+ # to trigger another retry attempt (up to MAX_CONFLICT_RETRIES).
+ return
+
+ # Exhausted retries โ fatal
+ message = (
+ "Another Telegram bot poller is already using this token. "
+ "Hermes stopped Telegram polling after %d retries. "
+ "Make sure only one gateway instance is running for this bot token."
+ % MAX_CONFLICT_RETRIES
+ )
+ logger.error("[%s] %s Original error: %s", self.name, message, error)
+ self._set_fatal_error("telegram_polling_conflict", message, retryable=False)
+ try:
+ if self._app and self._app.updater:
+ await self._app.updater.stop()
+ except Exception as stop_error:
+ logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True)
+ await self._notify_fatal_error()
+
+ async def _create_dm_topic(
+ self,
+ chat_id: int,
+ name: str,
+ icon_color: Optional[int] = None,
+ icon_custom_emoji_id: Optional[str] = None,
+ ) -> Optional[int]:
+ """Create a forum topic in a private (DM) chat.
+
+ Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats.
+ Returns the message_thread_id on success, None on failure.
+ """
+ if not self._bot:
+ return None
+ try:
+ kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name}
+ if icon_color is not None:
+ kwargs["icon_color"] = icon_color
+ if icon_custom_emoji_id:
+ kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
+
+ topic = await self._bot.create_forum_topic(**kwargs)
+ thread_id = topic.message_thread_id
+ logger.info(
+ "[%s] Created DM topic '%s' in chat %s -> thread_id=%s",
+ self.name, name, chat_id, thread_id,
+ )
+ return thread_id
+ except Exception as e:
+ error_text = str(e).lower()
+ # If topic already exists, try to find it via getForumTopicIconStickers
+ # or we just log and skip โ Telegram doesn't provide a "list topics" API
+ if "topic_name_duplicate" in error_text or "already" in error_text:
+ logger.info(
+ "[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)",
+ self.name, name, chat_id,
+ )
+ else:
+ logger.warning(
+ "[%s] Failed to create DM topic '%s' in chat %s: %s",
+ self.name, name, chat_id, e,
+ )
+ return None
+
+ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
+ """Save a newly created thread_id back into config.yaml so it persists across restarts."""
+ try:
+ config_path = _Path.home() / ".hermes" / "config.yaml"
+ if not config_path.exists():
+ logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
+ return
+
+ import yaml as _yaml
+ with open(config_path, "r") as f:
+ config = _yaml.safe_load(f) or {}
+
+ # Navigate to platforms.telegram.extra.dm_topics
+ dm_topics = (
+ config.get("platforms", {})
+ .get("telegram", {})
+ .get("extra", {})
+ .get("dm_topics", [])
+ )
+ if not dm_topics:
+ return
+
+ changed = False
+ for chat_entry in dm_topics:
+ if int(chat_entry.get("chat_id", 0)) != int(chat_id):
+ continue
+ for t in chat_entry.get("topics", []):
+ if t.get("name") == topic_name and not t.get("thread_id"):
+ t["thread_id"] = thread_id
+ changed = True
+ break
+
+ if changed:
+ with open(config_path, "w") as f:
+ _yaml.dump(config, f, default_flow_style=False, sort_keys=False)
+ logger.info(
+ "[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
+ self.name, thread_id, topic_name,
+ )
+ except Exception as e:
+ logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
+
+ async def _setup_dm_topics(self) -> None:
+ """Load or create configured DM topics for specified chats.
+
+ Reads config.extra['dm_topics'] โ a list of dicts:
+ [
+ {
+ "chat_id": 123456789,
+ "topics": [
+ {"name": "General", "icon_color": 7322096, "thread_id": 100},
+ {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"}
+ ]
+ }
+ ]
+
+ If a topic already has a thread_id in the config (persisted from a previous
+ creation), it is loaded into the cache without calling createForumTopic.
+ Only topics without a thread_id are created via the API, and their thread_id
+ is then saved back to config.yaml for future restarts.
+ """
+ if not self._dm_topics_config:
+ return
+
+ for chat_entry in self._dm_topics_config:
+ chat_id = chat_entry.get("chat_id")
+ topics = chat_entry.get("topics", [])
+ if not chat_id or not topics:
+ continue
+
+ logger.info(
+ "[%s] Setting up %d DM topic(s) for chat %s",
+ self.name, len(topics), chat_id,
+ )
+
+ for topic_conf in topics:
+ topic_name = topic_conf.get("name")
+ if not topic_name:
+ continue
+
+ cache_key = f"{chat_id}:{topic_name}"
+
+ # If thread_id is already persisted in config, just load into cache
+ existing_thread_id = topic_conf.get("thread_id")
+ if existing_thread_id:
+ self._dm_topics[cache_key] = int(existing_thread_id)
+ logger.info(
+ "[%s] DM topic loaded from config: %s -> thread_id=%s",
+ self.name, cache_key, existing_thread_id,
+ )
+ continue
+
+ # No persisted thread_id โ create the topic via API
+ icon_color = topic_conf.get("icon_color")
+ icon_emoji = topic_conf.get("icon_custom_emoji_id")
+
+ thread_id = await self._create_dm_topic(
+ chat_id=int(chat_id),
+ name=topic_name,
+ icon_color=icon_color,
+ icon_custom_emoji_id=icon_emoji,
+ )
+
+ if thread_id:
+ self._dm_topics[cache_key] = thread_id
+ logger.info(
+ "[%s] DM topic cached: %s -> thread_id=%s",
+ self.name, cache_key, thread_id,
+ )
+ # Persist thread_id to config so we don't recreate on next restart
+ self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
+
async def connect(self) -> bool:
"""Connect to Telegram and start polling for updates."""
if not TELEGRAM_AVAILABLE:
@@ -125,8 +468,46 @@ async def connect(self) -> bool:
return False
try:
+ from gateway.status import acquire_scoped_lock
+
+ self._token_lock_identity = self.config.token
+ acquired, existing = acquire_scoped_lock(
+ "telegram-bot-token",
+ self._token_lock_identity,
+ metadata={"platform": self.platform.value},
+ )
+ if not acquired:
+ owner_pid = existing.get("pid") if isinstance(existing, dict) else None
+ message = (
+ "Another local Hermes gateway is already using this Telegram bot token"
+ + (f" (PID {owner_pid})." if owner_pid else ".")
+ + " Stop the other gateway before starting a second Telegram poller."
+ )
+ logger.error("[%s] %s", self.name, message)
+ self._set_fatal_error("telegram_token_lock", message, retryable=False)
+ return False
+
# Build the application
- self._app = Application.builder().token(self.config.token).build()
+ builder = Application.builder().token(self.config.token)
+ fallback_ips = self._fallback_ips()
+ if not fallback_ips:
+ fallback_ips = await discover_fallback_ips()
+ logger.info(
+ "[%s] Auto-discovered Telegram fallback IPs: %s",
+ self.name,
+ ", ".join(fallback_ips),
+ )
+ if fallback_ips:
+ logger.warning(
+ "[%s] Telegram fallback IPs active: %s",
+ self.name,
+ ", ".join(fallback_ips),
+ )
+ transport = TelegramFallbackTransport(fallback_ips)
+ request = HTTPXRequest(httpx_kwargs={"transport": transport})
+ get_updates_request = HTTPXRequest(httpx_kwargs={"transport": transport})
+ builder = builder.request(request).get_updates_request(get_updates_request)
+ self._app = builder.build()
self._bot = self._app.bot
# Register handlers
@@ -147,33 +528,57 @@ async def connect(self) -> bool:
self._handle_media_message
))
- # Start polling in background
- await self._app.initialize()
+ # Start polling โ retry initialize() for transient TLS resets
+ try:
+ from telegram.error import NetworkError, TimedOut
+ except ImportError:
+ NetworkError = TimedOut = OSError # type: ignore[misc,assignment]
+ _max_connect = 3
+ for _attempt in range(_max_connect):
+ try:
+ await self._app.initialize()
+ break
+ except (NetworkError, TimedOut, OSError) as init_err:
+ if _attempt < _max_connect - 1:
+ wait = 2 ** _attempt
+ logger.warning(
+ "[%s] Connect attempt %d/%d failed: %s โ retrying in %ds",
+ self.name, _attempt + 1, _max_connect, init_err, wait,
+ )
+ await asyncio.sleep(wait)
+ else:
+ raise
await self._app.start()
- await self._app.updater.start_polling(allowed_updates=Update.ALL_TYPES)
+ 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,
+ )
# 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_bot_commands
await self._bot.set_my_commands([
- BotCommand("new", "Start a new conversation"),
- BotCommand("reset", "Reset conversation history"),
- BotCommand("model", "Show or change the model"),
- BotCommand("personality", "Set a personality"),
- BotCommand("retry", "Retry your last message"),
- BotCommand("undo", "Remove the last exchange"),
- BotCommand("status", "Show session info"),
- BotCommand("stop", "Stop the running agent"),
- BotCommand("sethome", "Set this chat as the home channel"),
- BotCommand("compress", "Compress conversation context"),
- BotCommand("title", "Set or show the session title"),
- BotCommand("resume", "Resume a previously-named session"),
- BotCommand("usage", "Show token usage for this session"),
- BotCommand("provider", "Show available providers"),
- BotCommand("insights", "Show usage insights and analytics"),
- BotCommand("update", "Update Hermes to the latest version"),
- BotCommand("reload_mcp", "Reload MCP servers from config"),
- BotCommand("help", "Show available commands"),
+ BotCommand(name, desc) for name, desc in telegram_bot_commands()
])
except Exception as e:
logger.warning(
@@ -183,29 +588,93 @@ async def connect(self) -> bool:
exc_info=True,
)
- self._running = True
+ self._mark_connected()
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.
+ # Failures here are non-fatal โ the bot works fine without topics.
+ try:
+ await self._setup_dm_topics()
+ except Exception as topics_err:
+ logger.warning(
+ "[%s] DM topics setup failed (non-fatal): %s",
+ self.name, topics_err, exc_info=True,
+ )
+
return True
except Exception as e:
+ if self._token_lock_identity:
+ try:
+ from gateway.status import release_scoped_lock
+ release_scoped_lock("telegram-bot-token", self._token_lock_identity)
+ except Exception:
+ pass
+ message = f"Telegram startup failed: {e}"
+ self._set_fatal_error("telegram_connect_error", message, retryable=True)
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
return False
async def disconnect(self) -> None:
- """Stop polling 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()
+ if pending_media_group_tasks:
+ await asyncio.gather(*pending_media_group_tasks, return_exceptions=True)
+ self._media_group_tasks.clear()
+ self._media_group_events.clear()
+
if self._app:
try:
- await self._app.updater.stop()
- await self._app.stop()
+ # Only stop the updater if it's running
+ if self._app.updater and self._app.updater.running:
+ await self._app.updater.stop()
+ if self._app.running:
+ await self._app.stop()
await self._app.shutdown()
except Exception as e:
logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True)
-
- self._running = False
+ if self._token_lock_identity:
+ try:
+ from gateway.status import release_scoped_lock
+ release_scoped_lock("telegram-bot-token", self._token_lock_identity)
+ except Exception as e:
+ logger.warning("[%s] Error releasing Telegram token lock: %s", self.name, e, exc_info=True)
+
+ for task in self._pending_photo_batch_tasks.values():
+ if task and not task.done():
+ task.cancel()
+ self._pending_photo_batch_tasks.clear()
+ self._pending_photo_batches.clear()
+
+ self._mark_disconnected()
self._app = None
self._bot = None
+ self._token_lock_identity = None
logger.info("[%s] Disconnected from Telegram", self.name)
-
+
+ def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool:
+ """Determine if this message chunk should thread to the original message.
+
+ Args:
+ reply_to: The original message ID to reply to
+ chunk_index: Index of this chunk (0 = first chunk)
+
+ Returns:
+ True if this chunk should be threaded to the original message
+ """
+ if not reply_to:
+ return False
+ mode = self._reply_to_mode
+ if mode == "off":
+ return False
+ elif mode == "all":
+ return True
+ else: # "first" (default)
+ return chunk_index == 0
+
async def send(
self,
chat_id: str,
@@ -221,36 +690,86 @@ async def send(
# Format and split message if needed
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+ if len(chunks) > 1:
+ # truncate_message appends a raw " (1/2)" suffix. Escape the
+ # MarkdownV2-special parentheses so Telegram doesn't reject the
+ # chunk and fall back to plain text.
+ chunks = [
+ re.sub(r" \((\d+)/(\d+)\)$", r" \\(\1/\2\\)", chunk)
+ for chunk in chunks
+ ]
message_ids = []
thread_id = metadata.get("thread_id") if metadata else None
+ try:
+ from telegram.error import NetworkError as _NetErr
+ except ImportError:
+ _NetErr = OSError # type: ignore[misc,assignment]
+
+ try:
+ from telegram.error import BadRequest as _BadReq
+ except ImportError:
+ _BadReq = None # type: ignore[assignment,misc]
+
for i, chunk in enumerate(chunks):
- # Try Markdown first, fall back to plain text if it fails
- try:
- msg = await self._bot.send_message(
- chat_id=int(chat_id),
- text=chunk,
- parse_mode=ParseMode.MARKDOWN_V2,
- reply_to_message_id=int(reply_to) if reply_to and i == 0 else None,
- message_thread_id=int(thread_id) if thread_id else None,
- )
- except Exception as md_error:
- # Markdown parsing failed, try plain text
- if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
- logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
- # Strip MDV2 escape backslashes so the user doesn't
- # see raw backslashes littered through the message.
- plain_chunk = _strip_mdv2(chunk)
- msg = await self._bot.send_message(
- chat_id=int(chat_id),
- text=plain_chunk,
- parse_mode=None, # Plain text
- reply_to_message_id=int(reply_to) if reply_to and i == 0 else None,
- message_thread_id=int(thread_id) if thread_id else None,
- )
- else:
- raise # Re-raise if not a parse error
+ should_thread = self._should_thread_reply(reply_to, i)
+ reply_to_id = int(reply_to) if should_thread else None
+ effective_thread_id = int(thread_id) if thread_id else None
+
+ msg = None
+ for _send_attempt in range(3):
+ try:
+ # Try Markdown first, fall back to plain text if it fails
+ try:
+ msg = await self._bot.send_message(
+ chat_id=int(chat_id),
+ text=chunk,
+ parse_mode=ParseMode.MARKDOWN_V2,
+ reply_to_message_id=reply_to_id,
+ message_thread_id=effective_thread_id,
+ )
+ except Exception as md_error:
+ # Markdown parsing failed, try plain text
+ if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
+ logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
+ plain_chunk = _strip_mdv2(chunk)
+ msg = await self._bot.send_message(
+ chat_id=int(chat_id),
+ text=plain_chunk,
+ parse_mode=None,
+ reply_to_message_id=reply_to_id,
+ message_thread_id=effective_thread_id,
+ )
+ else:
+ raise
+ break # success
+ except _NetErr as send_err:
+ # BadRequest is a subclass of NetworkError in
+ # python-telegram-bot but represents permanent errors
+ # (not transient network issues). Detect and handle
+ # specific cases instead of blindly retrying.
+ if _BadReq and isinstance(send_err, _BadReq):
+ err_lower = str(send_err).lower()
+ if "thread not found" in err_lower and effective_thread_id is not None:
+ # Thread doesn't exist โ retry without
+ # message_thread_id so the message still
+ # reaches the chat.
+ logger.warning(
+ "[%s] Thread %s not found, retrying without message_thread_id",
+ self.name, effective_thread_id,
+ )
+ effective_thread_id = None
+ continue
+ # Other BadRequest errors are permanent โ don't retry
+ raise
+ if _send_attempt < 2:
+ wait = 2 ** _send_attempt
+ logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
+ self.name, _send_attempt + 1, wait, send_err)
+ await asyncio.sleep(wait)
+ else:
+ raise
message_ids.append(str(msg.message_id))
return SendResult(
@@ -281,7 +800,10 @@ async def edit_message(
text=formatted,
parse_mode=ParseMode.MARKDOWN_V2,
)
- except Exception:
+ except Exception as fmt_err:
+ # "Message is not modified" is a no-op, not an error
+ if "not modified" in str(fmt_err).lower():
+ return SendResult(success=True, message_id=message_id)
# Fallback: retry without markdown formatting
await self._bot.edit_message_text(
chat_id=int(chat_id),
@@ -290,6 +812,46 @@ async def edit_message(
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
+ err_str = str(e).lower()
+ # "Message is not modified" โ content identical, treat as success
+ if "not modified" in err_str:
+ return SendResult(success=True, message_id=message_id)
+ # Message too long โ content exceeded 4096 chars (e.g. during
+ # streaming). Truncate and succeed so the stream consumer can
+ # split the overflow into a new message instead of dying.
+ if "message_too_long" in err_str or "too long" in err_str:
+ truncated = content[: self.MAX_MESSAGE_LENGTH - 20] + "โฆ"
+ try:
+ await self._bot.edit_message_text(
+ chat_id=int(chat_id),
+ message_id=int(message_id),
+ text=truncated,
+ )
+ except Exception:
+ pass # best-effort truncation
+ return SendResult(success=True, message_id=message_id)
+ # Flood control / RetryAfter โ back off and retry once
+ retry_after = getattr(e, "retry_after", None)
+ if retry_after is not None or "retry after" in err_str:
+ wait = retry_after if retry_after else 1.0
+ logger.warning(
+ "[%s] Telegram flood control, waiting %.1fs",
+ self.name, wait,
+ )
+ await asyncio.sleep(wait)
+ try:
+ await self._bot.edit_message_text(
+ chat_id=int(chat_id),
+ message_id=int(message_id),
+ text=content,
+ )
+ return SendResult(success=True, message_id=message_id)
+ except Exception as retry_err:
+ logger.error(
+ "[%s] Edit retry failed after flood wait: %s",
+ self.name, retry_err,
+ )
+ return SendResult(success=False, error=str(retry_err))
logger.error(
"[%s] Failed to edit Telegram message %s: %s",
self.name,
@@ -306,6 +868,7 @@ async def send_voice(
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
) -> SendResult:
"""Send audio as a native Telegram voice message or audio file."""
if not self._bot:
@@ -353,23 +916,26 @@ async def send_image_file(
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a local image file natively as a Telegram photo."""
if not self._bot:
return SendResult(success=False, error="Not connected")
-
+
try:
import os
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
-
+
+ _thread = metadata.get("thread_id") if metadata else None
with open(image_path, "rb") as image_file:
msg = await self._bot.send_photo(
chat_id=int(chat_id),
photo=image_file,
caption=caption[:1024] if caption else None,
reply_to_message_id=int(reply_to) if reply_to else None,
+ message_thread_id=int(_thread) if _thread else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
@@ -388,6 +954,7 @@ async def send_document(
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a document/file natively as a Telegram file attachment."""
@@ -399,6 +966,7 @@ async def send_document(
return SendResult(success=False, error=f"File not found: {file_path}")
display_name = file_name or os.path.basename(file_path)
+ _thread = metadata.get("thread_id") if metadata else None
with open(file_path, "rb") as f:
msg = await self._bot.send_document(
@@ -407,6 +975,7 @@ async def send_document(
filename=display_name,
caption=caption[:1024] if caption else None,
reply_to_message_id=int(reply_to) if reply_to else None,
+ message_thread_id=int(_thread) if _thread else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
@@ -419,6 +988,7 @@ async def send_video(
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SendResult:
"""Send a video natively as a Telegram video message."""
@@ -429,12 +999,14 @@ async def send_video(
if not os.path.exists(video_path):
return SendResult(success=False, error=f"Video file not found: {video_path}")
+ _thread = metadata.get("thread_id") if metadata else None
with open(video_path, "rb") as f:
msg = await self._bot.send_video(
chat_id=int(chat_id),
video=f,
caption=caption[:1024] if caption else None,
reply_to_message_id=int(reply_to) if reply_to else None,
+ message_thread_id=int(_thread) if _thread else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
@@ -610,14 +1182,30 @@ def _ph(value: str) -> str:
text = content
# 1) Protect fenced code blocks (``` ... ```)
+ # Per MarkdownV2 spec, \ and ` inside pre/code must be escaped.
+ def _protect_fenced(m):
+ raw = m.group(0)
+ # Split off opening ``` (with optional language) and closing ```
+ open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3
+ opening = raw[:open_end]
+ body_and_close = raw[open_end:]
+ body = body_and_close[:-3]
+ body = body.replace('\\', '\\\\').replace('`', '\\`')
+ return _ph(opening + body + '```')
+
text = re.sub(
r'(```(?:[^\n]*\n)?[\s\S]*?```)',
- lambda m: _ph(m.group(0)),
+ _protect_fenced,
text,
)
# 2) Protect inline code (`...`)
- text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
+ # Escape \ inside inline code per MarkdownV2 spec.
+ text = re.sub(
+ r'(`[^`]+`)',
+ lambda m: _ph(m.group(0).replace('\\', '\\\\')),
+ text,
+ )
# 3) Convert markdown links โ escape the display text; inside the URL
# only ')' and '\' need escaping per the MarkdownV2 spec.
@@ -655,23 +1243,89 @@ def _convert_header(m):
text,
)
- # 7) Escape remaining special characters in plain text
+ # 7) Convert strikethrough: ~~text~~ โ ~text~ (MarkdownV2)
+ text = re.sub(
+ r'~~(.+?)~~',
+ lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'),
+ text,
+ )
+
+ # 8) Convert spoiler: ||text|| โ ||text|| (protect from | escaping)
+ text = re.sub(
+ r'\|\|(.+?)\|\|',
+ lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'),
+ text,
+ )
+
+ # 9) Convert blockquotes: > at line start โ protect > from escaping
+ text = re.sub(
+ r'^(>{1,3}) (.+)$',
+ lambda m: _ph(m.group(1) + ' ' + _escape_mdv2(m.group(2))),
+ text,
+ flags=re.MULTILINE,
+ )
+
+ # 10) Escape remaining special characters in plain text
text = _escape_mdv2(text)
- # 8) Restore placeholders in reverse insertion order so that
+ # 11) Restore placeholders in reverse insertion order so that
# nested references (a placeholder inside another) resolve correctly.
for key in reversed(list(placeholders.keys())):
text = text.replace(key, placeholders[key])
+ # 12) Safety net: escape unescaped ( ) { } that slipped through
+ # placeholder processing. Split the text into code/non-code
+ # segments so we never touch content inside ``` or ` spans.
+ _code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text)
+ _safe_parts = []
+ for _idx, _seg in enumerate(_code_split):
+ if _idx % 2 == 1:
+ # Inside code span/block โ leave untouched
+ _safe_parts.append(_seg)
+ else:
+ # Outside code โ escape bare ( ) { }
+ def _esc_bare(m, _seg=_seg):
+ s = m.start()
+ ch = m.group(0)
+ # Already escaped
+ if s > 0 and _seg[s - 1] == '\\':
+ return ch
+ # ( that opens a MarkdownV2 link [text](url)
+ if ch == '(' and s > 0 and _seg[s - 1] == ']':
+ return ch
+ # ) that closes a link URL
+ if ch == ')':
+ before = _seg[:s]
+ if '](http' in before or '](' in before:
+ # Check depth
+ depth = 0
+ for j in range(s - 1, max(s - 2000, -1), -1):
+ if _seg[j] == '(':
+ depth -= 1
+ if depth < 0:
+ if j > 0 and _seg[j - 1] == ']':
+ return ch
+ break
+ elif _seg[j] == ')':
+ depth += 1
+ return '\\' + ch
+ _safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg))
+ text = ''.join(_safe_parts)
+
return text
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle incoming text messages."""
+ """Handle incoming text messages.
+
+ Telegram clients split long messages into multiple updates. Buffer
+ rapid successive text messages from the same user/chat and aggregate
+ them into a single MessageEvent before dispatching.
+ """
if not update.message or not update.message.text:
return
-
+
event = self._build_message_event(update.message, MessageType.TEXT)
- await self.handle_message(event)
+ self._enqueue_text_event(event)
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming command messages."""
@@ -716,6 +1370,114 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D
event.text = "\n".join(parts)
await self.handle_message(event)
+ # ------------------------------------------------------------------
+ # Text message aggregation (handles Telegram client-side splits)
+ # ------------------------------------------------------------------
+
+ def _text_batch_key(self, event: MessageEvent) -> str:
+ """Session-scoped key for text message batching."""
+ from gateway.session import build_session_key
+ return build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ )
+
+ def _enqueue_text_event(self, event: MessageEvent) -> None:
+ """Buffer a text event and reset the flush timer.
+
+ When Telegram splits a long user message into multiple updates,
+ they arrive within a few hundred milliseconds. This method
+ concatenates them and waits for a short quiet period before
+ dispatching the combined message.
+ """
+ key = self._text_batch_key(event)
+ existing = self._pending_text_batches.get(key)
+ if existing is None:
+ self._pending_text_batches[key] = event
+ else:
+ # Append text from the follow-up chunk
+ if event.text:
+ existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
+ # Merge any media that might be attached
+ if event.media_urls:
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+
+ # Cancel any pending flush and restart the timer
+ prior_task = self._pending_text_batch_tasks.get(key)
+ if prior_task and not prior_task.done():
+ prior_task.cancel()
+ self._pending_text_batch_tasks[key] = asyncio.create_task(
+ self._flush_text_batch(key)
+ )
+
+ async def _flush_text_batch(self, key: str) -> None:
+ """Wait for the quiet period then dispatch the aggregated text."""
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(self._text_batch_delay_seconds)
+ event = self._pending_text_batches.pop(key, None)
+ if not event:
+ return
+ logger.info(
+ "[Telegram] Flushing text batch %s (%d chars)",
+ key, len(event.text or ""),
+ )
+ await self.handle_message(event)
+ finally:
+ if self._pending_text_batch_tasks.get(key) is current_task:
+ self._pending_text_batch_tasks.pop(key, None)
+
+ # ------------------------------------------------------------------
+ # Photo batching
+ # ------------------------------------------------------------------
+
+ def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
+ """Return a batching key for Telegram photos/albums."""
+ from gateway.session import build_session_key
+ session_key = build_session_key(
+ event.source,
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
+ )
+ media_group_id = getattr(msg, "media_group_id", None)
+ if media_group_id:
+ return f"{session_key}:album:{media_group_id}"
+ return f"{session_key}:photo-burst"
+
+ async def _flush_photo_batch(self, batch_key: str) -> None:
+ """Send a buffered photo burst/album as a single MessageEvent."""
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(self._media_batch_delay_seconds)
+ event = self._pending_photo_batches.pop(batch_key, None)
+ if not event:
+ return
+ logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls))
+ await self.handle_message(event)
+ finally:
+ if self._pending_photo_batch_tasks.get(batch_key) is current_task:
+ self._pending_photo_batch_tasks.pop(batch_key, None)
+
+ def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
+ """Merge photo events into a pending batch and schedule flush."""
+ existing = self._pending_photo_batches.get(batch_key)
+ if existing is None:
+ self._pending_photo_batches[batch_key] = event
+ else:
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ if not existing.text:
+ existing.text = event.text
+ elif event.text not in existing.text:
+ existing.text = f"{existing.text}\n\n{event.text}".strip()
+
+ prior_task = self._pending_photo_batch_tasks.get(batch_key)
+ if prior_task and not prior_task.done():
+ prior_task.cancel()
+
+ self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key))
+
async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming media messages, downloading images to local cache."""
if not update.message:
@@ -767,14 +1529,22 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
if file_obj.file_path.lower().endswith(candidate):
ext = candidate
break
- # Save to cache and populate media_urls with the local path
+ # Save to local cache (for vision tool access)
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
event.media_urls = [cached_path]
- event.media_types = [f"image/{ext.lstrip('.')}"]
+ event.media_types = [f"image/{ext.lstrip('.')}" ]
logger.info("[Telegram] Cached user photo at %s", cached_path)
+ media_group_id = getattr(msg, "media_group_id", None)
+ if media_group_id:
+ await self._queue_media_group_event(str(media_group_id), event)
+ else:
+ batch_key = self._photo_batch_key(event, msg)
+ self._enqueue_photo_event(batch_key, event)
+ return
+
except Exception as e:
logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True)
-
+
# Download voice/audio messages to cache for STT transcription
if msg.voice:
try:
@@ -866,8 +1636,53 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA
except Exception as e:
logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True)
+ media_group_id = getattr(msg, "media_group_id", None)
+ if media_group_id:
+ await self._queue_media_group_event(str(media_group_id), event)
+ return
+
await self.handle_message(event)
+ async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
+ """Buffer Telegram media-group items so albums arrive as one logical event.
+
+ Telegram delivers albums as multiple updates with a shared media_group_id.
+ If we forward each item immediately, the gateway thinks the second image is a
+ new user message and interrupts the first. We debounce briefly and merge the
+ attachments into a single MessageEvent.
+ """
+ existing = self._media_group_events.get(media_group_id)
+ if existing is None:
+ self._media_group_events[media_group_id] = event
+ else:
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ if existing.text:
+ if event.text not in existing.text.split("\n\n"):
+ existing.text = f"{existing.text}\n\n{event.text}"
+ else:
+ existing.text = event.text
+
+ prior_task = self._media_group_tasks.get(media_group_id)
+ if prior_task:
+ prior_task.cancel()
+
+ self._media_group_tasks[media_group_id] = asyncio.create_task(
+ self._flush_media_group_event(media_group_id)
+ )
+
+ async def _flush_media_group_event(self, media_group_id: str) -> None:
+ try:
+ await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS)
+ event = self._media_group_events.pop(media_group_id, None)
+ if event is not None:
+ await self.handle_message(event)
+ except asyncio.CancelledError:
+ return
+ finally:
+ self._media_group_tasks.pop(media_group_id, None)
+
async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
"""
Describe a Telegram sticker via vision analysis, with caching.
@@ -935,6 +1750,99 @@ async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
emoji, set_name,
)
+ def _reload_dm_topics_from_config(self) -> None:
+ """Re-read dm_topics from config.yaml and load any new thread_ids into cache.
+
+ This allows topics created externally (e.g. by the agent via API) to be
+ recognized without a gateway restart.
+ """
+ try:
+ config_path = _Path.home() / ".hermes" / "config.yaml"
+ if not config_path.exists():
+ return
+
+ import yaml as _yaml
+ with open(config_path, "r") as f:
+ config = _yaml.safe_load(f) or {}
+
+ dm_topics = (
+ config.get("platforms", {})
+ .get("telegram", {})
+ .get("extra", {})
+ .get("dm_topics", [])
+ )
+ if not dm_topics:
+ return
+
+ # Update in-memory config and cache any new thread_ids
+ self._dm_topics_config = dm_topics
+ for chat_entry in dm_topics:
+ cid = chat_entry.get("chat_id")
+ if not cid:
+ continue
+ for t in chat_entry.get("topics", []):
+ tid = t.get("thread_id")
+ name = t.get("name")
+ if tid and name:
+ cache_key = f"{cid}:{name}"
+ if cache_key not in self._dm_topics:
+ self._dm_topics[cache_key] = int(tid)
+ logger.info(
+ "[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
+ self.name, cache_key, tid,
+ )
+ except Exception as e:
+ logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
+
+ def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
+ """Look up DM topic config by chat_id and thread_id.
+
+ Returns the topic config dict (name, skill, etc.) if this thread_id
+ matches a known DM topic, or None.
+ """
+ if not thread_id:
+ return None
+
+ thread_id_int = int(thread_id)
+
+ # Check cached topics first (created by us or loaded at startup)
+ for key, cached_tid in self._dm_topics.items():
+ if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
+ topic_name = key.split(":", 1)[1]
+ # Find the full config for this topic
+ for chat_entry in self._dm_topics_config:
+ if str(chat_entry.get("chat_id")) == chat_id:
+ for t in chat_entry.get("topics", []):
+ if t.get("name") == topic_name:
+ return t
+ return {"name": topic_name}
+
+ # Not in cache โ hot-reload config in case topics were added externally
+ self._reload_dm_topics_from_config()
+
+ # Check cache again after reload
+ for key, cached_tid in self._dm_topics.items():
+ if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
+ topic_name = key.split(":", 1)[1]
+ for chat_entry in self._dm_topics_config:
+ if str(chat_entry.get("chat_id")) == chat_id:
+ for t in chat_entry.get("topics", []):
+ if t.get("name") == topic_name:
+ return t
+ return {"name": topic_name}
+
+ return None
+
+ def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
+ """Cache a thread_id -> topic_name mapping discovered from an incoming message."""
+ cache_key = f"{chat_id}:{topic_name}"
+ if cache_key not in self._dm_topics:
+ self._dm_topics[cache_key] = int(thread_id)
+ logger.info(
+ "[%s] Cached DM topic from message: %s -> thread_id=%s",
+ self.name, cache_key, thread_id,
+ )
+
def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent:
"""Build a MessageEvent from a Telegram message."""
chat = message.chat
@@ -946,7 +1854,27 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
chat_type = "group"
elif chat.type == ChatType.CHANNEL:
chat_type = "channel"
-
+
+ # Resolve DM topic name and skill binding
+ thread_id_raw = message.message_thread_id
+ thread_id_str = str(thread_id_raw) if thread_id_raw else None
+ chat_topic = None
+ topic_skill = None
+
+ if chat_type == "dm" and thread_id_str:
+ topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
+ if topic_info:
+ chat_topic = topic_info.get("name")
+ topic_skill = topic_info.get("skill")
+
+ # Also check forum_topic_created service message for topic discovery
+ if hasattr(message, "forum_topic_created") and message.forum_topic_created:
+ created_name = message.forum_topic_created.name
+ if created_name:
+ self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
+ if not chat_topic:
+ chat_topic = created_name
+
# Build source
source = self.build_source(
chat_id=str(chat.id),
@@ -954,14 +1882,25 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
chat_type=chat_type,
user_id=str(user.id) if user else None,
user_name=user.full_name if user else None,
- thread_id=str(message.message_thread_id) if message.message_thread_id else None,
+ thread_id=thread_id_str,
+ chat_topic=chat_topic,
)
+ # Extract reply context if this message is a reply
+ reply_to_id = None
+ reply_to_text = None
+ if message.reply_to_message:
+ reply_to_id = str(message.reply_to_message.message_id)
+ reply_to_text = message.reply_to_message.text or message.reply_to_message.caption or None
+
return MessageEvent(
text=message.text or "",
message_type=msg_type,
source=source,
raw_message=message,
message_id=str(message.message_id),
+ reply_to_message_id=reply_to_id,
+ reply_to_text=reply_to_text,
+ auto_skill=topic_skill,
timestamp=message.date,
)
diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py
new file mode 100644
index 00000000000..7192369470b
--- /dev/null
+++ b/gateway/platforms/telegram_network.py
@@ -0,0 +1,233 @@
+"""Telegram-specific network helpers.
+
+Provides a hostname-preserving fallback transport for networks where
+api.telegram.org resolves to an endpoint that is unreachable from the current
+host. The transport keeps the logical request host and TLS SNI as
+api.telegram.org while retrying the TCP connection against one or more fallback
+IPv4 addresses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import ipaddress
+import logging
+import socket
+from typing import Iterable, Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+_TELEGRAM_API_HOST = "api.telegram.org"
+
+# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ
+# from the (potentially unreachable) IP returned by the local system resolver.
+_DOH_TIMEOUT = 4.0 # seconds โ bounded so connect() isn't noticeably delayed
+
+_DOH_PROVIDERS: list[dict] = [
+ {
+ "url": "https://dns.google/resolve",
+ "params": {"name": _TELEGRAM_API_HOST, "type": "A"},
+ "headers": {},
+ },
+ {
+ "url": "https://cloudflare-dns.com/dns-query",
+ "params": {"name": _TELEGRAM_API_HOST, "type": "A"},
+ "headers": {"Accept": "application/dns-json"},
+ },
+]
+
+# Last-resort IPs when DoH is also blocked. These are stable Telegram Bot API
+# endpoints in the 149.154.160.0/20 block (same seed used by OpenClaw).
+_SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"]
+
+
+class TelegramFallbackTransport(httpx.AsyncBaseTransport):
+ """Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.
+
+ Requests continue to target https://api.telegram.org/... logically, but on
+ connect failures the underlying TCP connection is retried against a known
+ reachable IP. This is effectively the programmatic equivalent of
+ ``curl --resolve api.telegram.org:443:``.
+ """
+
+ def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
+ self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))]
+ self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
+ self._fallbacks = {
+ ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips
+ }
+ self._sticky_ip: Optional[str] = None
+ self._sticky_lock = asyncio.Lock()
+
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips:
+ return await self._primary.handle_async_request(request)
+
+ sticky_ip = self._sticky_ip
+ attempt_order: list[Optional[str]] = [sticky_ip] if sticky_ip else [None]
+ for ip in self._fallback_ips:
+ if ip != sticky_ip:
+ attempt_order.append(ip)
+
+ last_error: Exception | None = None
+ for ip in attempt_order:
+ candidate = request if ip is None else _rewrite_request_for_ip(request, ip)
+ transport = self._primary if ip is None else self._fallbacks[ip]
+ try:
+ response = await transport.handle_async_request(candidate)
+ if ip is not None and self._sticky_ip != ip:
+ async with self._sticky_lock:
+ if self._sticky_ip != ip:
+ self._sticky_ip = ip
+ logger.warning(
+ "[Telegram] Primary api.telegram.org path unreachable; using sticky fallback IP %s",
+ ip,
+ )
+ return response
+ except Exception as exc:
+ last_error = exc
+ if not _is_retryable_connect_error(exc):
+ raise
+ if ip is None:
+ logger.warning(
+ "[Telegram] Primary api.telegram.org connection failed (%s); trying fallback IPs %s",
+ exc,
+ ", ".join(self._fallback_ips),
+ )
+ continue
+ logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc)
+ continue
+
+ assert last_error is not None
+ raise last_error
+
+ async def aclose(self) -> None:
+ await self._primary.aclose()
+ for transport in self._fallbacks.values():
+ await transport.aclose()
+
+
+def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:
+ normalized: list[str] = []
+ for value in values:
+ raw = str(value).strip()
+ if not raw:
+ continue
+ try:
+ addr = ipaddress.ip_address(raw)
+ except ValueError:
+ logger.warning("Ignoring invalid Telegram fallback IP: %r", raw)
+ continue
+ if addr.version != 4:
+ logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw)
+ continue
+ normalized.append(str(addr))
+ return normalized
+
+
+def parse_fallback_ip_env(value: str | None) -> list[str]:
+ if not value:
+ return []
+ parts = [part.strip() for part in value.split(",")]
+ return _normalize_fallback_ips(parts)
+
+
+def _resolve_system_dns() -> set[str]:
+ """Return the IPv4 addresses that the OS resolver gives for api.telegram.org."""
+ try:
+ results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)
+ return {addr[4][0] for addr in results}
+ except Exception:
+ return set()
+
+
+async def _query_doh_provider(
+ client: httpx.AsyncClient, provider: dict
+) -> list[str]:
+ """Query one DoH provider and return A-record IPs."""
+ try:
+ resp = await client.get(
+ provider["url"], params=provider["params"], headers=provider["headers"]
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ ips: list[str] = []
+ for answer in data.get("Answer", []):
+ if answer.get("type") != 1: # A record
+ continue
+ raw = answer.get("data", "").strip()
+ try:
+ ipaddress.ip_address(raw)
+ ips.append(raw)
+ except ValueError:
+ continue
+ return ips
+ except Exception as exc:
+ logger.debug("DoH query to %s failed: %s", provider["url"], exc)
+ return []
+
+
+async def discover_fallback_ips() -> list[str]:
+ """Auto-discover Telegram API IPs via DNS-over-HTTPS.
+
+ Resolves api.telegram.org through Google and Cloudflare DoH, collects all
+ unique IPs, and excludes the system-DNS-resolved IP (which is presumably
+ unreachable on this network). Falls back to a hardcoded seed list when DoH
+ is also unavailable.
+ """
+ async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
+ doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
+ system_dns_task = asyncio.to_thread(_resolve_system_dns)
+ results = await asyncio.gather(system_dns_task, *doh_tasks, return_exceptions=True)
+
+ # results[0] = system DNS IPs (set), results[1:] = DoH IP lists
+ system_ips: set[str] = results[0] if isinstance(results[0], set) else set()
+
+ doh_ips: list[str] = []
+ for r in results[1:]:
+ if isinstance(r, list):
+ doh_ips.extend(r)
+
+ # Deduplicate preserving order, exclude system-DNS IPs
+ seen: set[str] = set()
+ candidates: list[str] = []
+ for ip in doh_ips:
+ if ip not in seen and ip not in system_ips:
+ seen.add(ip)
+ candidates.append(ip)
+
+ # Validate through existing normalization
+ validated = _normalize_fallback_ips(candidates)
+
+ if validated:
+ logger.debug("Discovered Telegram fallback IPs via DoH: %s", ", ".join(validated))
+ return validated
+
+ logger.info(
+ "DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s",
+ ", ".join(system_ips) or "unknown",
+ ", ".join(_SEED_FALLBACK_IPS),
+ )
+ return list(_SEED_FALLBACK_IPS)
+
+
+def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request:
+ original_host = request.url.host or _TELEGRAM_API_HOST
+ url = request.url.copy_with(host=ip)
+ headers = request.headers.copy()
+ headers["host"] = original_host
+ extensions = dict(request.extensions)
+ extensions["sni_hostname"] = original_host
+ return httpx.Request(
+ method=request.method,
+ url=url,
+ headers=headers,
+ stream=request.stream,
+ extensions=extensions,
+ )
+
+
+def _is_retryable_connect_error(exc: Exception) -> bool:
+ return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError))
diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py
new file mode 100644
index 00000000000..2d75879b596
--- /dev/null
+++ b/gateway/platforms/webhook.py
@@ -0,0 +1,559 @@
+"""Generic webhook platform adapter.
+
+Runs an aiohttp HTTP server that receives webhook POSTs from external
+services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC signatures,
+transforms payloads into agent prompts, and routes responses back to the
+source or to another configured platform.
+
+Configuration lives in config.yaml under platforms.webhook.extra.routes.
+Each route defines:
+ - events: which event types to accept (header-based filtering)
+ - secret: HMAC secret for signature validation (REQUIRED)
+ - prompt: template string formatted with the webhook payload
+ - skills: optional list of skills to load for the agent
+ - deliver: where to send the response (github_comment, telegram, etc.)
+ - deliver_extra: additional delivery config (repo, pr_number, chat_id)
+
+Security:
+ - HMAC secret is required per route (validated at startup)
+ - Rate limiting per route (fixed-window, configurable)
+ - Idempotency cache prevents duplicate agent runs on webhook retries
+ - Body size limits checked before reading payload
+ - Set secret to "INSECURE_NO_AUTH" to skip validation (testing only)
+"""
+
+import asyncio
+import hashlib
+import hmac
+import json
+import logging
+import re
+import subprocess
+import time
+from typing import Any, Dict, List, Optional
+
+try:
+ from aiohttp import web
+
+ AIOHTTP_AVAILABLE = True
+except ImportError:
+ AIOHTTP_AVAILABLE = False
+ web = None # type: ignore[assignment]
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ SendResult,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_HOST = "0.0.0.0"
+DEFAULT_PORT = 8644
+_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
+
+
+def check_webhook_requirements() -> bool:
+ """Check if webhook adapter dependencies are available."""
+ return AIOHTTP_AVAILABLE
+
+
+class WebhookAdapter(BasePlatformAdapter):
+ """Generic webhook receiver that triggers agent runs from HTTP POSTs."""
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform.WEBHOOK)
+ self._host: str = config.extra.get("host", DEFAULT_HOST)
+ self._port: int = int(config.extra.get("port", DEFAULT_PORT))
+ self._global_secret: str = config.extra.get("secret", "")
+ self._routes: Dict[str, dict] = config.extra.get("routes", {})
+ self._runner = None
+
+ # Delivery info keyed by session chat_id โ consumed by send()
+ self._delivery_info: Dict[str, dict] = {}
+
+ # Reference to gateway runner for cross-platform delivery (set externally)
+ self.gateway_runner = None
+
+ # Idempotency: TTL cache of recently processed delivery IDs.
+ # Prevents duplicate agent runs when webhook providers retry.
+ self._seen_deliveries: Dict[str, float] = {}
+ self._idempotency_ttl: int = 3600 # 1 hour
+
+ # Rate limiting: per-route timestamps in a fixed window.
+ self._rate_counts: Dict[str, List[float]] = {}
+ self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
+
+ # Body size limit (auth-before-body pattern)
+ self._max_body_bytes: int = int(
+ config.extra.get("max_body_bytes", 1_048_576)
+ ) # 1MB
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ async def connect(self) -> bool:
+ # Validate routes at startup โ secret is required per route
+ for name, route in self._routes.items():
+ secret = route.get("secret", self._global_secret)
+ if not secret:
+ raise ValueError(
+ f"[webhook] Route '{name}' has no HMAC secret. "
+ f"Set 'secret' on the route or globally. "
+ f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
+ )
+
+ app = web.Application()
+ app.router.add_get("/health", self._handle_health)
+ app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
+
+ self._runner = web.AppRunner(app)
+ await self._runner.setup()
+ site = web.TCPSite(self._runner, self._host, self._port)
+ await site.start()
+ self._mark_connected()
+
+ route_names = ", ".join(self._routes.keys()) or "(none configured)"
+ logger.info(
+ "[webhook] Listening on %s:%d โ routes: %s",
+ self._host,
+ self._port,
+ route_names,
+ )
+ return True
+
+ async def disconnect(self) -> None:
+ if self._runner:
+ await self._runner.cleanup()
+ self._runner = None
+ self._mark_disconnected()
+ logger.info("[webhook] Disconnected")
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ """Deliver the agent's response to the configured destination.
+
+ chat_id is ``webhook:{route}:{delivery_id}`` โ we pop the delivery
+ info stored during webhook receipt so it doesn't leak memory.
+ """
+ delivery = self._delivery_info.pop(chat_id, {})
+ deliver_type = delivery.get("deliver", "log")
+
+ if deliver_type == "log":
+ logger.info("[webhook] Response for %s: %s", chat_id, content[:200])
+ return SendResult(success=True)
+
+ if deliver_type == "github_comment":
+ return await self._deliver_github_comment(content, delivery)
+
+ # Cross-platform delivery (telegram, discord, etc.)
+ if self.gateway_runner and deliver_type in (
+ "telegram",
+ "discord",
+ "slack",
+ "signal",
+ "sms",
+ ):
+ return await self._deliver_cross_platform(
+ deliver_type, content, delivery
+ )
+
+ logger.warning("[webhook] Unknown deliver type: %s", deliver_type)
+ return SendResult(
+ success=False, error=f"Unknown deliver type: {deliver_type}"
+ )
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ return {"name": chat_id, "type": "webhook"}
+
+ # ------------------------------------------------------------------
+ # HTTP handlers
+ # ------------------------------------------------------------------
+
+ async def _handle_health(self, request: "web.Request") -> "web.Response":
+ """GET /health โ simple health check."""
+ return web.json_response({"status": "ok", "platform": "webhook"})
+
+ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
+ """POST /webhooks/{route_name} โ receive and process a webhook event."""
+ route_name = request.match_info.get("route_name", "")
+ route_config = self._routes.get(route_name)
+
+ if not route_config:
+ return web.json_response(
+ {"error": f"Unknown route: {route_name}"}, status=404
+ )
+
+ # โโ Auth-before-body โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # Check Content-Length before reading the full payload.
+ content_length = request.content_length or 0
+ if content_length > self._max_body_bytes:
+ return web.json_response(
+ {"error": "Payload too large"}, status=413
+ )
+
+ # โโ Rate limiting โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ now = time.time()
+ window = self._rate_counts.setdefault(route_name, [])
+ window[:] = [t for t in window if now - t < 60]
+ if len(window) >= self._rate_limit:
+ return web.json_response(
+ {"error": "Rate limit exceeded"}, status=429
+ )
+ window.append(now)
+
+ # Read body
+ try:
+ raw_body = await request.read()
+ except Exception as e:
+ logger.error("[webhook] Failed to read body: %s", e)
+ return web.json_response({"error": "Bad request"}, status=400)
+
+ # Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode)
+ secret = route_config.get("secret", self._global_secret)
+ if secret and secret != _INSECURE_NO_AUTH:
+ if not self._validate_signature(request, raw_body, secret):
+ logger.warning(
+ "[webhook] Invalid signature for route %s", route_name
+ )
+ return web.json_response(
+ {"error": "Invalid signature"}, status=401
+ )
+
+ # Parse payload
+ try:
+ payload = json.loads(raw_body)
+ except json.JSONDecodeError:
+ # Try form-encoded as fallback
+ try:
+ import urllib.parse
+
+ payload = dict(
+ urllib.parse.parse_qsl(raw_body.decode("utf-8"))
+ )
+ except Exception:
+ return web.json_response(
+ {"error": "Cannot parse body"}, status=400
+ )
+
+ # Check event type filter
+ event_type = (
+ request.headers.get("X-GitHub-Event", "")
+ or request.headers.get("X-GitLab-Event", "")
+ or payload.get("event_type", "")
+ or "unknown"
+ )
+ allowed_events = route_config.get("events", [])
+ if allowed_events and event_type not in allowed_events:
+ logger.debug(
+ "[webhook] Ignoring event %s for route %s (allowed: %s)",
+ event_type,
+ route_name,
+ allowed_events,
+ )
+ return web.json_response(
+ {"status": "ignored", "event": event_type}
+ )
+
+ # Format prompt from template
+ prompt_template = route_config.get("prompt", "")
+ prompt = self._render_prompt(
+ prompt_template, payload, event_type, route_name
+ )
+
+ # Inject skill content if configured.
+ # We call build_skill_invocation_message() directly rather than
+ # using /skill-name slash commands โ the gateway's command parser
+ # would intercept those and break the flow.
+ skills = route_config.get("skills", [])
+ if skills:
+ try:
+ from agent.skill_commands import (
+ build_skill_invocation_message,
+ get_skill_commands,
+ )
+
+ skill_cmds = get_skill_commands()
+ for skill_name in skills:
+ cmd_key = f"/{skill_name}"
+ if cmd_key in skill_cmds:
+ skill_content = build_skill_invocation_message(
+ cmd_key, user_instruction=prompt
+ )
+ if skill_content:
+ prompt = skill_content
+ break # Load the first matching skill
+ else:
+ logger.warning(
+ "[webhook] Skill '%s' not found", skill_name
+ )
+ except Exception as e:
+ logger.warning("[webhook] Skill loading failed: %s", e)
+
+ # Build a unique delivery ID
+ delivery_id = request.headers.get(
+ "X-GitHub-Delivery",
+ request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
+ )
+
+ # โโ Idempotency โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # Skip duplicate deliveries (webhook retries).
+ now = time.time()
+ # Prune expired entries
+ self._seen_deliveries = {
+ k: v
+ for k, v in self._seen_deliveries.items()
+ if now - v < self._idempotency_ttl
+ }
+ if delivery_id in self._seen_deliveries:
+ logger.info(
+ "[webhook] Skipping duplicate delivery %s", delivery_id
+ )
+ return web.json_response(
+ {"status": "duplicate", "delivery_id": delivery_id},
+ status=200,
+ )
+ self._seen_deliveries[delivery_id] = now
+
+ # Use delivery_id in session key so concurrent webhooks on the
+ # same route get independent agent runs (not queued/interrupted).
+ session_chat_id = f"webhook:{route_name}:{delivery_id}"
+
+ # Store delivery info for send() โ consumed (popped) on delivery
+ deliver_config = {
+ "deliver": route_config.get("deliver", "log"),
+ "deliver_extra": self._render_delivery_extra(
+ route_config.get("deliver_extra", {}), payload
+ ),
+ "payload": payload,
+ }
+ self._delivery_info[session_chat_id] = deliver_config
+
+ # Build source and event
+ source = self.build_source(
+ chat_id=session_chat_id,
+ chat_name=f"webhook/{route_name}",
+ chat_type="webhook",
+ user_id=f"webhook:{route_name}",
+ user_name=route_name,
+ )
+ event = MessageEvent(
+ text=prompt,
+ message_type=MessageType.TEXT,
+ source=source,
+ raw_message=payload,
+ message_id=delivery_id,
+ )
+
+ logger.info(
+ "[webhook] %s event=%s route=%s prompt_len=%d delivery=%s",
+ request.method,
+ event_type,
+ route_name,
+ len(prompt),
+ delivery_id,
+ )
+
+ # Non-blocking โ return 202 Accepted immediately
+ task = asyncio.create_task(self.handle_message(event))
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+
+ return web.json_response(
+ {
+ "status": "accepted",
+ "route": route_name,
+ "event": event_type,
+ "delivery_id": delivery_id,
+ },
+ status=202,
+ )
+
+ # ------------------------------------------------------------------
+ # Signature validation
+ # ------------------------------------------------------------------
+
+ def _validate_signature(
+ self, request: "web.Request", body: bytes, secret: str
+ ) -> bool:
+ """Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
+ # GitHub: X-Hub-Signature-256 = sha256=
+ gh_sig = request.headers.get("X-Hub-Signature-256", "")
+ if gh_sig:
+ expected = "sha256=" + hmac.new(
+ secret.encode(), body, hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(gh_sig, expected)
+
+ # GitLab: X-Gitlab-Token =
+ gl_token = request.headers.get("X-Gitlab-Token", "")
+ if gl_token:
+ return hmac.compare_digest(gl_token, secret)
+
+ # Generic: X-Webhook-Signature =
+ generic_sig = request.headers.get("X-Webhook-Signature", "")
+ if generic_sig:
+ expected = hmac.new(
+ secret.encode(), body, hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(generic_sig, expected)
+
+ # No recognised signature header but secret is configured โ reject
+ logger.debug(
+ "[webhook] Secret configured but no signature header found"
+ )
+ return False
+
+ # ------------------------------------------------------------------
+ # Prompt rendering
+ # ------------------------------------------------------------------
+
+ def _render_prompt(
+ self,
+ template: str,
+ payload: dict,
+ event_type: str,
+ route_name: str,
+ ) -> str:
+ """Render a prompt template with the webhook payload.
+
+ Supports dot-notation access into nested dicts:
+ ``{pull_request.title}`` โ ``payload["pull_request"]["title"]``
+ """
+ if not template:
+ truncated = json.dumps(payload, indent=2)[:4000]
+ return (
+ f"Webhook event '{event_type}' on route "
+ f"'{route_name}':\n\n```json\n{truncated}\n```"
+ )
+
+ def _resolve(match: re.Match) -> str:
+ key = match.group(1)
+ value: Any = payload
+ for part in key.split("."):
+ if isinstance(value, dict):
+ value = value.get(part, f"{{{key}}}")
+ else:
+ return f"{{{key}}}"
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, indent=2)[:2000]
+ return str(value)
+
+ return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template)
+
+ def _render_delivery_extra(
+ self, extra: dict, payload: dict
+ ) -> dict:
+ """Render delivery_extra template values with payload data."""
+ rendered: Dict[str, Any] = {}
+ for key, value in extra.items():
+ if isinstance(value, str):
+ rendered[key] = self._render_prompt(value, payload, "", "")
+ else:
+ rendered[key] = value
+ return rendered
+
+ # ------------------------------------------------------------------
+ # Response delivery
+ # ------------------------------------------------------------------
+
+ async def _deliver_github_comment(
+ self, content: str, delivery: dict
+ ) -> SendResult:
+ """Post agent response as a GitHub PR/issue comment via ``gh`` CLI."""
+ extra = delivery.get("deliver_extra", {})
+ repo = extra.get("repo", "")
+ pr_number = extra.get("pr_number", "")
+
+ if not repo or not pr_number:
+ logger.error(
+ "[webhook] github_comment delivery missing repo or pr_number"
+ )
+ return SendResult(
+ success=False, error="Missing repo or pr_number"
+ )
+
+ try:
+ result = subprocess.run(
+ [
+ "gh",
+ "pr",
+ "comment",
+ str(pr_number),
+ "--repo",
+ repo,
+ "--body",
+ content,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 0:
+ logger.info(
+ "[webhook] Posted comment on %s#%s", repo, pr_number
+ )
+ return SendResult(success=True)
+ else:
+ logger.error(
+ "[webhook] gh pr comment failed: %s", result.stderr
+ )
+ return SendResult(success=False, error=result.stderr)
+ except FileNotFoundError:
+ logger.error(
+ "[webhook] 'gh' CLI not found โ install GitHub CLI for "
+ "github_comment delivery"
+ )
+ return SendResult(
+ success=False, error="gh CLI not installed"
+ )
+ except Exception as e:
+ logger.error("[webhook] github_comment delivery error: %s", e)
+ return SendResult(success=False, error=str(e))
+
+ async def _deliver_cross_platform(
+ self, platform_name: str, content: str, delivery: dict
+ ) -> SendResult:
+ """Route response to another platform (telegram, discord, etc.)."""
+ if not self.gateway_runner:
+ return SendResult(
+ success=False,
+ error="No gateway runner for cross-platform delivery",
+ )
+
+ try:
+ target_platform = Platform(platform_name)
+ except ValueError:
+ return SendResult(
+ success=False, error=f"Unknown platform: {platform_name}"
+ )
+
+ adapter = self.gateway_runner.adapters.get(target_platform)
+ if not adapter:
+ return SendResult(
+ success=False,
+ error=f"Platform {platform_name} not connected",
+ )
+
+ # Use home channel if no specific chat_id in deliver_extra
+ extra = delivery.get("deliver_extra", {})
+ chat_id = extra.get("chat_id", "")
+ if not chat_id:
+ home = self.gateway_runner.config.get_home_channel(target_platform)
+ if home:
+ chat_id = home.chat_id
+ else:
+ return SendResult(
+ success=False,
+ error=f"No chat_id or home channel for {platform_name}",
+ )
+
+ return await adapter.send(chat_id, content)
diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py
index 9d140bba33d..b83657401bd 100644
--- a/gateway/platforms/whatsapp.py
+++ b/gateway/platforms/whatsapp.py
@@ -16,7 +16,6 @@
"""
import asyncio
-import json
import logging
import os
import platform
@@ -24,7 +23,9 @@
_IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
-from typing import Dict, List, Optional, Any
+from typing import Dict, Optional, Any
+
+from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
@@ -72,6 +73,7 @@ def _kill_port_process(port: int) -> None:
MessageEvent,
MessageType,
SendResult,
+ SUPPORTED_DOCUMENT_TYPES,
cache_image_from_url,
cache_audio_from_url,
)
@@ -132,11 +134,13 @@ def __init__(self, config: PlatformConfig):
)
self._session_path: Path = Path(config.extra.get(
"session_path",
- Path.home() / ".hermes" / "whatsapp" / "session"
+ get_hermes_home() / "whatsapp" / "session"
))
+ self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
self._message_queue: asyncio.Queue = asyncio.Queue()
self._bridge_log_fh = None
self._bridge_log: Optional[Path] = None
+ self._poll_task: Optional[asyncio.Task] = None
async def connect(self) -> bool:
"""
@@ -179,9 +183,31 @@ async def connect(self) -> bool:
# Ensure session directory exists
self._session_path.mkdir(parents=True, exist_ok=True)
+ # Check if bridge is already running and connected
+ import aiohttp
+ import asyncio
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ f"http://127.0.0.1:{self._bridge_port}/health",
+ timeout=aiohttp.ClientTimeout(total=2)
+ ) as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ bridge_status = data.get("status", "unknown")
+ if bridge_status == "connected":
+ print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
+ self._mark_connected()
+ self._bridge_process = None # Not managed by us
+ self._poll_task = asyncio.create_task(self._poll_messages())
+ return True
+ else:
+ print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
+ except Exception:
+ pass # Bridge not running, start a new one
+
# Kill any orphaned bridge from a previous gateway run
_kill_port_process(self._bridge_port)
- import asyncio
await asyncio.sleep(1)
# Start the bridge process in its own process group.
@@ -191,6 +217,14 @@ async def connect(self) -> bool:
self._bridge_log = self._session_path.parent / "bridge.log"
bridge_log_fh = open(self._bridge_log, "a")
self._bridge_log_fh = bridge_log_fh
+
+ # Build bridge subprocess environment.
+ # Pass WHATSAPP_REPLY_PREFIX from config.yaml so the Node bridge
+ # can use it without the user needing to set a separate env var.
+ bridge_env = os.environ.copy()
+ if self._reply_prefix is not None:
+ bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
+
self._bridge_process = subprocess.Popen(
[
"node",
@@ -202,6 +236,7 @@ async def connect(self) -> bool:
stdout=bridge_log_fh,
stderr=bridge_log_fh,
preexec_fn=None if _IS_WINDOWS else os.setsid,
+ env=bridge_env,
)
# Wait for the bridge to connect to WhatsApp.
@@ -220,7 +255,7 @@ async def connect(self) -> bool:
try:
async with aiohttp.ClientSession() as session:
async with session.get(
- f"http://localhost:{self._bridge_port}/health",
+ f"http://127.0.0.1:{self._bridge_port}/health",
timeout=aiohttp.ClientTimeout(total=2)
) as resp:
if resp.status == 200:
@@ -252,7 +287,7 @@ async def connect(self) -> bool:
try:
async with aiohttp.ClientSession() as session:
async with session.get(
- f"http://localhost:{self._bridge_port}/health",
+ f"http://127.0.0.1:{self._bridge_port}/health",
timeout=aiohttp.ClientTimeout(total=2)
) as resp:
if resp.status == 200:
@@ -270,9 +305,9 @@ async def connect(self) -> bool:
print(f"[{self.name}] If session expired, re-pair: hermes whatsapp")
# Start message polling task
- asyncio.create_task(self._poll_messages())
+ self._poll_task = asyncio.create_task(self._poll_messages())
- self._running = True
+ self._mark_connected()
print(f"[{self.name}] Bridge started on port {self._bridge_port}")
return True
@@ -290,6 +325,23 @@ def _close_bridge_log(self) -> None:
pass
self._bridge_log_fh = None
+ async def _check_managed_bridge_exit(self) -> Optional[str]:
+ """Return a fatal error message if the managed bridge child exited."""
+ if self._bridge_process is None:
+ return None
+
+ returncode = self._bridge_process.poll()
+ if returncode is None:
+ return None
+
+ message = f"WhatsApp bridge process exited unexpectedly (code {returncode})."
+ if not self.has_fatal_error:
+ logger.error("[%s] %s", self.name, message)
+ self._set_fatal_error("whatsapp_bridge_exited", message, retryable=True)
+ self._close_bridge_log()
+ await self._notify_fatal_error()
+ return self.fatal_error_message or message
+
async def disconnect(self) -> None:
"""Stop the WhatsApp bridge and clean up any orphaned processes."""
if self._bridge_process:
@@ -314,11 +366,11 @@ async def disconnect(self) -> None:
self._bridge_process.kill()
except Exception as e:
print(f"[{self.name}] Error stopping bridge: {e}")
+ else:
+ # Bridge was not started by us, don't kill it
+ print(f"[{self.name}] Disconnecting (external bridge left running)")
- # Also kill any orphaned bridge processes on our port
- _kill_port_process(self._bridge_port)
-
- self._running = False
+ self._mark_disconnected()
self._bridge_process = None
self._close_bridge_log()
print(f"[{self.name}] Disconnected")
@@ -333,6 +385,9 @@ async def send(
"""Send a message via the WhatsApp bridge."""
if not self._running:
return SendResult(success=False, error="Not connected")
+ bridge_exit = await self._check_managed_bridge_exit()
+ if bridge_exit:
+ return SendResult(success=False, error=bridge_exit)
try:
import aiohttp
@@ -346,7 +401,7 @@ async def send(
payload["replyTo"] = reply_to
async with session.post(
- f"http://localhost:{self._bridge_port}/send",
+ f"http://127.0.0.1:{self._bridge_port}/send",
json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
@@ -378,11 +433,14 @@ async def edit_message(
"""Edit a previously sent message via the WhatsApp bridge."""
if not self._running:
return SendResult(success=False, error="Not connected")
+ bridge_exit = await self._check_managed_bridge_exit()
+ if bridge_exit:
+ return SendResult(success=False, error=bridge_exit)
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
- f"http://localhost:{self._bridge_port}/edit",
+ f"http://127.0.0.1:{self._bridge_port}/edit",
json={
"chatId": chat_id,
"messageId": message_id,
@@ -409,6 +467,9 @@ async def _send_media_to_bridge(
"""Send any media file via bridge /send-media endpoint."""
if not self._running:
return SendResult(success=False, error="Not connected")
+ bridge_exit = await self._check_managed_bridge_exit()
+ if bridge_exit:
+ return SendResult(success=False, error=bridge_exit)
try:
import aiohttp
@@ -427,7 +488,7 @@ async def _send_media_to_bridge(
async with aiohttp.ClientSession() as session:
async with session.post(
- f"http://localhost:{self._bridge_port}/send-media",
+ f"http://127.0.0.1:{self._bridge_port}/send-media",
json=payload,
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
@@ -497,13 +558,15 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send typing indicator via bridge."""
if not self._running:
return
+ if await self._check_managed_bridge_exit():
+ return
try:
import aiohttp
async with aiohttp.ClientSession() as session:
await session.post(
- f"http://localhost:{self._bridge_port}/typing",
+ f"http://127.0.0.1:{self._bridge_port}/typing",
json={"chatId": chat_id},
timeout=aiohttp.ClientTimeout(total=5)
)
@@ -514,13 +577,15 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a WhatsApp chat."""
if not self._running:
return {"name": "Unknown", "type": "dm"}
+ if await self._check_managed_bridge_exit():
+ return {"name": chat_id, "type": "dm"}
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(
- f"http://localhost:{self._bridge_port}/chat/{chat_id}",
+ f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}",
timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status == 200:
@@ -544,10 +609,14 @@ async def _poll_messages(self) -> None:
return
while self._running:
+ bridge_exit = await self._check_managed_bridge_exit()
+ if bridge_exit:
+ print(f"[{self.name}] {bridge_exit}")
+ break
try:
async with aiohttp.ClientSession() as session:
async with session.get(
- f"http://localhost:{self._bridge_port}/messages",
+ f"http://127.0.0.1:{self._bridge_port}/messages",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
@@ -559,6 +628,10 @@ async def _poll_messages(self) -> None:
except asyncio.CancelledError:
break
except Exception as e:
+ bridge_exit = await self._check_managed_bridge_exit()
+ if bridge_exit:
+ print(f"[{self.name}] {bridge_exit}")
+ break
print(f"[{self.name}] Poll error: {e}")
await asyncio.sleep(5)
@@ -593,7 +666,7 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
user_name=data.get("senderName"),
)
- # Download image media URLs to the local cache so the vision tool
+ # Download media URLs to the local cache so agent tools
# can access them reliably regardless of URL expiration.
raw_urls = data.get("mediaUrls", [])
cached_urls = []
@@ -609,6 +682,11 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
print(f"[{self.name}] Failed to cache image: {e}", flush=True)
cached_urls.append(url)
media_types.append("image/jpeg")
+ elif msg_type == MessageType.PHOTO and os.path.isabs(url):
+ # Local file path โ bridge already downloaded the image
+ cached_urls.append(url)
+ media_types.append("image/jpeg")
+ print(f"[{self.name}] Using bridge-cached image: {url}", flush=True)
elif msg_type == MessageType.VOICE and url.startswith(("http://", "https://")):
try:
cached_path = await cache_audio_from_url(url, ext=".ogg")
@@ -619,12 +697,59 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
print(f"[{self.name}] Failed to cache voice: {e}", flush=True)
cached_urls.append(url)
media_types.append("audio/ogg")
+ elif msg_type == MessageType.VOICE and os.path.isabs(url):
+ # Local file path โ bridge already downloaded the audio
+ cached_urls.append(url)
+ media_types.append("audio/ogg")
+ print(f"[{self.name}] Using bridge-cached audio: {url}", flush=True)
+ elif msg_type == MessageType.DOCUMENT and os.path.isabs(url):
+ # Local file path โ bridge already downloaded the document
+ cached_urls.append(url)
+ ext = Path(url).suffix.lower()
+ mime = SUPPORTED_DOCUMENT_TYPES.get(ext, "application/octet-stream")
+ media_types.append(mime)
+ print(f"[{self.name}] Using bridge-cached document: {url}", flush=True)
+ elif msg_type == MessageType.VIDEO and os.path.isabs(url):
+ cached_urls.append(url)
+ media_types.append("video/mp4")
+ print(f"[{self.name}] Using bridge-cached video: {url}", flush=True)
else:
cached_urls.append(url)
media_types.append("unknown")
-
+
+ # For text-readable documents, inject file content directly into
+ # the message text so the agent can read it inline.
+ # Cap at 100KB to match Telegram/Discord/Slack behaviour.
+ body = data.get("body", "")
+ MAX_TEXT_INJECT_BYTES = 100 * 1024
+ if msg_type == MessageType.DOCUMENT and cached_urls:
+ for doc_path in cached_urls:
+ ext = Path(doc_path).suffix.lower()
+ if ext in (".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"):
+ try:
+ file_size = Path(doc_path).stat().st_size
+ if file_size > MAX_TEXT_INJECT_BYTES:
+ print(f"[{self.name}] Skipping text injection for {doc_path} ({file_size} bytes > {MAX_TEXT_INJECT_BYTES})", flush=True)
+ continue
+ content = Path(doc_path).read_text(errors="replace")
+ fname = Path(doc_path).name
+ # Remove the doc__ prefix for display
+ display_name = fname
+ if "_" in fname:
+ parts = fname.split("_", 2)
+ if len(parts) >= 3:
+ display_name = parts[2]
+ injection = f"[Content of {display_name}]:\n{content}"
+ if body:
+ body = f"{injection}\n\n{body}"
+ else:
+ body = injection
+ print(f"[{self.name}] Injected text content from: {doc_path}", flush=True)
+ except Exception as e:
+ print(f"[{self.name}] Failed to read document text: {e}", flush=True)
+
return MessageEvent(
- text=data.get("body", ""),
+ text=body,
message_type=msg_type,
source=source,
raw_message=data,
@@ -635,4 +760,3 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
except Exception as e:
print(f"[{self.name}] Error building event: {e}")
return None
-
diff --git a/gateway/run.py b/gateway/run.py
index fe278a6b980..12c99157d9e 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -14,33 +14,77 @@
"""
import asyncio
+import json
import logging
import os
import re
+import shlex
import sys
import signal
+import tempfile
import threading
+import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, Any, List
+# ---------------------------------------------------------------------------
+# SSL certificate auto-detection for NixOS and other non-standard systems.
+# Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported.
+# ---------------------------------------------------------------------------
+def _ensure_ssl_certs() -> None:
+ """Set SSL_CERT_FILE if the system doesn't expose CA certs to Python."""
+ if "SSL_CERT_FILE" in os.environ:
+ return # user already configured it
+
+ import ssl
+
+ # 1. Python's compiled-in defaults
+ paths = ssl.get_default_verify_paths()
+ for candidate in (paths.cafile, paths.openssl_cafile):
+ if candidate and os.path.exists(candidate):
+ os.environ["SSL_CERT_FILE"] = candidate
+ return
+
+ # 2. certifi (ships its own Mozilla bundle)
+ try:
+ import certifi
+ os.environ["SSL_CERT_FILE"] = certifi.where()
+ return
+ except ImportError:
+ pass
+
+ # 3. Common distro / macOS locations
+ for candidate in (
+ "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu/Gentoo
+ "/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS 7
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # RHEL/CentOS 8+
+ "/etc/ssl/ca-bundle.pem", # SUSE/OpenSUSE
+ "/etc/ssl/cert.pem", # Alpine / macOS
+ "/etc/pki/tls/cert.pem", # Fedora
+ "/usr/local/etc/openssl@1.1/cert.pem", # macOS Homebrew Intel
+ "/opt/homebrew/etc/openssl@1.1/cert.pem", # macOS Homebrew ARM
+ ):
+ if os.path.exists(candidate):
+ os.environ["SSL_CERT_FILE"] = candidate
+ return
+
+_ensure_ssl_certs()
+
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Resolve Hermes home directory (respects HERMES_HOME override)
-_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+from hermes_constants import get_hermes_home
+_hermes_home = get_hermes_home()
-# Load environment variables from ~/.hermes/.env first
-from dotenv import load_dotenv
+# Load environment variables from ~/.hermes/.env first.
+# User-managed env files should override stale shell exports on restart.
+from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol
+from hermes_cli.env_loader import load_hermes_dotenv
_env_path = _hermes_home / '.env'
-if _env_path.exists():
- try:
- load_dotenv(_env_path, encoding="utf-8")
- except UnicodeDecodeError:
- load_dotenv(_env_path, encoding="latin-1")
-# Also try project .env as fallback
-load_dotenv()
+load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings โ overrides .env.
@@ -50,6 +94,9 @@
import yaml as _yaml
with open(_config_path, encoding="utf-8") as _f:
_cfg = _yaml.safe_load(_f) or {}
+ # Expand ${ENV_VAR} references before bridging to env vars.
+ from hermes_cli.config import _expand_env_vars
+ _cfg = _expand_env_vars(_cfg)
# Top-level simple values (fallback only โ don't override .env)
for _key, _val in _cfg.items():
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
@@ -64,6 +111,7 @@
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
+ "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
@@ -77,6 +125,7 @@
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
+ "persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key in _terminal_cfg:
@@ -85,35 +134,48 @@
os.environ[_env_var] = json.dumps(_val)
else:
os.environ[_env_var] = str(_val)
- _compression_cfg = _cfg.get("compression", {})
- if _compression_cfg and isinstance(_compression_cfg, dict):
- _compression_env_map = {
- "enabled": "CONTEXT_COMPRESSION_ENABLED",
- "threshold": "CONTEXT_COMPRESSION_THRESHOLD",
- "summary_model": "CONTEXT_COMPRESSION_MODEL",
- "summary_provider": "CONTEXT_COMPRESSION_PROVIDER",
- }
- for _cfg_key, _env_var in _compression_env_map.items():
- if _cfg_key in _compression_cfg:
- os.environ[_env_var] = str(_compression_cfg[_cfg_key])
- # Auxiliary model overrides (vision, web_extract).
- # Each task has provider + model; bridge non-default values to env vars.
+ # Compression config is read directly from config.yaml by run_agent.py
+ # and auxiliary_client.py โ no env var bridging needed.
+ # Auxiliary model/direct-endpoint overrides (vision, web_extract).
+ # Each task has provider/model/base_url/api_key; bridge non-default values to env vars.
_auxiliary_cfg = _cfg.get("auxiliary", {})
if _auxiliary_cfg and isinstance(_auxiliary_cfg, dict):
_aux_task_env = {
- "vision": ("AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL"),
- "web_extract": ("AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL"),
+ "vision": {
+ "provider": "AUXILIARY_VISION_PROVIDER",
+ "model": "AUXILIARY_VISION_MODEL",
+ "base_url": "AUXILIARY_VISION_BASE_URL",
+ "api_key": "AUXILIARY_VISION_API_KEY",
+ },
+ "web_extract": {
+ "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER",
+ "model": "AUXILIARY_WEB_EXTRACT_MODEL",
+ "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL",
+ "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY",
+ },
+ "approval": {
+ "provider": "AUXILIARY_APPROVAL_PROVIDER",
+ "model": "AUXILIARY_APPROVAL_MODEL",
+ "base_url": "AUXILIARY_APPROVAL_BASE_URL",
+ "api_key": "AUXILIARY_APPROVAL_API_KEY",
+ },
}
- for _task_key, (_prov_env, _model_env) in _aux_task_env.items():
+ for _task_key, _env_map in _aux_task_env.items():
_task_cfg = _auxiliary_cfg.get(_task_key, {})
if not isinstance(_task_cfg, dict):
continue
_prov = str(_task_cfg.get("provider", "")).strip()
_model = str(_task_cfg.get("model", "")).strip()
+ _base_url = str(_task_cfg.get("base_url", "")).strip()
+ _api_key = str(_task_cfg.get("api_key", "")).strip()
if _prov and _prov != "auto":
- os.environ[_prov_env] = _prov
+ os.environ[_env_map["provider"]] = _prov
if _model:
- os.environ[_model_env] = _model
+ os.environ[_env_map["model"]] = _model
+ if _base_url:
+ os.environ[_env_map["base_url"]] = _base_url
+ if _api_key:
+ os.environ[_env_map["api_key"]] = _api_key
_agent_cfg = _cfg.get("agent", {})
if _agent_cfg and isinstance(_agent_cfg, dict):
if "max_turns" in _agent_cfg:
@@ -159,11 +221,17 @@
build_session_context_prompt,
build_session_key,
)
-from gateway.delivery import DeliveryRouter, DeliveryTarget
+from gateway.delivery import DeliveryRouter
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
logger = logging.getLogger(__name__)
+# Sentinel placed into _running_agents immediately when a session starts
+# processing, *before* any await. Prevents a second message for the same
+# session from bypassing the "already running" guard during the async gap
+# between the guard check and actual agent creation.
+_AGENT_PENDING_SENTINEL = object()
+
def _resolve_runtime_agent_kwargs() -> dict:
"""Resolve provider credentials for gateway-created AIAgent instances."""
@@ -184,10 +252,33 @@ def _resolve_runtime_agent_kwargs() -> dict:
"base_url": runtime.get("base_url"),
"provider": runtime.get("provider"),
"api_mode": runtime.get("api_mode"),
+ "command": runtime.get("command"),
+ "args": list(runtime.get("args") or []),
+ "request_headers_resolver": runtime.get("request_headers_resolver"),
+ "payment_adapter": runtime.get("payment_adapter"),
+ "payment_config": runtime.get("payment_config"),
}
-def _resolve_gateway_model() -> str:
+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
+
+
+def _load_gateway_config() -> dict:
+ """Load and parse ~/.hermes/config.yaml, returning {} on any error."""
+ try:
+ config_path = _hermes_home / 'config.yaml'
+ if config_path.exists():
+ import yaml
+ with open(config_path, 'r', encoding='utf-8') as f:
+ return yaml.safe_load(f) or {}
+ except Exception:
+ logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml')
+ return {}
+
+
+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
@@ -195,20 +286,40 @@ def _resolve_gateway_model() -> str:
when the active provider is openai-codex.
"""
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):
+ model = model_cfg
+ elif isinstance(model_cfg, dict):
+ model = model_cfg.get("default", model)
+ return model
+
+
+def _resolve_hermes_bin() -> Optional[list[str]]:
+ """Resolve the Hermes update command as argv parts.
+
+ Tries in order:
+ 1. ``shutil.which("hermes")`` โ standard PATH lookup
+ 2. ``sys.executable -m hermes_cli.main`` โ fallback when Hermes is running
+ from a venv/module invocation and the ``hermes`` shim is not on PATH
+
+ Returns argv parts ready for quoting/joining, or ``None`` if neither works.
+ """
+ import shutil
+
+ hermes_bin = shutil.which("hermes")
+ if hermes_bin:
+ return [hermes_bin]
+
try:
- import yaml as _y
- _cfg_path = _hermes_home / "config.yaml"
- if _cfg_path.exists():
- with open(_cfg_path, encoding="utf-8") as _f:
- _cfg = _y.safe_load(_f) or {}
- _model_cfg = _cfg.get("model", {})
- if isinstance(_model_cfg, str):
- model = _model_cfg
- elif isinstance(_model_cfg, dict):
- model = _model_cfg.get("default", model)
+ import importlib.util
+
+ if importlib.util.find_spec("hermes_cli") is not None:
+ return [sys.executable, "-m", "hermes_cli.main"]
except Exception:
pass
- return model
+
+ return None
class GatewayRunner:
@@ -231,6 +342,7 @@ def __init__(self, config: Optional[GatewayConfig] = None):
self._show_reasoning = self._load_show_reasoning()
self._provider_routing = self._load_provider_routing()
self._fallback_model = self._load_fallback_model()
+ self._smart_model_routing = self._load_smart_model_routing()
# Wire process registry into session store for reset protection
from tools.process_registry import process_registry
@@ -241,15 +353,50 @@ def __init__(self, config: Optional[GatewayConfig] = None):
self.delivery_router = DeliveryRouter(self.config)
self._running = False
self._shutdown_event = asyncio.Event()
+ self._exit_cleanly = False
+ self._exit_with_failure = False
+ self._exit_reason: Optional[str] = None
# Track running agents per session for interrupt support
# Key: session_key, Value: AIAgent instance
self._running_agents: Dict[str, Any] = {}
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
-
+
+ # Cache AIAgent instances per session to preserve prompt caching.
+ # Without this, a new AIAgent is created per message, rebuilding the
+ # system prompt (including memory) every turn โ breaking prefix cache
+ # and costing ~10x more on providers with prompt caching (Anthropic).
+ # Key: session_key, Value: (AIAgent, config_signature_str)
+ import threading as _threading
+ self._agent_cache: Dict[str, tuple] = {}
+ self._agent_cache_lock = _threading.Lock()
+
+ # Track active fallback model/provider when primary is rate-limited.
+ # Set after an agent run where fallback was activated; cleared when
+ # the primary model succeeds again or the user switches via /model.
+ self._effective_model: Optional[str] = None
+ self._effective_provider: Optional[str] = None
+
# Track pending exec approvals per session
- # Key: session_key, Value: {"command": str, "pattern_key": str}
- self._pending_approvals: Dict[str, Dict[str, str]] = {}
+ # Key: session_key, Value: {"command": str, "pattern_key": str, ...}
+ self._pending_approvals: Dict[str, Dict[str, Any]] = {}
+
+ # Track platforms that failed to connect for background reconnection.
+ # Key: Platform enum, Value: {"config": platform_config, "attempts": int, "next_retry": float}
+ self._failed_platforms: Dict[Platform, Dict[str, Any]] = {}
+
+ # Persistent Honcho managers keyed by gateway session key.
+ # This preserves write_frequency="session" semantics across short-lived
+ # per-message AIAgent instances.
+ self._honcho_managers: Dict[str, Any] = {}
+ self._honcho_configs: Dict[str, Any] = {}
+
+ # Ensure tirith security scanner is available (downloads if needed)
+ try:
+ from tools.tirith_security import ensure_installed
+ ensure_installed(log_failures=False)
+ except Exception:
+ pass # Non-fatal โ fail-open at scan time if unavailable
# Initialize session database for session_search tool support
self._session_db = None
@@ -266,13 +413,145 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# Event hook system
from gateway.hooks import HookRegistry
self.hooks = HookRegistry()
+
+ # Per-chat voice reply mode: "off" | "voice_only" | "all"
+ self._voice_mode: Dict[str, str] = self._load_voice_modes()
+
+ # Track background tasks to prevent garbage collection mid-execution
+ self._background_tasks: set = set()
+
+ def _get_or_create_gateway_honcho(self, session_key: str):
+ """Return a persistent Honcho manager/config pair for this gateway session."""
+ if not hasattr(self, "_honcho_managers"):
+ self._honcho_managers = {}
+ if not hasattr(self, "_honcho_configs"):
+ self._honcho_configs = {}
+
+ if session_key in self._honcho_managers:
+ return self._honcho_managers[session_key], self._honcho_configs.get(session_key)
+
+ try:
+ from honcho_integration.client import HonchoClientConfig, get_honcho_client
+ from honcho_integration.session import HonchoSessionManager
+
+ hcfg = HonchoClientConfig.from_global_config()
+ if not hcfg.enabled or not hcfg.api_key:
+ return None, hcfg
+
+ client = get_honcho_client(hcfg)
+ manager = HonchoSessionManager(
+ honcho=client,
+ config=hcfg,
+ context_tokens=hcfg.context_tokens,
+ )
+ self._honcho_managers[session_key] = manager
+ self._honcho_configs[session_key] = hcfg
+ return manager, hcfg
+ except Exception as e:
+ logger.debug("Gateway Honcho init failed for %s: %s", session_key, e)
+ return None, None
+
+ def _shutdown_gateway_honcho(self, session_key: str) -> None:
+ """Flush and close the persistent Honcho manager for a gateway session."""
+ managers = getattr(self, "_honcho_managers", None)
+ configs = getattr(self, "_honcho_configs", None)
+ if managers is None or configs is None:
+ return
+
+ manager = managers.pop(session_key, None)
+ configs.pop(session_key, None)
+ if not manager:
+ return
+ try:
+ manager.shutdown()
+ except Exception as e:
+ logger.debug("Gateway Honcho shutdown failed for %s: %s", session_key, e)
+
+ def _shutdown_all_gateway_honcho(self) -> None:
+ """Flush and close all persistent Honcho managers."""
+ managers = getattr(self, "_honcho_managers", None)
+ if not managers:
+ return
+ for session_key in list(managers.keys()):
+ self._shutdown_gateway_honcho(session_key)
- def _flush_memories_for_session(self, old_session_id: str):
+ # -- Setup skill availability ----------------------------------------
+
+ def _has_setup_skill(self) -> bool:
+ """Check if the hermes-agent-setup skill is installed."""
+ try:
+ from tools.skill_manager_tool import _find_skill
+ return _find_skill("hermes-agent-setup") is not None
+ except Exception:
+ return False
+
+ # -- Voice mode persistence ------------------------------------------
+
+ _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
+
+ def _load_voice_modes(self) -> Dict[str, str]:
+ try:
+ data = json.loads(self._VOICE_MODE_PATH.read_text())
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return {}
+
+ if not isinstance(data, dict):
+ return {}
+
+ valid_modes = {"off", "voice_only", "all"}
+ return {
+ str(chat_id): mode
+ for chat_id, mode in data.items()
+ if mode in valid_modes
+ }
+
+ def _save_voice_modes(self) -> None:
+ try:
+ self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ self._VOICE_MODE_PATH.write_text(
+ json.dumps(self._voice_mode, indent=2)
+ )
+ except OSError as e:
+ logger.warning("Failed to save voice modes: %s", e)
+
+ def _set_adapter_auto_tts_disabled(self, adapter, chat_id: str, disabled: bool) -> None:
+ """Update an adapter's in-memory auto-TTS suppression set if present."""
+ disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
+ if not isinstance(disabled_chats, set):
+ return
+ if disabled:
+ disabled_chats.add(chat_id)
+ else:
+ disabled_chats.discard(chat_id)
+
+ def _sync_voice_mode_state_to_adapter(self, adapter) -> None:
+ """Restore persisted /voice off state into a live platform adapter."""
+ disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
+ if not isinstance(disabled_chats, set):
+ return
+ disabled_chats.clear()
+ disabled_chats.update(
+ chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
+ )
+
+ # -----------------------------------------------------------------
+
+ def _flush_memories_for_session(
+ self,
+ old_session_id: str,
+ honcho_session_key: Optional[str] = None,
+ ):
"""Prompt the agent to save memories/skills before context is lost.
Synchronous worker โ meant to be called via run_in_executor from
an async context so it doesn't block the event loop.
"""
+ # Skip cron sessions โ they run headless with no meaningful user
+ # conversation to extract memories from.
+ if old_session_id and old_session_id.startswith("cron_"):
+ logger.debug("Skipping memory flush for cron session: %s", old_session_id)
+ return
+
try:
history = self.session_store.load_transcript(old_session_id)
if not history or len(history) < 4:
@@ -295,7 +574,12 @@ def _flush_memories_for_session(self, old_session_id: str):
quiet_mode=True,
enabled_toolsets=["memory", "skills"],
session_id=old_session_id,
+ honcho_session_key=honcho_session_key,
)
+ # Fully silence the flush agent โ quiet_mode only suppresses init
+ # messages; tool call output still leaks to the terminal through
+ # _safe_print โ _print_fn. Set a no-op to prevent that.
+ tmp_agent._print_fn = lambda *a, **kw: None
# Build conversation history from transcript
msgs = [
@@ -304,6 +588,23 @@ def _flush_memories_for_session(self, old_session_id: str):
if m.get("role") in ("user", "assistant") and m.get("content")
]
+ # Read live memory state from disk so the flush agent can see
+ # what's already saved and avoid overwriting newer entries.
+ _current_memory = ""
+ try:
+ from tools.memory_tool import MEMORY_DIR
+ for fname, label in [
+ ("MEMORY.md", "MEMORY (your personal notes)"),
+ ("USER.md", "USER PROFILE (who the user is)"),
+ ]:
+ fpath = MEMORY_DIR / fname
+ if fpath.exists():
+ content = fpath.read_text(encoding="utf-8").strip()
+ if content:
+ _current_memory += f"\n\n## Current {label}:\n{content}"
+ except Exception:
+ pass # Non-fatal โ flush still works, just without the guard
+
# Give the agent a real turn to think about what to save
flush_prompt = (
"[System: This session is about to be automatically reset due to "
@@ -315,6 +616,20 @@ def _flush_memories_for_session(self, old_session_id: str):
"2. If you discovered a reusable workflow or solved a non-trivial "
"problem, consider saving it as a skill.\n"
"3. If nothing is worth saving, that's fine โ just skip.\n\n"
+ )
+
+ if _current_memory:
+ flush_prompt += (
+ "IMPORTANT โ here is the current live state of memory. Other "
+ "sessions, cron jobs, or the user may have updated it since this "
+ "conversation ended. Do NOT overwrite or remove entries unless "
+ "the conversation above reveals something that genuinely "
+ "supersedes them. Only add new information that is not already "
+ "captured below."
+ f"{_current_memory}\n\n"
+ )
+
+ flush_prompt += (
"Do NOT respond to the user. Just use the memory and skill_manage "
"tools if needed, then stop.]"
)
@@ -322,15 +637,126 @@ def _flush_memories_for_session(self, old_session_id: str):
tmp_agent.run_conversation(
user_message=flush_prompt,
conversation_history=msgs,
+ sync_honcho=False,
)
logger.info("Pre-reset memory flush completed for session %s", old_session_id)
+ # Flush any queued Honcho writes before the session is dropped
+ if getattr(tmp_agent, '_honcho', None):
+ try:
+ tmp_agent._honcho.shutdown()
+ except Exception:
+ pass
except Exception as e:
logger.debug("Pre-reset memory flush failed for session %s: %s", old_session_id, e)
- async def _async_flush_memories(self, old_session_id: str):
+ async def _async_flush_memories(
+ self,
+ old_session_id: str,
+ honcho_session_key: Optional[str] = None,
+ ):
"""Run the sync memory flush in a thread pool so it won't block the event loop."""
loop = asyncio.get_event_loop()
- await loop.run_in_executor(None, self._flush_memories_for_session, old_session_id)
+ await loop.run_in_executor(
+ None,
+ self._flush_memories_for_session,
+ old_session_id,
+ honcho_session_key,
+ )
+
+ @property
+ def should_exit_cleanly(self) -> bool:
+ return self._exit_cleanly
+
+ @property
+ def should_exit_with_failure(self) -> bool:
+ return self._exit_with_failure
+
+ @property
+ def exit_reason(self) -> Optional[str]:
+ return self._exit_reason
+
+ def _session_key_for_source(self, source: SessionSource) -> str:
+ """Resolve the current session key for a source, honoring gateway config when available."""
+ if hasattr(self, "session_store") and self.session_store is not None:
+ try:
+ session_key = self.session_store._generate_session_key(source)
+ if isinstance(session_key, str) and session_key:
+ return session_key
+ except Exception:
+ pass
+ config = getattr(self, "config", None)
+ return build_session_key(
+ source,
+ group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
+ )
+
+ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
+ from agent.smart_model_routing import resolve_turn_route
+
+ primary = {
+ "model": model,
+ "api_key": runtime_kwargs.get("api_key"),
+ "base_url": runtime_kwargs.get("base_url"),
+ "provider": runtime_kwargs.get("provider"),
+ "api_mode": runtime_kwargs.get("api_mode"),
+ "command": runtime_kwargs.get("command"),
+ "args": list(runtime_kwargs.get("args") or []),
+ }
+ return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
+
+ async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
+ """React to an adapter failure after startup.
+
+ If the error is retryable (e.g. network blip, DNS failure), queue the
+ platform for background reconnection instead of giving up permanently.
+ """
+ logger.error(
+ "Fatal %s adapter error (%s): %s",
+ adapter.platform.value,
+ adapter.fatal_error_code or "unknown",
+ adapter.fatal_error_message or "unknown error",
+ )
+
+ existing = self.adapters.get(adapter.platform)
+ if existing is adapter:
+ try:
+ await adapter.disconnect()
+ finally:
+ self.adapters.pop(adapter.platform, None)
+ self.delivery_router.adapters = self.adapters
+
+ # Queue retryable failures for background reconnection
+ if adapter.fatal_error_retryable:
+ platform_config = self.config.platforms.get(adapter.platform)
+ if platform_config and adapter.platform not in self._failed_platforms:
+ self._failed_platforms[adapter.platform] = {
+ "config": platform_config,
+ "attempts": 0,
+ "next_retry": time.monotonic() + 30,
+ }
+ logger.info(
+ "%s queued for background reconnection",
+ adapter.platform.value,
+ )
+
+ if not self.adapters and not self._failed_platforms:
+ self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected"
+ if adapter.fatal_error_retryable:
+ self._exit_with_failure = True
+ logger.error("No connected messaging platforms remain. Shutting down gateway for service restart.")
+ else:
+ logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
+ await self.stop()
+ elif not self.adapters and self._failed_platforms:
+ logger.warning(
+ "No connected messaging platforms remain, but %d platform(s) queued for reconnection",
+ len(self._failed_platforms),
+ )
+
+ def _request_clean_exit(self, reason: str) -> None:
+ self._exit_cleanly = True
+ self._exit_reason = reason
+ self._shutdown_event.set()
@staticmethod
def _load_prefill_messages() -> List[Dict[str, Any]]:
@@ -394,33 +820,30 @@ def _load_ephemeral_system_prompt() -> str:
@staticmethod
def _load_reasoning_config() -> dict | None:
- """Load reasoning effort from config or env var.
-
- Checks HERMES_REASONING_EFFORT env var first, then agent.reasoning_effort
- in config.yaml. Valid: "xhigh", "high", "medium", "low", "minimal", "none".
- Returns None to use default (medium).
+ """Load reasoning effort from config with env fallback.
+
+ Checks agent.reasoning_effort in config.yaml first, then
+ HERMES_REASONING_EFFORT as a fallback. Valid: "xhigh", "high",
+ "medium", "low", "minimal", "none". Returns None to use default
+ (medium).
"""
- effort = os.getenv("HERMES_REASONING_EFFORT", "")
- if not effort:
- try:
- import yaml as _y
- cfg_path = _hermes_home / "config.yaml"
- if cfg_path.exists():
- with open(cfg_path, encoding="utf-8") as _f:
- cfg = _y.safe_load(_f) or {}
- effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip()
- except Exception:
- pass
+ from hermes_constants import parse_reasoning_effort
+ effort = ""
+ try:
+ import yaml as _y
+ cfg_path = _hermes_home / "config.yaml"
+ if cfg_path.exists():
+ with open(cfg_path, encoding="utf-8") as _f:
+ cfg = _y.safe_load(_f) or {}
+ effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip()
+ except Exception:
+ pass
if not effort:
- return None
- effort = effort.lower().strip()
- if effort == "none":
- return {"enabled": False}
- valid = ("xhigh", "high", "medium", "low", "minimal")
- if effort in valid:
- return {"enabled": True, "effort": effort}
- logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
- return None
+ effort = os.getenv("HERMES_REASONING_EFFORT", "")
+ result = parse_reasoning_effort(effort)
+ if effort and effort.strip() and result is None:
+ logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
+ return result
@staticmethod
def _load_show_reasoning() -> bool:
@@ -505,6 +928,20 @@ def _load_fallback_model() -> dict | None:
pass
return None
+ @staticmethod
+ def _load_smart_model_routing() -> dict:
+ """Load optional smart cheap-vs-strong model routing config."""
+ try:
+ import yaml as _y
+ cfg_path = _hermes_home / "config.yaml"
+ if cfg_path.exists():
+ with open(cfg_path, encoding="utf-8") as _f:
+ cfg = _y.safe_load(_f) or {}
+ return cfg.get("smart_model_routing", {}) or {}
+ except Exception:
+ pass
+ return {}
+
async def start(self) -> bool:
"""
Start the gateway and all configured platform adapters.
@@ -513,15 +950,31 @@ async def start(self) -> bool:
"""
logger.info("Starting Hermes Gateway...")
logger.info("Session storage: %s", self.config.sessions_dir)
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(gateway_state="starting", exit_reason=None)
+ except Exception:
+ pass
# Warn if no user allowlists are configured and open access is not opted in
_any_allowlist = any(
os.getenv(v)
for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS",
+ "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
+ "EMAIL_ALLOWED_USERS",
+ "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
+ "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
- _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes")
+ _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
+ os.getenv(v, "").lower() in ("true", "1", "yes")
+ for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
+ "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")
+ )
if not _any_allowlist and not _allow_all:
logger.warning(
"No user allowlists configured. All unauthorized users will be denied. "
@@ -542,19 +995,24 @@ async def start(self) -> bool:
logger.warning("Process checkpoint recovery: %s", e)
connected_count = 0
+ enabled_platform_count = 0
+ startup_nonretryable_errors: list[str] = []
+ startup_retryable_errors: list[str] = []
# Initialize and connect each configured platform
for platform, platform_config in self.config.platforms.items():
if not platform_config.enabled:
continue
+ enabled_platform_count += 1
adapter = self._create_adapter(platform, platform_config)
if not adapter:
logger.warning("No adapter available for %s", platform.value)
continue
- # Set up message handler
+ # Set up message + fatal error handlers
adapter.set_message_handler(self._handle_message)
+ adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
# Try to connect
logger.info("Connecting to %s...", platform.value)
@@ -562,21 +1020,79 @@ async def start(self) -> bool:
success = await adapter.connect()
if success:
self.adapters[platform] = adapter
+ self._sync_voice_mode_state_to_adapter(adapter)
connected_count += 1
logger.info("โ %s connected", platform.value)
else:
logger.warning("โ %s failed to connect", platform.value)
+ if adapter.has_fatal_error:
+ target = (
+ startup_retryable_errors
+ if adapter.fatal_error_retryable
+ else startup_nonretryable_errors
+ )
+ target.append(
+ f"{platform.value}: {adapter.fatal_error_message}"
+ )
+ # Queue for reconnection if the error is retryable
+ if adapter.fatal_error_retryable:
+ self._failed_platforms[platform] = {
+ "config": platform_config,
+ "attempts": 1,
+ "next_retry": time.monotonic() + 30,
+ }
+ else:
+ startup_retryable_errors.append(
+ f"{platform.value}: failed to connect"
+ )
+ # No fatal error info means likely a transient issue โ queue for retry
+ self._failed_platforms[platform] = {
+ "config": platform_config,
+ "attempts": 1,
+ "next_retry": time.monotonic() + 30,
+ }
except Exception as e:
logger.error("โ %s error: %s", platform.value, e)
+ startup_retryable_errors.append(f"{platform.value}: {e}")
+ # Unexpected exceptions are typically transient โ queue for retry
+ self._failed_platforms[platform] = {
+ "config": platform_config,
+ "attempts": 1,
+ "next_retry": time.monotonic() + 30,
+ }
if connected_count == 0:
- logger.warning("No messaging platforms connected.")
+ if startup_nonretryable_errors:
+ reason = "; ".join(startup_nonretryable_errors)
+ logger.error("Gateway hit a non-retryable startup conflict: %s", reason)
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(gateway_state="startup_failed", exit_reason=reason)
+ except Exception:
+ pass
+ self._request_clean_exit(reason)
+ return True
+ if enabled_platform_count > 0:
+ reason = "; ".join(startup_retryable_errors) or "all configured messaging platforms failed to connect"
+ logger.error("Gateway failed to connect any configured messaging platform: %s", reason)
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(gateway_state="startup_failed", exit_reason=reason)
+ except Exception:
+ pass
+ return False
+ logger.warning("No messaging platforms enabled.")
logger.info("Gateway will continue running for cron job execution.")
# Update delivery router with adapters
self.delivery_router.adapters = self.adapters
self._running = True
+ try:
+ from gateway.status import write_runtime_status
+ write_runtime_status(gateway_state="running", exit_reason=None)
+ except Exception:
+ pass
# Emit gateway:startup hook
hook_count = len(self.hooks.loaded_hooks)
@@ -598,12 +1114,40 @@ async def start(self) -> bool:
except Exception as e:
logger.warning("Channel directory build failed: %s", e)
- # Check if we're restarting after a /update command
- await self._send_update_notification()
+ # Check if we're restarting after a /update command. If the update is
+ # still running, keep watching so we notify once it actually finishes.
+ notified = await self._send_update_notification()
+ if not notified and any(
+ path.exists()
+ for path in (
+ _hermes_home / ".update_pending.json",
+ _hermes_home / ".update_pending.claimed.json",
+ )
+ ):
+ self._schedule_update_notification_watch()
+
+ # Drain any recovered process watchers (from crash recovery checkpoint)
+ try:
+ from tools.process_registry import process_registry
+ while process_registry.pending_watchers:
+ watcher = process_registry.pending_watchers.pop(0)
+ asyncio.create_task(self._run_process_watcher(watcher))
+ logger.info("Resumed watcher for recovered process %s", watcher.get("session_id"))
+ except Exception as e:
+ logger.error("Recovered watcher setup error: %s", e)
# Start background session expiry watcher for proactive memory flushing
asyncio.create_task(self._session_expiry_watcher())
+ # Start background reconnection watcher for platforms that failed at startup
+ if self._failed_platforms:
+ logger.info(
+ "Starting reconnection watcher for %d failed platform(s): %s",
+ len(self._failed_platforms),
+ ", ".join(p.value for p in self._failed_platforms),
+ )
+ asyncio.create_task(self._platform_reconnect_watcher())
+
logger.info("Press Ctrl+C to stop")
return True
@@ -633,7 +1177,8 @@ async def _session_expiry_watcher(self, interval: int = 300):
entry.session_id, key,
)
try:
- await self._async_flush_memories(entry.session_id)
+ await self._async_flush_memories(entry.session_id, key)
+ self._shutdown_gateway_honcho(key)
self.session_store._pre_flushed_sessions.add(entry.session_id)
except Exception as e:
logger.debug("Proactive memory flush failed for %s: %s", entry.session_id, e)
@@ -645,23 +1190,150 @@ async def _session_expiry_watcher(self, interval: int = 300):
break
await asyncio.sleep(1)
+ async def _platform_reconnect_watcher(self) -> None:
+ """Background task that periodically retries connecting failed platforms.
+
+ Uses exponential backoff: 30s โ 60s โ 120s โ 240s โ 300s (cap).
+ Stops retrying a platform after 20 failed attempts or if the error
+ is non-retryable (e.g. bad auth token).
+ """
+ _MAX_ATTEMPTS = 20
+ _BACKOFF_CAP = 300 # 5 minutes max between retries
+
+ await asyncio.sleep(10) # initial delay โ let startup finish
+ while self._running:
+ if not self._failed_platforms:
+ # Nothing to reconnect โ sleep and check again
+ for _ in range(30):
+ if not self._running:
+ return
+ await asyncio.sleep(1)
+ continue
+
+ now = time.monotonic()
+ for platform in list(self._failed_platforms.keys()):
+ if not self._running:
+ return
+ info = self._failed_platforms[platform]
+ if now < info["next_retry"]:
+ continue # not time yet
+
+ if info["attempts"] >= _MAX_ATTEMPTS:
+ logger.warning(
+ "Giving up reconnecting %s after %d attempts",
+ platform.value, info["attempts"],
+ )
+ del self._failed_platforms[platform]
+ continue
+
+ platform_config = info["config"]
+ attempt = info["attempts"] + 1
+ logger.info(
+ "Reconnecting %s (attempt %d/%d)...",
+ platform.value, attempt, _MAX_ATTEMPTS,
+ )
+
+ try:
+ adapter = self._create_adapter(platform, platform_config)
+ if not adapter:
+ logger.warning(
+ "Reconnect %s: adapter creation returned None, removing from retry queue",
+ platform.value,
+ )
+ del self._failed_platforms[platform]
+ continue
+
+ adapter.set_message_handler(self._handle_message)
+ adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
+
+ success = await adapter.connect()
+ if success:
+ self.adapters[platform] = adapter
+ self._sync_voice_mode_state_to_adapter(adapter)
+ self.delivery_router.adapters = self.adapters
+ del self._failed_platforms[platform]
+ logger.info("โ %s reconnected successfully", platform.value)
+
+ # Rebuild channel directory with the new adapter
+ try:
+ from gateway.channel_directory import build_channel_directory
+ build_channel_directory(self.adapters)
+ except Exception:
+ pass
+ else:
+ # Check if the failure is non-retryable
+ if adapter.has_fatal_error and not adapter.fatal_error_retryable:
+ logger.warning(
+ "Reconnect %s: non-retryable error (%s), removing from retry queue",
+ platform.value, adapter.fatal_error_message,
+ )
+ del self._failed_platforms[platform]
+ else:
+ backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
+ info["attempts"] = attempt
+ info["next_retry"] = time.monotonic() + backoff
+ logger.info(
+ "Reconnect %s failed, next retry in %ds",
+ platform.value, backoff,
+ )
+ except Exception as e:
+ backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
+ info["attempts"] = attempt
+ info["next_retry"] = time.monotonic() + backoff
+ logger.warning(
+ "Reconnect %s error: %s, next retry in %ds",
+ platform.value, e, backoff,
+ )
+
+ # Check every 10 seconds for platforms that need reconnection
+ for _ in range(10):
+ if not self._running:
+ return
+ await asyncio.sleep(1)
+
async def stop(self) -> None:
"""Stop the gateway and disconnect all adapters."""
logger.info("Stopping gateway...")
self._running = False
-
- for platform, adapter in self.adapters.items():
+
+ for session_key, agent in list(self._running_agents.items()):
+ if agent is _AGENT_PENDING_SENTINEL:
+ continue
+ try:
+ agent.interrupt("Gateway shutting down")
+ logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20])
+ except Exception as e:
+ logger.debug("Failed interrupting agent during shutdown: %s", e)
+
+ for platform, adapter in list(self.adapters.items()):
+ try:
+ await adapter.cancel_background_tasks()
+ except Exception as e:
+ logger.debug("โ %s background-task cancel error: %s", platform.value, e)
try:
await adapter.disconnect()
logger.info("โ %s disconnected", platform.value)
except Exception as e:
logger.error("โ %s disconnect error: %s", platform.value, e)
-
+
+ # Cancel any pending background tasks
+ for _task in list(self._background_tasks):
+ _task.cancel()
+ self._background_tasks.clear()
+
self.adapters.clear()
+ self._running_agents.clear()
+ self._pending_messages.clear()
+ self._pending_approvals.clear()
+ self._shutdown_all_gateway_honcho()
self._shutdown_event.set()
- from gateway.status import remove_pid_file
+ from gateway.status import remove_pid_file, write_runtime_status
remove_pid_file()
+ try:
+ write_runtime_status(gateway_state="stopped", exit_reason=self._exit_reason)
+ except Exception:
+ pass
logger.info("Gateway stopped")
@@ -675,6 +1347,12 @@ def _create_adapter(
config: Any
) -> Optional[BasePlatformAdapter]:
"""Create the appropriate adapter for a platform."""
+ if hasattr(config, "extra") and isinstance(config.extra, dict):
+ config.extra.setdefault(
+ "group_sessions_per_user",
+ self.config.group_sessions_per_user,
+ )
+
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
@@ -724,6 +1402,50 @@ def _create_adapter(
return None
return EmailAdapter(config)
+ elif platform == Platform.SMS:
+ from gateway.platforms.sms import SmsAdapter, check_sms_requirements
+ if not check_sms_requirements():
+ logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set")
+ return None
+ return SmsAdapter(config)
+
+ elif platform == Platform.DINGTALK:
+ from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
+ if not check_dingtalk_requirements():
+ logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
+ return None
+ return DingTalkAdapter(config)
+
+ elif platform == Platform.MATTERMOST:
+ from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
+ if not check_mattermost_requirements():
+ logger.warning("Mattermost: MATTERMOST_TOKEN or MATTERMOST_URL not set, or aiohttp missing")
+ return None
+ return MattermostAdapter(config)
+
+ elif platform == Platform.MATRIX:
+ from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
+ if not check_matrix_requirements():
+ logger.warning("Matrix: matrix-nio not installed or credentials not set. Run: pip install 'matrix-nio[e2e]'")
+ return None
+ return MatrixAdapter(config)
+
+ elif platform == Platform.API_SERVER:
+ from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
+ if not check_api_server_requirements():
+ logger.warning("API Server: aiohttp not installed")
+ return None
+ return APIServerAdapter(config)
+
+ elif platform == Platform.WEBHOOK:
+ from gateway.platforms.webhook import WebhookAdapter, check_webhook_requirements
+ if not check_webhook_requirements():
+ logger.warning("Webhook: aiohttp not installed")
+ return None
+ adapter = WebhookAdapter(config)
+ adapter.gateway_runner = self # For cross-platform delivery
+ return adapter
+
return None
def _is_user_authorized(self, source: SessionSource) -> bool:
@@ -740,7 +1462,9 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
# Home Assistant events are system-generated (state changes), not
# user-initiated messages. The HASS_TOKEN already authenticates the
# connection, so HA events are always authorized.
- if source.platform == Platform.HOMEASSISTANT:
+ # Webhook events are authenticated via HMAC signature validation in
+ # the adapter itself โ no user allowlist applies.
+ if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK):
return True
user_id = source.user_id
@@ -754,6 +1478,10 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
+ Platform.SMS: "SMS_ALLOWED_USERS",
+ Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
+ Platform.MATRIX: "MATRIX_ALLOWED_USERS",
+ Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
@@ -762,6 +1490,10 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
+ Platform.SMS: "SMS_ALLOW_ALL_USERS",
+ Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
+ Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
+ Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
}
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
@@ -794,6 +1526,13 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
return bool(check_ids & allowed_ids)
+
+ def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
+ """Return how unauthorized DMs should be handled for a platform."""
+ config = getattr(self, "config", None)
+ if config and hasattr(config, "get_unauthorized_dm_behavior"):
+ return config.get_unauthorized_dm_behavior(platform)
+ return "pair"
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
"""
@@ -809,12 +1548,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
7. Return response
"""
source = event.source
-
+
# Check if user is authorized
if not self._is_user_authorized(source):
logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value)
# In DMs: offer pairing code. In groups: silently ignore.
- if source.chat_type == "dm":
+ if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
platform_name = source.platform.value if source.platform else "unknown"
code = self.pairing_store.generate_code(
platform_name, source.user_id, source.user_name or ""
@@ -839,12 +1578,117 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
)
return None
- # PRIORITY: If an agent is already running for this session, interrupt it
- # immediately. This is before command parsing to minimize latency -- the
- # user's "stop" message reaches the agent as fast as possible.
- _quick_key = build_session_key(source)
+ # PRIORITY handling when an agent is already running for this session.
+ # Default behavior is to interrupt immediately so user text/stop messages
+ # are handled with minimal latency.
+ #
+ # Special case: Telegram/photo bursts often arrive as multiple near-
+ # simultaneous updates. Do NOT interrupt for photo-only follow-ups here;
+ # let the adapter-level batching/queueing logic absorb them.
+ _quick_key = self._session_key_for_source(source)
if _quick_key in self._running_agents:
- running_agent = self._running_agents[_quick_key]
+ if event.get_command() == "status":
+ return await self._handle_status_command(event)
+
+ # Resolve the command once for all early-intercept checks below.
+ from hermes_cli.commands import resolve_command as _resolve_cmd_inner
+ _evt_cmd = event.get_command()
+ _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
+
+ # /stop must hard-kill the session when an agent is running.
+ # A soft interrupt (agent.interrupt()) doesn't help when the agent
+ # is truly hung โ the executor thread is blocked and never checks
+ # _interrupt_requested. Force-clean _running_agents so the session
+ # is unlocked and subsequent messages are processed normally.
+ if _cmd_def_inner and _cmd_def_inner.name == "stop":
+ running_agent = self._running_agents.get(_quick_key)
+ if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
+ running_agent.interrupt("Stop requested")
+ # Force-clean: remove the session lock regardless of agent state
+ adapter = self.adapters.get(source.platform)
+ if adapter and hasattr(adapter, 'get_pending_message'):
+ adapter.get_pending_message(_quick_key) # consume and discard
+ self._pending_messages.pop(_quick_key, None)
+ if _quick_key in self._running_agents:
+ del self._running_agents[_quick_key]
+ logger.info("HARD STOP for session %s โ session lock released", _quick_key[:20])
+ return "โก Force-stopped. The session is unlocked โ you can send a new message."
+
+ # /reset and /new must bypass the running-agent guard so they
+ # actually dispatch as commands instead of being queued as user
+ # text (which would be fed back to the agent with the same
+ # broken history โ #2170). Interrupt the agent first, then
+ # clear the adapter's pending queue so the stale "/reset" text
+ # doesn't get re-processed as a user message after the
+ # interrupt completes.
+ if _cmd_def_inner and _cmd_def_inner.name == "new":
+ running_agent = self._running_agents.get(_quick_key)
+ if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
+ running_agent.interrupt("Session reset requested")
+ # Clear any pending messages so the old text doesn't replay
+ adapter = self.adapters.get(source.platform)
+ if adapter and hasattr(adapter, 'get_pending_message'):
+ adapter.get_pending_message(_quick_key) # consume and discard
+ self._pending_messages.pop(_quick_key, None)
+ # Clean up the running agent entry so the reset handler
+ # doesn't think an agent is still active.
+ if _quick_key in self._running_agents:
+ del self._running_agents[_quick_key]
+ return await self._handle_reset_command(event)
+
+ # /queue โ queue without interrupting
+ if event.get_command() in ("queue", "q"):
+ queued_text = event.get_command_args().strip()
+ if not queued_text:
+ return "Usage: /queue "
+ adapter = self.adapters.get(source.platform)
+ if adapter:
+ from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT
+ queued_event = _ME(
+ text=queued_text,
+ message_type=_MT.TEXT,
+ source=event.source,
+ message_id=event.message_id,
+ )
+ adapter._pending_messages[_quick_key] = queued_event
+ return "Queued for the next turn."
+
+ if event.message_type == MessageType.PHOTO:
+ logger.debug("PRIORITY photo follow-up for session %s โ queueing without interrupt", _quick_key[:20])
+ adapter = self.adapters.get(source.platform)
+ if adapter:
+ # Reuse adapter queue semantics so photo bursts merge cleanly.
+ if _quick_key in adapter._pending_messages:
+ existing = adapter._pending_messages[_quick_key]
+ if getattr(existing, "message_type", None) == MessageType.PHOTO:
+ existing.media_urls.extend(event.media_urls)
+ existing.media_types.extend(event.media_types)
+ if event.text:
+ if not existing.text:
+ existing.text = event.text
+ elif event.text not in existing.text:
+ existing.text = f"{existing.text}\n\n{event.text}".strip()
+ else:
+ adapter._pending_messages[_quick_key] = event
+ else:
+ adapter._pending_messages[_quick_key] = event
+ return None
+
+ running_agent = self._running_agents.get(_quick_key)
+ if running_agent is _AGENT_PENDING_SENTINEL:
+ # Agent is being set up but not ready yet.
+ if event.get_command() == "stop":
+ # Force-clean the sentinel so the session is unlocked.
+ if _quick_key in self._running_agents:
+ del self._running_agents[_quick_key]
+ logger.info("HARD STOP (pending) for session %s โ sentinel cleared", _quick_key[:20])
+ return "โก Force-stopped. The agent was still starting โ session unlocked."
+ # Queue the message so it will be picked up after the
+ # agent starts.
+ adapter = self.adapters.get(source.platform)
+ if adapter:
+ adapter._pending_messages[_quick_key] = event
+ return None
logger.debug("PRIORITY interrupt for session %s", _quick_key[:20])
running_agent.interrupt(event.text)
if _quick_key in self._pending_messages:
@@ -852,87 +1696,125 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
else:
self._pending_messages[_quick_key] = event.text
return None
-
+
# Check for commands
command = event.get_command()
- # Emit command:* hook for any recognized slash command
- _known_commands = {"new", "reset", "help", "status", "stop", "model",
- "personality", "retry", "undo", "sethome", "set-home",
- "compress", "usage", "insights", "reload-mcp", "reload_mcp",
- "update", "title", "resume", "provider", "rollback",
- "background", "reasoning"}
- if command and command in _known_commands:
+ # Emit command:* hook for any recognized slash command.
+ # GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY
+ # in hermes_cli/commands.py โ no hardcoded set to maintain here.
+ from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd
+ if command and command in GATEWAY_KNOWN_COMMANDS:
await self.hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
"user_id": source.user_id,
"command": command,
"args": event.get_command_args().strip(),
})
-
- if command in ["new", "reset"]:
+
+ # Resolve aliases to canonical name so dispatch only checks canonicals.
+ _cmd_def = _resolve_cmd(command) if command else None
+ canonical = _cmd_def.name if _cmd_def else command
+
+ if canonical == "new":
return await self._handle_reset_command(event)
- if command == "help":
+ if canonical == "help":
return await self._handle_help_command(event)
- if command == "status":
+ if canonical == "status":
return await self._handle_status_command(event)
- if command == "stop":
+ if canonical == "stop":
return await self._handle_stop_command(event)
- if command == "model":
- return await self._handle_model_command(event)
-
- if command == "provider":
+ if canonical == "reasoning":
+ return await self._handle_reasoning_command(event)
+
+ if canonical == "verbose":
+ return await self._handle_verbose_command(event)
+
+ if canonical == "provider":
return await self._handle_provider_command(event)
- if command == "personality":
+ if canonical == "personality":
return await self._handle_personality_command(event)
+
+ if canonical == "plan":
+ try:
+ from agent.skill_commands import build_plan_path, build_skill_invocation_message
+
+ user_instruction = event.get_command_args().strip()
+ plan_path = build_plan_path(user_instruction)
+ event.text = build_skill_invocation_message(
+ "/plan",
+ user_instruction,
+ task_id=_quick_key,
+ runtime_note=(
+ "Save the markdown plan with write_file to this exact relative path "
+ f"inside the active workspace/backend cwd: {plan_path}"
+ ),
+ )
+ if not event.text:
+ return "Failed to load the bundled /plan skill."
+ canonical = None
+ except Exception as e:
+ logger.exception("Failed to prepare /plan command")
+ return f"Failed to enter plan mode: {e}"
- if command == "retry":
+ if canonical == "retry":
return await self._handle_retry_command(event)
- if command == "undo":
+ if canonical == "undo":
return await self._handle_undo_command(event)
- if command in ["sethome", "set-home"]:
+ if canonical == "sethome":
return await self._handle_set_home_command(event)
- if command == "compress":
+ if canonical == "compress":
return await self._handle_compress_command(event)
- if command == "usage":
+ if canonical == "usage":
return await self._handle_usage_command(event)
- if command == "insights":
+ if canonical == "insights":
return await self._handle_insights_command(event)
- if command in ("reload-mcp", "reload_mcp"):
+ if canonical == "reload-mcp":
return await self._handle_reload_mcp_command(event)
- if command == "update":
+ if canonical == "approve":
+ return await self._handle_approve_command(event)
+
+ if canonical == "deny":
+ return await self._handle_deny_command(event)
+
+ if canonical == "update":
return await self._handle_update_command(event)
- if command == "title":
+ if canonical == "title":
return await self._handle_title_command(event)
- if command == "resume":
+ if canonical == "resume":
return await self._handle_resume_command(event)
- if command == "rollback":
+ if canonical == "rollback":
return await self._handle_rollback_command(event)
- if command == "background":
+ if canonical == "background":
return await self._handle_background_command(event)
- if command == "reasoning":
- return await self._handle_reasoning_command(event)
-
+ if canonical == "voice":
+ return await self._handle_voice_command(event)
+
# User-defined quick commands (bypass agent loop, no LLM call)
if command:
- quick_commands = self.config.get("quick_commands", {})
+ if isinstance(self.config, dict):
+ quick_commands = self.config.get("quick_commands", {}) or {}
+ else:
+ quick_commands = getattr(self.config, "quick_commands", {}) or {}
+ if not isinstance(quick_commands, dict):
+ quick_commands = {}
if command in quick_commands:
qcmd = quick_commands[command]
if qcmd.get("type") == "exec":
@@ -953,8 +1835,34 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
return f"Quick command error: {e}"
else:
return f"Quick command '/{command}' has no command defined."
+ elif qcmd.get("type") == "alias":
+ target = qcmd.get("target", "").strip()
+ if target:
+ target = target if target.startswith("/") else f"/{target}"
+ target_command = target.lstrip("/")
+ user_args = event.get_command_args().strip()
+ event.text = f"{target} {user_args}".strip()
+ command = target_command
+ # Fall through to normal command dispatch below
+ else:
+ return f"Quick command '/{command}' has no target defined."
else:
- return f"Quick command '/{command}' has unsupported type (only 'exec' is supported)."
+ return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
+
+ # Plugin-registered slash commands
+ if command:
+ try:
+ from hermes_cli.plugins import get_plugin_command_handler
+ plugin_handler = get_plugin_command_handler(command)
+ if plugin_handler:
+ user_args = event.get_command_args().strip()
+ import asyncio as _aio
+ result = plugin_handler(user_args)
+ if _aio.iscoroutine(result):
+ result = await result
+ return str(result) if result else None
+ except Exception as e:
+ logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
# Skill slash commands: /skill-name loads the skill and sends to agent
if command:
@@ -964,36 +1872,41 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
cmd_key = f"/{command}"
if cmd_key in skill_cmds:
user_instruction = event.get_command_args().strip()
- msg = build_skill_invocation_message(cmd_key, user_instruction)
+ msg = build_skill_invocation_message(
+ cmd_key, user_instruction, task_id=_quick_key
+ )
if msg:
event.text = msg
# Fall through to normal message processing with skill content
except Exception as e:
logger.debug("Skill command check failed (non-fatal): %s", e)
- # Check for pending exec approval responses
- session_key_preview = build_session_key(source)
- if session_key_preview in self._pending_approvals:
- user_text = event.text.strip().lower()
- if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
- approval = self._pending_approvals.pop(session_key_preview)
- cmd = approval["command"]
- pattern_key = approval.get("pattern_key", "")
- logger.info("User approved dangerous command: %s...", cmd[:60])
- from tools.terminal_tool import terminal_tool
- from tools.approval import approve_session
- approve_session(session_key_preview, pattern_key)
- result = terminal_tool(command=cmd, force=True)
- return f"โ
Command approved and executed.\n\n```\n{result[:3500]}\n```"
- elif user_text in ("no", "n", "deny", "cancel", "nope"):
- self._pending_approvals.pop(session_key_preview)
- return "โ Command denied."
- elif user_text in ("full", "show", "view", "show full", "view full"):
- # Show full command without consuming the approval
- cmd = self._pending_approvals[session_key_preview]["command"]
- return f"Full command:\n\n```\n{cmd}\n```\n\nReply yes/no to approve or deny."
- # If it's not clearly an approval/denial, fall through to normal processing
-
+ # Pending exec approvals are handled by /approve and /deny commands above.
+ # No bare text matching โ "yes" in normal conversation must not trigger
+ # execution of a dangerous command.
+
+ # โโ Claim this session before any await โโโโโโโโโโโโโโโโโโโโโโโ
+ # Between here and _run_agent registering the real AIAgent, there
+ # are numerous await points (hooks, vision enrichment, STT,
+ # session hygiene compression). Without this sentinel a second
+ # message arriving during any of those yields would pass the
+ # "already running" guard and spin up a duplicate agent for the
+ # same session โ corrupting the transcript.
+ self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL
+
+ try:
+ return await self._handle_message_with_agent(event, source, _quick_key)
+ finally:
+ # If _run_agent replaced the sentinel with a real agent and
+ # then cleaned it up, this is a no-op. If we exited early
+ # (exception, command fallthrough, etc.) the sentinel must
+ # not linger or the session would be permanently locked out.
+ if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL:
+ del self._running_agents[_quick_key]
+
+ async def _handle_message_with_agent(self, event, source, _quick_key: str):
+ """Inner handler that runs under the _running_agents sentinel guard."""
+
# Get or create session
session_entry = self.session_store.get_or_create_session(source)
session_key = session_entry.session_key
@@ -1017,19 +1930,109 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Set environment variables for tools
self._set_session_env(context)
+ # Read privacy.redact_pii from config (re-read per message)
+ _redact_pii = False
+ try:
+ import yaml as _pii_yaml
+ with open(_config_path, encoding="utf-8") as _pf:
+ _pcfg = _pii_yaml.safe_load(_pf) or {}
+ _redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
+ except Exception:
+ pass
+
# Build the context prompt to inject
- context_prompt = build_session_context_prompt(context)
+ context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii)
# If the previous session expired and was auto-reset, prepend a notice
# so the agent knows this is a fresh conversation (not an intentional /reset).
if getattr(session_entry, 'was_auto_reset', False):
- context_prompt = (
- "[System note: The user's previous session expired due to inactivity. "
- "This is a fresh conversation with no prior context.]\n\n"
- + context_prompt
- )
+ reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle'
+ if reset_reason == "daily":
+ context_note = "[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]"
+ else:
+ context_note = "[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]"
+ context_prompt = context_note + "\n\n" + context_prompt
+
+ # Send a user-facing notification explaining the reset, unless:
+ # - notifications are disabled in config
+ # - the platform is excluded (e.g. api_server, webhook)
+ # - the expired session had no activity (nothing was cleared)
+ try:
+ policy = self.session_store.config.get_reset_policy(
+ platform=source.platform,
+ session_type=getattr(source, 'chat_type', 'dm'),
+ )
+ platform_name = source.platform.value if source.platform else ""
+ had_activity = getattr(session_entry, 'reset_had_activity', False)
+ should_notify = (
+ policy.notify
+ and had_activity
+ and platform_name not in policy.notify_exclude_platforms
+ )
+ if should_notify:
+ adapter = self.adapters.get(source.platform)
+ if adapter:
+ if reset_reason == "daily":
+ reason_text = f"daily schedule at {policy.at_hour}:00"
+ else:
+ hours = policy.idle_minutes // 60
+ mins = policy.idle_minutes % 60
+ duration = f"{hours}h" if not mins else f"{hours}h {mins}m" if hours else f"{mins}m"
+ reason_text = f"inactive for {duration}"
+ notice = (
+ f"โ Session automatically reset ({reason_text}). "
+ f"Conversation history cleared.\n"
+ f"Use /resume to browse and restore a previous session.\n"
+ f"Adjust reset timing in config.yaml under session_reset."
+ )
+ try:
+ session_info = self._format_session_info()
+ if session_info:
+ notice = f"{notice}\n\n{session_info}"
+ except Exception:
+ pass
+ await adapter.send(
+ source.chat_id, notice,
+ metadata=getattr(event, 'metadata', None),
+ )
+ except Exception as e:
+ logger.debug("Auto-reset notification failed (non-fatal): %s", e)
+
session_entry.was_auto_reset = False
-
+ session_entry.auto_reset_reason = None
+
+ # Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics)
+ # Only inject on NEW sessions โ for ongoing conversations the skill content
+ # is already in the conversation history from the first message.
+ if _is_new_session and getattr(event, "auto_skill", None):
+ try:
+ from agent.skill_commands import _load_skill_payload, _build_skill_message
+ _skill_name = event.auto_skill
+ _loaded = _load_skill_payload(_skill_name, task_id=_quick_key)
+ if _loaded:
+ _loaded_skill, _skill_dir, _display_name = _loaded
+ _activation_note = (
+ f'[SYSTEM: This conversation is in a topic with the "{_display_name}" skill '
+ f"auto-loaded. Follow its instructions for the duration of this session.]"
+ )
+ _skill_msg = _build_skill_message(
+ _loaded_skill, _skill_dir, _activation_note,
+ user_instruction=event.text,
+ )
+ if _skill_msg:
+ event.text = _skill_msg
+ logger.info(
+ "[Gateway] Auto-loaded skill '%s' for DM topic session %s",
+ _skill_name, session_key,
+ )
+ else:
+ logger.warning(
+ "[Gateway] DM topic skill '%s' not found in available skills",
+ _skill_name,
+ )
+ except Exception as e:
+ logger.warning("[Gateway] Failed to auto-load topic skill '%s': %s", event.auto_skill, e)
+
# Load conversation history from transcript
history = self.session_store.load_transcript(session_entry.session_id)
@@ -1044,9 +2047,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Token source priority:
# 1. Actual API-reported prompt_tokens from the last turn
# (stored in session_entry.last_prompt_tokens)
- # 2. Rough char-based estimate (str(msg)//4) with a 1.4x
- # safety factor to account for overestimation on tool-heavy
- # conversations (code/JSON tokenizes at 5-7+ chars/token).
+ # 2. Rough char-based estimate (str(msg)//4). Overestimates
+ # by 30-50% on code/JSON-heavy sessions, but that just
+ # means hygiene fires a bit early โ safe and harmless.
# -----------------------------------------------------------------
if history and len(history) >= 4:
from agent.model_metadata import (
@@ -1054,11 +2057,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
get_model_context_length,
)
- # Read model + compression config from config.yaml โ same
- # source of truth the agent itself uses.
+ # Read model + compression config from config.yaml.
+ # NOTE: hygiene threshold is intentionally HIGHER than the agent's
+ # own compressor (0.85 vs 0.50). Hygiene is a safety net for
+ # sessions that grew too large between turns โ it fires pre-agent
+ # to prevent API failures. The agent's own compressor handles
+ # normal context management during its tool loop with accurate
+ # real token counts. Having hygiene at 0.50 caused premature
+ # compression on every turn in long gateway sessions.
_hyg_model = "anthropic/claude-sonnet-4.6"
- _hyg_threshold_pct = 0.50
+ _hyg_threshold_pct = 0.85
_hyg_compression_enabled = True
+ _hyg_config_context_length = None
+ _hyg_provider = None
+ _hyg_base_url = None
+ _hyg_api_key = None
try:
_hyg_cfg_path = _hermes_home / "config.yaml"
if _hyg_cfg_path.exists():
@@ -1072,28 +2085,47 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
_hyg_model = _model_cfg
elif isinstance(_model_cfg, dict):
_hyg_model = _model_cfg.get("default", _hyg_model)
-
- # Read compression settings
+ # Read explicit context_length override from model config
+ # (same as run_agent.py lines 995-1005)
+ _raw_ctx = _model_cfg.get("context_length")
+ if _raw_ctx is not None:
+ try:
+ _hyg_config_context_length = int(_raw_ctx)
+ except (TypeError, ValueError):
+ pass
+ # Read provider for accurate context detection
+ _hyg_provider = _model_cfg.get("provider") or None
+ _hyg_base_url = _model_cfg.get("base_url") or None
+
+ # Read compression settings โ only use enabled flag.
+ # The threshold is intentionally separate from the agent's
+ # compression.threshold (hygiene runs higher).
_comp_cfg = _hyg_data.get("compression", {})
if isinstance(_comp_cfg, dict):
- _hyg_threshold_pct = float(
- _comp_cfg.get("threshold", _hyg_threshold_pct)
- )
_hyg_compression_enabled = str(
_comp_cfg.get("enabled", True)
).lower() in ("true", "1", "yes")
+
+ # Resolve provider/base_url from runtime if not in config
+ if not _hyg_provider or not _hyg_base_url:
+ try:
+ _hyg_runtime = _resolve_runtime_agent_kwargs()
+ _hyg_provider = _hyg_provider or _hyg_runtime.get("provider")
+ _hyg_base_url = _hyg_base_url or _hyg_runtime.get("base_url")
+ _hyg_api_key = _hyg_runtime.get("api_key")
+ except Exception:
+ pass
except Exception:
pass
- # Also check env overrides (same as run_agent.py)
- _hyg_threshold_pct = float(
- os.getenv("CONTEXT_COMPRESSION_THRESHOLD", str(_hyg_threshold_pct))
- )
- if os.getenv("CONTEXT_COMPRESSION_ENABLED", "").lower() in ("false", "0", "no"):
- _hyg_compression_enabled = False
-
if _hyg_compression_enabled:
- _hyg_context_length = get_model_context_length(_hyg_model)
+ _hyg_context_length = get_model_context_length(
+ _hyg_model,
+ base_url=_hyg_base_url or "",
+ api_key=_hyg_api_key or "",
+ config_context_length=_hyg_config_context_length,
+ provider=_hyg_provider or "",
+ )
_compress_token_threshold = int(
_hyg_context_length * _hyg_threshold_pct
)
@@ -1103,20 +2135,20 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Prefer actual API-reported tokens from the last turn
# (stored in session entry) over the rough char-based estimate.
- # The rough estimate (str(msg)//4) overestimates by 30-50% on
- # tool-heavy/code-heavy conversations, causing premature compression.
_stored_tokens = session_entry.last_prompt_tokens
if _stored_tokens > 0:
_approx_tokens = _stored_tokens
_token_source = "actual"
else:
_approx_tokens = estimate_messages_tokens_rough(history)
- # Apply safety factor only for rough estimates
- _compress_token_threshold = int(
- _compress_token_threshold * 1.4
- )
- _warn_token_threshold = int(_warn_token_threshold * 1.4)
_token_source = "estimated"
+ # Note: rough estimates overestimate by 30-50% for code/JSON-heavy
+ # sessions, but that just means hygiene fires a bit early โ which
+ # is safe and harmless. The 85% threshold already provides ample
+ # headroom (agent's own compressor runs at 50%). A previous 1.4x
+ # multiplier tried to compensate by inflating the threshold, but
+ # 85% * 1.4 = 119% of context โ which exceeds the model's limit
+ # and prevented hygiene from ever firing for ~200K models (GLM-5).
_needs_compress = _approx_tokens >= _compress_token_threshold
@@ -1164,6 +2196,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
enabled_toolsets=["memory"],
session_id=session_entry.session_id,
)
+ _hyg_agent._print_fn = lambda *a, **kw: None
loop = asyncio.get_event_loop()
_compressed, _ = await loop.run_in_executor(
@@ -1273,6 +2306,19 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
f"or ignore to skip."
)
+ # -----------------------------------------------------------------
+ # Voice channel awareness โ inject current voice channel state
+ # into context so the agent knows who is in the channel and who
+ # is speaking, without needing a separate tool call.
+ # -----------------------------------------------------------------
+ if source.platform == Platform.DISCORD:
+ adapter = self.adapters.get(Platform.DISCORD)
+ guild_id = self._get_guild_id(event)
+ if guild_id and adapter and hasattr(adapter, "get_voice_channel_context"):
+ vc_context = adapter.get_voice_channel_context(guild_id)
+ if vc_context:
+ context_prompt += f"\n\n{vc_context}"
+
# -----------------------------------------------------------------
# Auto-analyze images sent by the user
#
@@ -1319,6 +2365,37 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
message_text = await self._enrich_message_with_transcription(
message_text, audio_paths
)
+ # If STT failed, send a direct message to the user so they
+ # know voice isn't configured โ don't rely on the agent to
+ # relay the error clearly.
+ _stt_fail_markers = (
+ "No STT provider",
+ "STT is disabled",
+ "can't listen",
+ "VOICE_TOOLS_OPENAI_KEY",
+ )
+ if any(m in message_text for m in _stt_fail_markers):
+ _stt_adapter = self.adapters.get(source.platform)
+ _stt_meta = {"thread_id": source.thread_id} if source.thread_id else None
+ if _stt_adapter:
+ try:
+ _stt_msg = (
+ "๐ค I received your voice message but can't transcribe it โ "
+ "no speech-to-text provider is configured.\n\n"
+ "To enable voice: install faster-whisper "
+ "(`pip install faster-whisper` in the Hermes venv) "
+ "and set `stt.enabled: true` in config.yaml, "
+ "then /restart the gateway."
+ )
+ # Point to setup skill if it's installed
+ if self._has_setup_skill():
+ _stt_msg += "\n\nFor full setup instructions, type: `/skill hermes-agent-setup`"
+ await _stt_adapter.send(
+ source.chat_id, _stt_msg,
+ metadata=_stt_meta,
+ )
+ except Exception:
+ pass
# -----------------------------------------------------------------
# Enrich document messages with context notes for the agent
@@ -1352,6 +2429,23 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
)
message_text = f"{context_note}\n\n{message_text}"
+ # -----------------------------------------------------------------
+ # Inject reply context when user replies to a message not in history.
+ # Telegram (and other platforms) let users reply to specific messages,
+ # but if the quoted message is from a previous session, cron delivery,
+ # or background task, the agent has no context about what's being
+ # referenced. Prepend the quoted text so the agent understands. (#1594)
+ # -----------------------------------------------------------------
+ if getattr(event, 'reply_to_text', None) and event.reply_to_message_id:
+ reply_snippet = event.reply_to_text[:500]
+ found_in_history = any(
+ reply_snippet[:200] in (msg.get("content") or "")
+ for msg in history
+ if msg.get("role") in ("assistant", "user", "tool")
+ )
+ if not found_in_history:
+ message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'
+
try:
# Emit agent:start hook
hook_ctx = {
@@ -1361,7 +2455,31 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
"message": message_text[:500],
}
await self.hooks.emit("agent:start", hook_ctx)
-
+
+ # Expand @ context references (@file:, @folder:, @diff, etc.)
+ if "@" in message_text:
+ try:
+ from agent.context_references import preprocess_context_references_async
+ from agent.model_metadata import get_model_context_length
+ _msg_cwd = os.environ.get("MESSAGING_CWD", os.path.expanduser("~"))
+ _msg_ctx_len = get_model_context_length(
+ self._model, base_url=self._base_url or "")
+ _ctx_result = await preprocess_context_references_async(
+ message_text, cwd=_msg_cwd,
+ context_length=_msg_ctx_len, allowed_root=_msg_cwd)
+ if _ctx_result.blocked:
+ _adapter = self.adapters.get(source.platform)
+ if _adapter:
+ await _adapter.send(
+ source.chat_id,
+ "\n".join(_ctx_result.warnings) or "Context injection refused.",
+ )
+ return
+ if _ctx_result.expanded:
+ message_text = _ctx_result.message
+ except Exception as exc:
+ logger.debug("@ context reference expansion failed: %s", exc)
+
# Run the agent
agent_result = await self._run_agent(
message=message_text,
@@ -1369,12 +2487,54 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
history=history,
source=source,
session_id=session_entry.session_id,
- session_key=session_key
+ session_key=session_key,
+ event_message_id=event.message_id,
)
-
- response = agent_result.get("final_response", "")
+
+ # Stop persistent typing indicator now that the agent is done
+ try:
+ _typing_adapter = self.adapters.get(source.platform)
+ if _typing_adapter and hasattr(_typing_adapter, "stop_typing"):
+ await _typing_adapter.stop_typing(source.chat_id)
+ except Exception:
+ pass
+
+ response = agent_result.get("final_response") or ""
agent_messages = agent_result.get("messages", [])
+ # Surface error details when the agent failed silently (final_response=None)
+ if not response and agent_result.get("failed"):
+ error_detail = agent_result.get("error", "unknown error")
+ error_str = str(error_detail).lower()
+
+ # Detect context-overflow failures and give specific guidance.
+ # Generic 400 "Error" from Anthropic with large sessions is the
+ # most common cause of this (#1630).
+ _is_ctx_fail = any(p in error_str for p in (
+ "context", "token", "too large", "too long",
+ "exceed", "payload",
+ )) or (
+ "400" in error_str
+ and len(history) > 50
+ )
+
+ if _is_ctx_fail:
+ response = (
+ "โ ๏ธ Session too large for the model's context window.\n"
+ "Use /compact to compress the conversation, or "
+ "/reset to start fresh."
+ )
+ else:
+ response = (
+ f"The request failed: {str(error_detail)[:300]}\n"
+ "Try again or use /reset to start a fresh session."
+ )
+
+ # If the agent's session_id changed during compression, update
+ # session_entry so transcript writes below go to the right session.
+ if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
+ session_entry.session_id = agent_result["session_id"]
+
# Prepend reasoning/thinking if display is enabled
if getattr(self, "_show_reasoning", False) and response:
last_reasoning = agent_result.get("last_reasoning")
@@ -1406,9 +2566,22 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Check if the agent encountered a dangerous command needing approval
try:
from tools.approval import pop_pending
+ import time as _time
pending = pop_pending(session_key)
if pending:
+ pending["timestamp"] = _time.time()
self._pending_approvals[session_key] = pending
+ # Append structured instructions so the user knows how to respond
+ cmd_preview = pending.get("command", "")
+ if len(cmd_preview) > 200:
+ cmd_preview = cmd_preview[:200] + "..."
+ approval_hint = (
+ f"\n\nโ ๏ธ **Dangerous command requires approval:**\n"
+ f"```\n{cmd_preview}\n```\n"
+ f"Reply `/approve` to execute, `/approve session` to approve this pattern "
+ f"for the session, or `/deny` to cancel."
+ )
+ response = (response or "") + approval_hint
except Exception as e:
logger.debug("Failed to check pending approvals: %s", e)
@@ -1416,12 +2589,30 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# This preserves the complete agent loop (tool_calls, tool results,
# intermediate reasoning) so sessions can be resumed with full context
# and transcripts are useful for debugging and training data.
+ #
+ # IMPORTANT: When the agent failed before producing any response
+ # (e.g. context-overflow 400), do NOT persist the user's message.
+ # Persisting it would make the session even larger, causing the
+ # same failure on the next attempt โ an infinite loop. (#1630)
+ agent_failed_early = (
+ agent_result.get("failed")
+ and not agent_result.get("final_response")
+ )
+ if agent_failed_early:
+ logger.info(
+ "Skipping transcript persistence for failed request in "
+ "session %s to prevent session growth loop.",
+ session_entry.session_id,
+ )
+
ts = datetime.now().isoformat()
# If this is a fresh session (no history), write the full tool
# definitions as the first entry so the transcript is self-describing
# -- the same list of dicts sent as tools=[...] in the API request.
- if not history:
+ if agent_failed_early:
+ pass # Skip all transcript writes โ don't grow a broken session
+ elif not history:
tool_defs = agent_result.get("tools", [])
self.session_store.append_to_transcript(
session_entry.session_id,
@@ -1438,75 +2629,247 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# Use the filtered history length (history_offset) that was actually
# passed to the agent, not len(history) which includes session_meta
# entries that were stripped before the agent saw them.
- history_len = agent_result.get("history_offset", len(history))
- new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else []
-
- # If no new messages found (edge case), fall back to simple user/assistant
- if not new_messages:
- self.session_store.append_to_transcript(
- session_entry.session_id,
- {"role": "user", "content": message_text, "timestamp": ts}
- )
- if response:
+ if not agent_failed_early:
+ history_len = agent_result.get("history_offset", len(history))
+ new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else []
+
+ # If no new messages found (edge case), fall back to simple user/assistant
+ if not new_messages:
self.session_store.append_to_transcript(
session_entry.session_id,
- {"role": "assistant", "content": response, "timestamp": ts}
- )
- else:
- # The agent already persisted these messages to SQLite via
- # _flush_messages_to_session_db(), so skip the DB write here
- # to prevent the duplicate-write bug (#860). We still write
- # to JSONL for backward compatibility and as a backup.
- agent_persisted = self._session_db is not None
- for msg in new_messages:
- # Skip system messages (they're rebuilt each run)
- if msg.get("role") == "system":
- continue
- # Add timestamp to each message for debugging
- entry = {**msg, "timestamp": ts}
- self.session_store.append_to_transcript(
- session_entry.session_id, entry,
- skip_db=agent_persisted,
+ {"role": "user", "content": message_text, "timestamp": ts}
)
+ if response:
+ self.session_store.append_to_transcript(
+ session_entry.session_id,
+ {"role": "assistant", "content": response, "timestamp": ts}
+ )
+ else:
+ # The agent already persisted these messages to SQLite via
+ # _flush_messages_to_session_db(), so skip the DB write here
+ # to prevent the duplicate-write bug (#860). We still write
+ # to JSONL for backward compatibility and as a backup.
+ agent_persisted = self._session_db is not None
+ for msg in new_messages:
+ # Skip system messages (they're rebuilt each run)
+ if msg.get("role") == "system":
+ continue
+ # Add timestamp to each message for debugging
+ entry = {**msg, "timestamp": ts}
+ self.session_store.append_to_transcript(
+ session_entry.session_id, entry,
+ skip_db=agent_persisted,
+ )
- # Update session with actual prompt token count from the agent
+ # Update session with actual prompt token count and model from the agent
self.session_store.update_session(
session_entry.session_key,
+ input_tokens=agent_result.get("input_tokens", 0),
+ output_tokens=agent_result.get("output_tokens", 0),
+ cache_read_tokens=agent_result.get("cache_read_tokens", 0),
+ cache_write_tokens=agent_result.get("cache_write_tokens", 0),
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
+ model=agent_result.get("model"),
+ estimated_cost_usd=agent_result.get("estimated_cost_usd"),
+ cost_status=agent_result.get("cost_status"),
+ cost_source=agent_result.get("cost_source"),
+ provider=agent_result.get("provider"),
+ base_url=agent_result.get("base_url"),
)
-
+
+ # Auto voice reply: send TTS audio before the text response
+ _already_sent = bool(agent_result.get("already_sent"))
+ if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent):
+ await self._send_voice_reply(event, response)
+
+ # If streaming already delivered the response, extract and
+ # deliver any MEDIA: files before returning None. Streaming
+ # sends raw text chunks that include MEDIA: tags โ the normal
+ # post-processing in _process_message_background is skipped
+ # when already_sent is True, so media files would never be
+ # delivered without this.
+ if agent_result.get("already_sent"):
+ if response:
+ _media_adapter = self.adapters.get(source.platform)
+ if _media_adapter:
+ await self._deliver_media_from_response(
+ response, event, _media_adapter,
+ )
+ return None
+
return response
except Exception as e:
+ # Stop typing indicator on error too
+ try:
+ _err_adapter = self.adapters.get(source.platform)
+ if _err_adapter and hasattr(_err_adapter, "stop_typing"):
+ await _err_adapter.stop_typing(source.chat_id)
+ except Exception:
+ pass
logger.exception("Agent error in session %s", session_key)
+ error_type = type(e).__name__
+ error_detail = str(e)[:300] if str(e) else "no details available"
+ status_hint = ""
+ status_code = getattr(e, "status_code", None)
+ _hist_len = len(history) if 'history' in locals() else 0
+ if status_code == 401:
+ status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials."
+ elif status_code == 429:
+ # Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit
+ _err_body = getattr(e, "response", None)
+ _err_json = {}
+ try:
+ if _err_body is not None:
+ _err_json = _err_body.json().get("error", {})
+ except Exception:
+ pass
+ if _err_json.get("type") == "usage_limit_reached":
+ _resets_in = _err_json.get("resets_in_seconds")
+ if _resets_in and _resets_in > 0:
+ import math
+ _hours = math.ceil(_resets_in / 3600)
+ status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h."
+ else:
+ status_hint = " Your plan's usage limit has been reached. Please wait until it resets."
+ else:
+ status_hint = " You are being rate-limited. Please wait a moment and try again."
+ elif status_code == 529:
+ status_hint = " The API is temporarily overloaded. Please try again shortly."
+ elif status_code in (400, 500):
+ # 400 with a large session is context overflow.
+ # 500 with a large session often means the payload is too large
+ # for the API to process โ treat it the same way.
+ if _hist_len > 50:
+ return (
+ "โ ๏ธ Session too large for the model's context window.\n"
+ "Use /compact to compress the conversation, or "
+ "/reset to start fresh."
+ )
+ elif status_code == 400:
+ status_hint = " The request was rejected by the API."
return (
- "Sorry, I encountered an unexpected error. "
- "The details have been logged for debugging. "
+ f"Sorry, I encountered an error ({error_type}).\n"
+ f"{error_detail}\n"
+ f"{status_hint}"
"Try again or use /reset to start a fresh session."
)
finally:
# Clear session env
self._clear_session_env()
+ def _format_session_info(self) -> str:
+ """Resolve current model config and return a formatted info block.
+
+ Surfaces model, provider, context length, and endpoint so gateway
+ users can immediately see if context detection went wrong (e.g.
+ local models falling to the 128K default).
+ """
+ from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT
+
+ model = _resolve_gateway_model()
+ config_context_length = None
+ provider = None
+ base_url = None
+ api_key = None
+
+ try:
+ cfg_path = _hermes_home / "config.yaml"
+ if cfg_path.exists():
+ import yaml as _info_yaml
+ with open(cfg_path, encoding="utf-8") as f:
+ data = _info_yaml.safe_load(f) or {}
+ model_cfg = data.get("model", {})
+ if isinstance(model_cfg, dict):
+ raw_ctx = model_cfg.get("context_length")
+ if raw_ctx is not None:
+ try:
+ config_context_length = int(raw_ctx)
+ except (TypeError, ValueError):
+ pass
+ provider = model_cfg.get("provider") or None
+ base_url = model_cfg.get("base_url") or None
+ except Exception:
+ pass
+
+ # Resolve runtime credentials for probing
+ try:
+ runtime = _resolve_runtime_agent_kwargs()
+ provider = provider or runtime.get("provider")
+ base_url = base_url or runtime.get("base_url")
+ api_key = runtime.get("api_key")
+ except Exception:
+ pass
+
+ context_length = get_model_context_length(
+ model,
+ base_url=base_url or "",
+ api_key=api_key or "",
+ config_context_length=config_context_length,
+ provider=provider or "",
+ )
+
+ # Format context source hint
+ if config_context_length is not None:
+ ctx_source = "config"
+ elif context_length == DEFAULT_FALLBACK_CONTEXT:
+ ctx_source = "default โ set model.context_length in config to override"
+ else:
+ ctx_source = "detected"
+
+ # Format context length for display
+ if context_length >= 1_000_000:
+ ctx_display = f"{context_length / 1_000_000:.1f}M"
+ elif context_length >= 1_000:
+ ctx_display = f"{context_length // 1_000}K"
+ else:
+ ctx_display = str(context_length)
+
+ lines = [
+ f"โ Model: `{model}`",
+ f"โ Provider: {provider or 'openrouter'}",
+ f"โ Context: {ctx_display} tokens ({ctx_source})",
+ ]
+
+ # Show endpoint for local/custom setups
+ if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url):
+ lines.append(f"โ Endpoint: {base_url}")
+
+ return "\n".join(lines)
+
async def _handle_reset_command(self, event: MessageEvent) -> str:
"""Handle /new or /reset command."""
source = event.source
# Get existing session key
- session_key = self.session_store._generate_session_key(source)
+ session_key = self._session_key_for_source(source)
# Flush memories in the background (fire-and-forget) so the user
# gets the "Session reset!" response immediately.
try:
old_entry = self.session_store._entries.get(session_key)
if old_entry:
- asyncio.create_task(self._async_flush_memories(old_entry.session_id))
+ _flush_task = asyncio.create_task(
+ self._async_flush_memories(old_entry.session_id, session_key)
+ )
+ self._background_tasks.add(_flush_task)
+ _flush_task.add_done_callback(self._background_tasks.discard)
except Exception as e:
logger.debug("Gateway memory flush on reset failed: %s", e)
+
+ self._shutdown_gateway_honcho(session_key)
+ self._evict_cached_agent(session_key)
# Reset the session
new_entry = self.session_store.reset_session(session_key)
-
+
+ # Emit session:end hook (session is ending)
+ await self.hooks.emit("session:end", {
+ "platform": source.platform.value if source.platform else "",
+ "user_id": source.user_id,
+ "session_key": session_key,
+ })
+
# Emit session:reset hook
await self.hooks.emit("session:reset", {
"platform": source.platform.value if source.platform else "",
@@ -1514,12 +2877,22 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
"session_key": session_key,
})
+ # Resolve session config info to surface to the user
+ try:
+ session_info = self._format_session_info()
+ except Exception:
+ session_info = ""
+
if new_entry:
- return "โจ Session reset! I've started fresh with no memory of our previous conversation."
+ header = "โจ Session reset! Starting fresh."
else:
# No existing session, just create one
self.session_store.get_or_create_session(source, force_new=True)
- return "โจ New session started!"
+ header = "โจ New session started!"
+
+ if session_info:
+ return f"{header}\n\n{session_info}"
+ return header
async def _handle_status_command(self, event: MessageEvent) -> str:
"""Handle /status command."""
@@ -1547,43 +2920,41 @@ async def _handle_status_command(self, event: MessageEvent) -> str:
return "\n".join(lines)
async def _handle_stop_command(self, event: MessageEvent) -> str:
- """Handle /stop command - interrupt a running agent."""
+ """Handle /stop command - interrupt a running agent.
+
+ When an agent is truly hung (blocked thread that never checks
+ _interrupt_requested), the early intercept in _handle_message()
+ handles /stop before this method is reached. This handler fires
+ only through normal command dispatch (no running agent) or as a
+ fallback. Force-clean the session lock in all cases for safety.
+ """
source = event.source
session_entry = self.session_store.get_or_create_session(source)
session_key = session_entry.session_key
- if session_key in self._running_agents:
- agent = self._running_agents[session_key]
- agent.interrupt()
- return "โก Stopping the current task... The agent will finish its current step and respond."
+ agent = self._running_agents.get(session_key)
+ if agent is _AGENT_PENDING_SENTINEL:
+ # Force-clean the sentinel so the session is unlocked.
+ if session_key in self._running_agents:
+ del self._running_agents[session_key]
+ logger.info("HARD STOP (pending) for session %s โ sentinel cleared", session_key[:20])
+ return "โก Force-stopped. The agent was still starting โ session unlocked."
+ if agent:
+ agent.interrupt("Stop requested")
+ # Force-clean the session lock so a truly hung agent doesn't
+ # keep it locked forever.
+ if session_key in self._running_agents:
+ del self._running_agents[session_key]
+ return "โก Force-stopped. The session is unlocked โ you can send a new message."
else:
return "No active task to stop."
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
+ from hermes_cli.commands import gateway_help_lines
lines = [
"๐ **Hermes Commands**\n",
- "`/new` โ Start a new conversation",
- "`/reset` โ Reset conversation history",
- "`/status` โ Show session info",
- "`/stop` โ Interrupt the running agent",
- "`/model [provider:model]` โ Show/change model (or switch provider)",
- "`/provider` โ Show available providers and auth status",
- "`/personality [name]` โ Set a personality",
- "`/retry` โ Retry your last message",
- "`/undo` โ Remove the last exchange",
- "`/sethome` โ Set this chat as the home channel",
- "`/compress` โ Compress conversation context",
- "`/title [name]` โ Set or show the session title",
- "`/resume [name]` โ Resume a previously-named session",
- "`/usage` โ Show token usage for this session",
- "`/insights [days]` โ Show usage insights and analytics",
- "`/reasoning [level|show|hide]` โ Set reasoning effort or toggle display",
- "`/rollback [number]` โ List or restore filesystem checkpoints",
- "`/background ` โ Run a prompt in a separate background session",
- "`/reload-mcp` โ Reload MCP servers from config",
- "`/update` โ Update Hermes Agent to the latest version",
- "`/help` โ Show this message",
+ *gateway_help_lines(),
]
try:
from agent.skill_commands import get_skill_commands
@@ -1596,146 +2967,6 @@ async def _handle_help_command(self, event: MessageEvent) -> str:
pass
return "\n".join(lines)
- async def _handle_model_command(self, event: MessageEvent) -> str:
- """Handle /model command - show or change the current model."""
- import yaml
- from hermes_cli.models import (
- parse_model_input,
- validate_requested_model,
- curated_models_for_provider,
- normalize_provider,
- _PROVIDER_LABELS,
- )
-
- args = event.get_command_args().strip()
- config_path = _hermes_home / 'config.yaml'
-
- # Resolve current model and provider from config
- current = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
- current_provider = "openrouter"
- try:
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- cfg = yaml.safe_load(f) or {}
- model_cfg = cfg.get("model", {})
- if isinstance(model_cfg, str):
- current = model_cfg
- elif isinstance(model_cfg, dict):
- current = model_cfg.get("default", current)
- current_provider = model_cfg.get("provider", current_provider)
- except Exception:
- pass
-
- # Resolve "auto" to the actual provider using credential detection
- current_provider = normalize_provider(current_provider)
- if current_provider == "auto":
- try:
- from hermes_cli.auth import resolve_provider as _resolve_provider
- current_provider = _resolve_provider(current_provider)
- except Exception:
- current_provider = "openrouter"
-
- # Detect custom endpoint: provider resolved to openrouter but a custom
- # base URL is configured โ the user set up a custom endpoint.
- if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
- current_provider = "custom"
-
- if not args:
- provider_label = _PROVIDER_LABELS.get(current_provider, current_provider)
- lines = [
- f"๐ค **Current model:** `{current}`",
- f"**Provider:** {provider_label}",
- "",
- ]
- curated = curated_models_for_provider(current_provider)
- if curated:
- lines.append(f"**Available models ({provider_label}):**")
- for mid, desc in curated:
- marker = " โ" if mid == current else ""
- label = f" _{desc}_" if desc else ""
- lines.append(f"โข `{mid}`{label}{marker}")
- lines.append("")
- lines.append("To change: `/model model-name`")
- lines.append("Switch provider: `/model provider:model-name`")
- return "\n".join(lines)
-
- # Parse provider:model syntax
- target_provider, new_model = parse_model_input(args, current_provider)
- provider_changed = target_provider != current_provider
-
- # Resolve credentials for the target provider (for API probe)
- api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
- base_url = "https://openrouter.ai/api/v1"
- if provider_changed:
- try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=target_provider)
- api_key = runtime.get("api_key", "")
- base_url = runtime.get("base_url", "")
- except Exception as e:
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- return f"โ ๏ธ Could not resolve credentials for provider '{provider_label}': {e}"
- else:
- # Use current provider's base_url from config or registry
- try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=current_provider)
- api_key = runtime.get("api_key", "")
- base_url = runtime.get("base_url", "")
- except Exception:
- pass
-
- # Validate the model against the live API
- try:
- validation = validate_requested_model(
- new_model,
- target_provider,
- api_key=api_key,
- base_url=base_url,
- )
- except Exception:
- validation = {"accepted": True, "persist": True, "recognized": False, "message": None}
-
- if not validation.get("accepted"):
- msg = validation.get("message", "Invalid model")
- tip = "\n\nUse `/model` to see available models, `/provider` to see providers" if "Did you mean" not in msg else ""
- return f"โ ๏ธ {msg}{tip}"
-
- # Persist to config only if validation approves
- if validation.get("persist"):
- try:
- user_config = {}
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- if "model" not in user_config or not isinstance(user_config["model"], dict):
- user_config["model"] = {}
- user_config["model"]["default"] = new_model
- if provider_changed:
- user_config["model"]["provider"] = target_provider
- with open(config_path, 'w', encoding="utf-8") as f:
- yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
- except Exception as e:
- return f"โ ๏ธ Failed to save model change: {e}"
-
- # Set env vars so the next agent run picks up the change
- os.environ["HERMES_MODEL"] = new_model
- if provider_changed:
- os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider
-
- provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
- provider_note = f"\n**Provider:** {provider_label}" if provider_changed else ""
-
- warning = ""
- if validation.get("message"):
- warning = f"\nโ ๏ธ {validation['message']}"
-
- if validation.get("persist"):
- persist_note = "saved to config"
- else:
- persist_note = "this session only โ will revert on restart"
- return f"๐ค Model changed to `{new_model}` ({persist_note}){provider_note}{warning}\n_(takes effect on next message)_"
-
async def _handle_provider_command(self, event: MessageEvent) -> str:
"""Handle /provider command - show available providers."""
import yaml
@@ -1821,7 +3052,7 @@ async def _handle_personality_command(self, event: MessageEvent) -> str:
else:
preview = prompt[:50] + "..." if len(prompt) > 50 else prompt
lines.append(f"โข `{name}` โ {preview}")
- lines.append(f"\nUsage: `/personality `")
+ lines.append("\nUsage: `/personality `")
return "\n".join(lines)
def _resolve_prompt(value):
@@ -1937,25 +3168,435 @@ async def _handle_set_home_command(self, event: MessageEvent) -> str:
# Save to config.yaml
try:
- import yaml
- config_path = _hermes_home / 'config.yaml'
- user_config = {}
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- user_config[env_key] = chat_id
- with open(config_path, 'w', encoding="utf-8") as f:
- yaml.dump(user_config, f, default_flow_style=False)
- # Also set in the current environment so it takes effect immediately
- os.environ[env_key] = str(chat_id)
+ import yaml
+ config_path = _hermes_home / 'config.yaml'
+ user_config = {}
+ if config_path.exists():
+ with open(config_path, encoding="utf-8") as f:
+ user_config = yaml.safe_load(f) or {}
+ user_config[env_key] = chat_id
+ with open(config_path, 'w', encoding="utf-8") as f:
+ yaml.dump(user_config, f, default_flow_style=False)
+ # Also set in the current environment so it takes effect immediately
+ os.environ[env_key] = str(chat_id)
+ except Exception as e:
+ return f"Failed to save home channel: {e}"
+
+ return (
+ f"โ
Home channel set to **{chat_name}** (ID: {chat_id}).\n"
+ f"Cron jobs and cross-platform messages will be delivered here."
+ )
+
+ @staticmethod
+ def _get_guild_id(event: MessageEvent) -> Optional[int]:
+ """Extract Discord guild_id from the raw message object."""
+ raw = getattr(event, "raw_message", None)
+ if raw is None:
+ return None
+ # Slash command interaction
+ if hasattr(raw, "guild_id") and raw.guild_id:
+ return int(raw.guild_id)
+ # Regular message
+ if hasattr(raw, "guild") and raw.guild:
+ return raw.guild.id
+ return None
+
+ async def _handle_voice_command(self, event: MessageEvent) -> str:
+ """Handle /voice [on|off|tts|channel|leave|status] command."""
+ args = event.get_command_args().strip().lower()
+ chat_id = event.source.chat_id
+
+ adapter = self.adapters.get(event.source.platform)
+
+ if args in ("on", "enable"):
+ self._voice_mode[chat_id] = "voice_only"
+ self._save_voice_modes()
+ if adapter:
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
+ return (
+ "Voice mode enabled.\n"
+ "I'll reply with voice when you send voice messages.\n"
+ "Use /voice tts to get voice replies for all messages."
+ )
+ elif args in ("off", "disable"):
+ self._voice_mode[chat_id] = "off"
+ self._save_voice_modes()
+ if adapter:
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
+ return "Voice mode disabled. Text-only replies."
+ elif args == "tts":
+ self._voice_mode[chat_id] = "all"
+ self._save_voice_modes()
+ if adapter:
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
+ return (
+ "Auto-TTS enabled.\n"
+ "All replies will include a voice message."
+ )
+ elif args in ("channel", "join"):
+ return await self._handle_voice_channel_join(event)
+ elif args == "leave":
+ return await self._handle_voice_channel_leave(event)
+ elif args == "status":
+ mode = self._voice_mode.get(chat_id, "off")
+ labels = {
+ "off": "Off (text only)",
+ "voice_only": "On (voice reply to voice messages)",
+ "all": "TTS (voice reply to all messages)",
+ }
+ # Append voice channel info if connected
+ adapter = self.adapters.get(event.source.platform)
+ guild_id = self._get_guild_id(event)
+ if guild_id and hasattr(adapter, "get_voice_channel_info"):
+ info = adapter.get_voice_channel_info(guild_id)
+ if info:
+ lines = [
+ f"Voice mode: {labels.get(mode, mode)}",
+ f"Voice channel: #{info['channel_name']}",
+ f"Participants: {info['member_count']}",
+ ]
+ for m in info["members"]:
+ status = " (speaking)" if m.get("is_speaking") else ""
+ lines.append(f" - {m['display_name']}{status}")
+ return "\n".join(lines)
+ return f"Voice mode: {labels.get(mode, mode)}"
+ else:
+ # Toggle: off โ on, on/all โ off
+ current = self._voice_mode.get(chat_id, "off")
+ if current == "off":
+ self._voice_mode[chat_id] = "voice_only"
+ self._save_voice_modes()
+ if adapter:
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
+ return "Voice mode enabled."
+ else:
+ self._voice_mode[chat_id] = "off"
+ self._save_voice_modes()
+ if adapter:
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
+ return "Voice mode disabled."
+
+ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
+ """Join the user's current Discord voice channel."""
+ adapter = self.adapters.get(event.source.platform)
+ if not hasattr(adapter, "join_voice_channel"):
+ return "Voice channels are not supported on this platform."
+
+ guild_id = self._get_guild_id(event)
+ if not guild_id:
+ return "This command only works in a Discord server."
+
+ voice_channel = await adapter.get_user_voice_channel(
+ guild_id, event.source.user_id
+ )
+ if not voice_channel:
+ return "You need to be in a voice channel first."
+
+ # Wire callbacks BEFORE join so voice input arriving immediately
+ # after connection is not lost.
+ if hasattr(adapter, "_voice_input_callback"):
+ adapter._voice_input_callback = self._handle_voice_channel_input
+ if hasattr(adapter, "_on_voice_disconnect"):
+ adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
+
+ try:
+ success = await adapter.join_voice_channel(voice_channel)
+ except Exception as e:
+ logger.warning("Failed to join voice channel: %s", e)
+ adapter._voice_input_callback = None
+ err_lower = str(e).lower()
+ if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower:
+ return (
+ "Voice dependencies are missing (PyNaCl / davey). "
+ "Install or reinstall Hermes with the messaging extra, e.g. "
+ "`pip install hermes-agent[messaging]`."
+ )
+ return f"Failed to join voice channel: {e}"
+
+ if success:
+ adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
+ self._voice_mode[event.source.chat_id] = "all"
+ self._save_voice_modes()
+ self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
+ return (
+ f"Joined voice channel **{voice_channel.name}**.\n"
+ f"I'll speak my replies and listen to you. Use /voice leave to disconnect."
+ )
+ # Join failed โ clear callback
+ adapter._voice_input_callback = None
+ return "Failed to join voice channel. Check bot permissions (Connect + Speak)."
+
+ async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:
+ """Leave the Discord voice channel."""
+ adapter = self.adapters.get(event.source.platform)
+ guild_id = self._get_guild_id(event)
+
+ if not guild_id or not hasattr(adapter, "leave_voice_channel"):
+ return "Not in a voice channel."
+
+ if not hasattr(adapter, "is_in_voice_channel") or not adapter.is_in_voice_channel(guild_id):
+ return "Not in a voice channel."
+
+ try:
+ await adapter.leave_voice_channel(guild_id)
+ except Exception as e:
+ logger.warning("Error leaving voice channel: %s", e)
+ # Always clean up state even if leave raised an exception
+ self._voice_mode[event.source.chat_id] = "off"
+ self._save_voice_modes()
+ self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
+ if hasattr(adapter, "_voice_input_callback"):
+ adapter._voice_input_callback = None
+ return "Left voice channel."
+
+ def _handle_voice_timeout_cleanup(self, chat_id: str) -> None:
+ """Called by the adapter when a voice channel times out.
+
+ Cleans up runner-side voice_mode state that the adapter cannot reach.
+ """
+ self._voice_mode[chat_id] = "off"
+ self._save_voice_modes()
+ adapter = self.adapters.get(Platform.DISCORD)
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
+
+ async def _handle_voice_channel_input(
+ self, guild_id: int, user_id: int, transcript: str
+ ):
+ """Handle transcribed voice from a user in a voice channel.
+
+ Creates a synthetic MessageEvent and processes it through the
+ adapter's full message pipeline (session, typing, agent, TTS reply).
+ """
+ adapter = self.adapters.get(Platform.DISCORD)
+ if not adapter:
+ return
+
+ text_ch_id = adapter._voice_text_channels.get(guild_id)
+ if not text_ch_id:
+ return
+
+ # Check authorization before processing voice input
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ chat_id=str(text_ch_id),
+ user_id=str(user_id),
+ user_name=str(user_id),
+ chat_type="channel",
+ )
+ if not self._is_user_authorized(source):
+ logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
+ return
+
+ # Show transcript in text channel (after auth, with mention sanitization)
+ try:
+ channel = adapter._client.get_channel(text_ch_id)
+ if channel:
+ safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
+ await channel.send(f"**[Voice]** <@{user_id}>: {safe_text}")
+ except Exception:
+ pass
+
+ # Build a synthetic MessageEvent and feed through the normal pipeline
+ # Use SimpleNamespace as raw_message so _get_guild_id() can extract
+ # guild_id and _send_voice_reply() plays audio in the voice channel.
+ from types import SimpleNamespace
+ event = MessageEvent(
+ source=source,
+ text=transcript,
+ message_type=MessageType.VOICE,
+ raw_message=SimpleNamespace(guild_id=guild_id, guild=None),
+ )
+
+ await adapter.handle_message(event)
+
+ def _should_send_voice_reply(
+ self,
+ event: MessageEvent,
+ response: str,
+ agent_messages: list,
+ already_sent: bool = False,
+ ) -> bool:
+ """Decide whether the runner should send a TTS voice reply.
+
+ Returns False when:
+ - voice_mode is off for this chat
+ - response is empty or an error
+ - agent already called text_to_speech tool (dedup)
+ - voice input and base adapter auto-TTS already handled it (skip_double)
+ UNLESS streaming already consumed the response (already_sent=True),
+ in which case the base adapter won't have text for auto-TTS so the
+ runner must handle it.
+ """
+ if not response or response.startswith("Error:"):
+ return False
+
+ chat_id = event.source.chat_id
+ voice_mode = self._voice_mode.get(chat_id, "off")
+ is_voice_input = (event.message_type == MessageType.VOICE)
+
+ should = (
+ (voice_mode == "all")
+ or (voice_mode == "voice_only" and is_voice_input)
+ )
+ if not should:
+ return False
+
+ # Dedup: agent already called TTS tool
+ has_agent_tts = any(
+ msg.get("role") == "assistant"
+ and any(
+ tc.get("function", {}).get("name") == "text_to_speech"
+ for tc in (msg.get("tool_calls") or [])
+ )
+ for msg in agent_messages
+ )
+ if has_agent_tts:
+ return False
+
+ # Dedup: base adapter auto-TTS already handles voice input
+ # (play_tts plays in VC when connected, so runner can skip).
+ # When streaming already delivered the text (already_sent=True),
+ # the base adapter will receive None and can't run auto-TTS,
+ # so the runner must take over.
+ if is_voice_input and not already_sent:
+ return False
+
+ return True
+
+ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
+ """Generate TTS audio and send as a voice message before the text reply."""
+ import uuid as _uuid
+ audio_path = None
+ actual_path = None
+ try:
+ from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts
+
+ tts_text = _strip_markdown_for_tts(text[:4000])
+ if not tts_text:
+ return
+
+ # Use .mp3 extension so edge-tts conversion to opus works correctly.
+ # The TTS tool may convert to .ogg โ use file_path from result.
+ audio_path = os.path.join(
+ tempfile.gettempdir(), "hermes_voice",
+ f"tts_reply_{_uuid.uuid4().hex[:12]}.mp3",
+ )
+ os.makedirs(os.path.dirname(audio_path), exist_ok=True)
+
+ result_json = await asyncio.to_thread(
+ text_to_speech_tool, text=tts_text, output_path=audio_path
+ )
+ result = json.loads(result_json)
+
+ # Use the actual file path from result (may differ after opus conversion)
+ actual_path = result.get("file_path", audio_path)
+ if not result.get("success") or not os.path.isfile(actual_path):
+ logger.warning("Auto voice reply TTS failed: %s", result.get("error"))
+ return
+
+ adapter = self.adapters.get(event.source.platform)
+
+ # If connected to a voice channel, play there instead of sending a file
+ guild_id = self._get_guild_id(event)
+ if (guild_id
+ and hasattr(adapter, "play_in_voice_channel")
+ and hasattr(adapter, "is_in_voice_channel")
+ and adapter.is_in_voice_channel(guild_id)):
+ await adapter.play_in_voice_channel(guild_id, actual_path)
+ elif adapter and hasattr(adapter, "send_voice"):
+ send_kwargs: Dict[str, Any] = {
+ "chat_id": event.source.chat_id,
+ "audio_path": actual_path,
+ "reply_to": event.message_id,
+ }
+ if event.source.thread_id:
+ send_kwargs["metadata"] = {"thread_id": event.source.thread_id}
+ await adapter.send_voice(**send_kwargs)
+ except Exception as e:
+ logger.warning("Auto voice reply failed: %s", e, exc_info=True)
+ finally:
+ for p in {audio_path, actual_path} - {None}:
+ try:
+ os.unlink(p)
+ except OSError:
+ pass
+
+ async def _deliver_media_from_response(
+ self,
+ response: str,
+ event: MessageEvent,
+ adapter,
+ ) -> None:
+ """Extract MEDIA: tags and local file paths from a response and deliver them.
+
+ Called after streaming has already sent the text to the user, so the
+ text itself is already delivered โ this only handles file attachments
+ that the normal _process_message_background path would have caught.
+ """
+ from pathlib import Path
+
+ try:
+ media_files, _ = adapter.extract_media(response)
+ _, cleaned = adapter.extract_images(response)
+ local_files, _ = adapter.extract_local_files(cleaned)
+
+ _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
+
+ _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
+ _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
+ _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
+
+ for media_path, is_voice in media_files:
+ try:
+ ext = Path(media_path).suffix.lower()
+ if ext in _AUDIO_EXTS:
+ await adapter.send_voice(
+ chat_id=event.source.chat_id,
+ audio_path=media_path,
+ metadata=_thread_meta,
+ )
+ elif ext in _VIDEO_EXTS:
+ await adapter.send_video(
+ chat_id=event.source.chat_id,
+ video_path=media_path,
+ metadata=_thread_meta,
+ )
+ elif ext in _IMAGE_EXTS:
+ await adapter.send_image_file(
+ chat_id=event.source.chat_id,
+ image_path=media_path,
+ metadata=_thread_meta,
+ )
+ else:
+ await adapter.send_document(
+ chat_id=event.source.chat_id,
+ file_path=media_path,
+ metadata=_thread_meta,
+ )
+ except Exception as e:
+ logger.warning("[%s] Post-stream media delivery failed: %s", adapter.name, e)
+
+ for file_path in local_files:
+ try:
+ ext = Path(file_path).suffix.lower()
+ if ext in _IMAGE_EXTS:
+ await adapter.send_image_file(
+ chat_id=event.source.chat_id,
+ image_path=file_path,
+ metadata=_thread_meta,
+ )
+ else:
+ await adapter.send_document(
+ chat_id=event.source.chat_id,
+ file_path=file_path,
+ metadata=_thread_meta,
+ )
+ except Exception as e:
+ logger.warning("[%s] Post-stream file delivery failed: %s", adapter.name, e)
+
except Exception as e:
- return f"Failed to save home channel: {e}"
-
- return (
- f"โ
Home channel set to **{chat_name}** (ID: {chat_id}).\n"
- f"Cron jobs and cross-platform messages will be delivered here."
- )
-
+ logger.warning("Post-stream media extraction failed: %s", e)
+
async def _handle_rollback_command(self, event: MessageEvent) -> str:
"""Handle /rollback command โ list or restore filesystem checkpoints."""
from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
@@ -2035,9 +3676,11 @@ async def _handle_background_command(self, event: MessageEvent) -> str:
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}"
# Fire-and-forget the background task
- asyncio.create_task(
+ _task = asyncio.create_task(
self._run_background_task(prompt, source, task_id)
)
+ self._background_tasks.add(_task)
+ _task.add_done_callback(self._background_tasks.discard)
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
return f'๐ Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting โ results will appear when done.'
@@ -2065,63 +3708,28 @@ async def _run_background_task(
)
return
- # Read model from config via shared helper
- model = _resolve_gateway_model()
-
- # Determine toolset (same logic as _run_agent)
- default_toolset_map = {
- Platform.LOCAL: "hermes-cli",
- Platform.TELEGRAM: "hermes-telegram",
- Platform.DISCORD: "hermes-discord",
- Platform.WHATSAPP: "hermes-whatsapp",
- Platform.SLACK: "hermes-slack",
- Platform.SIGNAL: "hermes-signal",
- Platform.HOMEASSISTANT: "hermes-homeassistant",
- Platform.EMAIL: "hermes-email",
- }
- platform_toolsets_config = {}
- try:
- config_path = _hermes_home / 'config.yaml'
- if config_path.exists():
- import yaml
- with open(config_path, 'r', encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- platform_toolsets_config = user_config.get("platform_toolsets", {})
- except Exception:
- pass
-
- platform_config_key = {
- Platform.LOCAL: "cli",
- Platform.TELEGRAM: "telegram",
- Platform.DISCORD: "discord",
- Platform.WHATSAPP: "whatsapp",
- Platform.SLACK: "slack",
- Platform.SIGNAL: "signal",
- Platform.HOMEASSISTANT: "homeassistant",
- Platform.EMAIL: "email",
- }.get(source.platform, "telegram")
-
- config_toolsets = platform_toolsets_config.get(platform_config_key)
- if config_toolsets and isinstance(config_toolsets, list):
- enabled_toolsets = config_toolsets
- else:
- default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
- enabled_toolsets = [default_toolset]
+ user_config = _load_gateway_config()
+ model = _resolve_gateway_model(user_config)
+ platform_key = _platform_config_key(source.platform)
- platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
+ from hermes_cli.tools_config import _get_platform_tools
+ enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
+ reasoning_config = self._load_reasoning_config()
+ self._reasoning_config = reasoning_config
+ turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
def run_sync():
agent = AIAgent(
- model=model,
- **runtime_kwargs,
+ model=turn_route["model"],
+ **turn_route["runtime"],
max_iterations=max_iterations,
quiet_mode=True,
verbose_logging=False,
enabled_toolsets=enabled_toolsets,
- reasoning_config=self._reasoning_config,
+ reasoning_config=reasoning_config,
providers_allowed=pr.get("only"),
providers_ignored=pr.get("ignore"),
providers_order=pr.get("order"),
@@ -2219,6 +3827,8 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
args = event.get_command_args().strip().lower()
config_path = _hermes_home / "config.yaml"
+ self._reasoning_config = self._load_reasoning_config()
+ self._show_reasoning = self._load_show_reasoning()
def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
@@ -2288,6 +3898,68 @@ def _save_config_key(key_path: str, value):
else:
return f"๐ง โ Reasoning effort set to `{effort}` (this session only)"
+ async def _handle_verbose_command(self, event: MessageEvent) -> str:
+ """Handle /verbose command โ cycle tool progress display mode.
+
+ Gated by ``display.tool_progress_command`` in config.yaml (default off).
+ When enabled, cycles the tool progress mode through off โ new โ all โ
+ verbose โ off, same as the CLI.
+ """
+ import yaml
+
+ config_path = _hermes_home / "config.yaml"
+
+ # --- check config gate ------------------------------------------------
+ try:
+ user_config = {}
+ if config_path.exists():
+ with open(config_path, encoding="utf-8") as f:
+ user_config = yaml.safe_load(f) or {}
+ gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
+ except Exception:
+ gate_enabled = False
+
+ if not gate_enabled:
+ return (
+ "The `/verbose` command is not enabled for messaging platforms.\n\n"
+ "Enable it in `config.yaml`:\n```yaml\n"
+ "display:\n tool_progress_command: true\n```"
+ )
+
+ # --- cycle mode -------------------------------------------------------
+ cycle = ["off", "new", "all", "verbose"]
+ descriptions = {
+ "off": "โ๏ธ Tool progress: **OFF** โ no tool activity shown.",
+ "new": "โ๏ธ Tool progress: **NEW** โ shown when tool changes.",
+ "all": "โ๏ธ Tool progress: **ALL** โ every tool call shown.",
+ "verbose": "โ๏ธ Tool progress: **VERBOSE** โ full args and results.",
+ }
+
+ raw_progress = user_config.get("display", {}).get("tool_progress", "all")
+ # YAML 1.1 parses bare "off" as boolean False โ normalise back
+ if raw_progress is False:
+ current = "off"
+ elif raw_progress is True:
+ current = "all"
+ else:
+ current = str(raw_progress).lower()
+ if current not in cycle:
+ current = "all"
+ idx = (cycle.index(current) + 1) % len(cycle)
+ new_mode = cycle[idx]
+
+ # Save to config.yaml
+ try:
+ if "display" not in user_config or not isinstance(user_config.get("display"), dict):
+ user_config["display"] = {}
+ user_config["display"]["tool_progress"] = new_mode
+ with open(config_path, "w", encoding="utf-8") as f:
+ yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
+ return f"{descriptions[new_mode]}\n_(saved to config โ takes effect on next message)_"
+ except Exception as e:
+ logger.warning("Failed to save tool_progress mode: %s", e)
+ return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
+
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
source = event.source
@@ -2324,6 +3996,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
enabled_toolsets=["memory"],
session_id=session_entry.session_id,
)
+ tmp_agent._print_fn = lambda *a, **kw: None
loop = asyncio.get_event_loop()
compressed, _ = await loop.run_in_executor(
@@ -2356,6 +4029,20 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
if not self._session_db:
return "Session database not available."
+ # Ensure session exists in SQLite DB (it may only exist in session_store
+ # if this is the first command in a new session)
+ existing_title = self._session_db.get_session_title(session_id)
+ if existing_title is None:
+ # Session doesn't exist in DB yet โ create it
+ try:
+ self._session_db.create_session(
+ session_id=session_id,
+ source=source.platform.value if source.platform else "unknown",
+ user_id=source.user_id,
+ )
+ except Exception:
+ pass # Session might already exist, ignore errors
+
title_arg = event.get_command_args().strip()
if title_arg:
# Sanitize the title before setting
@@ -2374,12 +4061,12 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
except ValueError as e:
return f"โ ๏ธ {e}"
else:
- # Show the current title
+ # Show the current title and session ID
title = self._session_db.get_session_title(session_id)
if title:
- return f"๐ Session title: **{title}**"
+ return f"๐ Session: `{session_id}`\nTitle: **{title}**"
else:
- return "No title set. Usage: `/title My Session Name`"
+ return f"๐ Session: `{session_id}`\nNo title set. Usage: `/title My Session Name`"
async def _handle_resume_command(self, event: MessageEvent) -> str:
"""Handle /resume command โ switch to a previously-named session."""
@@ -2387,7 +4074,7 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
return "Session database not available."
source = event.source
- session_key = build_session_key(source)
+ session_key = self._session_key_for_source(source)
name = event.get_command_args().strip()
if not name:
@@ -2431,10 +4118,16 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
# Flush memories for current session before switching
try:
- asyncio.create_task(self._async_flush_memories(current_entry.session_id))
+ _flush_task = asyncio.create_task(
+ self._async_flush_memories(current_entry.session_id, session_key)
+ )
+ self._background_tasks.add(_flush_task)
+ _flush_task.add_done_callback(self._background_tasks.discard)
except Exception as e:
logger.debug("Memory flush on resume failed: %s", e)
+ self._shutdown_gateway_honcho(session_key)
+
# Clear any running agent for this session key
if session_key in self._running_agents:
del self._running_agents[session_key]
@@ -2457,7 +4150,7 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the session's last agent run."""
source = event.source
- session_key = build_session_key(source)
+ session_key = self._session_key_for_source(source)
agent = self._running_agents.get(session_key)
if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:
@@ -2608,13 +4301,85 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str:
logger.warning("MCP reload failed: %s", e)
return f"โ MCP reload failed: {e}"
+ # ------------------------------------------------------------------
+ # /approve & /deny โ explicit dangerous-command approval
+ # ------------------------------------------------------------------
+
+ _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes
+
+ async def _handle_approve_command(self, event: MessageEvent) -> str:
+ """Handle /approve command โ execute a pending dangerous command.
+
+ Usage:
+ /approve โ approve and execute the pending command
+ /approve session โ approve and remember for this session
+ /approve always โ approve this pattern permanently
+ """
+ source = event.source
+ session_key = self._session_key_for_source(source)
+
+ if session_key not in self._pending_approvals:
+ return "No pending command to approve."
+
+ import time as _time
+ approval = self._pending_approvals[session_key]
+
+ # Check for timeout
+ ts = approval.get("timestamp", 0)
+ if _time.time() - ts > self._APPROVAL_TIMEOUT_SECONDS:
+ self._pending_approvals.pop(session_key, None)
+ return "โ ๏ธ Approval expired (timed out after 5 minutes). Ask the agent to try again."
+
+ self._pending_approvals.pop(session_key)
+ cmd = approval["command"]
+ pattern_keys = approval.get("pattern_keys", [])
+ if not pattern_keys:
+ pk = approval.get("pattern_key", "")
+ pattern_keys = [pk] if pk else []
+
+ # Determine approval scope from args
+ args = event.get_command_args().strip().lower()
+ from tools.approval import approve_session, approve_permanent
+
+ if args in ("always", "permanent", "permanently"):
+ for pk in pattern_keys:
+ approve_permanent(pk)
+ scope_msg = " (pattern approved permanently)"
+ elif args in ("session", "ses"):
+ for pk in pattern_keys:
+ approve_session(session_key, pk)
+ scope_msg = " (pattern approved for this session)"
+ else:
+ # One-time approval โ just approve for session so the immediate
+ # replay works, but don't advertise it as session-wide
+ for pk in pattern_keys:
+ approve_session(session_key, pk)
+ scope_msg = ""
+
+ logger.info("User approved dangerous command via /approve: %s...%s", cmd[:60], scope_msg)
+ from tools.terminal_tool import terminal_tool
+ result = terminal_tool(command=cmd, force=True)
+ return f"โ
Command approved and executed{scope_msg}.\n\n```\n{result[:3500]}\n```"
+
+ async def _handle_deny_command(self, event: MessageEvent) -> str:
+ """Handle /deny command โ reject a pending dangerous command."""
+ source = event.source
+ session_key = self._session_key_for_source(source)
+
+ if session_key not in self._pending_approvals:
+ return "No pending command to deny."
+
+ self._pending_approvals.pop(session_key)
+ logger.info("User denied dangerous command via /deny")
+ return "โ Command denied."
+
async def _handle_update_command(self, event: MessageEvent) -> str:
"""Handle /update command โ update Hermes Agent to the latest version.
Spawns ``hermes update`` in a separate systemd scope so it survives the
- gateway restart that ``hermes update`` triggers at the end. A marker
- file is written so the *new* gateway process can notify the user of the
- result on startup.
+ gateway restart that ``hermes update`` may trigger at the end. Marker
+ files are written so either the current gateway process or the next one
+ can notify the user when the update finishes.
"""
import json
import shutil
@@ -2627,13 +4392,18 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
if not git_dir.exists():
return "โ Not a git repository โ cannot update."
- hermes_bin = shutil.which("hermes")
- if not hermes_bin:
- return "โ `hermes` command not found on PATH."
+ hermes_cmd = _resolve_hermes_bin()
+ if not hermes_cmd:
+ return (
+ "โ Could not locate the `hermes` command. "
+ "Hermes is running, but the update command could not find the "
+ "executable on PATH or via the current Python interpreter. "
+ "Try running `hermes update` manually in your terminal."
+ )
- # Write marker so the restarted gateway can notify this chat
pending_path = _hermes_home / ".update_pending.json"
output_path = _hermes_home / ".update_output.txt"
+ exit_code_path = _hermes_home / ".update_exit_code"
pending = {
"platform": event.source.platform.value,
"chat_id": event.source.chat_id,
@@ -2641,10 +4411,15 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
"timestamp": datetime.now().isoformat(),
}
pending_path.write_text(json.dumps(pending))
+ exit_code_path.unlink(missing_ok=True)
# Spawn `hermes update` in a separate cgroup so it survives gateway
- # restart. systemd-run --user --scope creates a transient scope unit.
- update_cmd = f"{hermes_bin} update > {output_path} 2>&1"
+ # restart. systemd-run --user --scope creates a transient scope unit.
+ hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
+ update_cmd = (
+ f"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; "
+ f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
+ )
try:
systemd_run = shutil.which("systemd-run")
if systemd_run:
@@ -2666,26 +4441,91 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
)
except Exception as e:
pending_path.unlink(missing_ok=True)
+ exit_code_path.unlink(missing_ok=True)
return f"โ Failed to start update: {e}"
+ self._schedule_update_notification_watch()
return "โ Starting Hermes updateโฆ I'll notify you when it's done."
- async def _send_update_notification(self) -> None:
- """If the gateway is starting after a ``/update``, notify the user."""
+ def _schedule_update_notification_watch(self) -> None:
+ """Ensure a background task is watching for update completion."""
+ existing_task = getattr(self, "_update_notification_task", None)
+ if existing_task and not existing_task.done():
+ return
+
+ try:
+ self._update_notification_task = asyncio.create_task(
+ self._watch_for_update_completion()
+ )
+ except RuntimeError:
+ logger.debug("Skipping update notification watcher: no running event loop")
+
+ async def _watch_for_update_completion(
+ self,
+ poll_interval: float = 2.0,
+ timeout: float = 1800.0,
+ ) -> None:
+ """Wait for ``hermes update`` to finish, then send its notification."""
+ pending_path = _hermes_home / ".update_pending.json"
+ claimed_path = _hermes_home / ".update_pending.claimed.json"
+ exit_code_path = _hermes_home / ".update_exit_code"
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+
+ while (pending_path.exists() or claimed_path.exists()) and loop.time() < deadline:
+ if exit_code_path.exists():
+ await self._send_update_notification()
+ return
+ await asyncio.sleep(poll_interval)
+
+ if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists():
+ logger.warning("Update watcher timed out waiting for completion marker")
+ exit_code_path.write_text("124")
+ await self._send_update_notification()
+
+ async def _send_update_notification(self) -> bool:
+ """If an update finished, notify the user.
+
+ Returns False when the update is still running so a caller can retry
+ later. Returns True after a definitive send/skip decision.
+ """
import json
import re as _re
pending_path = _hermes_home / ".update_pending.json"
+ claimed_path = _hermes_home / ".update_pending.claimed.json"
output_path = _hermes_home / ".update_output.txt"
+ exit_code_path = _hermes_home / ".update_exit_code"
- if not pending_path.exists():
- return
+ if not pending_path.exists() and not claimed_path.exists():
+ return False
+ cleanup = True
+ active_pending_path = claimed_path
try:
- pending = json.loads(pending_path.read_text())
+ if pending_path.exists():
+ try:
+ pending_path.replace(claimed_path)
+ except FileNotFoundError:
+ if not claimed_path.exists():
+ return True
+ elif not claimed_path.exists():
+ return True
+
+ pending = json.loads(claimed_path.read_text())
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
+ if not exit_code_path.exists():
+ logger.info("Update notification deferred: update still running")
+ cleanup = False
+ active_pending_path = pending_path
+ claimed_path.replace(pending_path)
+ return False
+
+ exit_code_raw = exit_code_path.read_text().strip() or "1"
+ exit_code = int(exit_code_raw)
+
# Read the captured update output
output = ""
if output_path.exists():
@@ -2699,19 +4539,34 @@ async def _send_update_notification(self) -> None:
# Strip ANSI escape codes for clean display
output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
if output:
- # Truncate if too long for a single message
if len(output) > 3500:
output = "โฆ" + output[-3500:]
- msg = f"โ
Hermes update finished โ gateway restarted.\n\n```\n{output}\n```"
+ if exit_code == 0:
+ msg = f"โ
Hermes update finished.\n\n```\n{output}\n```"
+ else:
+ msg = f"โ Hermes update failed.\n\n```\n{output}\n```"
else:
- msg = "โ
Hermes update finished โ gateway restarted successfully."
+ if exit_code == 0:
+ msg = "โ
Hermes update finished successfully."
+ else:
+ msg = "โ Hermes update failed. Check the gateway logs or run `hermes update` manually for details."
await adapter.send(chat_id, msg)
- logger.info("Sent post-update notification to %s:%s", platform_str, chat_id)
+ logger.info(
+ "Sent post-update notification to %s:%s (exit=%s)",
+ platform_str,
+ chat_id,
+ exit_code,
+ )
except Exception as e:
logger.warning("Post-update notification failed: %s", e)
finally:
- pending_path.unlink(missing_ok=True)
- output_path.unlink(missing_ok=True)
+ if cleanup:
+ active_pending_path.unlink(missing_ok=True)
+ claimed_path.unlink(missing_ok=True)
+ output_path.unlink(missing_ok=True)
+ exit_code_path.unlink(missing_ok=True)
+
+ return True
def _set_session_env(self, context: SessionContext) -> None:
"""Set environment variables for the current session."""
@@ -2719,10 +4574,12 @@ def _set_session_env(self, context: SessionContext) -> None:
os.environ["HERMES_SESSION_CHAT_ID"] = context.source.chat_id
if context.source.chat_name:
os.environ["HERMES_SESSION_CHAT_NAME"] = context.source.chat_name
+ if context.source.thread_id:
+ os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
def _clear_session_env(self) -> None:
"""Clear session environment variables."""
- for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME"]:
+ for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]:
if var in os.environ:
del os.environ[var]
@@ -2800,7 +4657,7 @@ async def _enrich_message_with_transcription(
audio_paths: List[str],
) -> str:
"""
- Auto-transcribe user voice/audio messages using OpenAI Whisper API
+ Auto-transcribe user voice/audio messages using the configured STT provider
and prepend the transcript to the message text.
Args:
@@ -2810,14 +4667,28 @@ async def _enrich_message_with_transcription(
Returns:
The enriched message string with transcriptions prepended.
"""
- from tools.transcription_tools import transcribe_audio
+ if not getattr(self.config, "stt_enabled", True):
+ disabled_note = "[The user sent voice message(s), but transcription is disabled in config."
+ if self._has_setup_skill():
+ disabled_note += (
+ " You have a skill called hermes-agent-setup that can help "
+ "users configure Hermes features including voice, tools, and more."
+ )
+ disabled_note += "]"
+ if user_text:
+ return f"{disabled_note}\n\n{user_text}"
+ return disabled_note
+
+ from tools.transcription_tools import transcribe_audio, get_stt_model_from_config
import asyncio
+ stt_model = get_stt_model_from_config()
+
enriched_parts = []
for path in audio_paths:
try:
logger.debug("Transcribing user voice: %s", path)
- result = await asyncio.to_thread(transcribe_audio, path)
+ result = await asyncio.to_thread(transcribe_audio, path, model=stt_model)
if result["success"]:
transcript = result["transcript"]
enriched_parts.append(
@@ -2826,12 +4697,24 @@ async def _enrich_message_with_transcription(
)
else:
error = result.get("error", "unknown error")
- if "OPENAI_API_KEY" in error or "VOICE_TOOLS_OPENAI_KEY" in error:
- enriched_parts.append(
+ if (
+ "No STT provider" in error
+ or error.startswith("Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set")
+ ):
+ _no_stt_note = (
"[The user sent a voice message but I can't listen "
- "to it right now~ VOICE_TOOLS_OPENAI_KEY isn't set up yet "
- "(';w;') Let them know!]"
+ "to it right now โ no STT provider is configured. "
+ "A direct message has already been sent to the user "
+ "with setup instructions."
)
+ if self._has_setup_skill():
+ _no_stt_note += (
+ " You have a skill called hermes-agent-setup "
+ "that can help users configure Hermes features "
+ "including voice, tools, and more."
+ )
+ _no_stt_note += "]"
+ enriched_parts.append(_no_stt_note)
else:
enriched_parts.append(
"[The user sent a voice message but I had trouble "
@@ -2871,6 +4754,7 @@ async def _run_process_watcher(self, watcher: dict) -> None:
session_key = watcher.get("session_key", "")
platform_name = watcher.get("platform", "")
chat_id = watcher.get("chat_id", "")
+ thread_id = watcher.get("thread_id", "")
notify_mode = self._load_background_notifications_mode()
logger.debug("Process watcher started: %s (every %ss, notify=%s)",
@@ -2918,7 +4802,8 @@ async def _run_process_watcher(self, watcher: dict) -> None:
break
if adapter and chat_id:
try:
- await adapter.send(chat_id, message_text)
+ send_meta = {"thread_id": thread_id} if thread_id else None
+ await adapter.send(chat_id, message_text, metadata=send_meta)
except Exception as e:
logger.error("Watcher delivery error: %s", e)
break
@@ -2937,12 +4822,62 @@ async def _run_process_watcher(self, watcher: dict) -> None:
break
if adapter and chat_id:
try:
- await adapter.send(chat_id, message_text)
+ send_meta = {"thread_id": thread_id} if thread_id else None
+ await adapter.send(chat_id, message_text, metadata=send_meta)
except Exception as e:
logger.error("Watcher delivery error: %s", e)
logger.debug("Process watcher ended: %s", session_id)
+ _MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling (#816)
+
+ @staticmethod
+ def _agent_config_signature(
+ model: str,
+ runtime: dict,
+ enabled_toolsets: list,
+ ephemeral_prompt: str,
+ ) -> str:
+ """Compute a stable string key from agent config values.
+
+ When this signature changes between messages, the cached AIAgent is
+ discarded and rebuilt. When it stays the same, the cached agent is
+ reused โ preserving the frozen system prompt and tool schemas for
+ prompt cache hits.
+ """
+ import hashlib, json as _j
+
+ # Fingerprint the FULL credential string instead of using a short
+ # prefix. OAuth/JWT-style tokens frequently share a common prefix
+ # (e.g. "eyJhbGci"), which can cause false cache hits across auth
+ # switches if only the first few characters are considered.
+ _api_key = str(runtime.get("api_key", "") or "")
+ _api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else ""
+
+ blob = _j.dumps(
+ [
+ model,
+ _api_key_fingerprint,
+ runtime.get("base_url", ""),
+ runtime.get("provider", ""),
+ runtime.get("api_mode", ""),
+ sorted(enabled_toolsets) if enabled_toolsets else [],
+ # reasoning_config excluded โ it's set per-message on the
+ # cached agent and doesn't affect system prompt or tools.
+ ephemeral_prompt or "",
+ ],
+ sort_keys=True,
+ default=str,
+ )
+ return hashlib.sha256(blob.encode()).hexdigest()[:16]
+
+ def _evict_cached_agent(self, session_key: str) -> None:
+ """Remove a cached agent for a session (called on /new, /model, etc)."""
+ _lock = getattr(self, "_agent_cache_lock", None)
+ if _lock:
+ with _lock:
+ self._agent_cache.pop(session_key, None)
+
async def _run_agent(
self,
message: str,
@@ -2950,7 +4885,9 @@ async def _run_agent(
history: List[Dict[str, Any]],
source: SessionSource,
session_id: str,
- session_key: str = None
+ session_key: str = None,
+ _interrupt_depth: int = 0,
+ event_message_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
Run the agent with the given message and context.
@@ -2967,65 +4904,21 @@ async def _run_agent(
from run_agent import AIAgent
import queue
- # Determine toolset based on platform.
- # Check config.yaml for per-platform overrides, fallback to hardcoded defaults.
- default_toolset_map = {
- Platform.LOCAL: "hermes-cli",
- Platform.TELEGRAM: "hermes-telegram",
- Platform.DISCORD: "hermes-discord",
- Platform.WHATSAPP: "hermes-whatsapp",
- Platform.SLACK: "hermes-slack",
- Platform.SIGNAL: "hermes-signal",
- Platform.HOMEASSISTANT: "hermes-homeassistant",
- Platform.EMAIL: "hermes-email",
- }
-
- # Try to load platform_toolsets from config
- platform_toolsets_config = {}
- try:
- config_path = _hermes_home / 'config.yaml'
- if config_path.exists():
- import yaml
- with open(config_path, 'r', encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- platform_toolsets_config = user_config.get("platform_toolsets", {})
- except Exception as e:
- logger.debug("Could not load platform_toolsets config: %s", e)
-
- # Map platform enum to config key
- platform_config_key = {
- Platform.LOCAL: "cli",
- Platform.TELEGRAM: "telegram",
- Platform.DISCORD: "discord",
- Platform.WHATSAPP: "whatsapp",
- Platform.SLACK: "slack",
- Platform.SIGNAL: "signal",
- Platform.HOMEASSISTANT: "homeassistant",
- Platform.EMAIL: "email",
- }.get(source.platform, "telegram")
-
- # Use config override if present (list of toolsets), otherwise hardcoded default
- config_toolsets = platform_toolsets_config.get(platform_config_key)
- if config_toolsets and isinstance(config_toolsets, list):
- enabled_toolsets = config_toolsets
- else:
- default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
- enabled_toolsets = [default_toolset]
-
+ user_config = _load_gateway_config()
+ platform_key = _platform_config_key(source.platform)
+
+ from hermes_cli.tools_config import _get_platform_tools
+ enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key))
+
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
- # Falls back to env vars for backward compatibility
- _progress_cfg = {}
- try:
- _tp_cfg_path = _hermes_home / "config.yaml"
- if _tp_cfg_path.exists():
- import yaml as _tp_yaml
- with open(_tp_cfg_path, encoding="utf-8") as _tp_f:
- _tp_data = _tp_yaml.safe_load(_tp_f) or {}
- _progress_cfg = _tp_data.get("display", {})
- except Exception:
- pass
+ # Falls back to env vars for backward compatibility.
+ # YAML 1.1 parses bare `off` as boolean False โ normalise before
+ # the `or` chain so it doesn't silently fall through to "all".
+ _raw_tp = user_config.get("display", {}).get("tool_progress")
+ if _raw_tp is False:
+ _raw_tp = "off"
progress_mode = (
- _progress_cfg.get("tool_progress")
+ _raw_tp
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all"
)
@@ -3048,47 +4941,8 @@ def progress_callback(tool_name: str, preview: str = None, args: dict = None):
last_tool[0] = tool_name
# Build progress message with primary argument preview
- tool_emojis = {
- "terminal": "๐ป",
- "process": "โ๏ธ",
- "web_search": "๐",
- "web_extract": "๐",
- "read_file": "๐",
- "write_file": "โ๏ธ",
- "patch": "๐ง",
- "search": "๐",
- "search_files": "๐",
- "list_directory": "๐",
- "image_generate": "๐จ",
- "text_to_speech": "๐",
- "browser_navigate": "๐",
- "browser_click": "๐",
- "browser_type": "โจ๏ธ",
- "browser_snapshot": "๐ธ",
- "browser_scroll": "๐",
- "browser_back": "โ๏ธ",
- "browser_press": "โจ๏ธ",
- "browser_close": "๐ช",
- "browser_get_images": "๐ผ๏ธ",
- "browser_vision": "๐๏ธ",
- "moa_query": "๐ง ",
- "mixture_of_agents": "๐ง ",
- "vision_analyze": "๐๏ธ",
- "skill_view": "๐",
- "skills_list": "๐",
- "todo": "๐",
- "memory": "๐ง ",
- "session_search": "๐",
- "send_message": "๐จ",
- "schedule_cronjob": "โฐ",
- "list_cronjobs": "โฐ",
- "remove_cronjob": "โฐ",
- "execute_code": "๐",
- "delegate_task": "๐",
- "clarify": "โ",
- "skill_manage": "๐",
- }
- emoji = tool_emojis.get(tool_name, "โ๏ธ")
+ from agent.display import get_tool_emoji
+ emoji = get_tool_emoji(tool_name, default="โ๏ธ")
# Verbose mode: show detailed arguments
if progress_mode == "verbose" and args:
@@ -3123,8 +4977,18 @@ def progress_callback(tool_name: str, preview: str = None, args: dict = None):
progress_queue.put(msg)
# Background task to send progress messages
- # Accumulates tool lines into a single message that gets edited
- _progress_metadata = {"thread_id": source.thread_id} if source.thread_id else None
+ # Accumulates tool lines into a single message that gets edited.
+ #
+ # Threading metadata is platform-specific:
+ # - Slack DM threading needs event_message_id fallback (reply thread)
+ # - Telegram uses message_thread_id only for forum topics; passing a
+ # normal DM/group message id as thread_id causes send failures
+ # - Other platforms should use explicit source.thread_id only
+ if source.platform == Platform.SLACK:
+ _progress_thread_id = source.thread_id or event_message_id
+ else:
+ _progress_thread_id = source.thread_id
+ _progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
async def send_progress_messages():
if not progress_queue:
@@ -3215,6 +5079,7 @@ async def send_progress_messages():
agent_holder = [None] # Mutable container for the agent instance
result_holder = [None] # Mutable container for the result
tools_holder = [None] # Mutable container for the tool definitions
+ stream_consumer_holder = [None] # Mutable container for stream consumer
# Bridge sync step_callback โ async hooks.emit for agent:step events
_loop_for_step = asyncio.get_event_loop()
@@ -3235,6 +5100,26 @@ def _step_callback_sync(iteration: int, tool_names: list) -> None:
except Exception as _e:
logger.debug("agent:step hook error: %s", _e)
+ # Bridge sync status_callback โ async adapter.send for context pressure
+ _status_adapter = self.adapters.get(source.platform)
+ _status_chat_id = source.chat_id
+ _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
+
+ def _status_callback_sync(event_type: str, message: str) -> None:
+ if not _status_adapter:
+ return
+ try:
+ asyncio.run_coroutine_threadsafe(
+ _status_adapter.send(
+ _status_chat_id,
+ message,
+ metadata=_status_thread_metadata,
+ ),
+ _loop_for_step,
+ )
+ except Exception as _e:
+ logger.debug("status_callback error (%s): %s", event_type, _e)
+
def run_sync():
# Pass session_key to process registry via env var so background
# processes can be mapped back to this gateway session
@@ -3261,7 +5146,7 @@ def run_sync():
except Exception:
pass
- model = _resolve_gateway_model()
+ model = _resolve_gateway_model(user_config)
try:
runtime_kwargs = _resolve_runtime_agent_kwargs()
@@ -3274,31 +5159,116 @@ def run_sync():
}
pr = self._provider_routing
- agent = AIAgent(
- model=model,
- **runtime_kwargs,
- max_iterations=max_iterations,
- quiet_mode=True,
- verbose_logging=False,
- enabled_toolsets=enabled_toolsets,
- ephemeral_system_prompt=combined_ephemeral or None,
- prefill_messages=self._prefill_messages or None,
- reasoning_config=self._reasoning_config,
- providers_allowed=pr.get("only"),
- providers_ignored=pr.get("ignore"),
- providers_order=pr.get("order"),
- provider_sort=pr.get("sort"),
- provider_require_parameters=pr.get("require_parameters", False),
- provider_data_collection=pr.get("data_collection"),
- session_id=session_id,
- tool_progress_callback=progress_callback if tool_progress_enabled else None,
- step_callback=_step_callback_sync if _hooks_ref.loaded_hooks else None,
- platform=platform_key,
- honcho_session_key=session_key,
- session_db=self._session_db,
- fallback_model=self._fallback_model,
+ honcho_manager, honcho_config = self._get_or_create_gateway_honcho(session_key)
+ reasoning_config = self._load_reasoning_config()
+ self._reasoning_config = reasoning_config
+ # Set up streaming consumer if enabled
+ _stream_consumer = None
+ _stream_delta_cb = None
+ _scfg = getattr(getattr(self, 'config', None), 'streaming', None)
+ if _scfg is None:
+ from gateway.config import StreamingConfig
+ _scfg = StreamingConfig()
+
+ if _scfg.enabled and _scfg.transport != "off":
+ try:
+ from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
+ _adapter = self.adapters.get(source.platform)
+ if _adapter:
+ _consumer_cfg = StreamConsumerConfig(
+ edit_interval=_scfg.edit_interval,
+ buffer_threshold=_scfg.buffer_threshold,
+ cursor=_scfg.cursor,
+ )
+ _stream_consumer = GatewayStreamConsumer(
+ adapter=_adapter,
+ chat_id=source.chat_id,
+ config=_consumer_cfg,
+ metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
+ )
+ _stream_delta_cb = _stream_consumer.on_delta
+ stream_consumer_holder[0] = _stream_consumer
+ except Exception as _sc_err:
+ logger.debug("Could not set up stream consumer: %s", _sc_err)
+
+ turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs)
+
+ # Check agent cache โ reuse the AIAgent from the previous message
+ # in this session to preserve the frozen system prompt and tool
+ # schemas for prompt cache hits.
+ _sig = self._agent_config_signature(
+ turn_route["model"],
+ turn_route["runtime"],
+ enabled_toolsets,
+ combined_ephemeral,
)
-
+ agent = None
+ _cache_lock = getattr(self, "_agent_cache_lock", None)
+ _cache = getattr(self, "_agent_cache", None)
+ if _cache_lock and _cache is not None:
+ with _cache_lock:
+ cached = _cache.get(session_key)
+ if cached and cached[1] == _sig:
+ agent = cached[0]
+ logger.debug("Reusing cached agent for session %s", session_key)
+
+ if agent is None:
+ # Config changed or first message โ create fresh agent
+ agent = AIAgent(
+ model=turn_route["model"],
+ **turn_route["runtime"],
+ max_iterations=max_iterations,
+ quiet_mode=True,
+ verbose_logging=False,
+ enabled_toolsets=enabled_toolsets,
+ ephemeral_system_prompt=combined_ephemeral or None,
+ prefill_messages=self._prefill_messages or None,
+ reasoning_config=reasoning_config,
+ providers_allowed=pr.get("only"),
+ providers_ignored=pr.get("ignore"),
+ providers_order=pr.get("order"),
+ provider_sort=pr.get("sort"),
+ provider_require_parameters=pr.get("require_parameters", False),
+ provider_data_collection=pr.get("data_collection"),
+ session_id=session_id,
+ platform=platform_key,
+ honcho_session_key=session_key,
+ honcho_manager=honcho_manager,
+ honcho_config=honcho_config,
+ session_db=self._session_db,
+ fallback_model=self._fallback_model,
+ )
+ if _cache_lock and _cache is not None:
+ with _cache_lock:
+ _cache[session_key] = (agent, _sig)
+ logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig)
+
+ # Per-message state โ callbacks and reasoning config change every
+ # turn and must not be baked into the cached agent constructor.
+ agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
+ agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
+ agent.stream_delta_callback = _stream_delta_cb
+ agent.status_callback = _status_callback_sync
+ agent.reasoning_config = reasoning_config
+
+ # Background review delivery โ send "๐พ Memory updated" etc. to user
+ def _bg_review_send(message: str) -> None:
+ if not _status_adapter:
+ return
+ try:
+ asyncio.run_coroutine_threadsafe(
+ _status_adapter.send(
+ _status_chat_id,
+ message,
+ metadata=_status_thread_metadata,
+ ),
+ _loop_for_step,
+ )
+ except Exception as _e:
+ logger.debug("background_review_callback error: %s", _e)
+
+ agent.background_review_callback = _bg_review_send
+
# Store agent reference for interrupt support
agent_holder[0] = agent
# Capture the full tool definitions for transcript logging
@@ -3344,7 +5314,18 @@ def run_sync():
if msg.get("mirror"):
mirror_src = msg.get("mirror_source", "another session")
content = f"[Delivered from {mirror_src}] {content}"
- agent_history.append({"role": role, "content": content})
+ entry = {"role": role, "content": content}
+ # Preserve reasoning fields on assistant messages so
+ # multi-turn reasoning context survives session reload.
+ # The agent's _build_api_kwargs converts these to the
+ # provider-specific format (reasoning_content, etc.).
+ if role == "assistant":
+ for _rkey in ("reasoning", "reasoning_details",
+ "codex_reasoning_items"):
+ _rval = msg.get(_rkey)
+ if _rval:
+ entry[_rkey] = _rval
+ agent_history.append(entry)
# Collect MEDIA paths already in history so we can exclude them
# from the current turn's extraction. This is compression-safe:
@@ -3361,15 +5342,24 @@ def run_sync():
result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id)
result_holder[0] = result
+
+ # Signal the stream consumer that the agent is done
+ if _stream_consumer is not None:
+ _stream_consumer.finish()
# Return final response, or a message if something went wrong
final_response = result.get("final_response")
- # Extract last actual prompt token count from the agent's compressor
+ # Extract actual token counts from the agent instance used for this run
_last_prompt_toks = 0
+ _input_toks = 0
+ _output_toks = 0
_agent = agent_holder[0]
if _agent and hasattr(_agent, "context_compressor"):
_last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0)
+ _input_toks = getattr(_agent, "session_prompt_tokens", 0)
+ _output_toks = getattr(_agent, "session_completion_tokens", 0)
+ _resolved_model = getattr(_agent, "model", None) if _agent else None
if not final_response:
error_msg = f"โ ๏ธ {result['error']}" if result.get("error") else "(No response generated)"
@@ -3380,6 +5370,9 @@ def run_sync():
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"last_prompt_tokens": _last_prompt_toks,
+ "input_tokens": _input_toks,
+ "output_tokens": _output_toks,
+ "model": _resolved_model,
}
# Scan tool results for MEDIA: tags that need to be delivered
@@ -3417,6 +5410,38 @@ def run_sync():
unique_tags.insert(0, "[[audio_as_voice]]")
final_response = final_response + "\n" + "\n".join(unique_tags)
+ # Sync session_id: the agent may have created a new session during
+ # mid-run context compression (_compress_context splits sessions).
+ # If so, update the session store entry so the NEXT message loads
+ # the compressed transcript, not the stale pre-compression one.
+ agent = agent_holder[0]
+ if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
+ logger.info(
+ "Session split detected: %s โ %s (compression)",
+ session_id, agent.session_id,
+ )
+ entry = self.session_store._entries.get(session_key)
+ if entry:
+ entry.session_id = agent.session_id
+ self.session_store._save()
+
+ effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
+
+ # Auto-generate session title after first exchange (non-blocking)
+ if final_response and self._session_db:
+ try:
+ from agent.title_generator import maybe_auto_title
+ all_msgs = result_holder[0].get("messages", []) if result_holder[0] else []
+ maybe_auto_title(
+ self._session_db,
+ effective_session_id,
+ message,
+ final_response,
+ all_msgs,
+ )
+ except Exception:
+ pass
+
return {
"final_response": final_response,
"last_reasoning": result.get("last_reasoning"),
@@ -3425,12 +5450,30 @@ def run_sync():
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"last_prompt_tokens": _last_prompt_toks,
+ "input_tokens": _input_toks,
+ "output_tokens": _output_toks,
+ "model": _resolved_model,
+ "session_id": effective_session_id,
}
# Start progress message sender if enabled
progress_task = None
if tool_progress_enabled:
progress_task = asyncio.create_task(send_progress_messages())
+
+ # Start stream consumer task โ polls for consumer creation since it
+ # happens inside run_sync (thread pool) after the agent is constructed.
+ stream_task = None
+
+ async def _start_stream_consumer():
+ """Wait for the stream consumer to be created, then run it."""
+ for _ in range(200): # Up to 10s wait
+ if stream_consumer_holder[0] is not None:
+ await stream_consumer_holder[0].run()
+ return
+ await asyncio.sleep(0.05)
+
+ stream_task = asyncio.create_task(_start_stream_consumer())
# Track this agent as running for this session (for interrupt support)
# We do this in a callback after the agent is created
@@ -3470,23 +5513,49 @@ async def monitor_for_interrupt():
# Run in thread pool to not block
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, run_sync)
-
- # Check if we were interrupted and have a pending message
+
+ # Track fallback model state: if the agent switched to a
+ # fallback model during this run, persist it so /model shows
+ # the actually-active model instead of the config default.
+ _agent = agent_holder[0]
+ if _agent is not None and hasattr(_agent, 'model'):
+ _cfg_model = _resolve_gateway_model()
+ if _agent.model != _cfg_model:
+ self._effective_model = _agent.model
+ self._effective_provider = getattr(_agent, 'provider', None)
+ # Fallback activated โ evict cached agent so the next
+ # message starts fresh and retries the primary model.
+ self._evict_cached_agent(session_key)
+ else:
+ # Primary model worked โ clear any stale fallback state
+ self._effective_model = None
+ self._effective_provider = None
+
+ # Check if we were interrupted OR have a queued message (/queue).
result = result_holder[0]
adapter = self.adapters.get(source.platform)
- # Get pending message from adapter if interrupted.
+ # Get pending message from adapter.
# Use session_key (not source.chat_id) to match adapter's storage keys.
pending = None
- if result and result.get("interrupted") and adapter:
- pending_event = adapter.get_pending_message(session_key) if session_key else None
- if pending_event:
- pending = pending_event.text
- elif result.get("interrupt_message"):
- pending = result.get("interrupt_message")
+ if result and adapter and session_key:
+ if result.get("interrupted"):
+ # Interrupted โ consume the interrupt message
+ pending_event = adapter.get_pending_message(session_key)
+ if pending_event:
+ pending = pending_event.text
+ elif result.get("interrupt_message"):
+ pending = result.get("interrupt_message")
+ else:
+ # Normal completion โ check for /queue'd messages that were
+ # stored without triggering an interrupt.
+ pending_event = adapter.get_pending_message(session_key)
+ if pending_event:
+ pending = pending_event.text
+ logger.debug("Processing queued message after agent completion: '%s...'", pending[:40])
if pending:
- logger.debug("Processing interrupted message: '%s...'", pending[:40])
+ logger.debug("Processing pending message: '%s...'", pending[:40])
# Clear the adapter's interrupt event so the next _run_agent call
# doesn't immediately re-trigger the interrupt before the new agent
@@ -3494,11 +5563,39 @@ async def monitor_for_interrupt():
if adapter and hasattr(adapter, '_active_sessions') and session_key and session_key in adapter._active_sessions:
adapter._active_sessions[session_key].clear()
- # Don't send the interrupted response to the user โ it's just noise
- # like "Operation interrupted." They already know they sent a new
- # message, so go straight to processing it.
-
- # Now process the pending message with updated history
+ # Cap recursion depth to prevent resource exhaustion when the
+ # user sends multiple messages while the agent keeps failing. (#816)
+ if _interrupt_depth >= self._MAX_INTERRUPT_DEPTH:
+ logger.warning(
+ "Interrupt recursion depth %d reached for session %s โ "
+ "queueing message instead of recursing.",
+ _interrupt_depth, session_key,
+ )
+ # Queue the pending message for normal processing on next turn
+ adapter = self.adapters.get(source.platform)
+ if adapter and hasattr(adapter, 'queue_message'):
+ adapter.queue_message(session_key, pending)
+ return result_holder[0] or {"final_response": response, "messages": history}
+
+ was_interrupted = result.get("interrupted")
+ if not was_interrupted:
+ # Queued message after normal completion โ deliver the first
+ # response before processing the queued follow-up.
+ # Skip if streaming already delivered it.
+ _sc = stream_consumer_holder[0]
+ _already_streamed = _sc and getattr(_sc, "already_sent", False)
+ first_response = result.get("final_response", "")
+ if first_response and not _already_streamed:
+ try:
+ await adapter.send(source.chat_id, first_response,
+ metadata=getattr(event, "metadata", None))
+ except Exception as e:
+ logger.warning("Failed to send first response before queued message: %s", e)
+ # else: interrupted โ discard the interrupted response ("Operation
+ # interrupted." is just noise; the user already knows they sent a
+ # new message).
+
+ # Process the pending message with updated history
updated_history = result.get("messages", history)
return await self._run_agent(
message=pending,
@@ -3506,13 +5603,25 @@ async def monitor_for_interrupt():
history=updated_history,
source=source,
session_id=session_id,
- session_key=session_key
+ session_key=session_key,
+ _interrupt_depth=_interrupt_depth + 1,
)
finally:
# Stop progress sender and interrupt monitor
if progress_task:
progress_task.cancel()
interrupt_monitor.cancel()
+
+ # Wait for stream consumer to finish its final edit
+ if stream_task:
+ try:
+ await asyncio.wait_for(stream_task, timeout=5.0)
+ except (asyncio.TimeoutError, asyncio.CancelledError):
+ stream_task.cancel()
+ try:
+ await stream_task
+ except asyncio.CancelledError:
+ pass
# Clean up tracking
tracking_task.cancel()
@@ -3526,6 +5635,12 @@ async def monitor_for_interrupt():
await task
except asyncio.CancelledError:
pass
+
+ # If streaming already delivered the response, mark it so the
+ # caller's send() is skipped (avoiding duplicate messages).
+ _sc = stream_consumer_holder[0]
+ if _sc and _sc.already_sent and isinstance(response, dict):
+ response["already_sent"] = True
return response
@@ -3638,8 +5753,18 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
except (ProcessLookupError, PermissionError):
pass
remove_pid_file()
+ # Also release all scoped locks left by the old process.
+ # Stopped (Ctrl+Z) processes don't release locks on exit,
+ # leaving stale lock files that block the new gateway from starting.
+ try:
+ from gateway.status import release_all_scoped_locks
+ _released = release_all_scoped_locks()
+ if _released:
+ logger.info("Released %d stale scoped lock(s) from old gateway.", _released)
+ except Exception:
+ pass
else:
- hermes_home = os.getenv("HERMES_HOME", "~/.hermes")
+ hermes_home = str(get_hermes_home())
logger.error(
"Another gateway instance is already running (PID %d, HERMES_HOME=%s). "
"Use 'hermes gateway restart' to replace it, or 'hermes gateway stop' first.",
@@ -3700,6 +5825,10 @@ def signal_handler():
success = await runner.start()
if not success:
return False
+ if runner.should_exit_cleanly:
+ if runner.exit_reason:
+ logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
+ return True
# Write PID file so CLI can detect gateway is running
import atexit
@@ -3720,6 +5849,11 @@ def signal_handler():
# Wait for shutdown
await runner.wait_for_shutdown()
+
+ if runner.should_exit_with_failure:
+ if runner.exit_reason:
+ logger.error("Gateway exiting with failure: %s", runner.exit_reason)
+ return False
# Stop cron ticker cleanly
cron_stop.set()
diff --git a/gateway/session.py b/gateway/session.py
index f6ede44f4e9..5aefb6c0129 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -8,21 +8,64 @@
- Dynamic system prompt injection (agent knows its context)
"""
+import hashlib
import logging
import os
import json
+import re
+import threading
import uuid
from pathlib import Path
from datetime import datetime, timedelta
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
+
+def _now() -> datetime:
+ """Return the current local time."""
+ return datetime.now()
+
+
+# ---------------------------------------------------------------------------
+# PII redaction helpers
+# ---------------------------------------------------------------------------
+
+_PHONE_RE = re.compile(r"^\+?\d[\d\-\s]{6,}$")
+
+
+def _hash_id(value: str) -> str:
+ """Deterministic 12-char hex hash of an identifier."""
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
+
+
+def _hash_sender_id(value: str) -> str:
+ """Hash a sender ID to ``user_<12hex>``."""
+ return f"user_{_hash_id(value)}"
+
+
+def _hash_chat_id(value: str) -> str:
+ """Hash the numeric portion of a chat ID, preserving platform prefix.
+
+ ``telegram:12345`` โ ``telegram:``
+ ``12345`` โ ````
+ """
+ colon = value.find(":")
+ if colon > 0:
+ prefix = value[:colon]
+ return f"{prefix}:{_hash_id(value[colon + 1:])}"
+ return _hash_id(value)
+
+
+def _looks_like_phone(value: str) -> bool:
+ """Return True if *value* looks like a phone number (E.164 or similar)."""
+ return bool(_PHONE_RE.match(value.strip()))
+
from .config import (
Platform,
GatewayConfig,
- SessionResetPolicy,
+ SessionResetPolicy, # noqa: F401 โ re-exported via gateway/__init__.py
HomeChannel,
)
@@ -146,7 +189,21 @@ def to_dict(self) -> Dict[str, Any]:
}
-def build_session_context_prompt(context: SessionContext) -> str:
+_PII_SAFE_PLATFORMS = frozenset({
+ Platform.WHATSAPP,
+ Platform.SIGNAL,
+ Platform.TELEGRAM,
+})
+"""Platforms where user IDs can be safely redacted (no in-message mention system
+that requires raw IDs). Discord is excluded because mentions use ``<@user_id>``
+and the LLM needs the real ID to tag users."""
+
+
+def build_session_context_prompt(
+ context: SessionContext,
+ *,
+ redact_pii: bool = False,
+) -> str:
"""
Build the dynamic system prompt section that tells the agent about its context.
@@ -154,7 +211,15 @@ def build_session_context_prompt(context: SessionContext) -> str:
- Where messages are coming from
- What platforms are connected
- Where it can deliver scheduled task outputs
+
+ When *redact_pii* is True **and** the source platform is in
+ ``_PII_SAFE_PLATFORMS``, phone numbers are stripped and user/chat IDs
+ are replaced with deterministic hashes before being sent to the LLM.
+ Platforms like Discord are excluded because mentions need real IDs.
+ Routing still uses the original values (they stay in SessionSource).
"""
+ # Only apply redaction on platforms where IDs aren't needed for mentions
+ redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS
lines = [
"## Current Session Context",
"",
@@ -165,7 +230,25 @@ def build_session_context_prompt(context: SessionContext) -> str:
if context.source.platform == Platform.LOCAL:
lines.append(f"**Source:** {platform_name} (the machine running this agent)")
else:
- lines.append(f"**Source:** {platform_name} ({context.source.description})")
+ # Build a description that respects PII redaction
+ src = context.source
+ if redact_pii:
+ # Build a safe description without raw IDs
+ _uname = src.user_name or (
+ _hash_sender_id(src.user_id) if src.user_id else "user"
+ )
+ _cname = src.chat_name or _hash_chat_id(src.chat_id)
+ if src.chat_type == "dm":
+ desc = f"DM with {_uname}"
+ elif src.chat_type == "group":
+ desc = f"group: {_cname}"
+ elif src.chat_type == "channel":
+ desc = f"channel: {_cname}"
+ else:
+ desc = _cname
+ else:
+ desc = src.description
+ lines.append(f"**Source:** {platform_name} ({desc})")
# Channel topic (if available - provides context about the channel's purpose)
if context.source.chat_topic:
@@ -175,8 +258,31 @@ def build_session_context_prompt(context: SessionContext) -> str:
if context.source.user_name:
lines.append(f"**User:** {context.source.user_name}")
elif context.source.user_id:
- lines.append(f"**User ID:** {context.source.user_id}")
+ uid = context.source.user_id
+ if redact_pii:
+ uid = _hash_sender_id(uid)
+ lines.append(f"**User ID:** {uid}")
+ # Platform-specific behavioral notes
+ if context.source.platform == Platform.SLACK:
+ lines.append("")
+ lines.append(
+ "**Platform notes:** You are running inside Slack. "
+ "You do NOT have access to Slack-specific APIs โ you cannot search "
+ "channel history, pin/unpin messages, manage channels, or list users. "
+ "Do not promise to perform these actions. If the user asks, explain "
+ "that you can only read messages sent directly to you and respond."
+ )
+ elif context.source.platform == Platform.DISCORD:
+ lines.append("")
+ lines.append(
+ "**Platform notes:** You are running inside Discord. "
+ "You do NOT have access to Discord-specific APIs โ you cannot search "
+ "channel history, pin messages, manage roles, or list server members. "
+ "Do not promise to perform these actions. If the user asks, explain "
+ "that you can only read messages sent directly to you and respond."
+ )
+
# Connected platforms
platforms_list = ["local (files on this machine)"]
for p in context.connected_platforms:
@@ -190,7 +296,8 @@ def build_session_context_prompt(context: SessionContext) -> str:
lines.append("")
lines.append("**Home Channels (default destinations):**")
for platform, home in context.home_channels.items():
- lines.append(f" - {platform.value}: {home.name} (ID: {home.chat_id})")
+ hc_id = _hash_chat_id(home.chat_id) if redact_pii else home.chat_id
+ lines.append(f" - {platform.value}: {home.name} (ID: {hc_id})")
# Delivery options for scheduled tasks
lines.append("")
@@ -200,7 +307,10 @@ def build_session_context_prompt(context: SessionContext) -> str:
if context.source.platform == Platform.LOCAL:
lines.append("- `\"origin\"` โ Local output (saved to files)")
else:
- lines.append(f"- `\"origin\"` โ Back to this chat ({context.source.chat_name or context.source.chat_id})")
+ _origin_label = context.source.chat_name or (
+ _hash_chat_id(context.source.chat_id) if redact_pii else context.source.chat_id
+ )
+ lines.append(f"- `\"origin\"` โ Back to this chat ({_origin_label})")
# Local always available
lines.append("- `\"local\"` โ Save to local files only (~/.hermes/cron/output/)")
@@ -239,7 +349,11 @@ class SessionEntry:
# Token tracking
input_tokens: int = 0
output_tokens: int = 0
+ cache_read_tokens: int = 0
+ cache_write_tokens: int = 0
total_tokens: int = 0
+ estimated_cost_usd: float = 0.0
+ cost_status: str = "unknown"
# Last API-reported prompt tokens (for accurate compression pre-check)
last_prompt_tokens: int = 0
@@ -247,6 +361,8 @@ class SessionEntry:
# Set when a session was created because the previous one expired;
# consumed once by the message handler to inject a notice into context
was_auto_reset: bool = False
+ auto_reset_reason: Optional[str] = None # "idle" or "daily"
+ reset_had_activity: bool = False # whether the expired session had any messages
def to_dict(self) -> Dict[str, Any]:
result = {
@@ -259,8 +375,12 @@ def to_dict(self) -> Dict[str, Any]:
"chat_type": self.chat_type,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
+ "cache_read_tokens": self.cache_read_tokens,
+ "cache_write_tokens": self.cache_write_tokens,
"total_tokens": self.total_tokens,
"last_prompt_tokens": self.last_prompt_tokens,
+ "estimated_cost_usd": self.estimated_cost_usd,
+ "cost_status": self.cost_status,
}
if self.origin:
result["origin"] = self.origin.to_dict()
@@ -290,36 +410,56 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
chat_type=data.get("chat_type", "dm"),
input_tokens=data.get("input_tokens", 0),
output_tokens=data.get("output_tokens", 0),
+ cache_read_tokens=data.get("cache_read_tokens", 0),
+ cache_write_tokens=data.get("cache_write_tokens", 0),
total_tokens=data.get("total_tokens", 0),
last_prompt_tokens=data.get("last_prompt_tokens", 0),
+ estimated_cost_usd=data.get("estimated_cost_usd", 0.0),
+ cost_status=data.get("cost_status", "unknown"),
)
-def build_session_key(source: SessionSource) -> str:
+def build_session_key(source: SessionSource, group_sessions_per_user: bool = True) -> str:
"""Build a deterministic session key from a message source.
This is the single source of truth for session key construction.
DM rules:
- - WhatsApp DMs include chat_id (multi-user support).
- - Other DMs include thread_id when present (e.g. Slack threaded DMs),
- so each DM thread gets its own session while top-level DMs share one.
- - Without thread_id or chat_id, all DMs share a single session.
+ - DMs include chat_id when present, so each private conversation is isolated.
+ - thread_id further differentiates threaded DMs within the same DM chat.
+ - Without chat_id, thread_id is used as a best-effort fallback.
+ - Without thread_id or chat_id, DMs share a single session.
Group/channel rules:
- - thread_id differentiates threads within a channel.
- - Without thread_id, all messages in a channel share one session.
+ - chat_id identifies the parent group/channel.
+ - user_id/user_id_alt isolates participants within that parent chat when available when
+ ``group_sessions_per_user`` is enabled.
+ - thread_id differentiates threads within that parent chat.
+ - Without participant identifiers, or when isolation is disabled, messages fall back to one
+ shared session per chat.
+ - Without identifiers, messages fall back to one session per platform/chat_type.
"""
platform = source.platform.value
if source.chat_type == "dm":
+ if source.chat_id:
+ if source.thread_id:
+ return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}"
+ return f"agent:main:{platform}:dm:{source.chat_id}"
if source.thread_id:
return f"agent:main:{platform}:dm:{source.thread_id}"
- if platform == "whatsapp" and source.chat_id:
- return f"agent:main:{platform}:dm:{source.chat_id}"
return f"agent:main:{platform}:dm"
+
+ participant_id = source.user_id_alt or source.user_id
+ key_parts = ["agent:main", platform, source.chat_type]
+
+ if source.chat_id:
+ key_parts.append(source.chat_id)
if source.thread_id:
- return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}:{source.thread_id}"
- return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}"
+ key_parts.append(source.thread_id)
+ if group_sessions_per_user and participant_id:
+ key_parts.append(str(participant_id))
+
+ return ":".join(key_parts)
class SessionStore:
@@ -337,6 +477,7 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,
self.config = config
self._entries: Dict[str, SessionEntry] = {}
self._loaded = False
+ self._lock = threading.Lock()
self._has_active_processes_fn = has_active_processes_fn
# on_auto_reset is deprecated โ memory flush now runs proactively
# via the background session expiry watcher in GatewayRunner.
@@ -352,21 +493,30 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,
def _ensure_loaded(self) -> None:
"""Load sessions index from disk if not already loaded."""
+ with self._lock:
+ self._ensure_loaded_locked()
+
+ def _ensure_loaded_locked(self) -> None:
+ """Load sessions index from disk. Must be called with self._lock held."""
if self._loaded:
return
-
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
sessions_file = self.sessions_dir / "sessions.json"
-
+
if sessions_file.exists():
try:
with open(sessions_file, "r", encoding="utf-8") as f:
data = json.load(f)
for key, entry_data in data.items():
- self._entries[key] = SessionEntry.from_dict(entry_data)
+ try:
+ self._entries[key] = SessionEntry.from_dict(entry_data)
+ except (ValueError, KeyError):
+ # Skip entries with unknown/removed platform values
+ continue
except Exception as e:
print(f"[gateway] Warning: Failed to load sessions: {e}")
-
+
self._loaded = True
def _save(self) -> None:
@@ -394,7 +544,10 @@ def _save(self) -> None:
def _generate_session_key(self, source: SessionSource) -> str:
"""Generate a session key from a source."""
- return build_session_key(source)
+ return build_session_key(
+ source,
+ group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
+ )
def _is_session_expired(self, entry: SessionEntry) -> bool:
"""Check if a session has expired based on its reset policy.
@@ -415,7 +568,7 @@ def _is_session_expired(self, entry: SessionEntry) -> bool:
if policy.mode == "none":
return False
- now = datetime.now()
+ now = _now()
if policy.mode in ("idle", "both"):
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
@@ -434,16 +587,19 @@ def _is_session_expired(self, entry: SessionEntry) -> bool:
return False
- def _should_reset(self, entry: SessionEntry, source: SessionSource) -> bool:
+ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[str]:
"""
Check if a session should be reset based on policy.
+ Returns the reset reason ("idle" or "daily") if a reset is needed,
+ or None if the session is still valid.
+
Sessions with active background processes are never reset.
"""
if self._has_active_processes_fn:
session_key = self._generate_session_key(source)
if self._has_active_processes_fn(session_key):
- return False
+ return None
policy = self.config.get_reset_policy(
platform=source.platform,
@@ -451,14 +607,14 @@ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> bool:
)
if policy.mode == "none":
- return False
+ return None
- now = datetime.now()
+ now = _now()
if policy.mode in ("idle", "both"):
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
if now > idle_deadline:
- return True
+ return "idle"
if policy.mode in ("daily", "both"):
today_reset = now.replace(
@@ -471,9 +627,9 @@ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> bool:
today_reset -= timedelta(days=1)
if entry.updated_at < today_reset:
- return True
+ return "daily"
- return False
+ return None
def has_any_sessions(self) -> bool:
"""Check if any sessions have ever been created (across all platforms).
@@ -493,149 +649,208 @@ def has_any_sessions(self) -> bool:
pass # fall through to heuristic
# Fallback: check if sessions.json was loaded with existing data.
# This covers the rare case where the DB is unavailable.
- self._ensure_loaded()
- return len(self._entries) > 1
-
+ with self._lock:
+ self._ensure_loaded_locked()
+ return len(self._entries) > 1
+
def get_or_create_session(
- self,
+ self,
source: SessionSource,
force_new: bool = False
) -> SessionEntry:
"""
Get an existing session or create a new one.
-
+
Evaluates reset policy to determine if the existing session is stale.
Creates a session record in SQLite when a new session starts.
"""
- self._ensure_loaded()
-
session_key = self._generate_session_key(source)
- now = datetime.now()
-
- if session_key in self._entries and not force_new:
- entry = self._entries[session_key]
-
- if not self._should_reset(entry, source):
- entry.updated_at = now
- self._save()
- return entry
+ now = _now()
+
+ # SQLite calls are made outside the lock to avoid holding it during I/O.
+ # All _entries / _loaded mutations are protected by self._lock.
+ db_end_session_id = None
+ db_create_kwargs = None
+
+ with self._lock:
+ self._ensure_loaded_locked()
+
+ if session_key in self._entries and not force_new:
+ entry = self._entries[session_key]
+
+ reset_reason = self._should_reset(entry, source)
+ if not reset_reason:
+ entry.updated_at = now
+ self._save()
+ return entry
+ else:
+ # Session is being auto-reset. The background expiry watcher
+ # should have already flushed memories proactively; discard
+ # the marker so it doesn't accumulate.
+ was_auto_reset = True
+ auto_reset_reason = reset_reason
+ # Track whether the expired session had any real conversation
+ reset_had_activity = entry.total_tokens > 0
+ db_end_session_id = entry.session_id
+ self._pre_flushed_sessions.discard(entry.session_id)
else:
- # Session is being auto-reset. The background expiry watcher
- # should have already flushed memories proactively; discard
- # the marker so it doesn't accumulate.
- was_auto_reset = True
- self._pre_flushed_sessions.discard(entry.session_id)
- if self._db:
- try:
- self._db.end_session(entry.session_id, "session_reset")
- except Exception as e:
- logger.debug("Session DB operation failed: %s", e)
- else:
- was_auto_reset = False
-
- # Create new session
- session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
-
- entry = SessionEntry(
- session_key=session_key,
- session_id=session_id,
- created_at=now,
- updated_at=now,
- origin=source,
- display_name=source.chat_name,
- platform=source.platform,
- chat_type=source.chat_type,
- was_auto_reset=was_auto_reset,
- )
-
- self._entries[session_key] = entry
- self._save()
-
- # Create session in SQLite
- if self._db:
+ was_auto_reset = False
+ auto_reset_reason = None
+ reset_had_activity = False
+
+ # Create new session
+ session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
+
+ entry = SessionEntry(
+ session_key=session_key,
+ session_id=session_id,
+ created_at=now,
+ updated_at=now,
+ origin=source,
+ display_name=source.chat_name,
+ platform=source.platform,
+ chat_type=source.chat_type,
+ was_auto_reset=was_auto_reset,
+ auto_reset_reason=auto_reset_reason,
+ reset_had_activity=reset_had_activity,
+ )
+
+ self._entries[session_key] = entry
+ self._save()
+ db_create_kwargs = {
+ "session_id": session_id,
+ "source": source.platform.value,
+ "user_id": source.user_id,
+ }
+
+ # SQLite operations outside the lock
+ if self._db and db_end_session_id:
try:
- self._db.create_session(
- session_id=session_id,
- source=source.platform.value,
- user_id=source.user_id,
- )
+ self._db.end_session(db_end_session_id, "session_reset")
+ except Exception as e:
+ logger.debug("Session DB operation failed: %s", e)
+
+ if self._db and db_create_kwargs:
+ try:
+ self._db.create_session(**db_create_kwargs)
except Exception as e:
print(f"[gateway] Warning: Failed to create SQLite session: {e}")
-
+
return entry
-
+
def update_session(
- self,
+ self,
session_key: str,
input_tokens: int = 0,
output_tokens: int = 0,
+ cache_read_tokens: int = 0,
+ cache_write_tokens: int = 0,
last_prompt_tokens: int = None,
+ model: str = None,
+ estimated_cost_usd: Optional[float] = None,
+ cost_status: Optional[str] = None,
+ cost_source: Optional[str] = None,
+ provider: Optional[str] = None,
+ base_url: Optional[str] = None,
) -> None:
"""Update a session's metadata after an interaction."""
- self._ensure_loaded()
-
- if session_key in self._entries:
- entry = self._entries[session_key]
- entry.updated_at = datetime.now()
- entry.input_tokens += input_tokens
- entry.output_tokens += output_tokens
- if last_prompt_tokens is not None:
- entry.last_prompt_tokens = last_prompt_tokens
- entry.total_tokens = entry.input_tokens + entry.output_tokens
- self._save()
-
- if self._db:
- try:
- self._db.update_token_counts(
- entry.session_id, input_tokens, output_tokens
- )
- except Exception as e:
- logger.debug("Session DB operation failed: %s", e)
-
+ db_session_id = None
+
+ with self._lock:
+ self._ensure_loaded_locked()
+
+ if session_key in self._entries:
+ entry = self._entries[session_key]
+ entry.updated_at = _now()
+ # Direct assignment โ the gateway receives cumulative totals
+ # from the cached agent, not per-call deltas.
+ entry.input_tokens = input_tokens
+ entry.output_tokens = output_tokens
+ entry.cache_read_tokens = cache_read_tokens
+ entry.cache_write_tokens = cache_write_tokens
+ if last_prompt_tokens is not None:
+ entry.last_prompt_tokens = last_prompt_tokens
+ if estimated_cost_usd is not None:
+ entry.estimated_cost_usd = estimated_cost_usd
+ if cost_status:
+ entry.cost_status = cost_status
+ entry.total_tokens = (
+ entry.input_tokens
+ + entry.output_tokens
+ + entry.cache_read_tokens
+ + entry.cache_write_tokens
+ )
+ self._save()
+ db_session_id = entry.session_id
+
+ if self._db and db_session_id:
+ try:
+ self._db.set_token_counts(
+ db_session_id,
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ cache_read_tokens=cache_read_tokens,
+ cache_write_tokens=cache_write_tokens,
+ estimated_cost_usd=estimated_cost_usd,
+ cost_status=cost_status,
+ cost_source=cost_source,
+ billing_provider=provider,
+ billing_base_url=base_url,
+ model=model,
+ absolute=True,
+ )
+ except Exception as e:
+ logger.debug("Session DB operation failed: %s", e)
+
def reset_session(self, session_key: str) -> Optional[SessionEntry]:
"""Force reset a session, creating a new session ID."""
- self._ensure_loaded()
-
- if session_key not in self._entries:
- return None
-
- old_entry = self._entries[session_key]
-
- # End old session in SQLite
- if self._db:
+ db_end_session_id = None
+ db_create_kwargs = None
+ new_entry = None
+
+ with self._lock:
+ self._ensure_loaded_locked()
+
+ if session_key not in self._entries:
+ return None
+
+ old_entry = self._entries[session_key]
+ db_end_session_id = old_entry.session_id
+
+ now = _now()
+ session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
+
+ new_entry = SessionEntry(
+ session_key=session_key,
+ session_id=session_id,
+ created_at=now,
+ updated_at=now,
+ origin=old_entry.origin,
+ display_name=old_entry.display_name,
+ platform=old_entry.platform,
+ chat_type=old_entry.chat_type,
+ )
+
+ self._entries[session_key] = new_entry
+ self._save()
+ db_create_kwargs = {
+ "session_id": session_id,
+ "source": old_entry.platform.value if old_entry.platform else "unknown",
+ "user_id": old_entry.origin.user_id if old_entry.origin else None,
+ }
+
+ if self._db and db_end_session_id:
try:
- self._db.end_session(old_entry.session_id, "session_reset")
+ self._db.end_session(db_end_session_id, "session_reset")
except Exception as e:
logger.debug("Session DB operation failed: %s", e)
-
- now = datetime.now()
- session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
-
- new_entry = SessionEntry(
- session_key=session_key,
- session_id=session_id,
- created_at=now,
- updated_at=now,
- origin=old_entry.origin,
- display_name=old_entry.display_name,
- platform=old_entry.platform,
- chat_type=old_entry.chat_type,
- )
-
- self._entries[session_key] = new_entry
- self._save()
-
- # Create new session in SQLite
- if self._db:
+
+ if self._db and db_create_kwargs:
try:
- self._db.create_session(
- session_id=session_id,
- source=old_entry.platform.value if old_entry.platform else "unknown",
- user_id=old_entry.origin.user_id if old_entry.origin else None,
- )
+ self._db.create_session(**db_create_kwargs)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)
-
+
return new_entry
def switch_session(self, session_key: str, target_session_id: str) -> Optional[SessionEntry]:
@@ -646,52 +861,58 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S
generating a fresh session ID, re-uses ``target_session_id`` so the
old transcript is loaded on the next message.
"""
- self._ensure_loaded()
+ db_end_session_id = None
+ new_entry = None
- if session_key not in self._entries:
- return None
+ with self._lock:
+ self._ensure_loaded_locked()
- old_entry = self._entries[session_key]
+ if session_key not in self._entries:
+ return None
- # Don't switch if already on that session
- if old_entry.session_id == target_session_id:
- return old_entry
+ old_entry = self._entries[session_key]
- # End the current session in SQLite
- if self._db:
+ # Don't switch if already on that session
+ if old_entry.session_id == target_session_id:
+ return old_entry
+
+ db_end_session_id = old_entry.session_id
+
+ now = _now()
+ new_entry = SessionEntry(
+ session_key=session_key,
+ session_id=target_session_id,
+ created_at=now,
+ updated_at=now,
+ origin=old_entry.origin,
+ display_name=old_entry.display_name,
+ platform=old_entry.platform,
+ chat_type=old_entry.chat_type,
+ )
+
+ self._entries[session_key] = new_entry
+ self._save()
+
+ if self._db and db_end_session_id:
try:
- self._db.end_session(old_entry.session_id, "session_switch")
+ self._db.end_session(db_end_session_id, "session_switch")
except Exception as e:
logger.debug("Session DB end_session failed: %s", e)
- now = datetime.now()
- new_entry = SessionEntry(
- session_key=session_key,
- session_id=target_session_id,
- created_at=now,
- updated_at=now,
- origin=old_entry.origin,
- display_name=old_entry.display_name,
- platform=old_entry.platform,
- chat_type=old_entry.chat_type,
- )
-
- self._entries[session_key] = new_entry
- self._save()
return new_entry
def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]:
"""List all sessions, optionally filtered by activity."""
- self._ensure_loaded()
-
- entries = list(self._entries.values())
-
+ with self._lock:
+ self._ensure_loaded_locked()
+ entries = list(self._entries.values())
+
if active_minutes is not None:
- cutoff = datetime.now() - timedelta(minutes=active_minutes)
+ cutoff = _now() - timedelta(minutes=active_minutes)
entries = [e for e in entries if e.updated_at >= cutoff]
-
+
entries.sort(key=lambda e: e.updated_at, reverse=True)
-
+
return entries
def get_transcript_path(self, session_id: str) -> Path:
@@ -737,13 +958,17 @@ def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) ->
try:
self._db.clear_messages(session_id)
for msg in messages:
+ role = msg.get("role", "unknown")
self._db.append_message(
session_id=session_id,
- role=msg.get("role", "unknown"),
+ role=role,
content=msg.get("content"),
tool_name=msg.get("tool_name"),
tool_calls=msg.get("tool_calls"),
tool_call_id=msg.get("tool_call_id"),
+ reasoning=msg.get("reasoning") if role == "assistant" else None,
+ reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
+ codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
)
except Exception as e:
logger.debug("Failed to rewrite transcript in DB: %s", e)
@@ -756,29 +981,51 @@ def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) ->
def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
"""Load all messages from a session's transcript."""
+ db_messages = []
# Try SQLite first
if self._db:
try:
- messages = self._db.get_messages_as_conversation(session_id)
- if messages:
- return messages
+ db_messages = self._db.get_messages_as_conversation(session_id)
except Exception as e:
logger.debug("Could not load messages from DB: %s", e)
-
- # Fall back to legacy JSONL
+
+ # Load legacy JSONL transcript (may contain more history than SQLite
+ # for sessions created before the DB layer was introduced).
transcript_path = self.get_transcript_path(session_id)
-
- if not transcript_path.exists():
- return []
-
- messages = []
- with open(transcript_path, "r", encoding="utf-8") as f:
- for line in f:
- line = line.strip()
- if line:
- messages.append(json.loads(line))
-
- return messages
+ jsonl_messages = []
+ if transcript_path.exists():
+ with open(transcript_path, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ try:
+ jsonl_messages.append(json.loads(line))
+ except json.JSONDecodeError:
+ logger.warning(
+ "Skipping corrupt line in transcript %s: %s",
+ session_id, line[:120],
+ )
+
+ # Prefer whichever source has more messages.
+ #
+ # Background: when a session pre-dates SQLite storage (or when the DB
+ # layer was added while a long-lived session was already active), the
+ # first post-migration turn writes only the *new* messages to SQLite
+ # (because _flush_messages_to_session_db skips messages already in
+ # conversation_history, assuming they're persisted). On the *next*
+ # turn load_transcript returns those few SQLite rows and ignores the
+ # full JSONL history โ the model sees a context of 1-4 messages instead
+ # of hundreds. Using the longer source prevents this silent truncation.
+ if len(jsonl_messages) > len(db_messages):
+ if db_messages:
+ logger.debug(
+ "Session %s: JSONL has %d messages vs SQLite %d โ "
+ "using JSONL (legacy session not yet fully migrated)",
+ session_id, len(jsonl_messages), len(db_messages),
+ )
+ return jsonl_messages
+
+ return db_messages
def build_session_context(
diff --git a/gateway/status.py b/gateway/status.py
index 78d71947fdf..b0ea693a222 100644
--- a/gateway/status.py
+++ b/gateway/status.py
@@ -11,22 +11,219 @@
concurrently under distinct configurations).
"""
+import hashlib
+import json
import os
+import sys
+from datetime import datetime, timezone
from pathlib import Path
-from typing import Optional
+from hermes_constants import get_hermes_home
+from typing import Any, Optional
+
+_GATEWAY_KIND = "hermes-gateway"
+_RUNTIME_STATUS_FILE = "gateway_state.json"
+_LOCKS_DIRNAME = "gateway-locks"
def _get_pid_path() -> Path:
"""Return the path to the gateway PID file, respecting HERMES_HOME."""
- home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+ home = get_hermes_home()
return home / "gateway.pid"
-def write_pid_file() -> None:
- """Write the current process PID to the gateway PID file."""
+def _get_runtime_status_path() -> Path:
+ """Return the persisted runtime health/status file path."""
+ return _get_pid_path().with_name(_RUNTIME_STATUS_FILE)
+
+
+def _get_lock_dir() -> Path:
+ """Return the machine-local directory for token-scoped gateway locks."""
+ override = os.getenv("HERMES_GATEWAY_LOCK_DIR")
+ if override:
+ return Path(override)
+ state_home = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local" / "state"))
+ return state_home / "hermes" / _LOCKS_DIRNAME
+
+
+def _utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _scope_hash(identity: str) -> str:
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
+
+
+def _get_scope_lock_path(scope: str, identity: str) -> Path:
+ return _get_lock_dir() / f"{scope}-{_scope_hash(identity)}.lock"
+
+
+def _get_process_start_time(pid: int) -> Optional[int]:
+ """Return the kernel start time for a process when available."""
+ stat_path = Path(f"/proc/{pid}/stat")
+ try:
+ # Field 22 in /proc//stat is process start time (clock ticks).
+ return int(stat_path.read_text().split()[21])
+ except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
+ return None
+
+
+def _read_process_cmdline(pid: int) -> Optional[str]:
+ """Return the process command line as a space-separated string."""
+ cmdline_path = Path(f"/proc/{pid}/cmdline")
+ try:
+ raw = cmdline_path.read_bytes()
+ except (FileNotFoundError, PermissionError, OSError):
+ return None
+
+ if not raw:
+ return None
+ return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip()
+
+
+def _looks_like_gateway_process(pid: int) -> bool:
+ """Return True when the live PID still looks like the Hermes gateway."""
+ cmdline = _read_process_cmdline(pid)
+ if not cmdline:
+ return False
+
+ patterns = (
+ "hermes_cli.main gateway",
+ "hermes_cli/main.py gateway",
+ "hermes gateway",
+ "gateway/run.py",
+ )
+ return any(pattern in cmdline for pattern in patterns)
+
+
+def _record_looks_like_gateway(record: dict[str, Any]) -> bool:
+ """Validate gateway identity from PID-file metadata when cmdline is unavailable."""
+ if record.get("kind") != _GATEWAY_KIND:
+ return False
+
+ argv = record.get("argv")
+ if not isinstance(argv, list) or not argv:
+ return False
+
+ cmdline = " ".join(str(part) for part in argv)
+ patterns = (
+ "hermes_cli.main gateway",
+ "hermes_cli/main.py gateway",
+ "hermes gateway",
+ "gateway/run.py",
+ )
+ return any(pattern in cmdline for pattern in patterns)
+
+
+def _build_pid_record() -> dict:
+ return {
+ "pid": os.getpid(),
+ "kind": _GATEWAY_KIND,
+ "argv": list(sys.argv),
+ "start_time": _get_process_start_time(os.getpid()),
+ }
+
+
+def _build_runtime_status_record() -> dict[str, Any]:
+ payload = _build_pid_record()
+ payload.update({
+ "gateway_state": "starting",
+ "exit_reason": None,
+ "platforms": {},
+ "updated_at": _utc_now_iso(),
+ })
+ return payload
+
+
+def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
+ if not path.exists():
+ return None
+ try:
+ raw = path.read_text().strip()
+ except OSError:
+ return None
+ if not raw:
+ return None
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError:
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload))
+
+
+def _read_pid_record() -> Optional[dict]:
pid_path = _get_pid_path()
- pid_path.parent.mkdir(parents=True, exist_ok=True)
- pid_path.write_text(str(os.getpid()))
+ if not pid_path.exists():
+ return None
+
+ raw = pid_path.read_text().strip()
+ if not raw:
+ return None
+
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError:
+ try:
+ return {"pid": int(raw)}
+ except ValueError:
+ return None
+
+ if isinstance(payload, int):
+ return {"pid": payload}
+ if isinstance(payload, dict):
+ return payload
+ return None
+
+
+def write_pid_file() -> None:
+ """Write the current process PID and metadata to the gateway PID file."""
+ _write_json_file(_get_pid_path(), _build_pid_record())
+
+
+def write_runtime_status(
+ *,
+ gateway_state: Optional[str] = None,
+ exit_reason: Optional[str] = None,
+ platform: Optional[str] = None,
+ platform_state: Optional[str] = None,
+ error_code: Optional[str] = None,
+ error_message: Optional[str] = None,
+) -> None:
+ """Persist gateway runtime health information for diagnostics/status."""
+ path = _get_runtime_status_path()
+ payload = _read_json_file(path) or _build_runtime_status_record()
+ payload.setdefault("platforms", {})
+ payload.setdefault("kind", _GATEWAY_KIND)
+ payload["pid"] = os.getpid()
+ payload["start_time"] = _get_process_start_time(os.getpid())
+ payload["updated_at"] = _utc_now_iso()
+
+ if gateway_state is not None:
+ payload["gateway_state"] = gateway_state
+ if exit_reason is not None:
+ payload["exit_reason"] = exit_reason
+
+ if platform is not None:
+ platform_payload = payload["platforms"].get(platform, {})
+ if platform_state is not None:
+ platform_payload["state"] = platform_state
+ if error_code is not None:
+ platform_payload["error_code"] = error_code
+ if error_message is not None:
+ platform_payload["error_message"] = error_message
+ platform_payload["updated_at"] = _utc_now_iso()
+ payload["platforms"][platform] = platform_payload
+
+ _write_json_file(path, payload)
+
+
+def read_runtime_status() -> Optional[dict[str, Any]]:
+ """Read the persisted gateway runtime health/status information."""
+ return _read_json_file(_get_runtime_status_path())
def remove_pid_file() -> None:
@@ -37,24 +234,157 @@ def remove_pid_file() -> None:
pass
+def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, Any]] = None) -> tuple[bool, Optional[dict[str, Any]]]:
+ """Acquire a machine-local lock keyed by scope + identity.
+
+ Used to prevent multiple local gateways from using the same external identity
+ at once (e.g. the same Telegram bot token across different HERMES_HOME dirs).
+ """
+ lock_path = _get_scope_lock_path(scope, identity)
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ record = {
+ **_build_pid_record(),
+ "scope": scope,
+ "identity_hash": _scope_hash(identity),
+ "metadata": metadata or {},
+ "updated_at": _utc_now_iso(),
+ }
+
+ existing = _read_json_file(lock_path)
+ if existing:
+ try:
+ existing_pid = int(existing["pid"])
+ except (KeyError, TypeError, ValueError):
+ existing_pid = None
+
+ if existing_pid == os.getpid() and existing.get("start_time") == record.get("start_time"):
+ _write_json_file(lock_path, record)
+ return True, existing
+
+ stale = existing_pid is None
+ if not stale:
+ try:
+ os.kill(existing_pid, 0)
+ except (ProcessLookupError, PermissionError):
+ stale = True
+ else:
+ current_start = _get_process_start_time(existing_pid)
+ if (
+ existing.get("start_time") is not None
+ and current_start is not None
+ and current_start != existing.get("start_time")
+ ):
+ stale = True
+ # Check if process is stopped (Ctrl+Z / SIGTSTP) โ stopped
+ # processes still respond to os.kill(pid, 0) but are not
+ # actually running. Treat them as stale so --replace works.
+ if not stale:
+ try:
+ _proc_status = Path(f"/proc/{existing_pid}/status")
+ if _proc_status.exists():
+ for _line in _proc_status.read_text().splitlines():
+ if _line.startswith("State:"):
+ _state = _line.split()[1]
+ if _state in ("T", "t"): # stopped or tracing stop
+ stale = True
+ break
+ except (OSError, PermissionError):
+ pass
+ if stale:
+ try:
+ lock_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ else:
+ return False, existing
+
+ try:
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ except FileExistsError:
+ return False, _read_json_file(lock_path)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(record, handle)
+ except Exception:
+ try:
+ lock_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ raise
+ return True, None
+
+
+def release_scoped_lock(scope: str, identity: str) -> None:
+ """Release a previously-acquired scope lock when owned by this process."""
+ lock_path = _get_scope_lock_path(scope, identity)
+ existing = _read_json_file(lock_path)
+ if not existing:
+ return
+ if existing.get("pid") != os.getpid():
+ return
+ if existing.get("start_time") != _get_process_start_time(os.getpid()):
+ return
+ try:
+ lock_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
+def release_all_scoped_locks() -> int:
+ """Remove all scoped lock files in the lock directory.
+
+ Called during --replace to clean up stale locks left by stopped/killed
+ gateway processes that did not release their locks gracefully.
+ Returns the number of lock files removed.
+ """
+ lock_dir = _get_lock_dir()
+ removed = 0
+ if lock_dir.exists():
+ for lock_file in lock_dir.glob("*.lock"):
+ try:
+ lock_file.unlink(missing_ok=True)
+ removed += 1
+ except OSError:
+ pass
+ return removed
+
+
def get_running_pid() -> Optional[int]:
"""Return the PID of a running gateway instance, or ``None``.
Checks the PID file and verifies the process is actually alive.
Cleans up stale PID files automatically.
"""
- pid_path = _get_pid_path()
- if not pid_path.exists():
+ record = _read_pid_record()
+ if not record:
+ remove_pid_file()
+ return None
+
+ try:
+ pid = int(record["pid"])
+ except (KeyError, TypeError, ValueError):
+ remove_pid_file()
return None
+
try:
- pid = int(pid_path.read_text().strip())
os.kill(pid, 0) # signal 0 = existence check, no actual signal sent
- return pid
- except (ValueError, ProcessLookupError, PermissionError):
- # Stale PID file โ process is gone
+ except (ProcessLookupError, PermissionError):
remove_pid_file()
return None
+ recorded_start = record.get("start_time")
+ current_start = _get_process_start_time(pid)
+ if recorded_start is not None and current_start is not None and current_start != recorded_start:
+ remove_pid_file()
+ return None
+
+ if not _looks_like_gateway_process(pid):
+ if not _record_looks_like_gateway(record):
+ remove_pid_file()
+ return None
+
+ return pid
+
def is_gateway_running() -> bool:
"""Check if the gateway daemon is currently running."""
diff --git a/gateway/sticker_cache.py b/gateway/sticker_cache.py
index 597f672ef86..f3b874019f4 100644
--- a/gateway/sticker_cache.py
+++ b/gateway/sticker_cache.py
@@ -9,13 +9,13 @@
"""
import json
-import os
import time
-from pathlib import Path
from typing import Optional
+from hermes_cli.config import get_hermes_home
-CACHE_PATH = Path(os.path.expanduser("~/.hermes/sticker_cache.json"))
+
+CACHE_PATH = get_hermes_home() / "sticker_cache.json"
# Vision prompt for describing stickers -- kept concise to save tokens
STICKER_VISION_PROMPT = (
diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py
new file mode 100644
index 00000000000..2ceb0fb1d81
--- /dev/null
+++ b/gateway/stream_consumer.py
@@ -0,0 +1,202 @@
+"""Gateway streaming consumer โ bridges sync agent callbacks to async platform delivery.
+
+The agent fires stream_delta_callback(text) synchronously from its worker thread.
+GatewayStreamConsumer:
+ 1. Receives deltas via on_delta() (thread-safe, sync)
+ 2. Queues them to an asyncio task via queue.Queue
+ 3. The async run() task buffers, rate-limits, and progressively edits
+ a single message on the target platform
+
+Design: Uses the edit transport (send initial message, then editMessageText).
+This is universally supported across Telegram, Discord, and Slack.
+
+Credit: jobless0x (#774, #1312), OutThisLife (#798), clicksingh (#697).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import queue
+import time
+from dataclasses import dataclass
+from typing import Any, Optional
+
+logger = logging.getLogger("gateway.stream_consumer")
+
+# Sentinel to signal the stream is complete
+_DONE = object()
+
+
+@dataclass
+class StreamConsumerConfig:
+ """Runtime config for a single stream consumer instance."""
+ edit_interval: float = 0.3
+ buffer_threshold: int = 40
+ cursor: str = " โ"
+
+
+class GatewayStreamConsumer:
+ """Async consumer that progressively edits a platform message with streamed tokens.
+
+ Usage::
+
+ consumer = GatewayStreamConsumer(adapter, chat_id, config, metadata=metadata)
+ # Pass consumer.on_delta as stream_delta_callback to AIAgent
+ agent = AIAgent(..., stream_delta_callback=consumer.on_delta)
+ # Start the consumer as an asyncio task
+ task = asyncio.create_task(consumer.run())
+ # ... run agent in thread pool ...
+ consumer.finish() # signal completion
+ await task # wait for final edit
+ """
+
+ def __init__(
+ self,
+ adapter: Any,
+ chat_id: str,
+ config: Optional[StreamConsumerConfig] = None,
+ metadata: Optional[dict] = None,
+ ):
+ self.adapter = adapter
+ self.chat_id = chat_id
+ self.cfg = config or StreamConsumerConfig()
+ self.metadata = metadata
+ self._queue: queue.Queue = queue.Queue()
+ self._accumulated = ""
+ self._message_id: Optional[str] = None
+ self._already_sent = False
+ self._edit_supported = True # Disabled on first edit failure (Signal/Email/HA)
+ self._last_edit_time = 0.0
+ self._last_sent_text = "" # Track last-sent text to skip redundant edits
+
+ @property
+ def already_sent(self) -> bool:
+ """True if at least one message was sent/edited โ signals the base
+ adapter to skip re-sending the final response."""
+ return self._already_sent
+
+ def on_delta(self, text: str) -> None:
+ """Thread-safe callback โ called from the agent's worker thread."""
+ if text:
+ self._queue.put(text)
+
+ def finish(self) -> None:
+ """Signal that the stream is complete."""
+ self._queue.put(_DONE)
+
+ async def run(self) -> None:
+ """Async task that drains the queue and edits the platform message."""
+ # Platform message length limit โ leave room for cursor + formatting
+ _raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
+ _safe_limit = max(500, _raw_limit - len(self.cfg.cursor) - 100)
+
+ try:
+ while True:
+ # Drain all available items from the queue
+ got_done = False
+ while True:
+ try:
+ item = self._queue.get_nowait()
+ if item is _DONE:
+ got_done = True
+ break
+ self._accumulated += item
+ except queue.Empty:
+ break
+
+ # Decide whether to flush an edit
+ now = time.monotonic()
+ elapsed = now - self._last_edit_time
+ should_edit = (
+ got_done
+ or (elapsed >= self.cfg.edit_interval
+ and len(self._accumulated) > 0)
+ or len(self._accumulated) >= self.cfg.buffer_threshold
+ )
+
+ if should_edit and self._accumulated:
+ # Split overflow: if accumulated text exceeds the platform
+ # limit, finalize the current message and start a new one.
+ while (
+ len(self._accumulated) > _safe_limit
+ and self._message_id is not None
+ ):
+ split_at = self._accumulated.rfind("\n", 0, _safe_limit)
+ if split_at < _safe_limit // 2:
+ split_at = _safe_limit
+ chunk = self._accumulated[:split_at]
+ await self._send_or_edit(chunk)
+ self._accumulated = self._accumulated[split_at:].lstrip("\n")
+ self._message_id = None
+ self._last_sent_text = ""
+
+ display_text = self._accumulated
+ if not got_done:
+ display_text += self.cfg.cursor
+
+ await self._send_or_edit(display_text)
+ self._last_edit_time = time.monotonic()
+
+ if got_done:
+ # Final edit without cursor
+ if self._accumulated and self._message_id:
+ await self._send_or_edit(self._accumulated)
+ return
+
+ await asyncio.sleep(0.05) # Small yield to not busy-loop
+
+ except asyncio.CancelledError:
+ # Best-effort final edit on cancellation
+ if self._accumulated and self._message_id:
+ try:
+ await self._send_or_edit(self._accumulated)
+ except Exception:
+ pass
+ except Exception as e:
+ logger.error("Stream consumer error: %s", e)
+
+ async def _send_or_edit(self, text: str) -> None:
+ """Send or edit the streaming message."""
+ try:
+ if self._message_id is not None:
+ if self._edit_supported:
+ # Skip if text is identical to what we last sent
+ if text == self._last_sent_text:
+ return
+ # Edit existing message
+ result = await self.adapter.edit_message(
+ chat_id=self.chat_id,
+ message_id=self._message_id,
+ content=text,
+ )
+ if result.success:
+ self._already_sent = True
+ self._last_sent_text = text
+ else:
+ # Edit not supported by this adapter โ stop streaming,
+ # let the normal send path handle the final response.
+ # Without this guard, adapters like Signal/Email would
+ # flood the chat with a new message every edit_interval.
+ logger.debug("Edit failed, disabling streaming for this adapter")
+ self._edit_supported = False
+ else:
+ # Editing not supported โ skip intermediate updates.
+ # The final response will be sent by the normal path.
+ pass
+ else:
+ # First message โ send new
+ result = await self.adapter.send(
+ chat_id=self.chat_id,
+ content=text,
+ metadata=self.metadata,
+ )
+ if result.success and result.message_id:
+ self._message_id = result.message_id
+ self._already_sent = True
+ self._last_sent_text = text
+ else:
+ # Initial send failed โ disable streaming for this session
+ self._edit_supported = False
+ except Exception as e:
+ logger.error("Stream send/edit error: %s", e)
diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py
index 3c7adeea69b..04778320234 100644
--- a/hermes_cli/__init__.py
+++ b/hermes_cli/__init__.py
@@ -11,5 +11,5 @@
- hermes cron - Manage cron jobs
"""
-__version__ = "0.2.0"
-__release_date__ = "2026.3.12"
+__version__ = "0.4.0"
+__release_date__ = "2026.3.23"
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index c1b08348441..abbe6a0085a 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -19,6 +19,7 @@
import logging
import os
import shutil
+import shlex
import stat
import base64
import hashlib
@@ -66,9 +67,12 @@
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
+DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
+DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
+DEFAULT_XGATE_BASE_URL = "https://ai.xgate.run/v1"
# =============================================================================
@@ -108,6 +112,20 @@ class ProviderConfig:
auth_type="oauth_external",
inference_base_url=DEFAULT_CODEX_BASE_URL,
),
+ "copilot": ProviderConfig(
+ id="copilot",
+ name="GitHub Copilot",
+ auth_type="api_key",
+ inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL,
+ api_key_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
+ ),
+ "copilot-acp": ProviderConfig(
+ id="copilot-acp",
+ name="GitHub Copilot ACP",
+ auth_type="external_process",
+ inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
+ base_url_env_var="COPILOT_ACP_BASE_URL",
+ ),
"zai": ProviderConfig(
id="zai",
name="Z.AI / GLM",
@@ -128,7 +146,7 @@ class ProviderConfig:
id="minimax",
name="MiniMax",
auth_type="api_key",
- inference_base_url="https://api.minimax.io/v1",
+ inference_base_url="https://api.minimax.io/anthropic",
api_key_env_vars=("MINIMAX_API_KEY",),
base_url_env_var="MINIMAX_BASE_URL",
),
@@ -139,14 +157,78 @@ class ProviderConfig:
inference_base_url="https://api.anthropic.com",
api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
+ "alibaba": ProviderConfig(
+ id="alibaba",
+ name="Alibaba Cloud (DashScope)",
+ auth_type="api_key",
+ inference_base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
+ api_key_env_vars=("DASHSCOPE_API_KEY",),
+ base_url_env_var="DASHSCOPE_BASE_URL",
+ ),
"minimax-cn": ProviderConfig(
id="minimax-cn",
name="MiniMax (China)",
auth_type="api_key",
- inference_base_url="https://api.minimaxi.com/v1",
+ inference_base_url="https://api.minimaxi.com/anthropic",
api_key_env_vars=("MINIMAX_CN_API_KEY",),
base_url_env_var="MINIMAX_CN_BASE_URL",
),
+ "deepseek": ProviderConfig(
+ id="deepseek",
+ name="DeepSeek",
+ auth_type="api_key",
+ inference_base_url="https://api.deepseek.com/v1",
+ api_key_env_vars=("DEEPSEEK_API_KEY",),
+ base_url_env_var="DEEPSEEK_BASE_URL",
+ ),
+ "ai-gateway": ProviderConfig(
+ id="ai-gateway",
+ name="AI Gateway",
+ auth_type="api_key",
+ inference_base_url="https://ai-gateway.vercel.sh/v1",
+ api_key_env_vars=("AI_GATEWAY_API_KEY",),
+ base_url_env_var="AI_GATEWAY_BASE_URL",
+ ),
+ "xgate": ProviderConfig(
+ id="xgate",
+ name="xgate",
+ auth_type="api_key",
+ inference_base_url=DEFAULT_XGATE_BASE_URL,
+ api_key_env_vars=("XGATE_API_KEY",),
+ base_url_env_var="XGATE_BASE_URL",
+ ),
+ "opencode-zen": ProviderConfig(
+ id="opencode-zen",
+ name="OpenCode Zen",
+ auth_type="api_key",
+ inference_base_url="https://opencode.ai/zen/v1",
+ api_key_env_vars=("OPENCODE_ZEN_API_KEY",),
+ base_url_env_var="OPENCODE_ZEN_BASE_URL",
+ ),
+ "opencode-go": ProviderConfig(
+ id="opencode-go",
+ name="OpenCode Go",
+ auth_type="api_key",
+ inference_base_url="https://opencode.ai/zen/go/v1",
+ api_key_env_vars=("OPENCODE_GO_API_KEY",),
+ base_url_env_var="OPENCODE_GO_BASE_URL",
+ ),
+ "kilocode": ProviderConfig(
+ id="kilocode",
+ name="Kilo Code",
+ auth_type="api_key",
+ inference_base_url="https://api.kilo.ai/api/gateway",
+ api_key_env_vars=("KILOCODE_API_KEY",),
+ base_url_env_var="KILOCODE_BASE_URL",
+ ),
+ "huggingface": ProviderConfig(
+ id="huggingface",
+ name="Hugging Face",
+ auth_type="api_key",
+ inference_base_url="https://router.huggingface.co/v1",
+ api_key_env_vars=("HF_TOKEN",),
+ base_url_env_var="HF_BASE_URL",
+ ),
}
@@ -174,6 +256,97 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
return default_url
+def _gh_cli_candidates() -> list[str]:
+ """Return candidate ``gh`` binary paths, including common Homebrew installs."""
+ candidates: list[str] = []
+
+ resolved = shutil.which("gh")
+ if resolved:
+ candidates.append(resolved)
+
+ for candidate in (
+ "/opt/homebrew/bin/gh",
+ "/usr/local/bin/gh",
+ str(Path.home() / ".local" / "bin" / "gh"),
+ ):
+ if candidate in candidates:
+ continue
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ candidates.append(candidate)
+
+ return candidates
+
+
+def _try_gh_cli_token() -> Optional[str]:
+ """Return a token from ``gh auth token`` when the GitHub CLI is available."""
+ for gh_path in _gh_cli_candidates():
+ try:
+ result = subprocess.run(
+ [gh_path, "auth", "token"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
+ logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
+ continue
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip()
+ return None
+
+
+_PLACEHOLDER_SECRET_VALUES = {
+ "*",
+ "**",
+ "***",
+ "changeme",
+ "your_api_key",
+ "your-api-key",
+ "placeholder",
+ "example",
+ "dummy",
+ "null",
+ "none",
+}
+
+
+def has_usable_secret(value: Any, *, min_length: int = 4) -> bool:
+ """Return True when a configured secret looks usable, not empty/placeholder."""
+ if not isinstance(value, str):
+ return False
+ cleaned = value.strip()
+ if len(cleaned) < min_length:
+ return False
+ if cleaned.lower() in _PLACEHOLDER_SECRET_VALUES:
+ return False
+ return True
+
+
+def _resolve_api_key_provider_secret(
+ provider_id: str, pconfig: ProviderConfig
+) -> tuple[str, str]:
+ """Resolve an API-key provider's token and indicate where it came from."""
+ if provider_id == "copilot":
+ # Use the dedicated copilot auth module for proper token validation
+ try:
+ from hermes_cli.copilot_auth import resolve_copilot_token
+ token, source = resolve_copilot_token()
+ if token:
+ return token, source
+ except ValueError as exc:
+ logger.warning("Copilot token validation failed: %s", exc)
+ except Exception:
+ pass
+ return "", ""
+
+ for env_var in pconfig.api_key_env_vars:
+ val = os.getenv(env_var, "").strip()
+ if has_usable_secret(val):
+ return val, env_var
+
+ return "", ""
+
+
# =============================================================================
# Z.AI Endpoint Detection
# =============================================================================
@@ -524,11 +697,22 @@ def resolve_provider(
"kimi": "kimi-coding", "moonshot": "kimi-coding",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
+ "github": "copilot", "github-copilot": "copilot",
+ "github-models": "copilot", "github-model": "copilot",
+ "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
+ "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
+ "daydreams": "xgate",
+ "opencode": "opencode-zen", "zen": "opencode-zen",
+ "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
+ "go": "opencode-go", "opencode-go-sub": "opencode-go",
+ "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
}
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
- if normalized in {"openrouter", "custom"}:
+ if normalized == "openrouter":
return "openrouter"
+ if normalized == "custom":
+ return "custom"
if normalized in PROVIDER_REGISTRY:
return normalized
if normalized != "auto":
@@ -552,15 +736,20 @@ def resolve_provider(
except Exception as e:
logger.debug("Could not detect active auth provider: %s", e)
- if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"):
+ if has_usable_secret(os.getenv("OPENAI_API_KEY")) or has_usable_secret(os.getenv("OPENROUTER_API_KEY")):
return "openrouter"
# Auto-detect API-key providers by checking their env vars
for pid, pconfig in PROVIDER_REGISTRY.items():
if pconfig.auth_type != "api_key":
continue
+ # GitHub tokens are commonly present for repo/tool access but should not
+ # hijack inference auto-selection unless the user explicitly chooses
+ # Copilot/GitHub Models as the provider.
+ if pid == "copilot":
+ continue
for env_var in pconfig.api_key_env_vars:
- if os.getenv(env_var, "").strip():
+ if has_usable_secret(os.getenv(env_var, "")):
return pid
return "openrouter"
@@ -1427,12 +1616,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
api_key = ""
key_source = ""
- for env_var in pconfig.api_key_env_vars:
- val = os.getenv(env_var, "").strip()
- if val:
- api_key = val
- key_source = env_var
- break
+ api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
env_url = ""
if pconfig.base_url_env_var:
@@ -1455,6 +1639,36 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
}
+def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
+ """Status snapshot for providers that run a local subprocess."""
+ pconfig = PROVIDER_REGISTRY.get(provider_id)
+ if not pconfig or pconfig.auth_type != "external_process":
+ return {"configured": False}
+
+ command = (
+ os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
+ or os.getenv("COPILOT_CLI_PATH", "").strip()
+ or "copilot"
+ )
+ raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
+ args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
+ base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
+ if not base_url:
+ base_url = pconfig.inference_base_url
+
+ resolved_command = shutil.which(command) if command else None
+ return {
+ "configured": bool(resolved_command or base_url.startswith("acp+tcp://")),
+ "provider": provider_id,
+ "name": pconfig.name,
+ "command": command,
+ "args": args,
+ "resolved_command": resolved_command,
+ "base_url": base_url,
+ "logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")),
+ }
+
+
def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Generic auth status dispatcher."""
target = provider_id or get_active_provider()
@@ -1462,6 +1676,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_nous_auth_status()
if target == "openai-codex":
return get_codex_auth_status()
+ if target == "copilot-acp":
+ return get_external_process_provider_status(target)
# API-key providers
pconfig = PROVIDER_REGISTRY.get(target)
if pconfig and pconfig.auth_type == "api_key":
@@ -1484,12 +1700,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
api_key = ""
key_source = ""
- for env_var in pconfig.api_key_env_vars:
- val = os.getenv(env_var, "").strip()
- if val:
- api_key = val
- key_source = env_var
- break
+ api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
env_url = ""
if pconfig.base_url_env_var:
@@ -1510,6 +1721,46 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
}
+def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
+ """Resolve runtime details for local subprocess-backed providers."""
+ pconfig = PROVIDER_REGISTRY.get(provider_id)
+ if not pconfig or pconfig.auth_type != "external_process":
+ raise AuthError(
+ f"Provider '{provider_id}' is not an external-process provider.",
+ provider=provider_id,
+ code="invalid_provider",
+ )
+
+ base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
+ if not base_url:
+ base_url = pconfig.inference_base_url
+
+ command = (
+ os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
+ or os.getenv("COPILOT_CLI_PATH", "").strip()
+ or "copilot"
+ )
+ raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
+ args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
+ resolved_command = shutil.which(command) if command else None
+ if not resolved_command and not base_url.startswith("acp+tcp://"):
+ raise AuthError(
+ f"Could not find the Copilot CLI command '{command}'. "
+ "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH.",
+ provider=provider_id,
+ code="missing_copilot_cli",
+ )
+
+ return {
+ "provider": provider_id,
+ "api_key": "copilot-acp",
+ "base_url": base_url.rstrip("/"),
+ "command": resolved_command or command,
+ "args": args,
+ "source": "process",
+ }
+
+
# =============================================================================
# External credential detection
# =============================================================================
@@ -1541,8 +1792,20 @@ def detect_external_credentials() -> List[Dict[str, Any]]:
# CLI Commands โ login / logout
# =============================================================================
-def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Path:
- """Update config.yaml and auth.json to reflect the active provider."""
+def _update_config_for_provider(
+ provider_id: str,
+ inference_base_url: str,
+ default_model: Optional[str] = None,
+) -> Path:
+ """Update config.yaml and auth.json to reflect the active provider.
+
+ When *default_model* is provided the function also writes it as the
+ ``model.default`` value. This prevents a race condition where the
+ gateway (which re-reads config per-message) picks up the new provider
+ before the caller has finished model selection, resulting in a
+ mismatched model/provider (e.g. ``anthropic/claude-opus-4.6`` sent to
+ MiniMax's API).
+ """
# Set active_provider in auth.json so auto-resolution picks this provider
with _auth_store_lock():
auth_store = _load_auth_store()
@@ -1571,7 +1834,20 @@ def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Pa
model_cfg = {}
model_cfg["provider"] = provider_id
- model_cfg["base_url"] = inference_base_url.rstrip("/")
+ if inference_base_url and inference_base_url.strip():
+ model_cfg["base_url"] = inference_base_url.rstrip("/")
+ else:
+ # Clear stale base_url to prevent contamination when switching providers
+ model_cfg.pop("base_url", None)
+
+ # When switching to a non-OpenRouter provider, ensure model.default is
+ # valid for the new provider. An OpenRouter-formatted name like
+ # "anthropic/claude-opus-4.6" will fail on direct-API providers.
+ if default_model:
+ cur_default = model_cfg.get("default", "")
+ if not cur_default or "/" in cur_default:
+ model_cfg["default"] = default_model
+
config["model"] = model_cfg
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
@@ -1755,7 +2031,7 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
print()
print("Login successful!")
- print(f" Auth state: ~/.hermes/auth.json")
+ print(" Auth state: ~/.hermes/auth.json")
print(f" Config updated: {config_path} (model.provider=openai-codex)")
@@ -1799,9 +2075,9 @@ def _codex_device_code_login() -> Dict[str, Any]:
# Step 2: Show user the code
print("To continue, follow these steps:\n")
- print(f" 1. Open this URL in your browser:")
+ print(" 1. Open this URL in your browser:")
print(f" \033[94m{issuer}/codex/device\033[0m\n")
- print(f" 2. Enter this code:")
+ print(" 2. Enter this code:")
print(f" \033[94m{user_code}\033[0m\n")
print("Waiting for sign-in... (press Ctrl+C to cancel)")
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index f1925651cd6..c4eb827e223 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -6,10 +6,13 @@
import json
import logging
import os
+import shutil
import subprocess
+import threading
import time
from pathlib import Path
-from typing import Dict, List, Any, Optional
+from hermes_constants import get_hermes_home
+from typing import Dict, List, Optional
from rich.console import Console
from rich.panel import Panel
@@ -25,7 +28,7 @@
# ANSI building blocks for conversation display
# =========================================================================
-_GOLD = "\033[1;33m"
+_GOLD = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
@@ -100,27 +103,22 @@ def _skin_branding(key: str, fallback: str) -> str:
# =========================================================================
def get_available_skills() -> Dict[str, List[str]]:
- """Scan ~/.hermes/skills/ and return skills grouped by category."""
- import os
-
- hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- skills_dir = hermes_home / "skills"
- skills_by_category = {}
-
- if not skills_dir.exists():
- return skills_by_category
-
- for skill_file in skills_dir.rglob("SKILL.md"):
- rel_path = skill_file.relative_to(skills_dir)
- parts = rel_path.parts
- if len(parts) >= 2:
- category = parts[0]
- skill_name = parts[-2]
- else:
- category = "general"
- skill_name = skill_file.parent.name
- skills_by_category.setdefault(category, []).append(skill_name)
+ """Return skills grouped by category, filtered by platform and disabled state.
+
+ Delegates to ``_find_all_skills()`` from ``tools/skills_tool`` which already
+ handles platform gating (``platforms:`` frontmatter) and respects the
+ user's ``skills.disabled`` config list.
+ """
+ try:
+ from tools.skills_tool import _find_all_skills
+ all_skills = _find_all_skills() # already filtered
+ except Exception:
+ return {}
+ skills_by_category: Dict[str, List[str]] = {}
+ for skill in all_skills:
+ category = skill.get("category") or "general"
+ skills_by_category.setdefault(category, []).append(skill["name"])
return skills_by_category
@@ -139,11 +137,13 @@ def check_for_updates() -> Optional[int]:
``~/.hermes/.update_check``). Returns the number of commits behind,
or ``None`` if the check fails or isn't applicable.
"""
- hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+ hermes_home = get_hermes_home()
repo_dir = hermes_home / "hermes-agent"
cache_file = hermes_home / ".update_check"
- # Must be a git repo
+ # Must be a git repo โ fall back to project root for dev installs
+ if not (repo_dir / ".git").exists():
+ repo_dir = Path(__file__).parent.parent.resolve()
if not (repo_dir / ".git").exists():
return None
@@ -190,6 +190,30 @@ def check_for_updates() -> Optional[int]:
return behind
+# =========================================================================
+# Non-blocking update check
+# =========================================================================
+
+_update_result: Optional[int] = None
+_update_check_done = threading.Event()
+
+
+def prefetch_update_check():
+ """Kick off update check in a background daemon thread."""
+ def _run():
+ global _update_result
+ _update_result = check_for_updates()
+ _update_check_done.set()
+ t = threading.Thread(target=_run, daemon=True)
+ t.start()
+
+
+def get_update_result(timeout: float = 0.5) -> Optional[int]:
+ """Get result of prefetched check. Returns None if not ready."""
+ _update_check_done.wait(timeout=timeout)
+ return _update_result
+
+
# =========================================================================
# Welcome banner
# =========================================================================
@@ -205,6 +229,17 @@ def _format_context_length(tokens: int) -> str:
return str(tokens)
+def _display_toolset_name(toolset_name: str) -> str:
+ """Normalize internal/legacy toolset identifiers for banner display."""
+ if not toolset_name:
+ return "unknown"
+ return (
+ toolset_name[:-6]
+ if toolset_name.endswith("_tools")
+ else toolset_name
+ )
+
+
def build_welcome_banner(console: Console, model: str, cwd: str,
tools: List[dict] = None,
enabled_toolsets: List[str] = None,
@@ -223,7 +258,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
get_toolset_for_tool: Callable to map tool name -> toolset name.
context_length: Model's context window size in tokens.
"""
- from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
+ from model_tools import check_tool_availability
if get_toolset_for_tool is None:
from model_tools import get_toolset_for_tool
@@ -245,8 +280,18 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
text = _skin_color("banner_text", "#FFF8DC")
session_color = _skin_color("session_border", "#8B8682")
- left_lines = ["", HERMES_CADUCEUS, ""]
+ # Use skin's custom caduceus art if provided
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ _bskin = get_active_skin()
+ _hero = _bskin.banner_hero if hasattr(_bskin, 'banner_hero') and _bskin.banner_hero else HERMES_CADUCEUS
+ except Exception:
+ _bskin = None
+ _hero = HERMES_CADUCEUS
+ left_lines = ["", _hero, ""]
model_short = model.split("/")[-1] if "/" in model else model
+ if model_short.endswith(".gguf"):
+ model_short = model_short[:-5]
if len(model_short) > 28:
model_short = model_short[:25] + "..."
ctx_str = f" [dim {dim}]ยท[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
@@ -261,12 +306,12 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
for tool in tools:
tool_name = tool["function"]["name"]
- toolset = get_toolset_for_tool(tool_name) or "other"
+ toolset = _display_toolset_name(get_toolset_for_tool(tool_name) or "other")
toolsets_dict.setdefault(toolset, []).append(tool_name)
for item in unavailable_toolsets:
toolset_id = item.get("id", item.get("name", "unknown"))
- display_name = f"{toolset_id}_tools" if not toolset_id.endswith("_tools") else toolset_id
+ display_name = _display_toolset_name(toolset_id)
if display_name not in toolsets_dict:
toolsets_dict[display_name] = []
for tool_name in item.get("tools", []):
@@ -306,10 +351,10 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
colored_names.append(f"[{text}]{name}[/]")
tools_str = ", ".join(colored_names)
- right_lines.append(f"[dim #B8860B]{toolset}:[/] {tools_str}")
+ right_lines.append(f"[dim {dim}]{toolset}:[/] {tools_str}")
if remaining_toolsets > 0:
- right_lines.append(f"[dim #B8860B](and {remaining_toolsets} more toolsets...)[/]")
+ right_lines.append(f"[dim {dim}](and {remaining_toolsets} more toolsets...)[/]")
# MCP Servers section (only if configured)
try:
@@ -320,12 +365,12 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
if mcp_status:
right_lines.append("")
- right_lines.append("[bold #FFBF00]MCP Servers[/]")
+ right_lines.append(f"[bold {accent}]MCP Servers[/]")
for srv in mcp_status:
if srv["connected"]:
right_lines.append(
- f"[dim #B8860B]{srv['name']}[/] [#FFF8DC]({srv['transport']})[/] "
- f"[dim #B8860B]โ[/] [#FFF8DC]{srv['tools']} tool(s)[/]"
+ f"[dim {dim}]{srv['name']}[/] [{text}]({srv['transport']})[/] "
+ f"[dim {dim}]โ[/] [{text}]{srv['tools']} tool(s)[/]"
)
else:
right_lines.append(
@@ -360,9 +405,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
summary_parts.append("/help for commands")
right_lines.append(f"[dim {dim}]{' ยท '.join(summary_parts)}[/]")
- # Update check โ show if behind origin/main
+ # Update check โ use prefetched result if available
try:
- behind = check_for_updates()
+ behind = get_update_result(timeout=0.5)
if behind and behind > 0:
commits_word = "commit" if behind == 1 else "commits"
right_lines.append(
@@ -386,6 +431,9 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
)
console.print()
- console.print(HERMES_AGENT_LOGO)
- console.print()
+ term_width = shutil.get_terminal_size().columns
+ if term_width >= 95:
+ _logo = _bskin.banner_logo if _bskin and hasattr(_bskin, 'banner_logo') and _bskin.banner_logo else HERMES_AGENT_LOGO
+ console.print(_logo)
+ console.print()
console.print(outer_panel)
diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py
index 425e5c84e0e..88a97511c56 100644
--- a/hermes_cli/callbacks.py
+++ b/hermes_cli/callbacks.py
@@ -8,8 +8,10 @@
import queue
import time as _time
+import getpass
from hermes_cli.banner import cprint, _DIM, _RST
+from hermes_cli.config import save_env_value_secure
def clarify_callback(cli, question, choices):
@@ -33,7 +35,7 @@ def clarify_callback(cli, question, choices):
cli._clarify_deadline = _time.monotonic() + timeout
cli._clarify_freetext = is_open_ended
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
@@ -45,13 +47,13 @@ def clarify_callback(cli, question, choices):
remaining = cli._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM}(clarify timed out after {timeout}s โ agent will decide){_RST}")
return (
@@ -71,7 +73,7 @@ def sudo_password_callback(cli) -> str:
cli._sudo_state = {"response_queue": response_queue}
cli._sudo_deadline = _time.monotonic() + timeout
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
@@ -79,7 +81,7 @@ def sudo_password_callback(cli) -> str:
result = response_queue.get(timeout=1)
cli._sudo_state = None
cli._sudo_deadline = 0
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
if result:
cprint(f"\n{_DIM} โ Password received (cached for session){_RST}")
@@ -90,60 +92,188 @@ def sudo_password_callback(cli) -> str:
remaining = cli._sudo_deadline - _time.monotonic()
if remaining <= 0:
break
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._sudo_state = None
cli._sudo_deadline = 0
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} โฑ Timeout โ continuing without sudo{_RST}")
return ""
-def approval_callback(cli, command: str, description: str) -> str:
- """Prompt for dangerous command approval through the TUI.
+def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
+ """Prompt for a secret value through the TUI (e.g. API keys for skills).
- Shows a selection UI with choices: once / session / always / deny.
- When the command is longer than 70 characters, a "view" option is
- included so the user can reveal the full text before deciding.
+ Returns a dict with keys: success, stored_as, validated, skipped, message.
+ The secret is stored in ~/.hermes/.env and never exposed to the model.
"""
- timeout = 60
+ if not getattr(cli, "_app", None):
+ if not hasattr(cli, "_secret_state"):
+ cli._secret_state = None
+ if not hasattr(cli, "_secret_deadline"):
+ cli._secret_deadline = 0
+ try:
+ value = getpass.getpass(f"{prompt} (hidden, Enter to skip): ")
+ except (EOFError, KeyboardInterrupt):
+ value = ""
+
+ if not value:
+ cprint(f"\n{_DIM} โญ Secret entry cancelled{_RST}")
+ return {
+ "success": True,
+ "reason": "cancelled",
+ "stored_as": var_name,
+ "validated": False,
+ "skipped": True,
+ "message": "Secret setup was skipped.",
+ }
+
+ stored = save_env_value_secure(var_name, value)
+ cprint(f"\n{_DIM} โ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
+ return {
+ **stored,
+ "skipped": False,
+ "message": "Secret stored securely. The secret value was not exposed to the model.",
+ }
+
+ timeout = 120
response_queue = queue.Queue()
- choices = ["once", "session", "always", "deny"]
- if len(command) > 70:
- choices.append("view")
-
- cli._approval_state = {
- "command": command,
- "description": description,
- "choices": choices,
- "selected": 0,
+
+ cli._secret_state = {
+ "var_name": var_name,
+ "prompt": prompt,
+ "metadata": metadata or {},
"response_queue": response_queue,
}
- cli._approval_deadline = _time.monotonic() + timeout
+ cli._secret_deadline = _time.monotonic() + timeout
+ # Avoid storing stale draft input as the secret when Enter is pressed.
+ if hasattr(cli, "_clear_secret_input_buffer"):
+ try:
+ cli._clear_secret_input_buffer()
+ except Exception:
+ pass
+ elif hasattr(cli, "_app") and cli._app:
+ try:
+ cli._app.current_buffer.reset()
+ except Exception:
+ pass
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
- result = response_queue.get(timeout=1)
- cli._approval_state = None
- cli._approval_deadline = 0
- if hasattr(cli, '_app') and cli._app:
+ value = response_queue.get(timeout=1)
+ cli._secret_state = None
+ cli._secret_deadline = 0
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
- return result
+
+ if not value:
+ cprint(f"\n{_DIM} โญ Secret entry cancelled{_RST}")
+ return {
+ "success": True,
+ "reason": "cancelled",
+ "stored_as": var_name,
+ "validated": False,
+ "skipped": True,
+ "message": "Secret setup was skipped.",
+ }
+
+ stored = save_env_value_secure(var_name, value)
+ cprint(f"\n{_DIM} โ Stored secret in ~/.hermes/.env as {var_name}{_RST}")
+ return {
+ **stored,
+ "skipped": False,
+ "message": "Secret stored securely. The secret value was not exposed to the model.",
+ }
except queue.Empty:
- remaining = cli._approval_deadline - _time.monotonic()
+ remaining = cli._secret_deadline - _time.monotonic()
if remaining <= 0:
break
- if hasattr(cli, '_app') and cli._app:
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
- cli._approval_state = None
- cli._approval_deadline = 0
- if hasattr(cli, '_app') and cli._app:
+ cli._secret_state = None
+ cli._secret_deadline = 0
+ if hasattr(cli, "_clear_secret_input_buffer"):
+ try:
+ cli._clear_secret_input_buffer()
+ except Exception:
+ pass
+ elif hasattr(cli, "_app") and cli._app:
+ try:
+ cli._app.current_buffer.reset()
+ except Exception:
+ pass
+ if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
- cprint(f"\n{_DIM} โฑ Timeout โ denying command{_RST}")
- return "deny"
+ cprint(f"\n{_DIM} โฑ Timeout โ secret capture cancelled{_RST}")
+ return {
+ "success": True,
+ "reason": "timeout",
+ "stored_as": var_name,
+ "validated": False,
+ "skipped": True,
+ "message": "Secret setup timed out and was skipped.",
+ }
+
+
+def approval_callback(cli, command: str, description: str) -> str:
+ """Prompt for dangerous command approval through the TUI.
+
+ Shows a selection UI with choices: once / session / always / deny.
+ When the command is longer than 70 characters, a "view" option is
+ included so the user can reveal the full text before deciding.
+
+ Uses cli._approval_lock to serialize concurrent requests (e.g. from
+ parallel delegation subtasks) so each prompt gets its own turn.
+ """
+ lock = getattr(cli, "_approval_lock", None)
+ if lock is None:
+ import threading
+ cli._approval_lock = threading.Lock()
+ lock = cli._approval_lock
+
+ with lock:
+ timeout = 60
+ response_queue = queue.Queue()
+ choices = ["once", "session", "always", "deny"]
+ if len(command) > 70:
+ choices.append("view")
+
+ cli._approval_state = {
+ "command": command,
+ "description": description,
+ "choices": choices,
+ "selected": 0,
+ "response_queue": response_queue,
+ }
+ cli._approval_deadline = _time.monotonic() + timeout
+
+ if hasattr(cli, "_app") and cli._app:
+ cli._app.invalidate()
+
+ while True:
+ try:
+ result = response_queue.get(timeout=1)
+ cli._approval_state = None
+ cli._approval_deadline = 0
+ if hasattr(cli, "_app") and cli._app:
+ cli._app.invalidate()
+ return result
+ except queue.Empty:
+ remaining = cli._approval_deadline - _time.monotonic()
+ if remaining <= 0:
+ break
+ if hasattr(cli, "_app") and cli._app:
+ cli._app.invalidate()
+
+ cli._approval_state = None
+ cli._approval_deadline = 0
+ if hasattr(cli, "_app") and cli._app:
+ cli._app.invalidate()
+ cprint(f"\n{_DIM} โฑ Timeout โ denying command{_RST}")
+ return "deny"
diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py
index 5de56890a83..97e1acc406d 100644
--- a/hermes_cli/claw.py
+++ b/hermes_cli/claw.py
@@ -18,10 +18,8 @@
print_header,
print_info,
print_success,
- print_warning,
print_error,
prompt_yes_no,
- prompt_choice,
)
logger = logging.getLogger(__name__)
@@ -127,7 +125,7 @@ def _cmd_migrate(args):
print()
print_error(f"OpenClaw directory not found: {source_dir}")
print_info("Make sure your OpenClaw installation is at the expected path.")
- print_info(f"You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
+ print_info("You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
return
# Find the migration script
@@ -208,7 +206,6 @@ def _print_migration_report(report: dict, dry_run: bool):
skipped = summary.get("skipped", 0)
conflicts = summary.get("conflict", 0)
errors = summary.get("error", 0)
- total = migrated + skipped + conflicts + errors
print()
if dry_run:
@@ -242,7 +239,7 @@ def _print_migration_report(report: dict, dry_run: bool):
print()
if conflict_items:
- print(color(f" โ Conflicts (skipped โ use --overwrite to force):", Colors.YELLOW))
+ print(color(" โ Conflicts (skipped โ use --overwrite to force):", Colors.YELLOW))
for item in conflict_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "already exists")
@@ -250,7 +247,7 @@ def _print_migration_report(report: dict, dry_run: bool):
print()
if skipped_items:
- print(color(f" โ Skipped:", Colors.DIM))
+ print(color(" โ Skipped:", Colors.DIM))
for item in skipped_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "")
@@ -258,7 +255,7 @@ def _print_migration_report(report: dict, dry_run: bool):
print()
if error_items:
- print(color(f" โ Errors:", Colors.RED))
+ print(color(" โ Errors:", Colors.RED))
for item in error_items:
kind = item.get("kind", "unknown")
reason = item.get("reason", "unknown error")
@@ -294,3 +291,18 @@ def _print_migration_report(report: dict, dry_run: bool):
elif migrated:
print()
print_success("Migration complete!")
+ # Warn if API keys were skipped (migrate_secrets not enabled)
+ skipped_keys = [
+ i for i in report.get("items", [])
+ if i.get("kind") == "provider-keys" and i.get("status") == "skipped"
+ ]
+ if skipped_keys:
+ print()
+ print(color(" โ API keys were NOT migrated (secrets migration is disabled by default).", Colors.YELLOW))
+ print(color(" Your OPENROUTER_API_KEY and other provider keys must be added manually.", Colors.YELLOW))
+ print()
+ print_info("To migrate API keys, re-run with:")
+ print_info(" hermes claw migrate --migrate-secrets")
+ print()
+ print_info("Or add your key manually:")
+ print_info(" hermes config set OPENROUTER_API_KEY sk-or-v1-...")
diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py
index 9fe34671458..169c63e8ace 100644
--- a/hermes_cli/codex_models.py
+++ b/hermes_cli/codex_models.py
@@ -18,6 +18,36 @@
"gpt-5.1-codex-mini",
]
+_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
+ ("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")),
+]
+
+
+def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
+ """Add Clawdbot-style synthetic forward-compat Codex models.
+
+ If a newer Codex slug isn't returned by live discovery, surface it when an
+ older compatible template model is present. This mirrors Clawdbot's
+ synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
+ """
+ ordered: List[str] = []
+ seen: set[str] = set()
+ for model_id in model_ids:
+ if model_id not in seen:
+ ordered.append(model_id)
+ seen.add(model_id)
+
+ for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
+ if synthetic_model in seen:
+ continue
+ if any(template in seen for template in template_models):
+ ordered.append(synthetic_model)
+ seen.add(synthetic_model)
+
+ return ordered
+
def _fetch_models_from_api(access_token: str) -> List[str]:
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
@@ -54,7 +84,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]:
sortable.append((rank, slug))
sortable.sort(key=lambda x: (x[0], x[1]))
- return [slug for _, slug in sortable]
+ return _add_forward_compat_models([slug for _, slug in sortable])
def _read_default_model(codex_home: Path) -> Optional[str]:
@@ -125,7 +155,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
if access_token:
api_models = _fetch_models_from_api(access_token)
if api_models:
- return api_models
+ return _add_forward_compat_models(api_models)
# Fall back to local sources
default_model = _read_default_model(codex_home)
@@ -140,4 +170,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
if model_id not in ordered:
ordered.append(model_id)
- return ordered
+ return _add_forward_compat_models(ordered)
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index a2f3f8163d8..d442f7f94d4 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -1,70 +1,389 @@
"""Slash command definitions and autocomplete for the Hermes CLI.
-Contains the shared built-in ``COMMANDS`` dict and ``SlashCommandCompleter``.
-The completer can optionally include dynamic skill slash commands supplied by the
-interactive CLI.
+Central registry for all slash commands. Every consumer -- CLI help, gateway
+dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete --
+derives its data from ``COMMAND_REGISTRY``.
+
+To add a command: add a ``CommandDef`` entry to ``COMMAND_REGISTRY``.
+To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
"""
from __future__ import annotations
+import os
+import re
from collections.abc import Callable, Mapping
+from dataclasses import dataclass
from typing import Any
+from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion
-# Commands organized by category for better help display
-COMMANDS_BY_CATEGORY = {
- "Session": {
- "/new": "Start a new conversation (reset history)",
- "/reset": "Reset conversation only (keep screen)",
- "/clear": "Clear screen and reset conversation (fresh start)",
- "/history": "Show conversation history",
- "/save": "Save the current conversation",
- "/retry": "Retry the last message (resend to agent)",
- "/undo": "Remove the last user/assistant exchange",
- "/title": "Set a title for the current session (usage: /title My Session Name)",
- "/compress": "Manually compress conversation context (flush memories + summarize)",
- "/rollback": "List or restore filesystem checkpoints (usage: /rollback [number])",
- "/background": "Run a prompt in the background (usage: /background )",
- },
- "Configuration": {
- "/config": "Show current configuration",
- "/model": "Show or change the current model",
- "/provider": "Show available providers and current provider",
- "/prompt": "View/set custom system prompt",
- "/personality": "Set a predefined personality",
- "/verbose": "Cycle tool progress display: off โ new โ all โ verbose",
- "/reasoning": "Manage reasoning effort and display (usage: /reasoning [level|show|hide])",
- "/skin": "Show or change the display skin/theme",
- },
- "Tools & Skills": {
- "/tools": "List available tools",
- "/toolsets": "List available toolsets",
- "/skills": "Search, install, inspect, or manage skills from online registries",
- "/cron": "Manage scheduled tasks (list, add, remove)",
- "/reload-mcp": "Reload MCP servers from config.yaml",
- },
- "Info": {
- "/help": "Show this help message",
- "/usage": "Show token usage for the current session",
- "/insights": "Show usage insights and analytics (last 30 days)",
- "/platforms": "Show gateway/messaging platform status",
- "/paste": "Check clipboard for an image and attach it",
- },
- "Exit": {
- "/quit": "Exit the CLI (also: /exit, /q)",
- },
-}
-
-# Flat dict for backwards compatibility and autocomplete
-COMMANDS = {}
-for category_commands in COMMANDS_BY_CATEGORY.values():
- COMMANDS.update(category_commands)
+# ---------------------------------------------------------------------------
+# CommandDef dataclass
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class CommandDef:
+ """Definition of a single slash command."""
+
+ name: str # canonical name without slash: "background"
+ description: str # human-readable description
+ category: str # "Session", "Configuration", etc.
+ aliases: tuple[str, ...] = () # alternative names: ("bg",)
+ args_hint: str = "" # argument placeholder: "", "[name]"
+ subcommands: tuple[str, ...] = () # tab-completable subcommands
+ cli_only: bool = False # only available in CLI
+ gateway_only: bool = False # only available in gateway/messaging
+ gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway
+
+
+# ---------------------------------------------------------------------------
+# Central registry -- single source of truth
+# ---------------------------------------------------------------------------
+
+COMMAND_REGISTRY: list[CommandDef] = [
+ # Session
+ CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
+ aliases=("reset",)),
+ CommandDef("clear", "Clear screen and start a new session", "Session",
+ cli_only=True),
+ CommandDef("history", "Show conversation history", "Session",
+ cli_only=True),
+ CommandDef("save", "Save the current conversation", "Session",
+ cli_only=True),
+ CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
+ CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
+ CommandDef("title", "Set a title for the current session", "Session",
+ args_hint="[name]"),
+ CommandDef("compress", "Manually compress conversation context", "Session"),
+ CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
+ args_hint="[number]"),
+ CommandDef("stop", "Kill all running background processes", "Session"),
+ CommandDef("approve", "Approve a pending dangerous command", "Session",
+ gateway_only=True, args_hint="[session|always]"),
+ CommandDef("deny", "Deny a pending dangerous command", "Session",
+ gateway_only=True),
+ CommandDef("background", "Run a prompt in the background", "Session",
+ aliases=("bg",), args_hint=""),
+ CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
+ aliases=("q",), args_hint=""),
+ CommandDef("status", "Show session info", "Session",
+ gateway_only=True),
+ CommandDef("sethome", "Set this chat as the home channel", "Session",
+ gateway_only=True, aliases=("set-home",)),
+ CommandDef("resume", "Resume a previously-named session", "Session",
+ args_hint="[name]"),
+
+ # Configuration
+ CommandDef("config", "Show current configuration", "Configuration",
+ cli_only=True),
+ CommandDef("provider", "Show available providers and current provider",
+ "Configuration"),
+ CommandDef("prompt", "View/set custom system prompt", "Configuration",
+ cli_only=True, args_hint="[text]", subcommands=("clear",)),
+ CommandDef("personality", "Set a predefined personality", "Configuration",
+ args_hint="[name]"),
+ CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
+ cli_only=True, aliases=("sb",)),
+ CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
+ "Configuration", cli_only=True,
+ gateway_config_gate="display.tool_progress_command"),
+ CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
+ args_hint="[level|show|hide]",
+ subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
+ CommandDef("skin", "Show or change the display skin/theme", "Configuration",
+ cli_only=True, args_hint="[name]"),
+ CommandDef("voice", "Toggle voice mode", "Configuration",
+ args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
+
+ # Tools & Skills
+ CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
+ args_hint="[list|disable|enable] [name...]", cli_only=True),
+ CommandDef("toolsets", "List available toolsets", "Tools & Skills",
+ cli_only=True),
+ CommandDef("skills", "Search, install, inspect, or manage skills",
+ "Tools & Skills", cli_only=True,
+ subcommands=("search", "browse", "inspect", "install")),
+ CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
+ cli_only=True, args_hint="[subcommand]",
+ subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
+ CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
+ aliases=("reload_mcp",)),
+ CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
+ cli_only=True, args_hint="[connect|disconnect|status]",
+ subcommands=("connect", "disconnect", "status")),
+ CommandDef("plugins", "List installed plugins and their status",
+ "Tools & Skills", cli_only=True),
+
+ # Info
+ CommandDef("help", "Show available commands", "Info"),
+ CommandDef("usage", "Show token usage for the current session", "Info"),
+ CommandDef("insights", "Show usage insights and analytics", "Info",
+ args_hint="[days]"),
+ CommandDef("platforms", "Show gateway/messaging platform status", "Info",
+ cli_only=True, aliases=("gateway",)),
+ CommandDef("paste", "Check clipboard for an image and attach it", "Info",
+ cli_only=True),
+ CommandDef("update", "Update Hermes Agent to the latest version", "Info",
+ gateway_only=True),
+
+ # Exit
+ CommandDef("quit", "Exit the CLI", "Exit",
+ cli_only=True, aliases=("exit", "q")),
+]
+
+
+# ---------------------------------------------------------------------------
+# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
+# ---------------------------------------------------------------------------
+
+def _build_command_lookup() -> dict[str, CommandDef]:
+ """Map every name and alias to its CommandDef."""
+ lookup: dict[str, CommandDef] = {}
+ for cmd in COMMAND_REGISTRY:
+ lookup[cmd.name] = cmd
+ for alias in cmd.aliases:
+ lookup[alias] = cmd
+ return lookup
+
+
+_COMMAND_LOOKUP: dict[str, CommandDef] = _build_command_lookup()
+
+
+def resolve_command(name: str) -> CommandDef | None:
+ """Resolve a command name or alias to its CommandDef.
+
+ Accepts names with or without the leading slash.
+ """
+ return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
+
+
+def register_plugin_command(cmd: CommandDef) -> None:
+ """Append a plugin-defined command to the registry and refresh lookups."""
+ COMMAND_REGISTRY.append(cmd)
+ rebuild_lookups()
+
+
+def rebuild_lookups() -> None:
+ """Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
+
+ Called after plugin commands are registered so they appear in help,
+ autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
+ """
+ global GATEWAY_KNOWN_COMMANDS
+
+ _COMMAND_LOOKUP.clear()
+ _COMMAND_LOOKUP.update(_build_command_lookup())
+
+ COMMANDS.clear()
+ for cmd in COMMAND_REGISTRY:
+ if not cmd.gateway_only:
+ COMMANDS[f"/{cmd.name}"] = _build_description(cmd)
+ for alias in cmd.aliases:
+ COMMANDS[f"/{alias}"] = f"{cmd.description} (alias for /{cmd.name})"
+
+ COMMANDS_BY_CATEGORY.clear()
+ for cmd in COMMAND_REGISTRY:
+ if not cmd.gateway_only:
+ cat = COMMANDS_BY_CATEGORY.setdefault(cmd.category, {})
+ cat[f"/{cmd.name}"] = COMMANDS[f"/{cmd.name}"]
+ for alias in cmd.aliases:
+ cat[f"/{alias}"] = COMMANDS[f"/{alias}"]
+
+ SUBCOMMANDS.clear()
+ for cmd in COMMAND_REGISTRY:
+ if cmd.subcommands:
+ SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
+ for cmd in COMMAND_REGISTRY:
+ key = f"/{cmd.name}"
+ if key in SUBCOMMANDS or not cmd.args_hint:
+ continue
+ m = _PIPE_SUBS_RE.search(cmd.args_hint)
+ if m:
+ SUBCOMMANDS[key] = m.group(0).split("|")
+
+ GATEWAY_KNOWN_COMMANDS = frozenset(
+ name
+ for cmd in COMMAND_REGISTRY
+ if not cmd.cli_only or cmd.gateway_config_gate
+ for name in (cmd.name, *cmd.aliases)
+ )
+def _build_description(cmd: CommandDef) -> str:
+ """Build a CLI-facing description string including usage hint."""
+ if cmd.args_hint:
+ return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
+ return cmd.description
+
+
+# Backwards-compatible flat dict: "/command" -> description
+COMMANDS: dict[str, str] = {}
+for _cmd in COMMAND_REGISTRY:
+ if not _cmd.gateway_only:
+ COMMANDS[f"/{_cmd.name}"] = _build_description(_cmd)
+ for _alias in _cmd.aliases:
+ COMMANDS[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
+
+# Backwards-compatible categorized dict
+COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
+for _cmd in COMMAND_REGISTRY:
+ if not _cmd.gateway_only:
+ _cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
+ _cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
+ for _alias in _cmd.aliases:
+ _cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
+
+
+# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
+SUBCOMMANDS: dict[str, list[str]] = {}
+for _cmd in COMMAND_REGISTRY:
+ if _cmd.subcommands:
+ SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
+
+# Also extract subcommands hinted in args_hint via pipe-separated patterns
+# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
+# NOTE: If a command already has explicit subcommands, this fallback is skipped.
+# Use the `subcommands` field on CommandDef for intentional tab-completable args.
+_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
+for _cmd in COMMAND_REGISTRY:
+ key = f"/{_cmd.name}"
+ if key in SUBCOMMANDS or not _cmd.args_hint:
+ continue
+ m = _PIPE_SUBS_RE.search(_cmd.args_hint)
+ if m:
+ SUBCOMMANDS[key] = m.group(0).split("|")
+
+
+# ---------------------------------------------------------------------------
+# Gateway helpers
+# ---------------------------------------------------------------------------
+
+# Set of all command names + aliases recognized by the gateway.
+# Includes config-gated commands so the gateway can dispatch them
+# (the handler checks the config gate at runtime).
+GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
+ name
+ for cmd in COMMAND_REGISTRY
+ if not cmd.cli_only or cmd.gateway_config_gate
+ for name in (cmd.name, *cmd.aliases)
+)
+
+
+def _resolve_config_gates() -> set[str]:
+ """Return canonical names of commands whose ``gateway_config_gate`` is truthy.
+
+ Reads ``config.yaml`` and walks the dot-separated key path for each
+ config-gated command. Returns an empty set on any error so callers
+ degrade gracefully.
+ """
+ gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate]
+ if not gated:
+ return set()
+ try:
+ import yaml
+ config_path = os.path.join(
+ os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")),
+ "config.yaml",
+ )
+ if os.path.exists(config_path):
+ with open(config_path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f) or {}
+ else:
+ cfg = {}
+ except Exception:
+ return set()
+ result: set[str] = set()
+ for cmd in gated:
+ val: Any = cfg
+ for key in cmd.gateway_config_gate.split("."):
+ if isinstance(val, dict):
+ val = val.get(key)
+ else:
+ val = None
+ break
+ if val:
+ result.add(cmd.name)
+ return result
+
+
+def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
+ """Check if *cmd* should appear in gateway surfaces (help, menus, mappings).
+
+ Unconditionally available when ``cli_only`` is False. When ``cli_only``
+ is True but ``gateway_config_gate`` is set, the command is available only
+ when the config value is truthy. Pass *config_overrides* (from
+ ``_resolve_config_gates()``) to avoid re-reading config for every command.
+ """
+ if not cmd.cli_only:
+ return True
+ if cmd.gateway_config_gate:
+ overrides = config_overrides if config_overrides is not None else _resolve_config_gates()
+ return cmd.name in overrides
+ return False
+
+
+def gateway_help_lines() -> list[str]:
+ """Generate gateway help text lines from the registry."""
+ overrides = _resolve_config_gates()
+ lines: list[str] = []
+ for cmd in COMMAND_REGISTRY:
+ if not _is_gateway_available(cmd, overrides):
+ continue
+ args = f" {cmd.args_hint}" if cmd.args_hint else ""
+ alias_parts: list[str] = []
+ for a in cmd.aliases:
+ # Skip internal aliases like reload_mcp (underscore variant)
+ if a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name:
+ continue
+ alias_parts.append(f"`/{a}`")
+ alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
+ lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
+ return lines
+
+
+def telegram_bot_commands() -> list[tuple[str, str]]:
+ """Return (command_name, description) pairs for Telegram setMyCommands.
+
+ Telegram command names cannot contain hyphens, so they are replaced with
+ underscores. Aliases are skipped -- Telegram shows one menu entry per
+ canonical command.
+ """
+ overrides = _resolve_config_gates()
+ result: list[tuple[str, str]] = []
+ for cmd in COMMAND_REGISTRY:
+ if not _is_gateway_available(cmd, overrides):
+ continue
+ tg_name = cmd.name.replace("-", "_")
+ result.append((tg_name, cmd.description))
+ return result
+
+
+def slack_subcommand_map() -> dict[str, str]:
+ """Return subcommand -> /command mapping for Slack /hermes handler.
+
+ Maps both canonical names and aliases so /hermes bg do stuff works
+ the same as /hermes background do stuff.
+ """
+ overrides = _resolve_config_gates()
+ mapping: dict[str, str] = {}
+ for cmd in COMMAND_REGISTRY:
+ if not _is_gateway_available(cmd, overrides):
+ continue
+ mapping[cmd.name] = f"/{cmd.name}"
+ for alias in cmd.aliases:
+ mapping[alias] = f"/{alias}"
+ return mapping
+
+
+# ---------------------------------------------------------------------------
+# Autocomplete
+# ---------------------------------------------------------------------------
+
class SlashCommandCompleter(Completer):
- """Autocomplete for built-in slash commands and optional skill commands."""
+ """Autocomplete for built-in slash commands, subcommands, and skill commands."""
def __init__(
self,
@@ -91,9 +410,233 @@ def _completion_text(cmd_name: str, word: str) -> str:
"""
return f"{cmd_name} " if cmd_name == word else cmd_name
+ @staticmethod
+ def _extract_path_word(text: str) -> str | None:
+ """Extract the current word if it looks like a file path.
+
+ Returns the path-like token under the cursor, or None if the
+ current word doesn't look like a path. A word is path-like when
+ it starts with ``./``, ``../``, ``~/``, ``/``, or contains a
+ ``/`` separator (e.g. ``src/main.py``).
+ """
+ if not text:
+ return None
+ # Walk backwards to find the start of the current "word".
+ # Words are delimited by spaces, but paths can contain almost anything.
+ i = len(text) - 1
+ while i >= 0 and text[i] != " ":
+ i -= 1
+ word = text[i + 1:]
+ if not word:
+ return None
+ # Only trigger path completion for path-like tokens
+ if word.startswith(("./", "../", "~/", "/")) or "/" in word:
+ return word
+ return None
+
+ @staticmethod
+ def _path_completions(word: str, limit: int = 30):
+ """Yield Completion objects for file paths matching *word*."""
+ expanded = os.path.expanduser(word)
+ # Split into directory part and prefix to match inside it
+ if expanded.endswith("/"):
+ search_dir = expanded
+ prefix = ""
+ else:
+ search_dir = os.path.dirname(expanded) or "."
+ prefix = os.path.basename(expanded)
+
+ try:
+ entries = os.listdir(search_dir)
+ except OSError:
+ return
+
+ count = 0
+ prefix_lower = prefix.lower()
+ for entry in sorted(entries):
+ if prefix and not entry.lower().startswith(prefix_lower):
+ continue
+ if count >= limit:
+ break
+
+ full_path = os.path.join(search_dir, entry)
+ is_dir = os.path.isdir(full_path)
+
+ # Build the completion text (what replaces the typed word)
+ if word.startswith("~"):
+ display_path = "~/" + os.path.relpath(full_path, os.path.expanduser("~"))
+ elif os.path.isabs(word):
+ display_path = full_path
+ else:
+ # Keep relative
+ display_path = os.path.relpath(full_path)
+
+ if is_dir:
+ display_path += "/"
+
+ suffix = "/" if is_dir else ""
+ meta = "dir" if is_dir else _file_size_label(full_path)
+
+ yield Completion(
+ display_path,
+ start_position=-len(word),
+ display=entry + suffix,
+ display_meta=meta,
+ )
+ count += 1
+
+ @staticmethod
+ def _extract_context_word(text: str) -> str | None:
+ """Extract a bare ``@`` token for context reference completions."""
+ if not text:
+ return None
+ # Walk backwards to find the start of the current word
+ i = len(text) - 1
+ while i >= 0 and text[i] != " ":
+ i -= 1
+ word = text[i + 1:]
+ if not word.startswith("@"):
+ return None
+ return word
+
+ @staticmethod
+ def _context_completions(word: str, limit: int = 30):
+ """Yield Claude Code-style @ context completions.
+
+ Bare ``@`` or ``@partial`` shows static references and matching
+ files/folders. ``@file:path`` and ``@folder:path`` are handled
+ by the existing path completion path.
+ """
+ lowered = word.lower()
+
+ # Static context references
+ _STATIC_REFS = (
+ ("@diff", "Git working tree diff"),
+ ("@staged", "Git staged diff"),
+ ("@file:", "Attach a file"),
+ ("@folder:", "Attach a folder"),
+ ("@git:", "Git log with diffs (e.g. @git:5)"),
+ ("@url:", "Fetch web content"),
+ )
+ for candidate, meta in _STATIC_REFS:
+ if candidate.lower().startswith(lowered) and candidate.lower() != lowered:
+ yield Completion(
+ candidate,
+ start_position=-len(word),
+ display=candidate,
+ display_meta=meta,
+ )
+
+ # If the user typed @file: or @folder:, delegate to path completions
+ for prefix in ("@file:", "@folder:"):
+ if word.startswith(prefix):
+ path_part = word[len(prefix):] or "."
+ expanded = os.path.expanduser(path_part)
+ if expanded.endswith("/"):
+ search_dir, match_prefix = expanded, ""
+ else:
+ search_dir = os.path.dirname(expanded) or "."
+ match_prefix = os.path.basename(expanded)
+
+ try:
+ entries = os.listdir(search_dir)
+ except OSError:
+ return
+
+ count = 0
+ prefix_lower = match_prefix.lower()
+ for entry in sorted(entries):
+ if match_prefix and not entry.lower().startswith(prefix_lower):
+ continue
+ if count >= limit:
+ break
+ full_path = os.path.join(search_dir, entry)
+ is_dir = os.path.isdir(full_path)
+ display_path = os.path.relpath(full_path)
+ suffix = "/" if is_dir else ""
+ kind = "folder" if is_dir else "file"
+ meta = "dir" if is_dir else _file_size_label(full_path)
+ completion = f"@{kind}:{display_path}{suffix}"
+ yield Completion(
+ completion,
+ start_position=-len(word),
+ display=entry + suffix,
+ display_meta=meta,
+ )
+ count += 1
+ return
+
+ # Bare @ or @partial โ show matching files/folders from cwd
+ query = word[1:] # strip the @
+ if not query:
+ search_dir, match_prefix = ".", ""
+ else:
+ expanded = os.path.expanduser(query)
+ if expanded.endswith("/"):
+ search_dir, match_prefix = expanded, ""
+ else:
+ search_dir = os.path.dirname(expanded) or "."
+ match_prefix = os.path.basename(expanded)
+
+ try:
+ entries = os.listdir(search_dir)
+ except OSError:
+ return
+
+ count = 0
+ prefix_lower = match_prefix.lower()
+ for entry in sorted(entries):
+ if match_prefix and not entry.lower().startswith(prefix_lower):
+ continue
+ if entry.startswith("."):
+ continue # skip hidden files in bare @ mode
+ if count >= limit:
+ break
+ full_path = os.path.join(search_dir, entry)
+ is_dir = os.path.isdir(full_path)
+ display_path = os.path.relpath(full_path)
+ suffix = "/" if is_dir else ""
+ kind = "folder" if is_dir else "file"
+ meta = "dir" if is_dir else _file_size_label(full_path)
+ completion = f"@{kind}:{display_path}{suffix}"
+ yield Completion(
+ completion,
+ start_position=-len(word),
+ display=entry + suffix,
+ display_meta=meta,
+ )
+ count += 1
+
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
+ # Try @ context completion (Claude Code-style)
+ ctx_word = self._extract_context_word(text)
+ if ctx_word is not None:
+ yield from self._context_completions(ctx_word)
+ return
+ # Try file path completion for non-slash input
+ path_word = self._extract_path_word(text)
+ if path_word is not None:
+ yield from self._path_completions(path_word)
+ return
+
+ # Check if we're completing a subcommand (base command already typed)
+ parts = text.split(maxsplit=1)
+ base_cmd = parts[0].lower()
+ if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
+ sub_text = parts[1] if len(parts) > 1 else ""
+ sub_lower = sub_text.lower()
+
+ # Static subcommand completions
+ if " " not in sub_text and base_cmd in SUBCOMMANDS:
+ for sub in SUBCOMMANDS[base_cmd]:
+ if sub.startswith(sub_lower) and sub != sub_lower:
+ yield Completion(
+ sub,
+ start_position=-len(sub_text),
+ display=sub,
+ )
return
word = text[1:]
@@ -119,3 +662,76 @@ def get_completions(self, document, complete_event):
display=cmd,
display_meta=f"โก {short_desc}",
)
+
+
+# ---------------------------------------------------------------------------
+# Inline auto-suggest (ghost text) for slash commands
+# ---------------------------------------------------------------------------
+
+class SlashCommandAutoSuggest(AutoSuggest):
+ """Inline ghost-text suggestions for slash commands and their subcommands.
+
+ Shows the rest of a command or subcommand in dim text as you type.
+ Falls back to history-based suggestions for non-slash input.
+ """
+
+ def __init__(
+ self,
+ history_suggest: AutoSuggest | None = None,
+ completer: SlashCommandCompleter | None = None,
+ ) -> None:
+ self._history = history_suggest
+ self._completer = completer # Reuse its model cache
+
+ def get_suggestion(self, buffer, document):
+ text = document.text_before_cursor
+
+ # Only suggest for slash commands
+ if not text.startswith("/"):
+ # Fall back to history for regular text
+ if self._history:
+ return self._history.get_suggestion(buffer, document)
+ return None
+
+ parts = text.split(maxsplit=1)
+ base_cmd = parts[0].lower()
+
+ if len(parts) == 1 and not text.endswith(" "):
+ # Still typing the command name: /upd โ suggest "ate"
+ word = text[1:].lower()
+ for cmd in COMMANDS:
+ cmd_name = cmd[1:] # strip leading /
+ if cmd_name.startswith(word) and cmd_name != word:
+ return Suggestion(cmd_name[len(word):])
+ return None
+
+ # Command is complete โ suggest subcommands or model names
+ sub_text = parts[1] if len(parts) > 1 else ""
+ sub_lower = sub_text.lower()
+
+ # Static subcommands
+ if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
+ if " " not in sub_text:
+ for sub in SUBCOMMANDS[base_cmd]:
+ if sub.startswith(sub_lower) and sub != sub_lower:
+ return Suggestion(sub[len(sub_text):])
+
+ # Fall back to history
+ if self._history:
+ return self._history.get_suggestion(buffer, document)
+ return None
+
+
+def _file_size_label(path: str) -> str:
+ """Return a compact human-readable file size, or '' on error."""
+ try:
+ size = os.path.getsize(path)
+ except OSError:
+ return ""
+ if size < 1024:
+ return f"{size}B"
+ if size < 1024 * 1024:
+ return f"{size / 1024:.0f}K"
+ if size < 1024 * 1024 * 1024:
+ return f"{size / (1024 * 1024):.1f}M"
+ return f"{size / (1024 * 1024 * 1024):.1f}G"
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index c05ebd5a457..aaa8fd02349 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -14,6 +14,7 @@
import os
import platform
+import re
import stat
import subprocess
import sys
@@ -22,19 +23,61 @@
from typing import Dict, Any, Optional, List, Tuple
_IS_WINDOWS = platform.system() == "Windows"
+_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
+# (managed by setup/provider flows directly).
+_EXTRA_ENV_KEYS = frozenset({
+ "OPENAI_API_KEY", "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
+ "AUXILIARY_VISION_MODEL",
+ "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
+ "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
+ "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
+ "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
+ "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
+ "WHATSAPP_MODE", "WHATSAPP_ENABLED",
+ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
+ "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
+})
import yaml
from hermes_cli.colors import Colors, color
+from hermes_cli.default_soul import DEFAULT_SOUL_MD
+
+
+# =============================================================================
+# Managed mode (NixOS declarative config)
+# =============================================================================
+
+def is_managed() -> bool:
+ """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).
+ """
+ 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(
+ 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,
+ )
# =============================================================================
# Config paths
# =============================================================================
-def get_hermes_home() -> Path:
- """Get the Hermes home directory (~/.hermes)."""
- return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+# Re-export from hermes_constants โ canonical definition lives there.
+from hermes_constants import get_hermes_home # noqa: F811,E402
def get_config_path() -> Path:
"""Get the main config file path."""
@@ -65,6 +108,15 @@ def _secure_file(path):
pass
+def _ensure_default_soul_md(home: Path) -> None:
+ """Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet."""
+ soul_path = home / "SOUL.md"
+ if soul_path.exists():
+ return
+ soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
+ _secure_file(soul_path)
+
+
def ensure_hermes_home():
"""Ensure ~/.hermes directory structure exists with secure permissions."""
home = get_hermes_home()
@@ -74,6 +126,7 @@ def ensure_hermes_home():
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
+ _ensure_default_soul_md(home)
# =============================================================================
@@ -91,7 +144,12 @@ def ensure_hermes_home():
"backend": "local",
"cwd": ".", # Use current directory
"timeout": 180,
+ # Environment variables to pass through to sandboxed execution
+ # (terminal and execute_code). Skill-declared required_environment_variables
+ # are passed through automatically; this list is for non-skill use cases.
+ "env_passthrough": [],
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
+ "docker_forward_env": [],
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
@@ -104,26 +162,44 @@ def ensure_hermes_home():
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example: ["/home/user/projects:/workspace/projects", "/data:/data"]
"docker_volumes": [],
+ # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
+ # Default off because passing host directories into a sandbox weakens isolation.
+ "docker_mount_cwd_to_workspace": False,
+ # Persistent shell โ keep a long-lived bash shell across execute() calls
+ # so cwd/env vars/shell variables survive between commands.
+ # Enabled by default for non-local backends (SSH); local is always opt-in
+ # via TERMINAL_LOCAL_PERSISTENT env var.
+ "persistent_shell": True,
},
"browser": {
"inactivity_timeout": 120,
+ "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
},
-
+
# Filesystem checkpoints โ automatic snapshots before destructive file ops.
# When enabled, the agent takes a snapshot of the working directory once per
# conversation turn (on first write_file/patch call). Use /rollback to restore.
"checkpoints": {
- "enabled": False,
+ "enabled": True,
"max_snapshots": 50, # Max checkpoints to keep per directory
},
"compression": {
"enabled": True,
- "threshold": 0.50,
- "summary_model": "google/gemini-3-flash-preview",
+ "threshold": 0.50, # compress when context usage exceeds this ratio
+ "target_ratio": 0.20, # fraction of threshold to preserve as recent tail
+ "protect_last_n": 20, # minimum recent messages to keep uncompressed
+ "summary_model": "", # empty = use main configured model
"summary_provider": "auto",
+ "summary_base_url": None,
+ },
+ "smart_model_routing": {
+ "enabled": False,
+ "max_simple_chars": 160,
+ "max_simple_words": 28,
+ "cheap_model": {},
},
# Auxiliary model config โ provider:model for each side task.
@@ -136,30 +212,51 @@ def ensure_hermes_home():
"vision": {
"provider": "auto", # auto | openrouter | nous | codex | custom
"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 โ increase for slow local vision models
},
"web_extract": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
},
"compression": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
},
"session_search": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
},
"skills_hub": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
+ },
+ "approval": {
+ "provider": "auto",
+ "model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku)
+ "base_url": "",
+ "api_key": "",
},
"mcp": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
},
"flush_memories": {
"provider": "auto",
"model": "",
+ "base_url": "",
+ "api_key": "",
},
},
@@ -167,14 +264,23 @@ def ensure_hermes_home():
"compact": False,
"personality": "kawaii",
"resume_display": "full",
+ "busy_input_mode": "interrupt",
"bell_on_complete": False,
"show_reasoning": False,
+ "streaming": False,
+ "show_cost": False, # Show $ cost in the status bar (off by default)
"skin": "default",
+ "tool_progress_command": False, # Enable /verbose command in messaging gateway
+ },
+
+ # Privacy settings
+ "privacy": {
+ "redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context
},
# Text-to-speech configuration
"tts": {
- "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai"
+ "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "neutts" (local)
"edge": {
"voice": "en-US-AriaNeural",
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
@@ -188,11 +294,31 @@ def ensure_hermes_home():
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
},
+ "neutts": {
+ "ref_audio": "", # Path to reference voice audio (empty = bundled default)
+ "ref_text": "", # Path to reference voice transcript (empty = bundled default)
+ "model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
+ "device": "cpu", # cpu, cuda, or mps
+ },
},
"stt": {
"enabled": True,
- "model": "whisper-1",
+ "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API)
+ "local": {
+ "model": "base", # tiny, base, small, medium, large-v3
+ },
+ "openai": {
+ "model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
+ },
+ },
+
+ "voice": {
+ "record_key": "ctrl+b",
+ "max_recording_seconds": 120,
+ "auto_tts": False,
+ "silence_threshold": 200, # RMS below this = silence (0-32767)
+ "silence_duration": 3.0, # Seconds of silence before auto-stop
},
"human_delay": {
@@ -216,6 +342,10 @@ def ensure_hermes_home():
"delegation": {
"model": "", # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model)
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
+ "base_url": "", # direct OpenAI-compatible endpoint for subagents
+ "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
+ "max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
+ # independent of the parent's max_iterations)
},
# Ephemeral prefill messages file โ JSON list of {role, content} dicts
@@ -236,6 +366,23 @@ def ensure_hermes_home():
"discord": {
"require_mention": True, # Require @mention to respond in server channels
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
+ "auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
+ },
+
+ # WhatsApp platform settings (gateway mode)
+ "whatsapp": {
+ # Reply prefix prepended to every outgoing WhatsApp message.
+ # Default (None) uses the built-in "โ *Hermes Agent*" header.
+ # Set to "" (empty string) to disable the header entirely.
+ # Supports \n for newlines, e.g. "๐ค *My Bot*\nโโโโโโ\n"
+ },
+
+ # Approval mode for dangerous commands:
+ # manual โ always prompt the user (default)
+ # smart โ use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
+ # off โ skip all approval prompts (equivalent to --yolo)
+ "approvals": {
+ "mode": "manual",
},
# Permanently allowed dangerous command patterns (added via "always" approval)
@@ -247,8 +394,22 @@ def ensure_hermes_home():
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
"personalities": {},
+ # Pre-exec security scanning via tirith
+ "security": {
+ "redact_secrets": True,
+ "tirith_enabled": True,
+ "tirith_path": "tirith",
+ "tirith_timeout": 5,
+ "tirith_fail_open": True,
+ "website_blocklist": {
+ "enabled": False,
+ "domains": [],
+ "shared_files": [],
+ },
+ },
+
# Config schema version - bump this when adding new required fields
- "_config_version": 7,
+ "_config_version": 10,
}
# =============================================================================
@@ -262,6 +423,7 @@ def ensure_hermes_home():
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
+ 10: ["TAVILY_API_KEY"],
}
# Required environment variables with metadata for migration prompts.
@@ -370,8 +532,108 @@ def ensure_hermes_home():
"category": "provider",
"advanced": True,
},
+ "DEEPSEEK_API_KEY": {
+ "description": "DeepSeek API key for direct DeepSeek access",
+ "prompt": "DeepSeek API Key",
+ "url": "https://platform.deepseek.com/api_keys",
+ "password": True,
+ "category": "provider",
+ },
+ "DEEPSEEK_BASE_URL": {
+ "description": "Custom DeepSeek API base URL (advanced)",
+ "prompt": "DeepSeek Base URL",
+ "url": "",
+ "password": False,
+ "category": "provider",
+ },
+ "XGATE_API_KEY": {
+ "description": "xgate API key",
+ "prompt": "xgate API Key",
+ "url": "https://ai.xgate.run/",
+ "password": True,
+ "category": "provider",
+ "advanced": True,
+ },
+ "XGATE_BASE_URL": {
+ "description": "Custom xgate base URL (advanced)",
+ "prompt": "xgate Base URL",
+ "url": "https://ai.xgate.run/v1",
+ "password": False,
+ "category": "provider",
+ "advanced": True,
+ },
+ "DASHSCOPE_API_KEY": {
+ "description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)",
+ "prompt": "DashScope API Key",
+ "url": "https://modelstudio.console.alibabacloud.com/",
+ "password": True,
+ "category": "provider",
+ },
+ "DASHSCOPE_BASE_URL": {
+ "description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)",
+ "prompt": "DashScope Base URL",
+ "url": "",
+ "password": False,
+ "category": "provider",
+ "advanced": True,
+ },
+ "OPENCODE_ZEN_API_KEY": {
+ "description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
+ "prompt": "OpenCode Zen API key",
+ "url": "https://opencode.ai/auth",
+ "password": True,
+ "category": "provider",
+ "advanced": True,
+ },
+ "OPENCODE_ZEN_BASE_URL": {
+ "description": "OpenCode Zen base URL override",
+ "prompt": "OpenCode Zen base URL (leave empty for default)",
+ "url": None,
+ "password": False,
+ "category": "provider",
+ "advanced": True,
+ },
+ "OPENCODE_GO_API_KEY": {
+ "description": "OpenCode Go API key ($10/month subscription for open models)",
+ "prompt": "OpenCode Go API key",
+ "url": "https://opencode.ai/auth",
+ "password": True,
+ "category": "provider",
+ "advanced": True,
+ },
+ "OPENCODE_GO_BASE_URL": {
+ "description": "OpenCode Go base URL override",
+ "prompt": "OpenCode Go base URL (leave empty for default)",
+ "url": None,
+ "password": False,
+ "category": "provider",
+ "advanced": True,
+ },
+ "HF_TOKEN": {
+ "description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)",
+ "prompt": "Hugging Face Token",
+ "url": "https://huggingface.co/settings/tokens",
+ "password": True,
+ "category": "provider",
+ },
+ "HF_BASE_URL": {
+ "description": "Hugging Face Inference Providers base URL override",
+ "prompt": "HF base URL (leave empty for default)",
+ "url": None,
+ "password": False,
+ "category": "provider",
+ "advanced": True,
+ },
# โโ Tool API keys โโ
+ "PARALLEL_API_KEY": {
+ "description": "Parallel API key for AI-native web search and extract",
+ "prompt": "Parallel API key",
+ "url": "https://parallel.ai/",
+ "tools": ["web_search", "web_extract"],
+ "password": True,
+ "category": "tool",
+ },
"FIRECRAWL_API_KEY": {
"description": "Firecrawl API key for web search and scraping",
"prompt": "Firecrawl API key",
@@ -388,6 +650,14 @@ def ensure_hermes_home():
"category": "tool",
"advanced": True,
},
+ "TAVILY_API_KEY": {
+ "description": "Tavily API key for AI-native web search, extract, and crawl",
+ "prompt": "Tavily API key",
+ "url": "https://app.tavily.com/home",
+ "tools": ["web_search", "web_extract", "web_crawl"],
+ "password": True,
+ "category": "tool",
+ },
"BROWSERBASE_API_KEY": {
"description": "Browserbase API key for cloud browser (optional โ local browser works without this)",
"prompt": "Browserbase API key",
@@ -404,6 +674,14 @@ def ensure_hermes_home():
"password": False,
"category": "tool",
},
+ "BROWSER_USE_API_KEY": {
+ "description": "Browser Use API key for cloud browser (optional โ local browser works without this)",
+ "prompt": "Browser Use API key",
+ "url": "https://browser-use.com/",
+ "tools": ["browser_navigate", "browser_click"],
+ "password": True,
+ "category": "tool",
+ },
"FAL_KEY": {
"description": "FAL API key for image generation",
"prompt": "FAL API key",
@@ -456,10 +734,15 @@ def ensure_hermes_home():
"description": "Honcho API key for AI-native persistent memory",
"prompt": "Honcho API key",
"url": "https://app.honcho.dev",
- "tools": ["query_user_context"],
+ "tools": ["honcho_context"],
"password": True,
"category": "tool",
},
+ "HONCHO_BASE_URL": {
+ "description": "Base URL for self-hosted Honcho instances (no API key needed)",
+ "prompt": "Honcho base URL (e.g. http://localhost:8000)",
+ "category": "tool",
+ },
# โโ Messaging platforms โโ
"TELEGRAM_BOT_TOKEN": {
@@ -508,6 +791,55 @@ def ensure_hermes_home():
"password": True,
"category": "messaging",
},
+ "MATTERMOST_URL": {
+ "description": "Mattermost server URL (e.g. https://mm.example.com)",
+ "prompt": "Mattermost server URL",
+ "url": "https://mattermost.com/deploy/",
+ "password": False,
+ "category": "messaging",
+ },
+ "MATTERMOST_TOKEN": {
+ "description": "Mattermost bot token or personal access token",
+ "prompt": "Mattermost bot token",
+ "url": None,
+ "password": True,
+ "category": "messaging",
+ },
+ "MATTERMOST_ALLOWED_USERS": {
+ "description": "Comma-separated Mattermost user IDs allowed to use the bot",
+ "prompt": "Allowed Mattermost user IDs (comma-separated)",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ },
+ "MATRIX_HOMESERVER": {
+ "description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
+ "prompt": "Matrix homeserver URL",
+ "url": "https://matrix.org/ecosystem/servers/",
+ "password": False,
+ "category": "messaging",
+ },
+ "MATRIX_ACCESS_TOKEN": {
+ "description": "Matrix access token (preferred over password login)",
+ "prompt": "Matrix access token",
+ "url": None,
+ "password": True,
+ "category": "messaging",
+ },
+ "MATRIX_USER_ID": {
+ "description": "Matrix user ID (e.g. @hermes:example.org)",
+ "prompt": "Matrix user ID (@user:server)",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ },
+ "MATRIX_ALLOWED_USERS": {
+ "description": "Comma-separated Matrix user IDs allowed to use the bot (@user:server format)",
+ "prompt": "Allowed Matrix user IDs (comma-separated)",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ },
"GATEWAY_ALLOW_ALL_USERS": {
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
"prompt": "Allow all users (true/false)",
@@ -516,6 +848,59 @@ def ensure_hermes_home():
"category": "messaging",
"advanced": True,
},
+ "API_SERVER_ENABLED": {
+ "description": "Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.",
+ "prompt": "Enable API server (true/false)",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ "advanced": True,
+ },
+ "API_SERVER_KEY": {
+ "description": "Bearer token for API server authentication. If empty, all requests are allowed (local use only).",
+ "prompt": "API server auth key (optional)",
+ "url": None,
+ "password": True,
+ "category": "messaging",
+ "advanced": True,
+ },
+ "API_SERVER_PORT": {
+ "description": "Port for the API server (default: 8642).",
+ "prompt": "API server port",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ "advanced": True,
+ },
+ "API_SERVER_HOST": {
+ "description": "Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access โ requires API_SERVER_KEY for security.",
+ "prompt": "API server host",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ "advanced": True,
+ },
+ "WEBHOOK_ENABLED": {
+ "description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
+ "prompt": "Enable webhooks (true/false)",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ },
+ "WEBHOOK_PORT": {
+ "description": "Port for the webhook HTTP server (default: 8644).",
+ "prompt": "Webhook port",
+ "url": None,
+ "password": False,
+ "category": "messaging",
+ },
+ "WEBHOOK_SECRET": {
+ "description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).",
+ "prompt": "Webhook secret",
+ "url": None,
+ "password": True,
+ "category": "messaging",
+ },
# โโ Agent settings โโ
"MESSAGING_CWD": {
@@ -662,7 +1047,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
"""
results = {"env_added": [], "config_added": [], "warnings": []}
-
+
+ # โโ Always: sanitize .env (split concatenated keys) โโ
+ try:
+ fixes = sanitize_env_file()
+ if fixes and not quiet:
+ print(f" โ Repaired .env file ({fixes} corrupted entries fixed)")
+ except Exception:
+ pass # best-effort; don't block migration on sanitize failure
+
# Check config version
current_ver, latest_ver = check_config_version()
@@ -705,6 +1098,18 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
tz_display = config["timezone"] or "(server-local)"
print(f" โ Added timezone to config.yaml: {tz_display}")
+ # โโ Version 8 โ 9: clear ANTHROPIC_TOKEN from .env โโ
+ # The new Anthropic auth flow no longer uses this env var.
+ if current_ver < 9:
+ try:
+ old_token = get_env_value("ANTHROPIC_TOKEN")
+ if old_token:
+ save_env_value("ANTHROPIC_TOKEN", "")
+ if not quiet:
+ print(" โ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
+ except Exception:
+ pass
+
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver} โ {latest_ver}")
@@ -785,7 +1190,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
print(f" โ Saved {name}")
print()
else:
- print(" Set later with: hermes config set KEY VALUE")
+ print(" Set later with: hermes config set ")
# Check for missing config fields
missing_config = get_missing_config_fields()
@@ -834,6 +1239,26 @@ def _deep_merge(base: dict, override: dict) -> dict:
return result
+def _expand_env_vars(obj):
+ """Recursively expand ``${VAR}`` references in config values.
+
+ Only string values are processed; dict keys, numbers, booleans, and
+ None are left untouched. Unresolved references (variable not in
+ ``os.environ``) are kept verbatim so callers can detect them.
+ """
+ if isinstance(obj, str):
+ return re.sub(
+ r"\${([^}]+)}",
+ lambda m: os.environ.get(m.group(1), m.group(0)),
+ obj,
+ )
+ if isinstance(obj, dict):
+ return {k: _expand_env_vars(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_expand_env_vars(item) for item in obj]
+ return obj
+
+
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize legacy root-level max_turns into agent.max_turns."""
config = dict(config)
@@ -854,6 +1279,7 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml."""
import copy
+ ensure_hermes_home()
config_path = get_config_path()
config = copy.deepcopy(DEFAULT_CONFIG)
@@ -874,7 +1300,59 @@ def load_config() -> Dict[str, Any]:
except Exception as e:
print(f"Warning: Failed to load config: {e}")
- return _normalize_max_turns_config(config)
+ return _expand_env_vars(_normalize_max_turns_config(config))
+
+
+_SECURITY_COMMENT = """
+# โโ Security โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# API keys, tokens, and passwords are redacted from tool output by default.
+# Set to false to see full values (useful for debugging auth issues).
+# tirith pre-exec scanning is enabled by default when the tirith binary
+# is available. Configure via security.tirith_* keys or env vars
+# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
+#
+# security:
+# redact_secrets: false
+# tirith_enabled: true
+# tirith_path: "tirith"
+# tirith_timeout: 5
+# tirith_fail_open: true
+"""
+
+_FALLBACK_COMMENT = """
+# โโ Fallback Model โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Automatic provider failover when primary is unavailable.
+# Uncomment and configure to enable. Triggers on rate limits (429),
+# overload (529), service errors (503), or connection failures.
+#
+# Supported providers:
+# openrouter (OPENROUTER_API_KEY) โ routes to any model
+# openai-codex (OAuth โ hermes login) โ OpenAI Codex
+# nous (OAuth โ hermes login) โ Nous Portal
+# zai (ZAI_API_KEY) โ Z.AI / GLM
+# kimi-coding (KIMI_API_KEY) โ Kimi / Moonshot
+# minimax (MINIMAX_API_KEY) โ MiniMax
+# minimax-cn (MINIMAX_CN_API_KEY) โ MiniMax (China)
+#
+# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
+#
+# fallback_model:
+# provider: openrouter
+# model: anthropic/claude-sonnet-4
+#
+# โโ Smart Model Routing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Optional cheap-vs-strong routing for simple turns.
+# Keeps the primary model for complex work, but can route short/simple
+# messages to a cheaper model across providers.
+#
+# smart_model_routing:
+# enabled: true
+# max_simple_chars: 160
+# max_simple_words: 28
+# cheap_model:
+# provider: openrouter
+# model: google/gemini-2.5-flash
+"""
_COMMENTED_SECTIONS = """
@@ -904,11 +1382,27 @@ def load_config() -> Dict[str, Any]:
# fallback_model:
# provider: openrouter
# model: anthropic/claude-sonnet-4
+#
+# โโ Smart Model Routing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Optional cheap-vs-strong routing for simple turns.
+# Keeps the primary model for complex work, but can route short/simple
+# messages to a cheaper model across providers.
+#
+# smart_model_routing:
+# enabled: true
+# max_simple_chars: 160
+# max_simple_words: 28
+# cheap_model:
+# provider: openrouter
+# model: google/gemini-2.5-flash
"""
def save_config(config: Dict[str, Any]):
"""Save configuration to ~/.hermes/config.yaml."""
+ if is_managed():
+ managed_error("save configuration")
+ return
from utils import atomic_yaml_write
ensure_hermes_home()
@@ -917,18 +1411,18 @@ def save_config(config: Dict[str, Any]):
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
- sections = []
+ parts = []
sec = normalized.get("security", {})
if not sec or sec.get("redact_secrets") is None:
- sections.append("security")
+ parts.append(_SECURITY_COMMENT)
fb = normalized.get("fallback_model", {})
if not fb or not (fb.get("provider") and fb.get("model")):
- sections.append("fallback")
+ parts.append(_FALLBACK_COMMENT)
atomic_yaml_write(
config_path,
normalized,
- extra_content=_COMMENTED_SECTIONS if sections else None,
+ extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
@@ -952,8 +1446,110 @@ def load_env() -> Dict[str, str]:
return env_vars
+def _sanitize_env_lines(lines: list) -> list:
+ """Fix corrupted .env lines before writing.
+
+ Handles two known corruption patterns:
+ 1. Concatenated KEY=VALUE pairs on a single line (missing newline between
+ entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
+ 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
+
+ Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
+ split on real Hermes env var names, avoiding false positives from values
+ that happen to contain uppercase text with ``=``.
+ """
+ # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
+ # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
+ known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
+
+ sanitized: list[str] = []
+ for line in lines:
+ raw = line.rstrip("\r\n")
+ stripped = raw.strip()
+
+ # Preserve blank lines and comments
+ if not stripped or stripped.startswith("#"):
+ sanitized.append(raw + "\n")
+ continue
+
+ # Detect concatenated KEY=VALUE pairs on one line.
+ # Search for known KEY= patterns at any position in the line.
+ split_positions = []
+ for key_name in known_keys:
+ needle = key_name + "="
+ idx = stripped.find(needle)
+ while idx >= 0:
+ split_positions.append(idx)
+ idx = stripped.find(needle, idx + len(needle))
+
+ if len(split_positions) > 1:
+ split_positions.sort()
+ # Deduplicate (shouldn't happen, but be safe)
+ split_positions = sorted(set(split_positions))
+ for i, pos in enumerate(split_positions):
+ end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
+ part = stripped[pos:end].strip()
+ if part:
+ sanitized.append(part + "\n")
+ else:
+ sanitized.append(stripped + "\n")
+
+ return sanitized
+
+
+def sanitize_env_file() -> int:
+ """Read, sanitize, and rewrite ~/.hermes/.env in place.
+
+ Returns the number of lines that were fixed (concatenation splits +
+ placeholder removals). Returns 0 when no changes are needed.
+ """
+ env_path = get_env_path()
+ if not env_path.exists():
+ return 0
+
+ read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
+ write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
+
+ with open(env_path, **read_kw) as f:
+ original_lines = f.readlines()
+
+ sanitized = _sanitize_env_lines(original_lines)
+
+ if sanitized == original_lines:
+ return 0
+
+ # Count fixes: difference in line count (from splits) + removed lines
+ fixes = abs(len(sanitized) - len(original_lines))
+ if fixes == 0:
+ # Lines changed content (e.g. *** removal) even if count is same
+ fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
+ fixes += abs(len(sanitized) - len(original_lines))
+
+ fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
+ try:
+ with os.fdopen(fd, "w", **write_kw) as f:
+ f.writelines(sanitized)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, env_path)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+ _secure_file(env_path)
+ return fixes
+
+
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env."""
+ if is_managed():
+ managed_error(f"set {key}")
+ return
+ if not _ENV_VAR_NAME_RE.match(key):
+ raise ValueError(f"Invalid environment variable name: {key!r}")
+ value = value.replace("\n", "").replace("\r", "")
ensure_hermes_home()
env_path = get_env_path()
@@ -966,6 +1562,8 @@ def save_env_value(key: str, value: str):
if env_path.exists():
with open(env_path, **read_kw) as f:
lines = f.readlines()
+ # Sanitize on every read: split concatenated keys, drop stale placeholders
+ lines = _sanitize_env_lines(lines)
# Find and update or append
found = False
@@ -996,6 +1594,8 @@ def save_env_value(key: str, value: str):
raise
_secure_file(env_path)
+ os.environ[key] = value
+
# Restrict .env permissions to owner-only (contains API keys)
if not _IS_WINDOWS:
try:
@@ -1004,6 +1604,37 @@ def save_env_value(key: str, value: str):
pass
+def save_anthropic_oauth_token(value: str, save_fn=None):
+ """Persist an Anthropic OAuth/setup token and clear the API-key slot."""
+ writer = save_fn or save_env_value
+ writer("ANTHROPIC_TOKEN", value)
+ writer("ANTHROPIC_API_KEY", "")
+
+
+def use_anthropic_claude_code_credentials(save_fn=None):
+ """Use Claude Code's own credential files instead of persisting env tokens."""
+ writer = save_fn or save_env_value
+ writer("ANTHROPIC_TOKEN", "")
+ writer("ANTHROPIC_API_KEY", "")
+
+
+def save_anthropic_api_key(value: str, save_fn=None):
+ """Persist an Anthropic API key and clear the OAuth/setup-token slot."""
+ writer = save_fn or save_env_value
+ writer("ANTHROPIC_API_KEY", value)
+ writer("ANTHROPIC_TOKEN", "")
+
+
+def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
+ save_env_value(key, value)
+ return {
+ "success": True,
+ "stored_as": key,
+ "validated": False,
+ }
+
+
+
def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment."""
# Check environment first
@@ -1031,7 +1662,6 @@ def redact_key(key: str) -> str:
def show_config():
"""Display current configuration."""
config = load_config()
- env_vars = load_env()
print()
print(color("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ", Colors.CYAN))
@@ -1051,23 +1681,26 @@ def show_config():
keys = [
("OPENROUTER_API_KEY", "OpenRouter"),
- ("ANTHROPIC_API_KEY", "Anthropic"),
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
+ ("PARALLEL_API_KEY", "Parallel"),
("FIRECRAWL_API_KEY", "Firecrawl"),
+ ("TAVILY_API_KEY", "Tavily"),
("BROWSERBASE_API_KEY", "Browserbase"),
+ ("BROWSER_USE_API_KEY", "Browser Use"),
("FAL_KEY", "FAL"),
]
for env_key, name in keys:
value = get_env_value(env_key)
print(f" {name:<14} {redact_key(value)}")
+ anthropic_value = get_env_value("ANTHROPIC_TOKEN") or get_env_value("ANTHROPIC_API_KEY")
+ print(f" {'Anthropic':<14} {redact_key(anthropic_value)}")
# Model settings
print()
print(color("โ Model", Colors.CYAN, Colors.BOLD))
print(f" Model: {config.get('model', 'not set')}")
print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}")
- print(f" Toolsets: {', '.join(config.get('toolsets', ['all']))}")
# Display
print()
@@ -1086,11 +1719,11 @@ def show_config():
print(f" Timeout: {terminal.get('timeout', 60)}s")
if terminal.get('backend') == 'docker':
- print(f" Docker image: {terminal.get('docker_image', 'python:3.11-slim')}")
+ print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'singularity':
- print(f" Image: {terminal.get('singularity_image', 'docker://python:3.11')}")
+ print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'modal':
- print(f" Modal image: {terminal.get('modal_image', 'python:3.11')}")
+ print(f" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
modal_token = get_env_value('MODAL_TOKEN_ID')
print(f" Modal token: {'configured' if modal_token else '(not set)'}")
elif terminal.get('backend') == 'daytona':
@@ -1120,7 +1753,10 @@ def show_config():
print(f" Enabled: {'yes' if enabled else 'no'}")
if enabled:
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
- print(f" Model: {compression.get('summary_model', 'google/gemini-3-flash-preview')}")
+ print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
+ print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
+ _sm = compression.get('summary_model', '') or '(main model)'
+ print(f" Model: {_sm}")
comp_provider = compression.get('summary_provider', 'auto')
if comp_provider != 'auto':
print(f" Provider: {comp_provider}")
@@ -1160,13 +1796,16 @@ def show_config():
print()
print(color("โ" * 60, Colors.DIM))
print(color(" hermes config edit # Edit config file", Colors.DIM))
- print(color(" hermes config set KEY VALUE", Colors.DIM))
+ print(color(" hermes config set ", Colors.DIM))
print(color(" hermes setup # Run setup wizard", Colors.DIM))
print()
def edit_config():
"""Open config file in user's editor."""
+ if is_managed():
+ managed_error("edit configuration")
+ return
config_path = get_config_path()
# Ensure config exists
@@ -1186,7 +1825,7 @@ def edit_config():
break
if not editor:
- print(f"No editor found. Config file is at:")
+ print("No editor found. Config file is at:")
print(f" {config_path}")
return
@@ -1196,10 +1835,14 @@ def edit_config():
def set_config_value(key: str, value: str):
"""Set a configuration value."""
+ if is_managed():
+ managed_error("set configuration values")
+ return
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
- 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID',
+ 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
+ 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
@@ -1258,9 +1901,11 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.cwd": "TERMINAL_CWD",
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
+ "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
@@ -1286,7 +1931,7 @@ def config_command(args):
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
if not key or not value:
- print("Usage: hermes config set KEY VALUE")
+ print("Usage: hermes config set ")
print()
print("Examples:")
print(" hermes config set model anthropic/claude-sonnet-4")
@@ -1391,7 +2036,7 @@ def config_command(args):
if missing_config:
print()
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
- print(f" Run 'hermes config migrate' to add them")
+ print(" Run 'hermes config migrate' to add them")
print()
@@ -1401,7 +2046,7 @@ def config_command(args):
print("Available commands:")
print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor")
- print(" hermes config set K V Set a config value")
+ print(" hermes config set Set a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py
new file mode 100644
index 00000000000..6f62eede4d2
--- /dev/null
+++ b/hermes_cli/copilot_auth.py
@@ -0,0 +1,294 @@
+"""GitHub Copilot authentication utilities.
+
+Implements the OAuth device code flow used by the Copilot CLI and handles
+token validation/exchange for the Copilot API.
+
+Token type support (per GitHub docs):
+ gho_ OAuth token โ (default via copilot login)
+ github_pat_ Fine-grained PAT โ (needs Copilot Requests permission)
+ ghu_ GitHub App token โ (via environment variable)
+ ghp_ Classic PAT โ NOT SUPPORTED
+
+Credential search order (matching Copilot CLI behaviour):
+ 1. COPILOT_GITHUB_TOKEN env var
+ 2. GH_TOKEN env var
+ 3. GITHUB_TOKEN env var
+ 4. gh auth token CLI fallback
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import subprocess
+import time
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+# OAuth device code flow constants (same client ID as opencode/Copilot CLI)
+COPILOT_OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz"
+COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code"
+COPILOT_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
+
+# Copilot API constants
+COPILOT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
+COPILOT_API_BASE_URL = "https://api.githubcopilot.com"
+
+# Token type prefixes
+_CLASSIC_PAT_PREFIX = "ghp_"
+_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
+
+# Env var search order (matches Copilot CLI)
+COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
+
+# Polling constants
+_DEVICE_CODE_POLL_INTERVAL = 5 # seconds
+_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds
+
+
+def is_classic_pat(token: str) -> bool:
+ """Check if a token is a classic PAT (ghp_*), which Copilot doesn't support."""
+ return token.strip().startswith(_CLASSIC_PAT_PREFIX)
+
+
+def validate_copilot_token(token: str) -> tuple[bool, str]:
+ """Validate that a token is usable with the Copilot API.
+
+ Returns (valid, message).
+ """
+ token = token.strip()
+ if not token:
+ return False, "Empty token"
+
+ if token.startswith(_CLASSIC_PAT_PREFIX):
+ return False, (
+ "Classic Personal Access Tokens (ghp_*) are not supported by the "
+ "Copilot API. Use one of:\n"
+ " โ `copilot login` or `hermes model` to authenticate via OAuth\n"
+ " โ A fine-grained PAT (github_pat_*) with Copilot Requests permission\n"
+ " โ `gh auth login` with the default device code flow (produces gho_* tokens)"
+ )
+
+ return True, "OK"
+
+
+def resolve_copilot_token() -> tuple[str, str]:
+ """Resolve a GitHub token suitable for Copilot API use.
+
+ Returns (token, source) where source describes where the token came from.
+ Raises ValueError if only a classic PAT is available.
+ """
+ # 1. Check env vars in priority order
+ for env_var in COPILOT_ENV_VARS:
+ val = os.getenv(env_var, "").strip()
+ if val:
+ valid, msg = validate_copilot_token(val)
+ if not valid:
+ logger.warning(
+ "Token from %s is not supported: %s", env_var, msg
+ )
+ continue
+ return val, env_var
+
+ # 2. Fall back to gh auth token
+ token = _try_gh_cli_token()
+ if token:
+ valid, msg = validate_copilot_token(token)
+ if not valid:
+ raise ValueError(
+ f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}"
+ )
+ return token, "gh auth token"
+
+ return "", ""
+
+
+def _gh_cli_candidates() -> list[str]:
+ """Return candidate ``gh`` binary paths, including common Homebrew installs."""
+ candidates: list[str] = []
+
+ resolved = shutil.which("gh")
+ if resolved:
+ candidates.append(resolved)
+
+ for candidate in (
+ "/opt/homebrew/bin/gh",
+ "/usr/local/bin/gh",
+ str(Path.home() / ".local" / "bin" / "gh"),
+ ):
+ if candidate in candidates:
+ continue
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+ candidates.append(candidate)
+
+ return candidates
+
+
+def _try_gh_cli_token() -> Optional[str]:
+ """Return a token from ``gh auth token`` when the GitHub CLI is available."""
+ for gh_path in _gh_cli_candidates():
+ try:
+ result = subprocess.run(
+ [gh_path, "auth", "token"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
+ logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
+ continue
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip()
+ return None
+
+
+# โโโ OAuth Device Code Flow โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def copilot_device_code_login(
+ *,
+ host: str = "github.com",
+ timeout_seconds: float = 300,
+) -> Optional[str]:
+ """Run the GitHub OAuth device code flow for Copilot.
+
+ Prints instructions for the user, polls for completion, and returns
+ the OAuth access token on success, or None on failure/cancellation.
+
+ This replicates the flow used by opencode and the Copilot CLI.
+ """
+ import urllib.request
+ import urllib.parse
+
+ domain = host.rstrip("/")
+ device_code_url = f"https://{domain}/login/device/code"
+ access_token_url = f"https://{domain}/login/oauth/access_token"
+
+ # Step 1: Request device code
+ data = urllib.parse.urlencode({
+ "client_id": COPILOT_OAUTH_CLIENT_ID,
+ "scope": "read:user",
+ }).encode()
+
+ req = urllib.request.Request(
+ device_code_url,
+ data=data,
+ headers={
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": "HermesAgent/1.0",
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ device_data = json.loads(resp.read().decode())
+ except Exception as exc:
+ logger.error("Failed to initiate device authorization: %s", exc)
+ print(f" โ Failed to start device authorization: {exc}")
+ return None
+
+ verification_uri = device_data.get("verification_uri", "https://github.com/login/device")
+ user_code = device_data.get("user_code", "")
+ device_code = device_data.get("device_code", "")
+ interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1)
+
+ if not device_code or not user_code:
+ print(" โ GitHub did not return a device code.")
+ return None
+
+ # Step 2: Show instructions
+ print()
+ print(f" Open this URL in your browser: {verification_uri}")
+ print(f" Enter this code: {user_code}")
+ print()
+ print(" Waiting for authorization...", end="", flush=True)
+
+ # Step 3: Poll for completion
+ deadline = time.time() + timeout_seconds
+
+ while time.time() < deadline:
+ time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
+
+ poll_data = urllib.parse.urlencode({
+ "client_id": COPILOT_OAUTH_CLIENT_ID,
+ "device_code": device_code,
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+ }).encode()
+
+ poll_req = urllib.request.Request(
+ access_token_url,
+ data=poll_data,
+ headers={
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": "HermesAgent/1.0",
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(poll_req, timeout=10) as resp:
+ result = json.loads(resp.read().decode())
+ except Exception:
+ print(".", end="", flush=True)
+ continue
+
+ if result.get("access_token"):
+ print(" โ")
+ return result["access_token"]
+
+ error = result.get("error", "")
+ if error == "authorization_pending":
+ print(".", end="", flush=True)
+ continue
+ elif error == "slow_down":
+ # RFC 8628: add 5 seconds to polling interval
+ server_interval = result.get("interval")
+ if isinstance(server_interval, (int, float)) and server_interval > 0:
+ interval = int(server_interval)
+ else:
+ interval += 5
+ print(".", end="", flush=True)
+ continue
+ elif error == "expired_token":
+ print()
+ print(" โ Device code expired. Please try again.")
+ return None
+ elif error == "access_denied":
+ print()
+ print(" โ Authorization was denied.")
+ return None
+ elif error:
+ print()
+ print(f" โ Authorization failed: {error}")
+ return None
+
+ print()
+ print(" โ Timed out waiting for authorization.")
+ return None
+
+
+# โโโ Copilot API Headers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def copilot_request_headers(
+ *,
+ is_agent_turn: bool = True,
+ is_vision: bool = False,
+) -> dict[str, str]:
+ """Build the standard headers for Copilot API requests.
+
+ Replicates the header set used by opencode and the Copilot CLI.
+ """
+ headers: dict[str, str] = {
+ "Editor-Version": "vscode/1.104.1",
+ "User-Agent": "HermesAgent/1.0",
+ "Openai-Intent": "conversation-edits",
+ "x-initiator": "agent" if is_agent_turn else "user",
+ }
+ if is_vision:
+ headers["Copilot-Vision-Request"] = "true"
+
+ return headers
diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py
index b76ef5bac8b..97a22579426 100644
--- a/hermes_cli/cron.py
+++ b/hermes_cli/cron.py
@@ -1,15 +1,14 @@
"""
Cron subcommand for hermes CLI.
-Handles: hermes cron [list|status|tick]
-
-Cronjobs are executed automatically by the gateway daemon (hermes gateway).
-Install the gateway as a service for background execution:
- hermes gateway install
+Handles standalone cron management commands like list, create, edit,
+pause/resume/run/remove, status, and tick.
"""
+import json
import sys
from pathlib import Path
+from typing import Iterable, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT))
@@ -17,62 +16,87 @@
from hermes_cli.colors import Colors, color
+def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
+ if skills is None:
+ if single_skill is None:
+ return None
+ raw_items = [single_skill]
+ else:
+ raw_items = list(skills)
+
+ normalized: List[str] = []
+ for item in raw_items:
+ text = str(item or "").strip()
+ if text and text not in normalized:
+ normalized.append(text)
+ return normalized
+
+
+def _cron_api(**kwargs):
+ from tools.cronjob_tools import cronjob as cronjob_tool
+
+ return json.loads(cronjob_tool(**kwargs))
+
+
def cron_list(show_all: bool = False):
"""List all scheduled jobs."""
from cron.jobs import list_jobs
-
+
jobs = list_jobs(include_disabled=show_all)
-
+
if not jobs:
print(color("No scheduled jobs.", Colors.DIM))
- print(color("Create one with the /cron add command in chat, or via Telegram.", Colors.DIM))
+ print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM))
return
-
+
print()
print(color("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ", Colors.CYAN))
print(color("โ Scheduled Jobs โ", Colors.CYAN))
print(color("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ", Colors.CYAN))
print()
-
+
for job in jobs:
job_id = job.get("id", "?")[:8]
name = job.get("name", "(unnamed)")
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
- enabled = job.get("enabled", True)
+ state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
next_run = job.get("next_run_at", "?")
-
+
repeat_info = job.get("repeat", {})
repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0)
-
- if repeat_times:
- repeat_str = f"{repeat_completed}/{repeat_times}"
- else:
- repeat_str = "โ"
-
+ repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "โ"
+
deliver = job.get("deliver", ["local"])
if isinstance(deliver, str):
deliver = [deliver]
deliver_str = ", ".join(deliver)
-
- if not enabled:
- status = color("[disabled]", Colors.RED)
- else:
+
+ skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
+ if state == "paused":
+ status = color("[paused]", Colors.YELLOW)
+ elif state == "completed":
+ status = color("[completed]", Colors.BLUE)
+ elif job.get("enabled", True):
status = color("[active]", Colors.GREEN)
-
+ else:
+ status = color("[disabled]", Colors.RED)
+
print(f" {color(job_id, Colors.YELLOW)} {status}")
print(f" Name: {name}")
print(f" Schedule: {schedule}")
print(f" Repeat: {repeat_str}")
print(f" Next run: {next_run}")
print(f" Deliver: {deliver_str}")
+ if skills:
+ print(f" Skills: {', '.join(skills)}")
print()
-
- # Warn if gateway isn't running
+
from hermes_cli.gateway import find_gateway_pids
if not find_gateway_pids():
print(color(" โ Gateway is not running โ jobs won't fire automatically.", Colors.YELLOW))
print(color(" Start it with: hermes gateway install", Colors.DIM))
+ print(color(" sudo hermes gateway install --system # Linux servers", Colors.DIM))
print()
@@ -86,9 +110,9 @@ def cron_status():
"""Show cron execution status."""
from cron.jobs import list_jobs
from hermes_cli.gateway import find_gateway_pids
-
+
print()
-
+
pids = find_gateway_pids()
if pids:
print(color("โ Gateway is running โ cron jobs will fire automatically", Colors.GREEN))
@@ -97,11 +121,12 @@ def cron_status():
print(color("โ Gateway is not running โ cron jobs will NOT fire", Colors.RED))
print()
print(" To enable automatic execution:")
- print(" hermes gateway install # Install as system service (recommended)")
+ print(" hermes gateway install # Install as a user service")
+ print(" sudo hermes gateway install --system # Linux servers: boot-time system service")
print(" hermes gateway # Or run in foreground")
-
+
print()
-
+
jobs = list_jobs(include_disabled=False)
if jobs:
next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")]
@@ -110,25 +135,131 @@ def cron_status():
print(f" Next run: {min(next_runs)}")
else:
print(" No active jobs")
-
+
print()
+def cron_create(args):
+ result = _cron_api(
+ action="create",
+ schedule=args.schedule,
+ prompt=args.prompt,
+ name=getattr(args, "name", None),
+ deliver=getattr(args, "deliver", None),
+ repeat=getattr(args, "repeat", None),
+ skill=getattr(args, "skill", None),
+ skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
+ )
+ if not result.get("success"):
+ print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
+ return 1
+ print(color(f"Created job: {result['job_id']}", Colors.GREEN))
+ print(f" Name: {result['name']}")
+ print(f" Schedule: {result['schedule']}")
+ if result.get("skills"):
+ print(f" Skills: {', '.join(result['skills'])}")
+ print(f" Next run: {result['next_run_at']}")
+ return 0
+
+
+def cron_edit(args):
+ from cron.jobs import get_job
+
+ job = get_job(args.job_id)
+ if not job:
+ print(color(f"Job not found: {args.job_id}", Colors.RED))
+ return 1
+
+ existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")]))
+ replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None))
+ add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or []
+ remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or [])
+
+ final_skills = None
+ if getattr(args, "clear_skills", False):
+ final_skills = []
+ elif replacement_skills is not None:
+ final_skills = replacement_skills
+ elif add_skills or remove_skills:
+ final_skills = [skill for skill in existing_skills if skill not in remove_skills]
+ for skill in add_skills:
+ if skill not in final_skills:
+ final_skills.append(skill)
+
+ result = _cron_api(
+ action="update",
+ job_id=args.job_id,
+ schedule=getattr(args, "schedule", None),
+ prompt=getattr(args, "prompt", None),
+ name=getattr(args, "name", None),
+ deliver=getattr(args, "deliver", None),
+ repeat=getattr(args, "repeat", None),
+ skills=final_skills,
+ )
+ if not result.get("success"):
+ print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
+ return 1
+
+ updated = result["job"]
+ print(color(f"Updated job: {updated['job_id']}", Colors.GREEN))
+ print(f" Name: {updated['name']}")
+ print(f" Schedule: {updated['schedule']}")
+ if updated.get("skills"):
+ print(f" Skills: {', '.join(updated['skills'])}")
+ else:
+ print(" Skills: none")
+ return 0
+
+
+def _job_action(action: str, job_id: str, success_verb: str) -> int:
+ result = _cron_api(action=action, job_id=job_id)
+ if not result.get("success"):
+ print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED))
+ return 1
+ job = result.get("job") or result.get("removed_job") or {}
+ print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN))
+ if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"):
+ print(f" Next run: {result['job']['next_run_at']}")
+ if action == "run":
+ print(" It will run on the next scheduler tick.")
+ return 0
+
+
def cron_command(args):
"""Handle cron subcommands."""
subcmd = getattr(args, 'cron_command', None)
-
+
if subcmd is None or subcmd == "list":
show_all = getattr(args, 'all', False)
cron_list(show_all)
-
- elif subcmd == "tick":
- cron_tick()
-
- elif subcmd == "status":
+ return 0
+
+ if subcmd == "status":
cron_status()
-
- else:
- print(f"Unknown cron command: {subcmd}")
- print("Usage: hermes cron [list|status|tick]")
- sys.exit(1)
+ return 0
+
+ if subcmd == "tick":
+ cron_tick()
+ return 0
+
+ if subcmd in {"create", "add"}:
+ return cron_create(args)
+
+ if subcmd == "edit":
+ return cron_edit(args)
+
+ if subcmd == "pause":
+ return _job_action("pause", args.job_id, "Paused")
+
+ if subcmd == "resume":
+ return _job_action("resume", args.job_id, "Resumed")
+
+ if subcmd == "run":
+ return _job_action("run", args.job_id, "Triggered")
+
+ if subcmd in {"remove", "rm", "delete"}:
+ return _job_action("remove", args.job_id, "Removed")
+
+ print(f"Unknown cron command: {subcmd}")
+ print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
+ sys.exit(1)
diff --git a/hermes_cli/default_soul.py b/hermes_cli/default_soul.py
new file mode 100644
index 00000000000..8ee0a0cbeb5
--- /dev/null
+++ b/hermes_cli/default_soul.py
@@ -0,0 +1,11 @@
+"""Default SOUL.md template seeded into HERMES_HOME on first run."""
+
+DEFAULT_SOUL_MD = (
+ "You are Hermes Agent, an intelligent AI assistant created by Nous Research. "
+ "You are helpful, knowledgeable, and direct. You assist users with a wide "
+ "range of tasks including answering questions, writing and editing code, "
+ "analyzing information, creative work, and executing actions via your tools. "
+ "You communicate clearly, admit uncertainty when appropriate, and prioritize "
+ "being genuinely useful over being verbose unless otherwise directed below. "
+ "Be targeted and efficient in your exploration and investigations."
+)
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index a10f249bda6..053f92a2750 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -8,7 +8,6 @@
import sys
import subprocess
import shutil
-from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
@@ -26,10 +25,6 @@
# Also try project .env as dev fallback
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
-# Point mini-swe-agent at ~/.hermes/ so it shares our config
-os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(HERMES_HOME))
-os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
-
from hermes_cli.colors import Colors, color
from hermes_constants import OPENROUTER_MODELS_URL
@@ -38,6 +33,7 @@
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
+ "ANTHROPIC_TOKEN",
"OPENAI_BASE_URL",
"GLM_API_KEY",
"ZAI_API_KEY",
@@ -45,6 +41,7 @@
"KIMI_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
+ "KILOCODE_API_KEY",
)
@@ -53,6 +50,33 @@ def _has_provider_env_config(content: str) -> bool:
return any(key in content for key in _PROVIDER_ENV_HINTS)
+def _honcho_is_configured_for_doctor() -> bool:
+ """Return True when Honcho is configured, even if this process has no active session."""
+ try:
+ from honcho_integration.client import HonchoClientConfig
+
+ cfg = HonchoClientConfig.from_global_config()
+ return bool(cfg.enabled and cfg.api_key)
+ except Exception:
+ return False
+
+
+def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
+ """Adjust runtime-gated tool availability for doctor diagnostics."""
+ if not _honcho_is_configured_for_doctor():
+ return available, unavailable
+
+ updated_available = list(available)
+ updated_unavailable = []
+ for item in unavailable:
+ if item.get("name") == "honcho":
+ if "honcho" not in updated_available:
+ updated_available.append("honcho")
+ continue
+ updated_unavailable.append(item)
+ return updated_available, updated_unavailable
+
+
def check_ok(text: str, detail: str = ""):
print(f" {color('โ', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
@@ -66,9 +90,46 @@ def check_info(text: str):
print(f" {color('โ', Colors.CYAN)} {text}")
+def _check_gateway_service_linger(issues: list[str]) -> None:
+ """Warn when a systemd user gateway service will stop after logout."""
+ try:
+ from hermes_cli.gateway import (
+ get_systemd_linger_status,
+ get_systemd_unit_path,
+ is_linux,
+ )
+ except Exception as e:
+ check_warn("Gateway service linger", f"(could not import gateway helpers: {e})")
+ return
+
+ if not is_linux():
+ return
+
+ unit_path = get_systemd_unit_path()
+ if not unit_path.exists():
+ return
+
+ print()
+ print(color("โ Gateway Service", Colors.CYAN, Colors.BOLD))
+
+ linger_enabled, linger_detail = get_systemd_linger_status()
+ if linger_enabled is True:
+ check_ok("Systemd linger enabled", "(gateway service survives logout)")
+ elif linger_enabled is False:
+ check_warn("Systemd linger disabled", "(gateway may stop after logout)")
+ check_info("Run: sudo loginctl enable-linger $USER")
+ issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
+ else:
+ check_warn("Could not verify systemd linger", f"({linger_detail})")
+
+
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
+
+ # Doctor runs from the interactive CLI, so CLI-gated tool availability
+ # checks (like cronjob management) should see the same context as `hermes`.
+ os.environ.setdefault("HERMES_INTERACTIVE", "1")
issues = []
manual_issues = [] # issues that can't be auto-fixed
@@ -316,6 +377,8 @@ def run_doctor(args):
check_warn(f"~/.hermes/state.db exists but has issues: {e}")
else:
check_info("~/.hermes/state.db not created yet (will be created on first session)")
+
+ _check_gateway_service_linger(issues)
# =========================================================================
# Check: External tools
@@ -384,7 +447,7 @@ def run_doctor(args):
check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)")
issues.append("Set DAYTONA_API_KEY environment variable")
try:
- from daytona import Daytona
+ from daytona import Daytona # noqa: F401 โ SDK presence check
check_ok("daytona SDK", "(installed)")
except ImportError:
check_fail("daytona SDK not installed", "(pip install daytona)")
@@ -466,17 +529,22 @@ def run_doctor(args):
else:
check_warn("OpenRouter API", "(not configured)")
- anthropic_key = os.getenv("ANTHROPIC_API_KEY")
+ anthropic_key = os.getenv("ANTHROPIC_TOKEN") or os.getenv("ANTHROPIC_API_KEY")
if anthropic_key:
print(" Checking Anthropic API...", end="", flush=True)
try:
import httpx
+ from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
+
+ headers = {"anthropic-version": "2023-06-01"}
+ if _is_oauth_token(anthropic_key):
+ headers["Authorization"] = f"Bearer {anthropic_key}"
+ headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
+ else:
+ headers["x-api-key"] = anthropic_key
response = httpx.get(
"https://api.anthropic.com/v1/models",
- headers={
- "x-api-key": anthropic_key,
- "anthropic-version": "2023-06-01"
- },
+ headers=headers,
timeout=10
)
if response.status_code == 200:
@@ -498,6 +566,8 @@ def run_doctor(args):
# MiniMax APIs don't support /models endpoint โ https://github.com/NousResearch/hermes-agent/issues/811
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
+ ("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
+ ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
]
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
_key = ""
@@ -543,18 +613,6 @@ def run_doctor(args):
print()
print(color("โ Submodules", Colors.CYAN, Colors.BOLD))
- # mini-swe-agent (terminal tool backend)
- mini_swe_dir = PROJECT_ROOT / "mini-swe-agent"
- if mini_swe_dir.exists() and (mini_swe_dir / "pyproject.toml").exists():
- try:
- __import__("minisweagent")
- check_ok("mini-swe-agent", "(terminal backend)")
- except ImportError:
- check_warn("mini-swe-agent found but not installed", "(run: uv pip install -e ./mini-swe-agent)")
- issues.append("Install mini-swe-agent: uv pip install -e ./mini-swe-agent")
- else:
- check_warn("mini-swe-agent not found", "(run: git submodule update --init --recursive)")
-
# tinker-atropos (RL training backend)
tinker_dir = PROJECT_ROOT / "tinker-atropos"
if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
@@ -582,6 +640,7 @@ def run_doctor(args):
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
available, unavailable = check_tool_availability()
+ available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)
for tid in available:
info = TOOLSET_REQUIREMENTS.get(tid, {})
@@ -634,6 +693,41 @@ def run_doctor(args):
else:
check_warn("No GITHUB_TOKEN", "(60 req/hr rate limit โ set in ~/.hermes/.env for better rates)")
+ # =========================================================================
+ # Honcho memory
+ # =========================================================================
+ print()
+ print(color("โ Honcho Memory", Colors.CYAN, Colors.BOLD))
+
+ try:
+ from honcho_integration.client import HonchoClientConfig, resolve_config_path
+ hcfg = HonchoClientConfig.from_global_config()
+ _honcho_cfg_path = resolve_config_path()
+
+ if not _honcho_cfg_path.exists():
+ check_warn("Honcho config not found", "run: hermes honcho setup")
+ elif not hcfg.enabled:
+ check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
+ elif not hcfg.api_key:
+ check_fail("Honcho API key not set", "run: hermes honcho setup")
+ issues.append("No Honcho API key โ run 'hermes honcho setup'")
+ else:
+ from honcho_integration.client import get_honcho_client, reset_honcho_client
+ reset_honcho_client()
+ try:
+ get_honcho_client(hcfg)
+ check_ok(
+ "Honcho connected",
+ f"workspace={hcfg.workspace_id} mode={hcfg.memory_mode} freq={hcfg.write_frequency}",
+ )
+ except Exception as _e:
+ check_fail("Honcho connection failed", str(_e))
+ issues.append(f"Honcho unreachable: {_e}")
+ except ImportError:
+ check_warn("honcho-ai not installed", "pip install honcho-ai")
+ except Exception as _e:
+ check_warn("Honcho check failed", str(_e))
+
# =========================================================================
# Summary
# =========================================================================
diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py
new file mode 100644
index 00000000000..0066d25b005
--- /dev/null
+++ b/hermes_cli/env_loader.py
@@ -0,0 +1,45 @@
+"""Helpers for loading Hermes .env files consistently across entrypoints."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+
+def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
+ try:
+ load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
+ except UnicodeDecodeError:
+ load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
+
+
+def load_hermes_dotenv(
+ *,
+ hermes_home: str | os.PathLike | None = None,
+ project_env: str | os.PathLike | None = None,
+) -> list[Path]:
+ """Load Hermes environment files with user config taking precedence.
+
+ Behavior:
+ - `~/.hermes/.env` overrides stale shell-exported values when present.
+ - project `.env` acts as a dev fallback and only fills missing values when
+ the user env exists.
+ - if no user env exists, the project `.env` also overrides stale shell vars.
+ """
+ loaded: list[Path] = []
+
+ home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+ user_env = home_path / ".env"
+ project_env_path = Path(project_env) if project_env else None
+
+ if user_env.exists():
+ _load_dotenv_with_fallback(user_env, override=True)
+ loaded.append(user_env)
+
+ if project_env_path and project_env_path.exists():
+ _load_dotenv_with_fallback(project_env_path, override=not loaded)
+ loaded.append(project_env_path)
+
+ return loaded
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index 26a8f59877e..ba13cb4e8e8 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -6,6 +6,7 @@
import asyncio
import os
+import shutil
import signal
import subprocess
import sys
@@ -13,7 +14,7 @@
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
-from hermes_cli.config import get_env_value, save_env_value
+from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
from hermes_cli.setup import (
print_header, print_info, print_success, print_warning, print_error,
prompt, prompt_choice, prompt_yes_no,
@@ -30,6 +31,7 @@ def find_gateway_pids() -> list:
pids = []
patterns = [
"hermes_cli.main gateway",
+ "hermes_cli/main.py gateway",
"hermes gateway",
"gateway/run.py",
]
@@ -119,22 +121,287 @@ def is_windows() -> bool:
# Service Configuration
# =============================================================================
-SERVICE_NAME = "hermes-gateway"
+_SERVICE_BASE = "hermes-gateway"
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
-def get_systemd_unit_path() -> Path:
- return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
+
+def get_service_name() -> str:
+ """Derive a systemd service name scoped to this HERMES_HOME.
+
+ Default ``~/.hermes`` returns ``hermes-gateway`` (backward compatible).
+ Any other HERMES_HOME appends a short hash so multiple installations
+ can each have their own systemd service without conflicting.
+ """
+ import hashlib
+ from pathlib import Path as _Path # local import to avoid monkeypatch interference
+ home = get_hermes_home().resolve()
+ default = (_Path.home() / ".hermes").resolve()
+ if home == default:
+ return _SERVICE_BASE
+ suffix = hashlib.sha256(str(home).encode()).hexdigest()[:8]
+ return f"{_SERVICE_BASE}-{suffix}"
+
+
+SERVICE_NAME = _SERVICE_BASE # backward-compat for external importers; prefer get_service_name()
+
+
+def get_systemd_unit_path(system: bool = False) -> Path:
+ name = get_service_name()
+ if system:
+ return Path("/etc/systemd/system") / f"{name}.service"
+ return Path.home() / ".config" / "systemd" / "user" / f"{name}.service"
+
+
+def _ensure_user_systemd_env() -> None:
+ """Ensure DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR are set for systemctl --user.
+
+ On headless servers (SSH sessions), these env vars may be missing even when
+ the user's systemd instance is running (via linger). Without them,
+ ``systemctl --user`` fails with "Failed to connect to bus: No medium found".
+ We detect the standard socket path and set the vars so all subsequent
+ subprocess calls inherit them.
+ """
+ uid = os.getuid()
+ if "XDG_RUNTIME_DIR" not in os.environ:
+ runtime_dir = f"/run/user/{uid}"
+ if Path(runtime_dir).exists():
+ os.environ["XDG_RUNTIME_DIR"] = runtime_dir
+
+ if "DBUS_SESSION_BUS_ADDRESS" not in os.environ:
+ xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}")
+ bus_path = Path(xdg_runtime) / "bus"
+ if bus_path.exists():
+ os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus_path}"
+
+
+def _systemctl_cmd(system: bool = False) -> list[str]:
+ if not system:
+ _ensure_user_systemd_env()
+ return ["systemctl"] if system else ["systemctl", "--user"]
+
+
+def _journalctl_cmd(system: bool = False) -> list[str]:
+ return ["journalctl"] if system else ["journalctl", "--user"]
+
+
+def _service_scope_label(system: bool = False) -> str:
+ return "system" if system else "user"
+
+
+def get_installed_systemd_scopes() -> list[str]:
+ scopes = []
+ seen_paths: set[Path] = set()
+ for system, label in ((False, "user"), (True, "system")):
+ unit_path = get_systemd_unit_path(system=system)
+ if unit_path in seen_paths:
+ continue
+ if unit_path.exists():
+ scopes.append(label)
+ seen_paths.add(unit_path)
+ return scopes
+
+
+def has_conflicting_systemd_units() -> bool:
+ return len(get_installed_systemd_scopes()) > 1
+
+
+def print_systemd_scope_conflict_warning() -> None:
+ scopes = get_installed_systemd_scopes()
+ if len(scopes) < 2:
+ return
+
+ rendered_scopes = " + ".join(scopes)
+ print_warning(f"Both user and system gateway services are installed ({rendered_scopes}).")
+ print_info(" This is confusing and can make start/stop/status behavior ambiguous.")
+ print_info(" Default gateway commands target the user service unless you pass --system.")
+ print_info(" Keep one of these:")
+ print_info(" hermes gateway uninstall")
+ print_info(" sudo hermes gateway uninstall --system")
+
+
+def _require_root_for_system_service(action: str) -> None:
+ if os.geteuid() != 0:
+ print(f"System gateway {action} requires root. Re-run with sudo.")
+ sys.exit(1)
+
+
+def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, str]:
+ import getpass
+ import grp
+ import pwd
+
+ username = (run_as_user or os.getenv("SUDO_USER") or os.getenv("USER") or os.getenv("LOGNAME") or getpass.getuser()).strip()
+ if not username:
+ raise ValueError("Could not determine which user the gateway service should run as")
+ if username == "root":
+ raise ValueError("Refusing to install the gateway system service as root; pass --run-as USER")
+
+ try:
+ user_info = pwd.getpwnam(username)
+ except KeyError as e:
+ raise ValueError(f"Unknown user: {username}") from e
+
+ group_name = grp.getgrgid(user_info.pw_gid).gr_name
+ return username, group_name, user_info.pw_dir
+
+
+def _read_systemd_user_from_unit(unit_path: Path) -> str | None:
+ if not unit_path.exists():
+ return None
+
+ for line in unit_path.read_text(encoding="utf-8").splitlines():
+ if line.startswith("User="):
+ value = line.split("=", 1)[1].strip()
+ return value or None
+ return None
+
+
+def _default_system_service_user() -> str | None:
+ for candidate in (os.getenv("SUDO_USER"), os.getenv("USER"), os.getenv("LOGNAME")):
+ if candidate and candidate.strip() and candidate.strip() != "root":
+ return candidate.strip()
+ return None
+
+
+def prompt_linux_gateway_install_scope() -> str | None:
+ choice = prompt_choice(
+ " Choose how the gateway should run in the background:",
+ [
+ "User service (no sudo; best for laptops/dev boxes; may need linger after logout)",
+ "System service (starts on boot; requires sudo; still runs as your user)",
+ "Skip service install for now",
+ ],
+ default=0,
+ )
+ return {0: "user", 1: "system", 2: None}[choice]
+
+
+def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, bool]:
+ scope = prompt_linux_gateway_install_scope()
+ if scope is None:
+ return None, False
+
+ if scope == "system":
+ run_as_user = _default_system_service_user()
+ if os.geteuid() != 0:
+ print_warning(" System service install requires sudo, so Hermes can't create it from this user session.")
+ if run_as_user:
+ print_info(f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}")
+ else:
+ print_info(" After setup, run: sudo hermes gateway install --system --run-as-user ")
+ print_info(" Then start it with: sudo hermes gateway start --system")
+ return scope, False
+
+ if not run_as_user:
+ while True:
+ run_as_user = prompt(" Run the system gateway service as which user?", default="")
+ run_as_user = (run_as_user or "").strip()
+ if run_as_user and run_as_user != "root":
+ break
+ print_error(" Enter a non-root username.")
+
+ systemd_install(force=force, system=True, run_as_user=run_as_user)
+ return scope, True
+
+ systemd_install(force=force, system=False)
+ return scope, True
+
+
+def get_systemd_linger_status() -> tuple[bool | None, str]:
+ """Return whether systemd user lingering is enabled for the current user.
+
+ Returns:
+ (True, "") when linger is enabled.
+ (False, "") when linger is disabled.
+ (None, detail) when the status could not be determined.
+ """
+ if not is_linux():
+ return None, "not supported on this platform"
+
+ import shutil
+
+ if not shutil.which("loginctl"):
+ return None, "loginctl not found"
+
+ username = os.getenv("USER") or os.getenv("LOGNAME")
+ if not username:
+ try:
+ import pwd
+ username = pwd.getpwuid(os.getuid()).pw_name
+ except Exception:
+ return None, "could not determine current user"
+
+ try:
+ result = subprocess.run(
+ ["loginctl", "show-user", username, "--property=Linger", "--value"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ except Exception as e:
+ return None, str(e)
+
+ if result.returncode != 0:
+ detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip()
+ return None, detail or "loginctl query failed"
+
+ value = (result.stdout or "").strip().lower()
+ if value in {"yes", "true", "1"}:
+ return True, ""
+ if value in {"no", "false", "0"}:
+ return False, ""
+
+ rendered = value or ""
+ return None, f"unexpected loginctl output: {rendered}"
+
+
+def print_systemd_linger_guidance() -> None:
+ """Print the current linger status and the fix when it is disabled."""
+ linger_enabled, linger_detail = get_systemd_linger_status()
+ if linger_enabled is True:
+ print("โ Systemd linger is enabled (service survives logout)")
+ elif linger_enabled is False:
+ print("โ Systemd linger is disabled (gateway may stop when you log out)")
+ print(" Run: sudo loginctl enable-linger $USER")
+ else:
+ print(f"โ Could not verify systemd linger ({linger_detail})")
+ print(" If you want the gateway user service to survive logout, run:")
+ print(" sudo loginctl enable-linger $USER")
def get_launchd_plist_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
+def _detect_venv_dir() -> Path | None:
+ """Detect the active virtualenv directory.
+
+ Checks ``sys.prefix`` first (works regardless of the directory name),
+ then falls back to probing common directory names under PROJECT_ROOT.
+ Returns ``None`` when no virtualenv can be found.
+ """
+ # If we're running inside a virtualenv, sys.prefix points to it.
+ if sys.prefix != sys.base_prefix:
+ venv = Path(sys.prefix)
+ if venv.is_dir():
+ return venv
+
+ # Fallback: check common virtualenv directory names under the project root.
+ for candidate in (".venv", "venv"):
+ venv = PROJECT_ROOT / candidate
+ if venv.is_dir():
+ return venv
+
+ return None
+
+
def get_python_path() -> str:
- if is_windows():
- venv_python = PROJECT_ROOT / "venv" / "Scripts" / "python.exe"
- else:
- venv_python = PROJECT_ROOT / "venv" / "bin" / "python"
- if venv_python.exists():
- return str(venv_python)
+ venv = _detect_venv_dir()
+ if venv is not None:
+ if is_windows():
+ venv_python = venv / "Scripts" / "python.exe"
+ else:
+ venv_python = venv / "bin" / "python"
+ if venv_python.exists():
+ return str(venv_python)
return sys.executable
def get_hermes_cli_path() -> str:
@@ -153,34 +420,76 @@ def get_hermes_cli_path() -> str:
# Systemd (Linux)
# =============================================================================
-def generate_systemd_unit() -> str:
- import shutil
+def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
- venv_dir = str(PROJECT_ROOT / "venv")
- venv_bin = str(PROJECT_ROOT / "venv" / "bin")
+ detected_venv = _detect_venv_dir()
+ venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
+ venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin")
node_bin = str(PROJECT_ROOT / "node_modules" / ".bin")
- # Build a PATH that includes the venv, node_modules, and standard system dirs
- sane_path = f"{venv_bin}:{node_bin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
-
- hermes_cli = shutil.which("hermes") or f"{python_path} -m hermes_cli.main"
+ path_entries = [venv_bin, node_bin]
+ resolved_node = shutil.which("node")
+ if resolved_node:
+ resolved_node_dir = str(Path(resolved_node).resolve().parent)
+ if resolved_node_dir not in path_entries:
+ path_entries.append(resolved_node_dir)
+ path_entries.extend(["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"])
+ sane_path = ":".join(path_entries)
+
+ hermes_home = str(get_hermes_home().resolve())
+
+ if system:
+ username, group_name, home_dir = _system_service_identity(run_as_user)
+ return f"""[Unit]
+Description={SERVICE_DESCRIPTION}
+After=network-online.target
+Wants=network-online.target
+StartLimitIntervalSec=600
+StartLimitBurst=5
+
+[Service]
+Type=simple
+User={username}
+Group={group_name}
+ExecStart={python_path} -m hermes_cli.main gateway run --replace
+WorkingDirectory={working_dir}
+Environment="HOME={home_dir}"
+Environment="USER={username}"
+Environment="LOGNAME={username}"
+Environment="PATH={sane_path}"
+Environment="VIRTUAL_ENV={venv_dir}"
+Environment="HERMES_HOME={hermes_home}"
+Restart=on-failure
+RestartSec=30
+KillMode=mixed
+KillSignal=SIGTERM
+TimeoutStopSec=60
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
+"""
+
return f"""[Unit]
Description={SERVICE_DESCRIPTION}
After=network.target
+StartLimitIntervalSec=600
+StartLimitBurst=5
[Service]
Type=simple
ExecStart={python_path} -m hermes_cli.main gateway run --replace
-ExecStop={hermes_cli} gateway stop
WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
+Environment="HERMES_HOME={hermes_home}"
Restart=on-failure
-RestartSec=10
+RestartSec=30
KillMode=mixed
KillSignal=SIGTERM
-TimeoutStopSec=15
+TimeoutStopSec=60
StandardOutput=journal
StandardError=journal
@@ -188,92 +497,255 @@ def generate_systemd_unit() -> str:
WantedBy=default.target
"""
-def systemd_install(force: bool = False):
- unit_path = get_systemd_unit_path()
-
+def _normalize_service_definition(text: str) -> str:
+ return "\n".join(line.rstrip() for line in text.strip().splitlines())
+
+
+def systemd_unit_is_current(system: bool = False) -> bool:
+ unit_path = get_systemd_unit_path(system=system)
+ if not unit_path.exists():
+ return False
+
+ installed = unit_path.read_text(encoding="utf-8")
+ expected_user = _read_systemd_user_from_unit(unit_path) if system else None
+ expected = generate_systemd_unit(system=system, run_as_user=expected_user)
+ return _normalize_service_definition(installed) == _normalize_service_definition(expected)
+
+
+
+def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
+ """Rewrite the installed systemd unit when the generated definition has changed."""
+ unit_path = get_systemd_unit_path(system=system)
+ if not unit_path.exists() or systemd_unit_is_current(system=system):
+ return False
+
+ expected_user = _read_systemd_user_from_unit(unit_path) if system else None
+ unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8")
+ subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
+ print(f"โป Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install")
+ return True
+
+
+
+def _print_linger_enable_warning(username: str, detail: str | None = None) -> None:
+ print()
+ print("โ Linger not enabled โ gateway may stop when you close this terminal.")
+ if detail:
+ print(f" Auto-enable failed: {detail}")
+ print()
+ print(" On headless servers (VPS, cloud instances) run:")
+ print(f" sudo loginctl enable-linger {username}")
+ print()
+ print(" Then restart the gateway:")
+ print(f" systemctl --user restart {get_service_name()}.service")
+ print()
+
+
+
+def _ensure_linger_enabled() -> None:
+ """Enable linger when possible so the user gateway survives logout."""
+ if not is_linux():
+ return
+
+ import getpass
+ import shutil
+
+ username = getpass.getuser()
+ linger_file = Path(f"/var/lib/systemd/linger/{username}")
+ if linger_file.exists():
+ print("โ Systemd linger is enabled (service survives logout)")
+ return
+
+ linger_enabled, linger_detail = get_systemd_linger_status()
+ if linger_enabled is True:
+ print("โ Systemd linger is enabled (service survives logout)")
+ return
+
+ if not shutil.which("loginctl"):
+ _print_linger_enable_warning(username, linger_detail or "loginctl not found")
+ return
+
+ print("Enabling linger so the gateway survives SSH logout...")
+ try:
+ result = subprocess.run(
+ ["loginctl", "enable-linger", username],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ except Exception as e:
+ _print_linger_enable_warning(username, str(e))
+ return
+
+ if result.returncode == 0:
+ print("โ Linger enabled โ gateway will persist after logout")
+ return
+
+ detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip()
+ _print_linger_enable_warning(username, detail or linger_detail)
+
+
+def _select_systemd_scope(system: bool = False) -> bool:
+ if system:
+ return True
+ return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()
+
+
+def systemd_install(force: bool = False, system: bool = False, run_as_user: str | None = None):
+ if system:
+ _require_root_for_system_service("install")
+
+ unit_path = get_systemd_unit_path(system=system)
+ scope_flag = " --system" if system else ""
+
if unit_path.exists() and not force:
+ if not systemd_unit_is_current(system=system):
+ print(f"โป Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
+ refresh_systemd_unit_if_needed(system=system)
+ subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
+ print(f"โ {_service_scope_label(system).capitalize()} service definition updated")
+ return
print(f"Service already installed at: {unit_path}")
print("Use --force to reinstall")
return
-
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
- print(f"Installing systemd service to: {unit_path}")
- unit_path.write_text(generate_systemd_unit())
-
- subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
- subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True)
-
+ print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
+ unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8")
+
+ subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
+ subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
+
print()
- print("โ Service installed and enabled!")
+ print(f"โ {_service_scope_label(system).capitalize()} service installed and enabled!")
print()
print("Next steps:")
- print(f" hermes gateway start # Start the service")
- print(f" hermes gateway status # Check status")
- print(f" journalctl --user -u {SERVICE_NAME} -f # View logs")
+ print(f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service")
+ print(f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status")
+ print(f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs")
print()
- print("To enable lingering (keeps running after logout):")
- print(" sudo loginctl enable-linger $USER")
-def systemd_uninstall():
- subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False)
- subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False)
-
- unit_path = get_systemd_unit_path()
+ if system:
+ configured_user = _read_systemd_user_from_unit(unit_path)
+ if configured_user:
+ print(f"Configured to run as: {configured_user}")
+ else:
+ _ensure_linger_enabled()
+
+ print_systemd_scope_conflict_warning()
+
+
+def systemd_uninstall(system: bool = False):
+ system = _select_systemd_scope(system)
+ if system:
+ _require_root_for_system_service("uninstall")
+
+ subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False)
+ subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False)
+
+ unit_path = get_systemd_unit_path(system=system)
if unit_path.exists():
unit_path.unlink()
print(f"โ Removed {unit_path}")
-
- subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
- print("โ Service uninstalled")
-def systemd_start():
- subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True)
- print("โ Service started")
+ subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
+ print(f"โ {_service_scope_label(system).capitalize()} service uninstalled")
+
+
+def systemd_start(system: bool = False):
+ system = _select_systemd_scope(system)
+ if system:
+ _require_root_for_system_service("start")
+ refresh_systemd_unit_if_needed(system=system)
+ subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True)
+ print(f"โ {_service_scope_label(system).capitalize()} service started")
+
-def systemd_stop():
- subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True)
- print("โ Service stopped")
-def systemd_restart():
- subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True)
- print("โ Service restarted")
+def systemd_stop(system: bool = False):
+ system = _select_systemd_scope(system)
+ if system:
+ _require_root_for_system_service("stop")
+ subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True)
+ print(f"โ {_service_scope_label(system).capitalize()} service stopped")
+
+
+
+def systemd_restart(system: bool = False):
+ system = _select_systemd_scope(system)
+ if system:
+ _require_root_for_system_service("restart")
+ refresh_systemd_unit_if_needed(system=system)
+ subprocess.run(_systemctl_cmd(system) + ["restart", get_service_name()], check=True)
+ print(f"โ {_service_scope_label(system).capitalize()} service restarted")
+
+
+
+def systemd_status(deep: bool = False, system: bool = False):
+ system = _select_systemd_scope(system)
+ unit_path = get_systemd_unit_path(system=system)
+ scope_flag = " --system" if system else ""
-def systemd_status(deep: bool = False):
- # Check if service unit file exists
- unit_path = get_systemd_unit_path()
if not unit_path.exists():
print("โ Gateway service is not installed")
- print(" Run: hermes gateway install")
+ print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}")
return
-
- # Show detailed status first
+
+ if has_conflicting_systemd_units():
+ print_systemd_scope_conflict_warning()
+ print()
+
+ if not systemd_unit_is_current(system=system):
+ print("โ Installed gateway service definition is outdated")
+ print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
+ print()
+
subprocess.run(
- ["systemctl", "--user", "status", SERVICE_NAME, "--no-pager"],
- capture_output=False
+ _systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"],
+ capture_output=False,
)
-
- # Check if service is active
+
result = subprocess.run(
- ["systemctl", "--user", "is-active", SERVICE_NAME],
+ _systemctl_cmd(system) + ["is-active", get_service_name()],
capture_output=True,
- text=True
+ text=True,
)
-
+
status = result.stdout.strip()
-
+
if status == "active":
- print("โ Gateway service is running")
+ print(f"โ {_service_scope_label(system).capitalize()} gateway service is running")
else:
- print("โ Gateway service is stopped")
- print(" Run: hermes gateway start")
-
+ print(f"โ {_service_scope_label(system).capitalize()} gateway service is stopped")
+ print(f" Run: {'sudo ' if system else ''}hermes gateway start{scope_flag}")
+
+ configured_user = _read_systemd_user_from_unit(unit_path) if system else None
+ if configured_user:
+ print(f"Configured to run as: {configured_user}")
+
+ runtime_lines = _runtime_health_lines()
+ if runtime_lines:
+ print()
+ print("Recent gateway health:")
+ for line in runtime_lines:
+ print(f" {line}")
+
+ if system:
+ print("โ System service starts at boot without requiring systemd linger")
+ elif deep:
+ print_systemd_linger_guidance()
+ else:
+ linger_enabled, _ = get_systemd_linger_status()
+ if linger_enabled is True:
+ print("โ Systemd linger is enabled (service survives logout)")
+ elif linger_enabled is False:
+ print("โ Systemd linger is disabled (gateway may stop when you log out)")
+ print(" Run: sudo loginctl enable-linger $USER")
+
if deep:
print()
print("Recent logs:")
- subprocess.run([
- "journalctl", "--user", "-u", SERVICE_NAME,
- "-n", "20", "--no-pager"
- ])
+ subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"])
# =============================================================================
@@ -283,7 +755,7 @@ def systemd_status(deep: bool = False):
def generate_launchd_plist() -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
- log_dir = Path.home() / ".hermes" / "logs"
+ log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
return f"""
@@ -300,6 +772,7 @@ def generate_launchd_plist() -> str:
hermes_cli.main
gateway
run
+ --replace
WorkingDirectory
@@ -323,10 +796,45 @@ def generate_launchd_plist() -> str:
"""
+def launchd_plist_is_current() -> bool:
+ """Check if the installed launchd plist matches the currently generated one."""
+ plist_path = get_launchd_plist_path()
+ if not plist_path.exists():
+ return False
+
+ installed = plist_path.read_text(encoding="utf-8")
+ expected = generate_launchd_plist()
+ return _normalize_service_definition(installed) == _normalize_service_definition(expected)
+
+
+def refresh_launchd_plist_if_needed() -> bool:
+ """Rewrite the installed launchd plist when the generated definition has changed.
+
+ Unlike systemd, launchd picks up plist changes on the next ``launchctl stop``/
+ ``launchctl start`` cycle โ no daemon-reload is needed. We still unload/reload
+ to make launchd re-read the updated plist immediately.
+ """
+ plist_path = get_launchd_plist_path()
+ if not plist_path.exists() or launchd_plist_is_current():
+ return False
+
+ plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
+ # Unload/reload so launchd picks up the new definition
+ subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
+ subprocess.run(["launchctl", "load", str(plist_path)], check=False)
+ print("โป Updated gateway launchd service definition to match the current Hermes install")
+ return True
+
+
def launchd_install(force: bool = False):
plist_path = get_launchd_plist_path()
if plist_path.exists() and not force:
+ if not launchd_plist_is_current():
+ print(f"โป Repairing outdated launchd service at: {plist_path}")
+ refresh_launchd_plist_if_needed()
+ print("โ Service definition updated")
+ return
print(f"Service already installed at: {plist_path}")
print("Use --force to reinstall")
return
@@ -355,32 +863,97 @@ def launchd_uninstall():
print("โ Service uninstalled")
def launchd_start():
- subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
+ refresh_launchd_plist_if_needed()
+ plist_path = get_launchd_plist_path()
+ try:
+ subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
+ except subprocess.CalledProcessError as e:
+ if e.returncode != 3 or not plist_path.exists():
+ raise
+ print("โป launchd job was unloaded; reloading service definition")
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
+ subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
print("โ Service started")
def launchd_stop():
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
print("โ Service stopped")
+def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
+ """Wait for the gateway process (by saved PID) to exit.
+
+ Uses the PID from the gateway.pid file โ not launchd labels โ so this
+ works correctly when multiple gateway instances run under separate
+ HERMES_HOME directories.
+
+ Args:
+ timeout: Total seconds to wait before giving up.
+ force_after: Seconds of graceful waiting before sending SIGKILL.
+ """
+ import time
+ from gateway.status import get_running_pid
+
+ deadline = time.monotonic() + timeout
+ force_deadline = time.monotonic() + force_after
+ force_sent = False
+
+ while time.monotonic() < deadline:
+ pid = get_running_pid()
+ if pid is None:
+ return # Process exited cleanly.
+
+ if not force_sent and time.monotonic() >= force_deadline:
+ # Grace period expired โ force-kill the specific PID.
+ try:
+ os.kill(pid, signal.SIGKILL)
+ print(f"โ Gateway PID {pid} did not exit gracefully; sent SIGKILL")
+ except (ProcessLookupError, PermissionError):
+ return # Already gone or we can't touch it.
+ force_sent = True
+
+ time.sleep(0.3)
+
+ # Timed out even after SIGKILL.
+ remaining_pid = get_running_pid()
+ if remaining_pid is not None:
+ print(f"โ Gateway PID {remaining_pid} still running after {timeout}s โ restart may fail")
+
+
def launchd_restart():
- launchd_stop()
+ try:
+ launchd_stop()
+ except subprocess.CalledProcessError as e:
+ if e.returncode != 3:
+ raise
+ print("โป launchd job was unloaded; skipping stop")
+ _wait_for_gateway_exit()
launchd_start()
def launchd_status(deep: bool = False):
+ plist_path = get_launchd_plist_path()
result = subprocess.run(
["launchctl", "list", "ai.hermes.gateway"],
capture_output=True,
text=True
)
+
+ print(f"Launchd plist: {plist_path}")
+ if launchd_plist_is_current():
+ print("โ Service definition matches the current Hermes install")
+ else:
+ print("โ Service definition is stale relative to the current Hermes install")
+ print(" Run: hermes gateway start")
if result.returncode == 0:
print("โ Gateway service is loaded")
print(result.stdout)
else:
print("โ Gateway service is not loaded")
+ print(" Service definition exists locally but launchd has not loaded it.")
+ print(" Run: hermes gateway start")
if deep:
- log_file = Path.home() / ".hermes" / "logs" / "gateway.log"
+ log_file = get_hermes_home() / "logs" / "gateway.log"
if log_file.exists():
print()
print("Recent logs:")
@@ -506,6 +1079,64 @@ def run_gateway(verbose: bool = False, replace: bool = False):
"help": "Paste your member ID from step 7 above."},
],
},
+ {
+ "key": "matrix",
+ "label": "Matrix",
+ "emoji": "๐",
+ "token_var": "MATRIX_ACCESS_TOKEN",
+ "setup_instructions": [
+ "1. Works with any Matrix homeserver (self-hosted Synapse/Conduit/Dendrite or matrix.org)",
+ "2. Create a bot user on your homeserver, or use your own account",
+ "3. Get an access token: Element โ Settings โ Help & About โ Access Token",
+ " Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
+ " -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'",
+ "4. Alternatively, provide user ID + password and Hermes will log in directly",
+ "5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'matrix-nio[e2e]')",
+ "6. To find your user ID: it's @username:your-server (shown in Element profile)",
+ ],
+ "vars": [
+ {"name": "MATRIX_HOMESERVER", "prompt": "Homeserver URL (e.g. https://matrix.example.org)", "password": False,
+ "help": "Your Matrix homeserver URL. Works with any self-hosted instance."},
+ {"name": "MATRIX_ACCESS_TOKEN", "prompt": "Access token (leave empty to use password login instead)", "password": True,
+ "help": "Paste your access token, or leave empty and provide user ID + password below."},
+ {"name": "MATRIX_USER_ID", "prompt": "User ID (@bot:server โ required for password login)", "password": False,
+ "help": "Full Matrix user ID, e.g. @hermes:matrix.example.org"},
+ {"name": "MATRIX_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, e.g. @you:server)", "password": False,
+ "is_allowlist": True,
+ "help": "Matrix user IDs who can interact with the bot."},
+ {"name": "MATRIX_HOME_ROOM", "prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False,
+ "help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications."},
+ ],
+ },
+ {
+ "key": "mattermost",
+ "label": "Mattermost",
+ "emoji": "๐ฌ",
+ "token_var": "MATTERMOST_TOKEN",
+ "setup_instructions": [
+ "1. In Mattermost: Integrations โ Bot Accounts โ Add Bot Account",
+ " (System Console โ Integrations โ Bot Accounts must be enabled)",
+ "2. Give it a username (e.g. hermes) and copy the bot token",
+ "3. Works with any self-hosted Mattermost instance โ enter your server URL",
+ "4. To find your user ID: click your avatar (top-left) โ Profile",
+ " Your user ID is displayed there โ click it to copy.",
+ " โ This is NOT your username โ it's a 26-character alphanumeric ID.",
+ "5. To get a channel ID: click the channel name โ View Info โ copy the ID",
+ ],
+ "vars": [
+ {"name": "MATTERMOST_URL", "prompt": "Server URL (e.g. https://mm.example.com)", "password": False,
+ "help": "Your Mattermost server URL. Works with any self-hosted instance."},
+ {"name": "MATTERMOST_TOKEN", "prompt": "Bot token", "password": True,
+ "help": "Paste the bot token from step 2 above."},
+ {"name": "MATTERMOST_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated)", "password": False,
+ "is_allowlist": True,
+ "help": "Your Mattermost user ID from step 4 above."},
+ {"name": "MATTERMOST_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False,
+ "help": "Channel ID where Hermes delivers cron results and notifications."},
+ {"name": "MATTERMOST_REPLY_MODE", "prompt": "Reply mode โ 'off' for flat messages, 'thread' for threaded replies (default: off)", "password": False,
+ "help": "off = flat channel messages, thread = replies nest under your message."},
+ ],
+ },
{
"key": "whatsapp",
"label": "WhatsApp",
@@ -544,6 +1175,51 @@ def run_gateway(verbose: bool = False, replace: bool = False):
"help": "Only emails from these addresses will be processed."},
],
},
+ {
+ "key": "sms",
+ "label": "SMS (Twilio)",
+ "emoji": "๐ฑ",
+ "token_var": "TWILIO_ACCOUNT_SID",
+ "setup_instructions": [
+ "1. Create a Twilio account at https://www.twilio.com/",
+ "2. Get your Account SID and Auth Token from the Twilio Console dashboard",
+ "3. Buy or configure a phone number capable of sending SMS",
+ "4. Set up your webhook URL for inbound SMS:",
+ " Twilio Console โ Phone Numbers โ Active Numbers โ your number",
+ " โ Messaging โ A MESSAGE COMES IN โ Webhook โ https://your-server:8080/webhooks/twilio",
+ ],
+ "vars": [
+ {"name": "TWILIO_ACCOUNT_SID", "prompt": "Twilio Account SID", "password": False,
+ "help": "Found on the Twilio Console dashboard."},
+ {"name": "TWILIO_AUTH_TOKEN", "prompt": "Twilio Auth Token", "password": True,
+ "help": "Found on the Twilio Console dashboard (click to reveal)."},
+ {"name": "TWILIO_PHONE_NUMBER", "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", "password": False,
+ "help": "The Twilio phone number to send SMS from."},
+ {"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated, E.164 format)", "password": False,
+ "is_allowlist": True,
+ "help": "Only messages from these phone numbers will be processed."},
+ {"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone number (for cron/notification delivery, or empty)", "password": False,
+ "help": "Phone number to deliver cron job results and notifications to."},
+ ],
+ },
+ {
+ "key": "dingtalk",
+ "label": "DingTalk",
+ "emoji": "๐ฌ",
+ "token_var": "DINGTALK_CLIENT_ID",
+ "setup_instructions": [
+ "1. Go to https://open-dev.dingtalk.com โ Create Application",
+ "2. Under 'Credentials', copy the AppKey (Client ID) and AppSecret (Client Secret)",
+ "3. Enable 'Stream Mode' under the bot settings",
+ "4. Add the bot to a group chat or message it directly",
+ ],
+ "vars": [
+ {"name": "DINGTALK_CLIENT_ID", "prompt": "AppKey (Client ID)", "password": False,
+ "help": "The AppKey from your DingTalk application credentials."},
+ {"name": "DINGTALK_CLIENT_SECRET", "prompt": "AppSecret (Client Secret)", "password": True,
+ "help": "The AppSecret from your DingTalk application credentials."},
+ ],
+ },
]
@@ -557,7 +1233,7 @@ def _platform_status(platform: dict) -> str:
val = get_env_value(token_var)
if token_var == "WHATSAPP_ENABLED":
if val and val.lower() == "true":
- session_file = Path.home() / ".hermes" / "whatsapp" / "session" / "creds.json"
+ session_file = get_hermes_home() / "whatsapp" / "session" / "creds.json"
if session_file.exists():
return "configured + paired"
return "enabled, not paired"
@@ -578,11 +1254,50 @@ def _platform_status(platform: dict) -> str:
if any([val, pwd, imap, smtp]):
return "partially configured"
return "not configured"
+ if platform.get("key") == "matrix":
+ homeserver = get_env_value("MATRIX_HOMESERVER")
+ password = get_env_value("MATRIX_PASSWORD")
+ if (val or password) and homeserver:
+ e2ee = get_env_value("MATRIX_ENCRYPTION")
+ suffix = " + E2EE" if e2ee and e2ee.lower() in ("true", "1", "yes") else ""
+ return f"configured{suffix}"
+ if val or password or homeserver:
+ return "partially configured"
+ return "not configured"
if val:
return "configured"
return "not configured"
+def _runtime_health_lines() -> list[str]:
+ """Summarize the latest persisted gateway runtime health state."""
+ try:
+ from gateway.status import read_runtime_status
+ except Exception:
+ return []
+
+ state = read_runtime_status()
+ if not state:
+ return []
+
+ lines: list[str] = []
+ gateway_state = state.get("gateway_state")
+ exit_reason = state.get("exit_reason")
+ platforms = state.get("platforms", {}) or {}
+
+ for platform, pdata in platforms.items():
+ if pdata.get("state") == "fatal":
+ message = pdata.get("error_message") or "unknown error"
+ lines.append(f"โ {platform}: {message}")
+
+ if gateway_state == "startup_failed" and exit_reason:
+ lines.append(f"โ Last startup issue: {exit_reason}")
+ elif gateway_state == "stopped" and exit_reason:
+ lines.append(f"โ Last shutdown reason: {exit_reason}")
+
+ return lines
+
+
def _setup_standard_platform(platform: dict):
"""Interactive setup for Telegram, Discord, or Slack."""
emoji = platform["emoji"]
@@ -617,14 +1332,26 @@ def _setup_standard_platform(platform: dict):
# Allowlist fields get special handling for the deny-by-default security model
if var.get("is_allowlist"):
- print_info(f" The gateway DENIES all users by default for security.")
- print_info(f" Enter user IDs to create an allowlist, or leave empty")
- print_info(f" and you'll be asked about open access next.")
+ print_info(" The gateway DENIES all users by default for security.")
+ print_info(" Enter user IDs to create an allowlist, or leave empty")
+ print_info(" and you'll be asked about open access next.")
value = prompt(f" {var['prompt']}", password=False)
if value:
cleaned = value.replace(" ", "")
+ # For Discord, strip common prefixes (user:123, <@123>, <@!123>)
+ if "DISCORD" in var["name"]:
+ parts = []
+ for uid in cleaned.split(","):
+ uid = uid.strip()
+ if uid.startswith("<@") and uid.endswith(">"):
+ uid = uid.lstrip("<@!").rstrip(">")
+ if uid.lower().startswith("user:"):
+ uid = uid[5:]
+ if uid:
+ parts.append(uid)
+ cleaned = ",".join(parts)
save_env_value(var["name"], cleaned)
- print_success(f" Saved โ only these users can interact with the bot.")
+ print_success(" Saved โ only these users can interact with the bot.")
allowed_val_set = cleaned
else:
# No allowlist โ ask about open access vs DM pairing
@@ -653,7 +1380,7 @@ def _setup_standard_platform(platform: dict):
print_warning(f" Skipped โ {label} won't work without this.")
return
else:
- print_info(f" Skipped (can configure later)")
+ print_info(" Skipped (can configure later)")
# If an allowlist was set and home channel wasn't, offer to reuse
# the first user ID (common for Telegram DMs).
@@ -679,7 +1406,7 @@ def _setup_whatsapp():
def _is_service_installed() -> bool:
"""Check if the gateway is installed as a system service."""
if is_linux():
- return get_systemd_unit_path().exists()
+ return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()
elif is_macos():
return get_launchd_plist_path().exists()
return False
@@ -687,12 +1414,27 @@ def _is_service_installed() -> bool:
def _is_service_running() -> bool:
"""Check if the gateway service is currently running."""
- if is_linux() and get_systemd_unit_path().exists():
- result = subprocess.run(
- ["systemctl", "--user", "is-active", SERVICE_NAME],
- capture_output=True, text=True
- )
- return result.stdout.strip() == "active"
+ if is_linux():
+ user_unit_exists = get_systemd_unit_path(system=False).exists()
+ system_unit_exists = get_systemd_unit_path(system=True).exists()
+
+ if user_unit_exists:
+ result = subprocess.run(
+ _systemctl_cmd(False) + ["is-active", get_service_name()],
+ capture_output=True, text=True
+ )
+ if result.stdout.strip() == "active":
+ return True
+
+ if system_unit_exists:
+ result = subprocess.run(
+ _systemctl_cmd(True) + ["is-active", get_service_name()],
+ capture_output=True, text=True
+ )
+ if result.stdout.strip() == "active":
+ return True
+
+ return False
elif is_macos() and get_launchd_plist_path().exists():
result = subprocess.run(
["launchctl", "list", "ai.hermes.gateway"],
@@ -814,12 +1556,15 @@ def _setup_signal():
print_success("Signal configured!")
print_info(f" URL: {url}")
print_info(f" Account: {account}")
- print_info(f" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing")
+ print_info(" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing")
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
def gateway_setup():
"""Interactive setup for messaging platforms + gateway service."""
+ if is_managed():
+ managed_error("run gateway setup")
+ return
print()
print(color("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ", Colors.MAGENTA))
@@ -834,6 +1579,10 @@ def gateway_setup():
service_installed = _is_service_installed()
service_running = _is_service_running()
+ if is_linux() and has_conflicting_systemd_units():
+ print_systemd_scope_conflict_warning()
+ print()
+
if service_installed and service_running:
print_success("Gateway service is installed and running.")
elif service_installed:
@@ -915,16 +1664,18 @@ def gateway_setup():
platform_name = "systemd" if is_linux() else "launchd"
if prompt_yes_no(f" Install the gateway as a {platform_name} service? (runs in background, starts on boot)", True):
try:
- force = False
+ installed_scope = None
+ did_install = False
if is_linux():
- systemd_install(force)
+ installed_scope, did_install = install_linux_gateway_from_setup(force=False)
else:
- launchd_install(force)
+ launchd_install(force=False)
+ did_install = True
print()
- if prompt_yes_no(" Start the service now?", True):
+ if did_install and prompt_yes_no(" Start the service now?", True):
try:
if is_linux():
- systemd_start()
+ systemd_start(system=installed_scope == "system")
else:
launchd_start()
except subprocess.CalledProcessError as e:
@@ -934,6 +1685,8 @@ def gateway_setup():
print_info(" You can try manually: hermes gateway install")
else:
print_info(" You can install later: hermes gateway install")
+ if is_linux():
+ print_info(" Or as a boot-time service: sudo hermes gateway install --system")
print_info(" Or run in foreground: hermes gateway")
else:
print_info(" Service install not supported on this platform.")
@@ -966,9 +1719,14 @@ def gateway_command(args):
# Service management commands
if subcmd == "install":
+ if is_managed():
+ managed_error("install gateway service (managed by NixOS)")
+ return
force = getattr(args, 'force', False)
+ system = getattr(args, 'system', False)
+ run_as_user = getattr(args, 'run_as_user', None)
if is_linux():
- systemd_install(force)
+ systemd_install(force=force, system=system, run_as_user=run_as_user)
elif is_macos():
launchd_install(force)
else:
@@ -977,8 +1735,12 @@ def gateway_command(args):
sys.exit(1)
elif subcmd == "uninstall":
+ if is_managed():
+ managed_error("uninstall gateway service (managed by NixOS)")
+ return
+ system = getattr(args, 'system', False)
if is_linux():
- systemd_uninstall()
+ systemd_uninstall(system=system)
elif is_macos():
launchd_uninstall()
else:
@@ -986,8 +1748,9 @@ def gateway_command(args):
sys.exit(1)
elif subcmd == "start":
+ system = getattr(args, 'system', False)
if is_linux():
- systemd_start()
+ systemd_start(system=system)
elif is_macos():
launchd_start()
else:
@@ -995,12 +1758,13 @@ def gateway_command(args):
sys.exit(1)
elif subcmd == "stop":
- # Try service first, fall back to killing processes directly
+ # Try service first, then sweep any stray/manual gateway processes.
service_available = False
+ system = getattr(args, 'system', False)
- if is_linux() and get_systemd_unit_path().exists():
+ if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
try:
- systemd_stop()
+ systemd_stop(system=system)
service_available = True
except subprocess.CalledProcessError:
pass # Fall through to process kill
@@ -1010,26 +1774,31 @@ def gateway_command(args):
service_available = True
except subprocess.CalledProcessError:
pass
-
+
+ killed = kill_gateway_processes()
if not service_available:
- # Kill gateway processes directly
- killed = kill_gateway_processes()
if killed:
print(f"โ Stopped {killed} gateway process(es)")
else:
print("โ No gateway processes found")
+ elif killed:
+ print(f"โ Stopped {killed} additional manual gateway process(es)")
elif subcmd == "restart":
# Try service first, fall back to killing and restarting
service_available = False
+ system = getattr(args, 'system', False)
+ service_configured = False
- if is_linux() and get_systemd_unit_path().exists():
+ if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+ service_configured = True
try:
- systemd_restart()
+ systemd_restart(system=system)
service_available = True
except subprocess.CalledProcessError:
pass
elif is_macos() and get_launchd_plist_path().exists():
+ service_configured = True
try:
launchd_restart()
service_available = True
@@ -1037,24 +1806,47 @@ def gateway_command(args):
pass
if not service_available:
+ # systemd/launchd restart failed โ check if linger is the issue
+ if is_linux():
+ linger_ok, _detail = get_systemd_linger_status()
+ if linger_ok is not True:
+ import getpass
+ _username = getpass.getuser()
+ print()
+ print("โ Cannot restart gateway as a service โ linger is not enabled.")
+ print(" The gateway user service requires linger to function on headless servers.")
+ print()
+ print(f" Run: sudo loginctl enable-linger {_username}")
+ print()
+ print(" Then restart the gateway:")
+ print(" hermes gateway restart")
+ return
+
+ if service_configured:
+ print()
+ print("โ Gateway service restart failed.")
+ print(" The service definition exists, but the service manager did not recover it.")
+ print(" Fix the service, then retry: hermes gateway start")
+ sys.exit(1)
+
# Manual restart: kill existing processes
killed = kill_gateway_processes()
if killed:
print(f"โ Stopped {killed} gateway process(es)")
-
- import time
- time.sleep(2)
-
+
+ _wait_for_gateway_exit(timeout=10.0, force_after=5.0)
+
# Start fresh
print("Starting gateway...")
run_gateway(verbose=False)
elif subcmd == "status":
deep = getattr(args, 'deep', False)
+ system = getattr(args, 'system', False)
# Check for service first
- if is_linux() and get_systemd_unit_path().exists():
- systemd_status(deep)
+ if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+ systemd_status(deep, system=system)
elif is_macos() and get_launchd_plist_path().exists():
launchd_status(deep)
else:
@@ -1063,12 +1855,26 @@ def gateway_command(args):
if pids:
print(f"โ Gateway is running (PID: {', '.join(map(str, pids))})")
print(" (Running manually, not as a system service)")
+ runtime_lines = _runtime_health_lines()
+ if runtime_lines:
+ print()
+ print("Recent gateway health:")
+ for line in runtime_lines:
+ print(f" {line}")
print()
print("To install as a service:")
print(" hermes gateway install")
+ print(" sudo hermes gateway install --system")
else:
print("โ Gateway is not running")
+ runtime_lines = _runtime_health_lines()
+ if runtime_lines:
+ print()
+ print("Recent gateway health:")
+ for line in runtime_lines:
+ print(f" {line}")
print()
print("To start:")
print(" hermes gateway # Run in foreground")
- print(" hermes gateway install # Install as service")
+ print(" hermes gateway install # Install as user service")
+ print(" sudo hermes gateway install --system # Install as boot-time system service")
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 87fc6b7fc14..fc94658e2b8 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -18,16 +18,34 @@
hermes cron list # List cron jobs
hermes cron status # Check if cron scheduler is running
hermes doctor # Check configuration and dependencies
- hermes version # Show version
- hermes update # Update to latest version
- hermes uninstall # Uninstall Hermes Agent
- hermes sessions browse # Interactive session picker with search
- hermes claw migrate # Migrate from OpenClaw to Hermes
+ hermes honcho setup # Configure Honcho AI memory integration
+ hermes honcho status # Show Honcho config and connection status
+ hermes honcho sessions # List directory โ session name mappings
+ hermes honcho map # Map current directory to a session name
+ hermes honcho peer # Show peer names and dialectic settings
+ hermes honcho peer --user NAME # Set user peer name
+ hermes honcho peer --ai NAME # Set AI peer name
+ hermes honcho peer --reasoning LEVEL # Set dialectic reasoning level
+ hermes honcho mode # Show current memory mode
+ hermes honcho mode [hybrid|honcho|local] # Set memory mode
+ hermes honcho tokens # Show token budget settings
+ hermes honcho tokens --context N # Set session.context() token cap
+ hermes honcho tokens --dialectic N # Set dialectic result char cap
+ hermes honcho identity # Show AI peer identity representation
+ hermes honcho identity # Seed AI peer identity from a file (SOUL.md etc.)
+ hermes honcho migrate # Step-by-step migration guide: OpenClaw native โ Hermes + Honcho
+ hermes version Show version
+ hermes update Update to latest version
+ hermes uninstall Uninstall Hermes Agent
+ hermes acp Run as an ACP server for editor integration
+ hermes sessions browse Interactive session picker with search
+
hermes claw migrate --dry-run # Preview migration without changes
"""
import argparse
import os
+import subprocess
import sys
from pathlib import Path
from typing import Optional
@@ -36,22 +54,16 @@
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT))
-# Load .env from ~/.hermes/.env first, then project root as dev fallback
-from dotenv import load_dotenv
-from hermes_cli.config import get_env_path, get_hermes_home
-_user_env = get_env_path()
-if _user_env.exists():
- try:
- load_dotenv(dotenv_path=_user_env, encoding="utf-8")
- except UnicodeDecodeError:
- load_dotenv(dotenv_path=_user_env, encoding="latin-1")
-load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False)
+# Load .env from ~/.hermes/.env first, then project root as dev fallback.
+# User-managed env files should override stale shell exports on restart.
+from hermes_cli.config import get_hermes_home
+from hermes_cli.env_loader import load_hermes_dotenv
+load_hermes_dotenv(project_env=PROJECT_ROOT / '.env')
-# Point mini-swe-agent at ~/.hermes/ so it shares our config
-os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
-os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
import logging
+import time as _time
+from datetime import datetime
from hermes_cli import __version__, __release_date__
from hermes_constants import OPENROUTER_BASE_URL
@@ -59,6 +71,24 @@
logger = logging.getLogger(__name__)
+def _relative_time(ts) -> str:
+ """Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
+ if not ts:
+ return "?"
+ delta = _time.time() - ts
+ if delta < 60:
+ return "just now"
+ if delta < 3600:
+ return f"{int(delta / 60)}m ago"
+ if delta < 86400:
+ return f"{int(delta / 3600)}h ago"
+ if delta < 172800:
+ return "yesterday"
+ if delta < 604800:
+ return f"{int(delta / 86400)}d ago"
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
+
+
def _has_any_provider_configured() -> bool:
"""Check if at least one inference provider is usable."""
from hermes_cli.config import get_env_path, get_hermes_home
@@ -70,7 +100,7 @@ def _has_any_provider_configured() -> bool:
from hermes_cli.auth import PROVIDER_REGISTRY
# Collect all provider env vars
- provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL"}
+ provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
for pconfig in PROVIDER_REGISTRY.values():
if pconfig.auth_type == "api_key":
provider_env_vars.update(pconfig.api_key_env_vars)
@@ -92,6 +122,17 @@ def _has_any_provider_configured() -> bool:
except Exception:
pass
+ # Check provider-specific auth fallbacks (for example, Copilot via gh auth).
+ try:
+ for provider_id, pconfig in PROVIDER_REGISTRY.items():
+ if pconfig.auth_type != "api_key":
+ continue
+ status = get_auth_status(provider_id)
+ if status.get("logged_in"):
+ return True
+ except Exception:
+ pass
+
# Check for Nous Portal OAuth credentials
auth_file = get_hermes_home() / "auth.json"
if auth_file.exists():
@@ -106,6 +147,18 @@ def _has_any_provider_configured() -> bool:
except Exception:
pass
+
+ # Check for Claude Code OAuth credentials (~/.claude/.credentials.json)
+ # These are used by resolve_anthropic_token() at runtime but were missing
+ # from this startup gate check.
+ try:
+ from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
+ creds = read_claude_code_credentials()
+ if creds and (is_claude_code_token_valid(creds) or creds.get("refreshToken")):
+ return True
+ except Exception:
+ pass
+
return False
@@ -123,28 +176,9 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
# Try curses-based picker first
try:
import curses
- import time as _time
- from datetime import datetime
result_holder = [None]
- def _relative_time(ts):
- if not ts:
- return "?"
- delta = _time.time() - ts
- if delta < 60:
- return "just now"
- elif delta < 3600:
- return f"{int(delta / 60)}m ago"
- elif delta < 86400:
- return f"{int(delta / 3600)}h ago"
- elif delta < 172800:
- return "yesterday"
- elif delta < 604800:
- return f"{int(delta / 86400)}d ago"
- else:
- return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
-
def _format_row(s, max_x):
"""Format a session row for display."""
title = (s.get("title") or "").strip()
@@ -335,26 +369,6 @@ def _curses_browse(stdscr):
pass
# Fallback: numbered list (Windows without curses, etc.)
- import time as _time
- from datetime import datetime
-
- def _relative_time_fb(ts):
- if not ts:
- return "?"
- delta = _time.time() - ts
- if delta < 60:
- return "just now"
- elif delta < 3600:
- return f"{int(delta / 60)}m ago"
- elif delta < 86400:
- return f"{int(delta / 3600)}h ago"
- elif delta < 172800:
- return "yesterday"
- elif delta < 604800:
- return f"{int(delta / 86400)}d ago"
- else:
- return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
-
print("\n Browse sessions (enter number to resume, q to cancel)\n")
for i, s in enumerate(sessions):
title = (s.get("title") or "").strip()
@@ -362,7 +376,7 @@ def _relative_time_fb(ts):
label = title or preview or s["id"]
if len(label) > 50:
label = label[:47] + "..."
- last_active = _relative_time_fb(s.get("last_active"))
+ last_active = _relative_time(s.get("last_active"))
src = s.get("source", "")[:6]
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
@@ -376,7 +390,7 @@ def _relative_time_fb(ts):
return sessions[idx]["id"]
print(f" Invalid selection. Enter 1-{len(sessions)} or q to cancel.")
except ValueError:
- print(f" Invalid input. Enter a number or q to cancel.")
+ print(" Invalid input. Enter a number or q to cancel.")
except (KeyboardInterrupt, EOFError):
print()
return None
@@ -461,6 +475,15 @@ def cmd_chat(args):
print()
print(" Run: hermes setup")
print()
+
+ from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance
+
+ if not is_interactive_stdin():
+ print_noninteractive_setup_guidance(
+ "No interactive TTY detected for the first-run setup prompt."
+ )
+ sys.exit(1)
+
try:
reply = input("Run setup now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
@@ -472,6 +495,13 @@ def cmd_chat(args):
print("You can run 'hermes setup' at any time to configure.")
sys.exit(1)
+ # Start update check in background (runs while other init happens)
+ try:
+ from hermes_cli.banner import prefetch_update_check
+ prefetch_update_check()
+ except Exception:
+ pass
+
# Sync bundled skills on every CLI launch (fast -- skips unchanged skills)
try:
from tools.skills_sync import sync_skills
@@ -483,6 +513,10 @@ def cmd_chat(args):
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
+ # --source: tag session source for filtering (e.g. 'tool' for third-party integrations)
+ if getattr(args, "source", None):
+ os.environ["HERMES_SESSION_SOURCE"] = args.source
+
# Import and run the CLI
from cli import main as cli_main
@@ -491,6 +525,7 @@ def cmd_chat(args):
"model": args.model,
"provider": getattr(args, "provider", None),
"toolsets": args.toolsets,
+ "skills": getattr(args, "skills", None),
"verbose": args.verbose,
"quiet": getattr(args, "quiet", False),
"query": args.query,
@@ -502,7 +537,11 @@ def cmd_chat(args):
# Filter out None values
kwargs = {k: v for k, v in kwargs.items() if v is not None}
- cli_main(**kwargs)
+ try:
+ cli_main(**kwargs)
+ except ValueError as e:
+ print(f"Error: {e}")
+ sys.exit(1)
def cmd_gateway(args):
@@ -513,7 +552,6 @@ def cmd_gateway(args):
def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
- import os
import subprocess
from pathlib import Path
from hermes_cli.config import get_env_value, save_env_value
@@ -632,7 +670,7 @@ def cmd_whatsapp(args):
print("โ Bridge dependencies already installed")
# โโ Step 5: Check for existing session โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
+ session_dir = get_hermes_home() / "whatsapp" / "session"
session_dir.mkdir(parents=True, exist_ok=True)
if (session_dir / "creds.json").exists():
@@ -707,12 +745,9 @@ def cmd_setup(args):
def cmd_model(args):
"""Select default model โ starts with provider selection, then model picker."""
from hermes_cli.auth import (
- resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY,
- _prompt_model_selection, _save_model_choice, _update_config_for_provider,
- resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error,
- _login_nous,
+ resolve_provider, AuthError, format_auth_error,
)
- from hermes_cli.config import load_config, save_config, get_env_value, save_env_value
+ from hermes_cli.config import load_config, get_env_value
config = load_config()
current_model = config.get("model")
@@ -729,8 +764,8 @@ def cmd_model(args):
config_provider = model_cfg.get("provider")
effective_provider = (
- os.getenv("HERMES_INFERENCE_PROVIDER")
- or config_provider
+ config_provider
+ or os.getenv("HERMES_INFERENCE_PROVIDER")
or "auto"
)
try:
@@ -748,11 +783,20 @@ def cmd_model(args):
"openrouter": "OpenRouter",
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
+ "copilot-acp": "GitHub Copilot ACP",
+ "copilot": "GitHub Copilot",
"anthropic": "Anthropic",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
+ "opencode-zen": "OpenCode Zen",
+ "opencode-go": "OpenCode Go",
+ "ai-gateway": "AI Gateway",
+ "kilocode": "Kilo Code",
+ "xgate": "xgate",
+ "alibaba": "Alibaba Cloud (DashScope)",
+ "huggingface": "Hugging Face",
"custom": "Custom endpoint",
}
active_label = provider_labels.get(active, active)
@@ -767,11 +811,20 @@ def cmd_model(args):
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
("nous", "Nous Portal (Nous Research subscription)"),
("openai-codex", "OpenAI Codex"),
+ ("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
+ ("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
("anthropic", "Anthropic (Claude models โ API key or Claude Code)"),
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
("minimax", "MiniMax (global direct API)"),
("minimax-cn", "MiniMax China (domestic direct API)"),
+ ("xgate", "xgate (ai.xgate.run inference endpoint)"),
+ ("kilocode", "Kilo Code (Kilo Gateway API)"),
+ ("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
+ ("opencode-go", "OpenCode Go (open models, $10/month subscription)"),
+ ("ai-gateway", "AI Gateway (Vercel โ 200+ models, pay-per-use)"),
+ ("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
+ ("huggingface", "Hugging Face Inference Providers (20+ open models)"),
]
# Add user-defined custom providers from config.yaml
@@ -830,6 +883,10 @@ def cmd_model(args):
_model_flow_nous(config, current_model)
elif selected_provider == "openai-codex":
_model_flow_openai_codex(config, current_model)
+ elif selected_provider == "copilot-acp":
+ _model_flow_copilot_acp(config, current_model)
+ elif selected_provider == "copilot":
+ _model_flow_copilot(config, current_model)
elif selected_provider == "custom":
_model_flow_custom(config)
elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map:
@@ -840,7 +897,7 @@ def cmd_model(args):
_model_flow_anthropic(config, current_model)
elif selected_provider == "kimi-coding":
_model_flow_kimi(config, current_model)
- elif selected_provider in ("zai", "minimax", "minimax-cn"):
+ elif selected_provider in ("zai", "minimax", "minimax-cn", "xgate", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"):
_model_flow_api_key_provider(config, selected_provider, current_model)
@@ -1041,6 +1098,7 @@ def _model_flow_openai_codex(config, current_model=""):
_codex_token = _codex_creds.get("api_key")
except Exception:
pass
+
codex_models = get_codex_model_ids(access_token=_codex_token)
selected = _prompt_model_selection(codex_models, current_model=current_model)
@@ -1056,6 +1114,7 @@ def _model_flow_openai_codex(config, current_model=""):
print("No change.")
+
def _model_flow_custom(config):
"""Custom endpoint: collect URL, API key, and model name.
@@ -1079,10 +1138,21 @@ def _model_flow_custom(config):
base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip()
api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
+ context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return
+ context_length = None
+ if context_length_str:
+ try:
+ context_length = int(context_length_str.replace(",", "").replace("k", "000").replace("K", "000"))
+ if context_length <= 0:
+ context_length = None
+ except ValueError:
+ print(f"Invalid context length: {context_length_str} โ will auto-detect.")
+ context_length = None
+
if not base_url and not current_url:
print("No URL provided. Cancelled.")
return
@@ -1095,8 +1165,32 @@ def _model_flow_custom(config):
effective_key = api_key or current_key
+ from hermes_cli.models import probe_api_models
+
+ probe = probe_api_models(effective_key, effective_url)
+ if probe.get("used_fallback") and probe.get("resolved_base_url"):
+ print(
+ f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, "
+ f"not the exact URL you entered. Saving the working base URL instead."
+ )
+ effective_url = probe["resolved_base_url"]
+ if base_url:
+ base_url = effective_url
+ elif probe.get("models") is not None:
+ print(
+ f"Verified endpoint via {probe.get('probed_url')} "
+ f"({len(probe.get('models') or [])} model(s) visible)"
+ )
+ else:
+ print(
+ f"Warning: could not verify this endpoint via {probe.get('probed_url')}. "
+ f"Hermes will still save it."
+ )
+ if probe.get("suggested_base_url"):
+ print(f" If this server expects /v1, try base URL: {probe['suggested_base_url']}")
+
if base_url:
- save_env_value("OPENAI_BASE_URL", base_url)
+ save_env_value("OPENAI_BASE_URL", effective_url)
if api_key:
save_env_value("OPENAI_API_KEY", api_key)
@@ -1121,14 +1215,14 @@ def _model_flow_custom(config):
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
# Auto-save to custom_providers so it appears in the menu next time
- _save_custom_provider(effective_url, effective_key, model_name or "")
+ _save_custom_provider(effective_url, effective_key, model_name or "", context_length=context_length)
-def _save_custom_provider(base_url, api_key="", model=""):
+def _save_custom_provider(base_url, api_key="", model="", context_length=None):
"""Save a custom endpoint to custom_providers in config.yaml.
Deduplicates by base_url โ if the URL already exists, updates the
- model name but doesn't add a duplicate entry.
+ model name and context_length but doesn't add a duplicate entry.
Auto-generates a display name from the URL hostname.
"""
from hermes_cli.config import load_config, save_config
@@ -1138,14 +1232,24 @@ def _save_custom_provider(base_url, api_key="", model=""):
if not isinstance(providers, list):
providers = []
- # Check if this URL is already saved โ update model if so
+ # Check if this URL is already saved โ update model/context_length if so
for entry in providers:
if isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/"):
+ changed = False
if model and entry.get("model") != model:
entry["model"] = model
+ changed = True
+ if model and context_length:
+ models_cfg = entry.get("models", {})
+ if not isinstance(models_cfg, dict):
+ models_cfg = {}
+ models_cfg[model] = {"context_length": context_length}
+ entry["models"] = models_cfg
+ changed = True
+ if changed:
cfg["custom_providers"] = providers
save_config(cfg)
- return # already saved, updated model if needed
+ return # already saved, updated if needed
# Auto-generate a name from the URL
import re
@@ -1167,6 +1271,8 @@ def _save_custom_provider(base_url, api_key="", model=""):
entry["api_key"] = api_key
if model:
entry["model"] = model
+ if model and context_length:
+ entry["models"] = {model: {"context_length": context_length}}
providers.append(entry)
cfg["custom_providers"] = providers
@@ -1344,6 +1450,25 @@ def _model_flow_named_custom(config, provider_info):
# Curated model lists for direct API-key providers
_PROVIDER_MODELS = {
+ "copilot-acp": [
+ "copilot-acp",
+ ],
+ "copilot": [
+ "gpt-5.4",
+ "gpt-5.4-mini",
+ "gpt-5-mini",
+ "gpt-5.3-codex",
+ "gpt-5.2-codex",
+ "gpt-4.1",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "claude-opus-4.6",
+ "claude-sonnet-4.6",
+ "claude-sonnet-4.5",
+ "claude-haiku-4.5",
+ "gemini-2.5-pro",
+ "grok-code-fast-1",
+ ],
"zai": [
"glm-5",
"glm-4.7",
@@ -1358,6 +1483,12 @@ def _model_flow_named_custom(config, provider_info):
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
+ "moonshot": [
+ "kimi-k2.5",
+ "kimi-k2-thinking",
+ "kimi-k2-turbo-preview",
+ "kimi-k2-0905-preview",
+ ],
"minimax": [
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
@@ -1368,9 +1499,398 @@ def _model_flow_named_custom(config, provider_info):
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
],
+ "kilocode": [
+ "anthropic/claude-opus-4.6",
+ "anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.4",
+ "google/gemini-3-pro-preview",
+ "google/gemini-3-flash-preview",
+ ],
+ # Curated HF model list โ only agentic models that map to OpenRouter defaults.
+ # Format: HF model ID โ OpenRouter equivalent noted in comment
+ "huggingface": [
+ "Qwen/Qwen3.5-397B-A17B", # โ qwen/qwen3.5-plus
+ "Qwen/Qwen3.5-35B-A3B", # โ qwen/qwen3.5-35b-a3b
+ "deepseek-ai/DeepSeek-V3.2", # โ deepseek/deepseek-chat
+ "moonshotai/Kimi-K2.5", # โ moonshotai/kimi-k2.5
+ "MiniMaxAI/MiniMax-M2.5", # โ minimax/minimax-m2.5
+ "zai-org/GLM-5", # โ z-ai/glm-5
+ "XiaomiMiMo/MiMo-V2-Flash", # โ xiaomi/mimo-v2-pro
+ "moonshotai/Kimi-K2-Thinking", # โ moonshotai/kimi-k2-thinking
+ ],
}
+def _current_reasoning_effort(config) -> str:
+ agent_cfg = config.get("agent")
+ if isinstance(agent_cfg, dict):
+ return str(agent_cfg.get("reasoning_effort") or "").strip().lower()
+ return ""
+
+
+def _set_reasoning_effort(config, effort: str) -> None:
+ agent_cfg = config.get("agent")
+ if not isinstance(agent_cfg, dict):
+ agent_cfg = {}
+ config["agent"] = agent_cfg
+ agent_cfg["reasoning_effort"] = effort
+
+
+def _prompt_reasoning_effort_selection(efforts, current_effort=""):
+ """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current."""
+ ordered = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
+ if not ordered:
+ return None
+
+ def _label(effort):
+ if effort == current_effort:
+ return f"{effort} โ currently in use"
+ return effort
+
+ disable_label = "Disable reasoning"
+ skip_label = "Skip (keep current)"
+
+ if current_effort == "none":
+ default_idx = len(ordered)
+ elif current_effort in ordered:
+ default_idx = ordered.index(current_effort)
+ elif "medium" in ordered:
+ default_idx = ordered.index("medium")
+ else:
+ default_idx = 0
+
+ try:
+ from simple_term_menu import TerminalMenu
+
+ choices = [f" {_label(effort)}" for effort in ordered]
+ choices.append(f" {disable_label}")
+ choices.append(f" {skip_label}")
+ menu = TerminalMenu(
+ choices,
+ cursor_index=default_idx,
+ menu_cursor="-> ",
+ menu_cursor_style=("fg_green", "bold"),
+ menu_highlight_style=("fg_green",),
+ cycle_cursor=True,
+ clear_screen=False,
+ title="Select reasoning effort:",
+ )
+ idx = menu.show()
+ if idx is None:
+ return None
+ print()
+ if idx < len(ordered):
+ return ordered[idx]
+ if idx == len(ordered):
+ return "none"
+ return None
+ except (ImportError, NotImplementedError):
+ pass
+
+ print("Select reasoning effort:")
+ for i, effort in enumerate(ordered, 1):
+ print(f" {i}. {_label(effort)}")
+ n = len(ordered)
+ print(f" {n + 1}. {disable_label}")
+ print(f" {n + 2}. {skip_label}")
+ print()
+
+ while True:
+ try:
+ choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip()
+ if not choice:
+ return None
+ idx = int(choice)
+ if 1 <= idx <= n:
+ return ordered[idx - 1]
+ if idx == n + 1:
+ return "none"
+ if idx == n + 2:
+ return None
+ print(f"Please enter 1-{n + 2}")
+ except ValueError:
+ print("Please enter a number")
+ except (KeyboardInterrupt, EOFError):
+ return None
+
+
+def _model_flow_copilot(config, current_model=""):
+ """GitHub Copilot flow using env vars, gh CLI, or OAuth device code."""
+ from hermes_cli.auth import (
+ PROVIDER_REGISTRY,
+ _prompt_model_selection,
+ _save_model_choice,
+ deactivate_provider,
+ resolve_api_key_provider_credentials,
+ )
+ from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
+ from hermes_cli.models import (
+ fetch_api_models,
+ fetch_github_model_catalog,
+ github_model_reasoning_efforts,
+ copilot_model_api_mode,
+ normalize_copilot_model_id,
+ )
+
+ provider_id = "copilot"
+ pconfig = PROVIDER_REGISTRY[provider_id]
+
+ creds = resolve_api_key_provider_credentials(provider_id)
+ api_key = creds.get("api_key", "")
+ source = creds.get("source", "")
+
+ if not api_key:
+ print("No GitHub token configured for GitHub Copilot.")
+ print()
+ print(" Supported token types:")
+ print(" โ OAuth token (gho_*) via `copilot login` or device code flow")
+ print(" โ Fine-grained PAT (github_pat_*) with Copilot Requests permission")
+ print(" โ GitHub App token (ghu_*) via environment variable")
+ print(" โ Classic PAT (ghp_*) NOT supported by Copilot API")
+ print()
+ print(" Options:")
+ print(" 1. Login with GitHub (OAuth device code flow)")
+ print(" 2. Enter a token manually")
+ print(" 3. Cancel")
+ print()
+ try:
+ choice = input(" Choice [1-3]: ").strip()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return
+
+ if choice == "1":
+ try:
+ from hermes_cli.copilot_auth import copilot_device_code_login
+ token = copilot_device_code_login()
+ if token:
+ save_env_value("COPILOT_GITHUB_TOKEN", token)
+ print(" Copilot token saved.")
+ print()
+ else:
+ print(" Login cancelled or failed.")
+ return
+ except Exception as exc:
+ print(f" Login failed: {exc}")
+ return
+ elif choice == "2":
+ try:
+ new_key = input(" Token (COPILOT_GITHUB_TOKEN): ").strip()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return
+ if not new_key:
+ print(" Cancelled.")
+ return
+ # Validate token type
+ try:
+ from hermes_cli.copilot_auth import validate_copilot_token
+ valid, msg = validate_copilot_token(new_key)
+ if not valid:
+ print(f" โ {msg}")
+ return
+ except ImportError:
+ pass
+ save_env_value("COPILOT_GITHUB_TOKEN", new_key)
+ print(" Token saved.")
+ print()
+ else:
+ print(" Cancelled.")
+ return
+
+ creds = resolve_api_key_provider_credentials(provider_id)
+ api_key = creds.get("api_key", "")
+ source = creds.get("source", "")
+ else:
+ if source in ("GITHUB_TOKEN", "GH_TOKEN"):
+ print(f" GitHub token: {api_key[:8]}... โ ({source})")
+ elif source == "gh auth token":
+ print(" GitHub token: โ (from `gh auth token`)")
+ else:
+ print(" GitHub token: โ")
+ print()
+
+ effective_base = pconfig.inference_base_url
+
+ catalog = fetch_github_model_catalog(api_key)
+ live_models = [item.get("id", "") for item in catalog if item.get("id")] if catalog else fetch_api_models(api_key, effective_base)
+ normalized_current_model = normalize_copilot_model_id(
+ current_model,
+ catalog=catalog,
+ api_key=api_key,
+ ) or current_model
+ if live_models:
+ model_list = [model_id for model_id in live_models if model_id]
+ print(f" Found {len(model_list)} model(s) from GitHub Copilot")
+ else:
+ model_list = _PROVIDER_MODELS.get(provider_id, [])
+ if model_list:
+ print(" โ Could not auto-detect models from GitHub Copilot โ showing defaults.")
+ print(' Use "Enter custom model name" if you do not see your model.')
+
+ if model_list:
+ selected = _prompt_model_selection(model_list, current_model=normalized_current_model)
+ else:
+ try:
+ selected = input("Model name: ").strip()
+ except (KeyboardInterrupt, EOFError):
+ selected = None
+
+ if selected:
+ selected = normalize_copilot_model_id(
+ selected,
+ catalog=catalog,
+ api_key=api_key,
+ ) or selected
+ # Clear stale custom-endpoint overrides so the Copilot provider wins cleanly.
+ if get_env_value("OPENAI_BASE_URL"):
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+
+ initial_cfg = load_config()
+ current_effort = _current_reasoning_effort(initial_cfg)
+ reasoning_efforts = github_model_reasoning_efforts(
+ selected,
+ catalog=catalog,
+ api_key=api_key,
+ )
+ selected_effort = None
+ if reasoning_efforts:
+ print(f" {selected} supports reasoning controls.")
+ selected_effort = _prompt_reasoning_effort_selection(
+ reasoning_efforts, current_effort=current_effort
+ )
+
+ _save_model_choice(selected)
+
+ cfg = load_config()
+ model = cfg.get("model")
+ if not isinstance(model, dict):
+ model = {"default": model} if model else {}
+ cfg["model"] = model
+ model["provider"] = provider_id
+ model["base_url"] = effective_base
+ model["api_mode"] = copilot_model_api_mode(
+ selected,
+ catalog=catalog,
+ api_key=api_key,
+ )
+ if selected_effort is not None:
+ _set_reasoning_effort(cfg, selected_effort)
+ save_config(cfg)
+ deactivate_provider()
+
+ print(f"Default model set to: {selected} (via {pconfig.name})")
+ if reasoning_efforts:
+ if selected_effort == "none":
+ print("Reasoning disabled for this model.")
+ elif selected_effort:
+ print(f"Reasoning effort set to: {selected_effort}")
+ else:
+ print("No change.")
+
+
+def _model_flow_copilot_acp(config, current_model=""):
+ """GitHub Copilot ACP flow using the local Copilot CLI."""
+ from hermes_cli.auth import (
+ PROVIDER_REGISTRY,
+ _prompt_model_selection,
+ _save_model_choice,
+ deactivate_provider,
+ get_external_process_provider_status,
+ resolve_api_key_provider_credentials,
+ resolve_external_process_provider_credentials,
+ )
+ from hermes_cli.models import (
+ fetch_github_model_catalog,
+ normalize_copilot_model_id,
+ )
+ from hermes_cli.config import load_config, save_config
+
+ del config
+
+ provider_id = "copilot-acp"
+ pconfig = PROVIDER_REGISTRY[provider_id]
+
+ status = get_external_process_provider_status(provider_id)
+ resolved_command = status.get("resolved_command") or status.get("command") or "copilot"
+ effective_base = status.get("base_url") or pconfig.inference_base_url
+
+ print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.")
+ print(" Hermes currently starts its own ACP subprocess for each request.")
+ print(" Hermes uses your selected model as a hint for the Copilot ACP session.")
+ print(f" Command: {resolved_command}")
+ print(f" Backend marker: {effective_base}")
+ print()
+
+ try:
+ creds = resolve_external_process_provider_credentials(provider_id)
+ except Exception as exc:
+ print(f" โ {exc}")
+ print(" Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere.")
+ return
+
+ effective_base = creds.get("base_url") or effective_base
+
+ catalog_api_key = ""
+ try:
+ catalog_creds = resolve_api_key_provider_credentials("copilot")
+ catalog_api_key = catalog_creds.get("api_key", "")
+ except Exception:
+ pass
+
+ catalog = fetch_github_model_catalog(catalog_api_key)
+ normalized_current_model = normalize_copilot_model_id(
+ current_model,
+ catalog=catalog,
+ api_key=catalog_api_key,
+ ) or current_model
+
+ if catalog:
+ model_list = [item.get("id", "") for item in catalog if item.get("id")]
+ print(f" Found {len(model_list)} model(s) from GitHub Copilot")
+ else:
+ model_list = _PROVIDER_MODELS.get("copilot", [])
+ if model_list:
+ print(" โ Could not auto-detect models from GitHub Copilot โ showing defaults.")
+ print(' Use "Enter custom model name" if you do not see your model.')
+
+ if model_list:
+ selected = _prompt_model_selection(
+ model_list,
+ current_model=normalized_current_model,
+ )
+ else:
+ try:
+ selected = input("Model name: ").strip()
+ except (KeyboardInterrupt, EOFError):
+ selected = None
+
+ if not selected:
+ print("No change.")
+ return
+
+ selected = normalize_copilot_model_id(
+ selected,
+ catalog=catalog,
+ api_key=catalog_api_key,
+ ) or selected
+ _save_model_choice(selected)
+
+ cfg = load_config()
+ model = cfg.get("model")
+ if not isinstance(model, dict):
+ model = {"default": model} if model else {}
+ cfg["model"] = model
+ model["provider"] = provider_id
+ model["base_url"] = effective_base
+ model["api_mode"] = "chat_completions"
+ save_config(cfg)
+ deactivate_provider()
+
+ print(f"Default model set to: {selected} (via {pconfig.name})")
+
+
def _model_flow_kimi(config, current_model=""):
"""Kimi / Moonshot model selection with automatic endpoint routing.
@@ -1439,8 +1959,8 @@ def _model_flow_kimi(config, current_model=""):
"kimi-k2-thinking-turbo",
]
else:
- # Legacy Moonshot models
- model_list = _PROVIDER_MODELS.get(provider_id, [])
+ # Legacy Moonshot models (excludes Coding Plan-only models)
+ model_list = _PROVIDER_MODELS.get("moonshot", [])
if model_list:
selected = _prompt_model_selection(model_list, current_model=current_model)
@@ -1479,7 +1999,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
"""Generic flow for API-key providers (z.ai, MiniMax)."""
from hermes_cli.auth import (
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
- _update_config_for_provider, deactivate_provider,
+ deactivate_provider,
)
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
@@ -1527,19 +2047,25 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
save_env_value(base_url_env, override)
effective_base = override
- # Model selection โ try live /models endpoint first, fall back to defaults
- from hermes_cli.models import fetch_api_models
- 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)
+ # Model selection โ try live /models endpoint first, fall back to defaults.
+ # Providers with large live catalogs (100+ models) use a curated list instead
+ # so users see familiar model names rather than an overwhelming dump.
+ curated = _PROVIDER_MODELS.get(provider_id, [])
+ if curated and len(curated) >= 8:
+ # Curated list is substantial โ use it directly, skip live probe
+ live_models = None
+ else:
+ from hermes_cli.models import fetch_api_models
+ 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:
model_list = live_models
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
else:
- model_list = _PROVIDER_MODELS.get(provider_id, [])
+ model_list = curated
if model_list:
- print(f" โ Could not auto-detect models from API โ showing defaults.")
- print(f" Use \"Enter custom model name\" if you don't see your model.")
+ print(f" Showing {len(model_list)} curated models โ use \"Enter custom model name\" for others.")
# else: no defaults either, will fall through to raw input
if model_list:
@@ -1574,24 +2100,112 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
print("No change.")
+def _run_anthropic_oauth_flow(save_env_value):
+ """Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
+ from agent.anthropic_adapter import (
+ run_oauth_setup_token,
+ read_claude_code_credentials,
+ is_claude_code_token_valid,
+ )
+ from hermes_cli.config import (
+ save_anthropic_oauth_token,
+ use_anthropic_claude_code_credentials,
+ )
+
+ def _activate_claude_code_credentials_if_available() -> bool:
+ try:
+ creds = read_claude_code_credentials()
+ except Exception:
+ creds = None
+ if creds and (
+ is_claude_code_token_valid(creds)
+ or bool(creds.get("refreshToken"))
+ ):
+ use_anthropic_claude_code_credentials(save_fn=save_env_value)
+ print(" โ Claude Code credentials linked.")
+ print(" Hermes will use Claude's credential store directly instead of copying a setup-token into ~/.hermes/.env.")
+ return True
+ return False
+
+ try:
+ print()
+ print(" Running 'claude setup-token' โ follow the prompts below.")
+ print(" A browser window will open for you to authorize access.")
+ print()
+ token = run_oauth_setup_token()
+ if token:
+ if _activate_claude_code_credentials_if_available():
+ return True
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
+ print(" โ OAuth credentials saved.")
+ return True
+
+ # Subprocess completed but no token auto-detected โ ask user to paste
+ print()
+ print(" If the setup-token was displayed above, paste it here:")
+ print()
+ try:
+ manual_token = input(" Paste setup-token (or Enter to cancel): ").strip()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return False
+ if manual_token:
+ save_anthropic_oauth_token(manual_token, save_fn=save_env_value)
+ print(" โ Setup-token saved.")
+ return True
+
+ print(" โ Could not detect saved credentials.")
+ return False
+
+ except FileNotFoundError:
+ # Claude CLI not installed โ guide user through manual setup
+ print()
+ print(" The 'claude' CLI is required for OAuth login.")
+ print()
+ print(" To install and authenticate:")
+ print()
+ print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
+ print(" 2. Run: claude setup-token")
+ print(" 3. Follow the browser prompts to authorize")
+ print(" 4. Re-run: hermes model")
+ print()
+ print(" Or paste an existing setup-token now (sk-ant-oat-...):")
+ print()
+ try:
+ token = input(" Setup-token (or Enter to cancel): ").strip()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return False
+ if token:
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
+ print(" โ Setup-token saved.")
+ return True
+ print(" Cancelled โ install Claude Code and try again.")
+ return False
+
+
def _model_flow_anthropic(config, current_model=""):
- """Flow for Anthropic provider โ setup-token, API key, or Claude Code creds."""
+ """Flow for Anthropic provider โ OAuth subscription, API key, or Claude Code creds."""
import os
from hermes_cli.auth import (
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
- _update_config_for_provider, deactivate_provider,
+ deactivate_provider,
+ )
+ from hermes_cli.config import (
+ get_env_value, save_env_value, load_config, save_config,
+ save_anthropic_api_key,
)
- from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
from hermes_cli.models import _PROVIDER_MODELS
pconfig = PROVIDER_REGISTRY["anthropic"]
- # Check for existing credentials
+ # Check ALL credential sources
existing_key = (
- get_env_value("ANTHROPIC_API_KEY")
- or os.getenv("ANTHROPIC_API_KEY", "")
- or get_env_value("ANTHROPIC_TOKEN")
+ get_env_value("ANTHROPIC_TOKEN")
or os.getenv("ANTHROPIC_TOKEN", "")
+ or get_env_value("ANTHROPIC_API_KEY")
+ or os.getenv("ANTHROPIC_API_KEY", "")
+ or os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
)
cc_available = False
try:
@@ -1602,27 +2216,37 @@ def _model_flow_anthropic(config, current_model=""):
except Exception:
pass
- if existing_key:
- print(f" Anthropic credentials: {existing_key[:12]}... โ")
+ has_creds = bool(existing_key) or cc_available
+ needs_auth = not has_creds
+
+ if has_creds:
+ # Show what we found
+ if existing_key:
+ print(f" Anthropic credentials: {existing_key[:12]}... โ")
+ elif cc_available:
+ print(" Claude Code credentials: โ (auto-detected)")
+ print()
+ print(" 1. Use existing credentials")
+ print(" 2. Reauthenticate (new OAuth login)")
+ print(" 3. Cancel")
print()
try:
- update = input("Update credentials? [y/N]: ").strip().lower()
+ choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
- update = ""
- if update != "y":
- pass # skip to model selection
- else:
- existing_key = "" # fall through to auth choice below
- elif cc_available:
- print(" Claude Code credentials: โ (auto-detected)")
- print()
-
- if not existing_key and not cc_available:
- # No credentials โ show auth method choice
+ choice = "1"
+
+ if choice == "2":
+ needs_auth = True
+ elif choice == "3":
+ return
+ # choice == "1" or default: use existing, proceed to model selection
+
+ if needs_auth:
+ # Show auth method choice
print()
print(" Choose authentication method:")
print()
- print(" 1. Claude Pro/Max subscription (setup-token)")
+ print(" 1. Claude Pro/Max subscription (OAuth login)")
print(" 2. Anthropic API key (pay-per-token)")
print(" 3. Cancel")
print()
@@ -1633,40 +2257,22 @@ def _model_flow_anthropic(config, current_model=""):
return
if choice == "1":
- print()
- print(" To get a setup-token from your Claude subscription:")
- print()
- print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
- print(" 2. Run: claude setup-token")
- print(" 3. Open the URL it prints in your browser")
- print(" 4. Log in and click \"Authorize\"")
- print(" 5. Paste the auth code back into Claude Code")
- print(" 6. Copy the resulting sk-ant-oat01-... token")
- print()
- try:
- token = input(" Paste setup-token here: ").strip()
- except (KeyboardInterrupt, EOFError):
- print()
- return
- if not token:
- print(" Cancelled.")
+ if not _run_anthropic_oauth_flow(save_env_value):
return
- save_env_value("ANTHROPIC_API_KEY", token)
- print(" โ Setup-token saved.")
elif choice == "2":
print()
print(" Get an API key at: https://console.anthropic.com/settings/keys")
print()
try:
- api_key = input(" API key (sk-ant-api03-...): ").strip()
+ api_key = input(" API key (sk-ant-...): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not api_key:
print(" Cancelled.")
return
- save_env_value("ANTHROPIC_API_KEY", api_key)
+ save_anthropic_api_key(api_key, save_fn=save_env_value)
print(" โ API key saved.")
else:
@@ -1692,14 +2298,17 @@ def _model_flow_anthropic(config, current_model=""):
_save_model_choice(selected)
- # Update config with provider
+ # Update config with provider โ clear base_url since
+ # resolve_runtime_provider() always hardcodes Anthropic's URL.
+ # Leaving a stale base_url in config can contaminate other
+ # providers if the user switches without running 'hermes model'.
cfg = load_config()
model = cfg.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["provider"] = "anthropic"
- model["base_url"] = pconfig.inference_base_url
+ model.pop("base_url", None)
save_config(cfg)
deactivate_provider()
@@ -1759,6 +2368,18 @@ def cmd_version(args):
except ImportError:
print("OpenAI SDK: Not installed")
+ # Show update status (synchronous โ acceptable since user asked for version info)
+ try:
+ from hermes_cli.banner import check_for_updates
+ 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 โ run 'hermes update'")
+ elif behind == 0:
+ print("Up to date")
+ except Exception:
+ pass
+
def cmd_uninstall(args):
"""Uninstall Hermes Agent."""
@@ -1788,6 +2409,12 @@ def _update_via_zip(args):
print("โ Extracting...")
with zipfile.ZipFile(zip_path, 'r') as zf:
+ # Validate paths to prevent zip-slip (path traversal)
+ tmp_dir_real = os.path.realpath(tmp_dir)
+ for member in zf.infolist():
+ member_path = os.path.realpath(os.path.join(tmp_dir, member.filename))
+ if not member_path.startswith(tmp_dir_real + os.sep) and member_path != tmp_dir_real:
+ raise ValueError(f"Zip-slip detected: {member.filename} escapes extraction directory")
zf.extractall(tmp_dir)
# GitHub ZIPs extract to hermes-agent-/
@@ -1825,20 +2452,33 @@ def _update_via_zip(args):
print(f"โ ZIP update failed: {e}")
sys.exit(1)
- # Reinstall Python dependencies
+ # Reinstall Python dependencies (try .[all] first for optional extras,
+ # fall back to . if extras fail โ mirrors the install script behavior)
print("โ Updating Python dependencies...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
- subprocess.run(
- [uv_bin, "pip", "install", "-e", ".", "--quiet"],
- cwd=PROJECT_ROOT, check=True,
- env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
- )
+ uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
+ try:
+ subprocess.run(
+ [uv_bin, "pip", "install", "-e", ".[all]", "--quiet"],
+ cwd=PROJECT_ROOT, check=True, env=uv_env,
+ )
+ except subprocess.CalledProcessError:
+ print(" โ Optional extras failed, installing base dependencies...")
+ subprocess.run(
+ [uv_bin, "pip", "install", "-e", ".", "--quiet"],
+ cwd=PROJECT_ROOT, check=True, env=uv_env,
+ )
else:
- venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
- if venv_pip.exists():
- subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
+ # Use sys.executable to explicitly call the venv's pip module,
+ # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu
+ pip_cmd = [sys.executable, "-m", "pip"]
+ try:
+ subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
+ except subprocess.CalledProcessError:
+ print(" โ Optional extras failed, installing base dependencies...")
+ subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
# Sync skills
try:
@@ -1862,9 +2502,184 @@ def _update_via_zip(args):
print("โ Update complete!")
+def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
+ status = subprocess.run(
+ git_cmd + ["status", "--porcelain"],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ if not status.stdout.strip():
+ return None
+
+ from datetime import datetime, timezone
+
+ stash_name = datetime.now(timezone.utc).strftime("hermes-update-autostash-%Y%m%d-%H%M%S")
+ print("โ Local changes detected โ stashing before update...")
+ subprocess.run(
+ git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name],
+ cwd=cwd,
+ check=True,
+ )
+ stash_ref = subprocess.run(
+ git_cmd + ["rev-parse", "--verify", "refs/stash"],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout.strip()
+ return stash_ref
+
+
+
+def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]:
+ stash_list = subprocess.run(
+ git_cmd + ["stash", "list", "--format=%gd %H"],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ for line in stash_list.stdout.splitlines():
+ selector, _, commit = line.partition(" ")
+ if commit.strip() == stash_ref:
+ return selector.strip()
+ return None
+
+
+
+def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None:
+ print(" Check `git status` first so you don't accidentally reapply the same change twice.")
+ print(" Find the saved entry with: git stash list --format='%gd %H %s'")
+ if stash_selector:
+ print(f" Remove it with: git stash drop {stash_selector}")
+ else:
+ print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}")
+
+
+
+def _restore_stashed_changes(
+ git_cmd: list[str],
+ cwd: Path,
+ stash_ref: str,
+ prompt_user: bool = False,
+) -> bool:
+ if prompt_user:
+ print()
+ print("โ Local changes were stashed before updating.")
+ print(" Restoring them may reapply local customizations onto the updated codebase.")
+ print(" Review the result afterward if Hermes behaves unexpectedly.")
+ print("Restore local changes now? [Y/n]")
+ response = input().strip().lower()
+ if response not in ("", "y", "yes"):
+ print("Skipped restoring local changes.")
+ print("Your changes are still preserved in git stash.")
+ print(f"Restore manually with: git stash apply {stash_ref}")
+ return False
+
+ print("โ Restoring local changes...")
+ restore = subprocess.run(
+ git_cmd + ["stash", "apply", stash_ref],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ )
+
+ # Check for unmerged (conflicted) files โ can happen even when returncode is 0
+ unmerged = subprocess.run(
+ git_cmd + ["diff", "--name-only", "--diff-filter=U"],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ )
+ has_conflicts = bool(unmerged.stdout.strip())
+
+ if restore.returncode != 0 or has_conflicts:
+ print("โ Update pulled new code, but restoring local changes hit conflicts.")
+ if restore.stdout.strip():
+ print(restore.stdout.strip())
+ if restore.stderr.strip():
+ print(restore.stderr.strip())
+
+ # Show which files conflicted
+ conflicted_files = unmerged.stdout.strip()
+ if conflicted_files:
+ print("\nConflicted files:")
+ for f in conflicted_files.splitlines():
+ print(f" โข {f}")
+
+ print("\nYour stashed changes are preserved โ nothing is lost.")
+ print(f" Stash ref: {stash_ref}")
+
+ # Ask before resetting (if interactive)
+ do_reset = True
+ if prompt_user:
+ print("\nReset working tree to clean state so Hermes can run?")
+ print(" (You can re-apply your changes later with: git stash apply)")
+ print("[Y/n] ", end="", flush=True)
+ response = input().strip().lower()
+ if response not in ("", "y", "yes"):
+ do_reset = False
+
+ if do_reset:
+ subprocess.run(
+ git_cmd + ["reset", "--hard", "HEAD"],
+ cwd=cwd,
+ capture_output=True,
+ )
+ print("Working tree reset to clean state.")
+ else:
+ print("Working tree left as-is (may have conflict markers).")
+ print("Resolve conflicts manually, then run: git stash drop")
+
+ print(f"Restore your changes with: git stash apply {stash_ref}")
+ # In non-interactive mode (gateway /update), don't abort โ the code
+ # update itself succeeded, only the stash restore had conflicts.
+ # Aborting would report the entire update as failed.
+ if prompt_user:
+ sys.exit(1)
+ return False
+
+ stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
+ if stash_selector is None:
+ print("โ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
+ print(" The stash was left in place. You can remove it manually after checking the result.")
+ _print_stash_cleanup_guidance(stash_ref)
+ else:
+ drop = subprocess.run(
+ git_cmd + ["stash", "drop", stash_selector],
+ cwd=cwd,
+ capture_output=True,
+ text=True,
+ )
+ if drop.returncode != 0:
+ print("โ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
+ if drop.stdout.strip():
+ print(drop.stdout.strip())
+ if drop.stderr.strip():
+ print(drop.stderr.strip())
+ print(" The stash was left in place. You can remove it manually after checking the result.")
+ _print_stash_cleanup_guidance(stash_ref, stash_selector)
+
+ print("โ Local changes were restored on top of the updated codebase.")
+ print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
+ return True
+
+def _invalidate_update_cache():
+ """Delete the update-check cache so ``hermes --version`` doesn't
+ report a stale "commits behind" count after a successful update."""
+ try:
+ cache_file = Path(os.getenv(
+ "HERMES_HOME", Path.home() / ".hermes"
+ )) / ".update_check"
+ if cache_file.exists():
+ cache_file.unlink()
+ except Exception:
+ pass
+
def cmd_update(args):
"""Update Hermes Agent to the latest version."""
- import subprocess
import shutil
print("โ Updating Hermes Agent...")
@@ -1898,56 +2713,159 @@ def cmd_update(args):
# Fetch and pull
try:
- print("โ Fetching updates...")
git_cmd = ["git"]
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
-
- subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
-
- # Get current branch
+
+ print("โ Fetching updates...")
+ fetch_result = subprocess.run(
+ git_cmd + ["fetch", "origin"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if fetch_result.returncode != 0:
+ stderr = fetch_result.stderr.strip()
+ if "Could not resolve host" in stderr or "unable to access" in stderr:
+ print("โ Network error โ cannot reach the remote repository.")
+ print(f" {stderr.splitlines()[0]}" if stderr else "")
+ elif "Authentication failed" in stderr or "could not read Username" in stderr:
+ print("โ Authentication failed โ check your git credentials or SSH key.")
+ else:
+ print(f"โ Failed to fetch updates from origin.")
+ if stderr:
+ print(f" {stderr.splitlines()[0]}")
+ sys.exit(1)
+
+ # Get current branch (returns literal "HEAD" when detached)
result = subprocess.run(
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
- check=True
+ check=True,
)
- branch = result.stdout.strip()
-
+ current_branch = result.stdout.strip()
+
+ # Always update against main
+ branch = "main"
+
+ # If user is on a non-main branch or detached HEAD, switch to main
+ if current_branch != "main":
+ label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'"
+ print(f" โ Currently on {label} โ switching to main for update...")
+ # Stash before checkout so uncommitted work isn't lost
+ auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
+ subprocess.run(
+ git_cmd + ["checkout", "main"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ else:
+ auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
+
+ prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
+
# Check if there are updates
result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
- check=True
+ check=True,
)
commit_count = int(result.stdout.strip())
-
+
if commit_count == 0:
+ _invalidate_update_cache()
+ # Restore stash and switch back to original branch if we moved
+ if auto_stash_ref is not None:
+ _restore_stashed_changes(
+ git_cmd, PROJECT_ROOT, auto_stash_ref,
+ prompt_user=prompt_for_restore,
+ )
+ if current_branch not in ("main", "HEAD"):
+ subprocess.run(
+ git_cmd + ["checkout", current_branch],
+ cwd=PROJECT_ROOT, capture_output=True, text=True, check=False,
+ )
print("โ Already up to date!")
return
-
+
print(f"โ Found {commit_count} new commit(s)")
+
print("โ Pulling updates...")
- subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
+ update_succeeded = False
+ try:
+ pull_result = subprocess.run(
+ git_cmd + ["pull", "--ff-only", "origin", branch],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if pull_result.returncode != 0:
+ # ff-only failed โ local and remote have diverged (e.g. upstream
+ # force-pushed or rebase). Since local changes are already
+ # stashed, reset to match the remote exactly.
+ print(" โ Fast-forward not possible (history diverged), resetting to match remote...")
+ reset_result = subprocess.run(
+ git_cmd + ["reset", "--hard", f"origin/{branch}"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if reset_result.returncode != 0:
+ print(f"โ Failed to reset to origin/{branch}.")
+ if reset_result.stderr.strip():
+ print(f" {reset_result.stderr.strip()}")
+ print(" Try manually: git fetch origin && git reset --hard origin/main")
+ sys.exit(1)
+ update_succeeded = True
+ finally:
+ if auto_stash_ref is not None:
+ # Don't attempt stash restore if the code update itself failed โ
+ # working tree is in an unknown state.
+ if not update_succeeded:
+ print(f" โน๏ธ Local changes preserved in stash (ref: {auto_stash_ref})")
+ print(f" Restore manually with: git stash apply")
+ else:
+ _restore_stashed_changes(
+ git_cmd,
+ PROJECT_ROOT,
+ auto_stash_ref,
+ prompt_user=prompt_for_restore,
+ )
+
+ _invalidate_update_cache()
- # Reinstall Python dependencies (prefer uv for speed, fall back to pip)
+ # Reinstall Python dependencies (try .[all] first for optional extras,
+ # fall back to . if extras fail โ mirrors the install script behavior)
print("โ Updating Python dependencies...")
uv_bin = shutil.which("uv")
if uv_bin:
- subprocess.run(
- [uv_bin, "pip", "install", "-e", ".", "--quiet"],
- cwd=PROJECT_ROOT, check=True,
- env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
- )
+ uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
+ try:
+ subprocess.run(
+ [uv_bin, "pip", "install", "-e", ".[all]", "--quiet"],
+ cwd=PROJECT_ROOT, check=True, env=uv_env,
+ )
+ except subprocess.CalledProcessError:
+ print(" โ Optional extras failed, installing base dependencies...")
+ subprocess.run(
+ [uv_bin, "pip", "install", "-e", ".", "--quiet"],
+ cwd=PROJECT_ROOT, check=True, env=uv_env,
+ )
else:
- venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
- if venv_pip.exists():
- subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
- else:
- subprocess.run(["pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
+ # Use sys.executable to explicitly call the venv's pip module,
+ # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu
+ pip_cmd = [sys.executable, "-m", "pip"]
+ try:
+ subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
+ except subprocess.CalledProcessError:
+ print(" โ Optional extras failed, installing base dependencies...")
+ subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
# Check for Node.js deps
if (PROJECT_ROOT / "package.json").exists():
@@ -2001,7 +2919,10 @@ def cmd_update(args):
print(f" โน๏ธ {len(missing_config)} new config option(s) available")
print()
- response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
+ if sys.stdin.isatty():
+ response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
+ else:
+ response = "n"
if response in ('', 'y', 'yes'):
print()
@@ -2019,26 +2940,121 @@ def cmd_update(args):
print()
print("โ Update complete!")
- # Auto-restart gateway if it's running as a systemd service
+ # Auto-restart gateway if it's running.
+ # Uses the PID file (scoped to HERMES_HOME) to find this
+ # installation's gateway โ safe with multiple installations.
try:
- check = subprocess.run(
- ["systemctl", "--user", "is-active", "hermes-gateway"],
- capture_output=True, text=True, timeout=5,
+ from gateway.status import get_running_pid, remove_pid_file
+ from hermes_cli.gateway import (
+ get_service_name, get_launchd_plist_path, is_macos, is_linux,
+ refresh_launchd_plist_if_needed,
+ _ensure_user_systemd_env, get_systemd_linger_status,
)
- if check.stdout.strip() == "active":
- print()
- print("โ Gateway service is running โ restarting to pick up changes...")
- restart = subprocess.run(
- ["systemctl", "--user", "restart", "hermes-gateway"],
- capture_output=True, text=True, timeout=15,
+ import signal as _signal
+
+ _gw_service_name = get_service_name()
+ existing_pid = get_running_pid()
+ has_systemd_service = False
+ has_launchd_service = False
+
+ try:
+ _ensure_user_systemd_env()
+ check = subprocess.run(
+ ["systemctl", "--user", "is-active", _gw_service_name],
+ capture_output=True, text=True, timeout=5,
)
- if restart.returncode == 0:
- print("โ Gateway restarted.")
- else:
- print(f"โ Gateway restart failed: {restart.stderr.strip()}")
- print(" Try manually: hermes gateway restart")
- except (FileNotFoundError, subprocess.TimeoutExpired):
- pass # No systemd (macOS, WSL1, etc.) โ skip silently
+ has_systemd_service = check.stdout.strip() == "active"
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+
+ # Check for macOS launchd service
+ if is_macos():
+ try:
+ plist_path = get_launchd_plist_path()
+ if plist_path.exists():
+ check = subprocess.run(
+ ["launchctl", "list", "ai.hermes.gateway"],
+ capture_output=True, text=True, timeout=5,
+ )
+ has_launchd_service = check.returncode == 0
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ pass
+
+ if existing_pid or has_systemd_service or has_launchd_service:
+ print()
+
+ # When a service manager is handling the gateway, let it
+ # manage the lifecycle โ don't manually SIGTERM the PID
+ # (launchd KeepAlive would respawn immediately, causing races).
+ if has_systemd_service:
+ import time as _time
+ if existing_pid:
+ try:
+ os.kill(existing_pid, _signal.SIGTERM)
+ print(f"โ Stopped gateway process (PID {existing_pid})")
+ except ProcessLookupError:
+ pass
+ except PermissionError:
+ print(f"โ Permission denied killing gateway PID {existing_pid}")
+ remove_pid_file()
+ _time.sleep(1) # Brief pause for port/socket release
+ print("โ Restarting gateway service...")
+ restart = subprocess.run(
+ ["systemctl", "--user", "restart", _gw_service_name],
+ capture_output=True, text=True, timeout=15,
+ )
+ if restart.returncode == 0:
+ print("โ Gateway restarted.")
+ else:
+ print(f"โ Gateway restart failed: {restart.stderr.strip()}")
+ # Check if linger is the issue
+ if is_linux():
+ linger_ok, _detail = get_systemd_linger_status()
+ if linger_ok is not True:
+ import getpass
+ _username = getpass.getuser()
+ print()
+ print(" Linger must be enabled for the gateway user service to function.")
+ print(f" Run: sudo loginctl enable-linger {_username}")
+ print()
+ print(" Then restart the gateway:")
+ print(" hermes gateway restart")
+ else:
+ print(" Try manually: hermes gateway restart")
+ elif has_launchd_service:
+ # Refresh the plist first (picks up --replace and other
+ # changes from the update we just pulled).
+ refresh_launchd_plist_if_needed()
+ # Explicit stop+start โ don't rely on KeepAlive respawn
+ # after a manual SIGTERM, which would race with the
+ # PID file cleanup.
+ print("โ Restarting gateway service...")
+ stop = subprocess.run(
+ ["launchctl", "stop", "ai.hermes.gateway"],
+ capture_output=True, text=True, timeout=10,
+ )
+ start = subprocess.run(
+ ["launchctl", "start", "ai.hermes.gateway"],
+ capture_output=True, text=True, timeout=10,
+ )
+ if start.returncode == 0:
+ print("โ Gateway restarted via launchd.")
+ else:
+ print(f"โ Gateway restart failed: {start.stderr.strip()}")
+ print(" Try manually: hermes gateway restart")
+ elif existing_pid:
+ try:
+ os.kill(existing_pid, _signal.SIGTERM)
+ print(f"โ Stopped gateway process (PID {existing_pid})")
+ except ProcessLookupError:
+ pass # Already gone
+ except PermissionError:
+ print(f"โ Permission denied killing gateway PID {existing_pid}")
+ remove_pid_file()
+ print(" โน๏ธ Gateway was running manually (not as a service).")
+ print(" Restart it with: hermes gateway run")
+ except Exception as e:
+ logger.debug("Gateway restart during update failed: %s", e)
print()
print("Tip: You can now select a provider and model:")
@@ -2069,7 +3085,7 @@ def _coalesce_session_name_args(argv: list) -> list:
_SUBCOMMANDS = {
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
- "sessions", "insights", "version", "update", "uninstall",
+ "mcp", "sessions", "insights", "version", "update", "uninstall",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -2113,8 +3129,9 @@ def main():
hermes config edit Edit config in $EDITOR
hermes config set model gpt-4 Set a config value
hermes gateway Run messaging gateway
+ hermes -s hermes-agent-dev,github-auth
hermes -w Start in isolated git worktree
- hermes gateway install Install as system service
+ hermes gateway install Install gateway background service
hermes sessions list List past sessions
hermes sessions browse Interactive session picker
hermes sessions rename ID T Rename/title a session
@@ -2151,6 +3168,12 @@ def main():
default=False,
help="Run in an isolated git worktree (for parallel agents)"
)
+ parser.add_argument(
+ "--skills", "-s",
+ action="append",
+ default=None,
+ help="Preload one or more skills for the session (repeat flag or comma-separate)"
+ )
parser.add_argument(
"--yolo",
action="store_true",
@@ -2186,9 +3209,15 @@ def main():
"-t", "--toolsets",
help="Comma-separated toolsets to enable"
)
+ chat_parser.add_argument(
+ "-s", "--skills",
+ action="append",
+ default=None,
+ help="Preload one or more skills for the session (repeat flag or comma-separate)"
+ )
chat_parser.add_argument(
"--provider",
- choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn"],
+ choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "xgate", "kilocode"],
default=None,
help="Inference provider (default: auto)"
)
@@ -2240,6 +3269,11 @@ def main():
default=False,
help="Include the session ID in the agent's system prompt"
)
+ chat_parser.add_argument(
+ "--source",
+ default=None,
+ help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists."
+ )
chat_parser.set_defaults(func=cmd_chat)
# =========================================================================
@@ -2270,23 +3304,30 @@ def main():
# gateway start
gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service")
+ gateway_start.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway stop
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
+ gateway_stop.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway restart
gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service")
+ gateway_restart.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway status
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
gateway_status.add_argument("--deep", action="store_true", help="Deep status check")
+ gateway_status.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway install
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service")
gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
+ gateway_install.add_argument("--system", action="store_true", help="Install as a Linux system-level service (starts at boot)")
+ gateway_install.add_argument("--run-as-user", dest="run_as_user", help="User account the Linux system service should run as")
# gateway uninstall
gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service")
+ gateway_uninstall.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway setup
gateway_setup = gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
@@ -2434,13 +3475,48 @@ def main():
# cron list
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
cron_list.add_argument("--all", action="store_true", help="Include disabled jobs")
-
+
+ # cron create/add
+ cron_create = cron_subparsers.add_parser("create", aliases=["add"], help="Create a scheduled job")
+ cron_create.add_argument("schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'")
+ cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction")
+ cron_create.add_argument("--name", help="Optional human-friendly job name")
+ cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
+ cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
+ cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
+
+ # cron edit
+ cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job")
+ cron_edit.add_argument("job_id", help="Job ID to edit")
+ cron_edit.add_argument("--schedule", help="New schedule")
+ cron_edit.add_argument("--prompt", help="New prompt/task instruction")
+ cron_edit.add_argument("--name", help="New job name")
+ cron_edit.add_argument("--deliver", help="New delivery target")
+ cron_edit.add_argument("--repeat", type=int, help="New repeat count")
+ cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.")
+ cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
+ cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
+ cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
+
+ # lifecycle actions
+ cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
+ cron_pause.add_argument("job_id", help="Job ID to pause")
+
+ cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job")
+ cron_resume.add_argument("job_id", help="Job ID to resume")
+
+ cron_run = cron_subparsers.add_parser("run", help="Run a job on the next scheduler tick")
+ cron_run.add_argument("job_id", help="Job ID to trigger")
+
+ cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job")
+ cron_remove.add_argument("job_id", help="Job ID to remove")
+
# cron status
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
-
+
# cron tick (mostly for debugging)
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
-
+
cron_parser.set_defaults(func=cmd_cron)
# =========================================================================
@@ -2527,7 +3603,7 @@ def cmd_pairing(args):
skills_parser = subparsers.add_parser(
"skills",
help="Search, install, configure, and manage skills",
- description="Search, install, inspect, audit, configure, and manage skills from GitHub, ClawHub, and other registries."
+ description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries."
)
skills_subparsers = skills_parser.add_subparsers(dest="skills_action")
@@ -2535,18 +3611,19 @@ def cmd_pairing(args):
skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)")
skills_browse.add_argument("--source", default="all",
- choices=["all", "official", "github", "clawhub", "lobehub"],
+ choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"],
help="Filter by source (default: all)")
skills_search = skills_subparsers.add_parser("search", help="Search skill registries")
skills_search.add_argument("query", help="Search query")
- skills_search.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"])
+ skills_search.add_argument("--source", default="all", choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"])
skills_search.add_argument("--limit", type=int, default=10, help="Max results")
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
skills_install.add_argument("identifier", help="Skill identifier (e.g. openai/skills/skill-creator)")
skills_install.add_argument("--category", default="", help="Category folder to install into")
- skills_install.add_argument("--force", action="store_true", help="Install despite caution verdict")
+ skills_install.add_argument("--force", action="store_true", help="Install despite blocked scan verdict")
+ skills_install.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt (needed in TUI mode)")
skills_inspect = skills_subparsers.add_parser("inspect", help="Preview a skill without installing")
skills_inspect.add_argument("identifier", help="Skill identifier")
@@ -2554,6 +3631,12 @@ def cmd_pairing(args):
skills_list = skills_subparsers.add_parser("list", help="List installed skills")
skills_list.add_argument("--source", default="all", choices=["all", "hub", "builtin", "local"])
+ skills_check = skills_subparsers.add_parser("check", help="Check installed hub skills for updates")
+ skills_check.add_argument("name", nargs="?", help="Specific skill to check (default: all)")
+
+ skills_update = skills_subparsers.add_parser("update", help="Update installed hub skills")
+ skills_update.add_argument("name", nargs="?", help="Specific skill to update (default: all outdated skills)")
+
skills_audit = skills_subparsers.add_parser("audit", help="Re-scan installed hub skills")
skills_audit.add_argument("name", nargs="?", help="Specific skill to audit (default: all)")
@@ -2595,25 +3678,241 @@ def cmd_skills(args):
skills_parser.set_defaults(func=cmd_skills)
+ # =========================================================================
+ # plugins command
+ # =========================================================================
+ plugins_parser = subparsers.add_parser(
+ "plugins",
+ help="Manage plugins โ install, update, remove, list",
+ description="Install plugins from Git repositories, update, remove, or list them.",
+ )
+ plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
+
+ plugins_install = plugins_subparsers.add_parser(
+ "install", help="Install a plugin from a Git URL or owner/repo"
+ )
+ plugins_install.add_argument(
+ "identifier",
+ help="Git URL or owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)",
+ )
+ plugins_install.add_argument(
+ "--force", "-f", action="store_true",
+ help="Remove existing plugin and reinstall",
+ )
+
+ plugins_update = plugins_subparsers.add_parser(
+ "update", help="Pull latest changes for an installed plugin"
+ )
+ plugins_update.add_argument("name", help="Plugin name to update")
+
+ plugins_remove = plugins_subparsers.add_parser(
+ "remove", aliases=["rm", "uninstall"], help="Remove an installed plugin"
+ )
+ plugins_remove.add_argument("name", help="Plugin directory name to remove")
+
+ plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
+
+ def cmd_plugins(args):
+ from hermes_cli.plugins_cmd import plugins_command
+ plugins_command(args)
+
+ plugins_parser.set_defaults(func=cmd_plugins)
+
+ # =========================================================================
+ # honcho command
+ # =========================================================================
+ honcho_parser = subparsers.add_parser(
+ "honcho",
+ help="Manage Honcho AI memory integration",
+ description=(
+ "Honcho is a memory layer that persists across sessions.\n\n"
+ "Each conversation is stored as a peer interaction in a workspace. "
+ "Honcho builds a representation of the user over time โ conclusions, "
+ "patterns, context โ and surfaces the relevant slice at the start of "
+ "each turn so Hermes knows who you are without you having to repeat yourself.\n\n"
+ "Modes: hybrid (Honcho + local MEMORY.md), honcho (Honcho only), "
+ "local (MEMORY.md only). Write frequency is configurable so memory "
+ "writes never block the response."
+ ),
+ formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
+ )
+ honcho_subparsers = honcho_parser.add_subparsers(dest="honcho_command")
+
+ honcho_subparsers.add_parser("setup", help="Interactive setup wizard for Honcho integration")
+ honcho_subparsers.add_parser("status", help="Show current Honcho config and connection status")
+ honcho_subparsers.add_parser("sessions", help="List known Honcho session mappings")
+
+ honcho_map = honcho_subparsers.add_parser(
+ "map", help="Map current directory to a Honcho session name (no arg = list mappings)"
+ )
+ honcho_map.add_argument(
+ "session_name", nargs="?", default=None,
+ help="Session name to associate with this directory. Omit to list current mappings.",
+ )
+
+ honcho_peer = honcho_subparsers.add_parser(
+ "peer", help="Show or update peer names and dialectic reasoning level"
+ )
+ honcho_peer.add_argument("--user", metavar="NAME", help="Set user peer name")
+ honcho_peer.add_argument("--ai", metavar="NAME", help="Set AI peer name")
+ honcho_peer.add_argument(
+ "--reasoning",
+ metavar="LEVEL",
+ choices=("minimal", "low", "medium", "high", "max"),
+ help="Set default dialectic reasoning level (minimal/low/medium/high/max)",
+ )
+
+ honcho_mode = honcho_subparsers.add_parser(
+ "mode", help="Show or set memory mode (hybrid/honcho/local)"
+ )
+ honcho_mode.add_argument(
+ "mode", nargs="?", metavar="MODE",
+ choices=("hybrid", "honcho", "local"),
+ help="Memory mode to set (hybrid/honcho/local). Omit to show current.",
+ )
+
+ honcho_tokens = honcho_subparsers.add_parser(
+ "tokens", help="Show or set token budget for context and dialectic"
+ )
+ honcho_tokens.add_argument(
+ "--context", type=int, metavar="N",
+ help="Max tokens Honcho returns from session.context() per turn",
+ )
+ honcho_tokens.add_argument(
+ "--dialectic", type=int, metavar="N",
+ help="Max chars of dialectic result to inject into system prompt",
+ )
+
+ honcho_identity = honcho_subparsers.add_parser(
+ "identity", help="Seed or show the AI peer's Honcho identity representation"
+ )
+ honcho_identity.add_argument(
+ "file", nargs="?", default=None,
+ help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.",
+ )
+ honcho_identity.add_argument(
+ "--show", action="store_true",
+ help="Show current AI peer representation from Honcho",
+ )
+
+ honcho_subparsers.add_parser(
+ "migrate",
+ help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho",
+ )
+
+ def cmd_honcho(args):
+ from honcho_integration.cli import honcho_command
+ honcho_command(args)
+
+ honcho_parser.set_defaults(func=cmd_honcho)
+
# =========================================================================
# tools command
# =========================================================================
tools_parser = subparsers.add_parser(
"tools",
help="Configure which tools are enabled per platform",
- description="Interactive tool configuration โ enable/disable tools for CLI, Telegram, Discord, etc."
+ description=(
+ "Enable, disable, or list tools for CLI, Telegram, Discord, etc.\n\n"
+ "Built-in toolsets use plain names (e.g. web, memory).\n"
+ "MCP tools use server:tool notation (e.g. github:create_issue).\n\n"
+ "Run 'hermes tools' with no subcommand for the interactive configuration UI."
+ ),
)
tools_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary of enabled tools per platform and exit"
)
+ tools_sub = tools_parser.add_subparsers(dest="tools_action")
+
+ # hermes tools list [--platform cli]
+ tools_list_p = tools_sub.add_parser(
+ "list",
+ help="Show all tools and their enabled/disabled status",
+ )
+ tools_list_p.add_argument(
+ "--platform", default="cli",
+ help="Platform to show (default: cli)",
+ )
+
+ # hermes tools disable [--platform cli]
+ tools_disable_p = tools_sub.add_parser(
+ "disable",
+ help="Disable toolsets or MCP tools",
+ )
+ tools_disable_p.add_argument(
+ "names", nargs="+", metavar="NAME",
+ help="Toolset name (e.g. web) or MCP tool in server:tool form",
+ )
+ tools_disable_p.add_argument(
+ "--platform", default="cli",
+ help="Platform to apply to (default: cli)",
+ )
+
+ # hermes tools enable [--platform cli]
+ tools_enable_p = tools_sub.add_parser(
+ "enable",
+ help="Enable toolsets or MCP tools",
+ )
+ tools_enable_p.add_argument(
+ "names", nargs="+", metavar="NAME",
+ help="Toolset name or MCP tool in server:tool form",
+ )
+ tools_enable_p.add_argument(
+ "--platform", default="cli",
+ help="Platform to apply to (default: cli)",
+ )
def cmd_tools(args):
- from hermes_cli.tools_config import tools_command
- tools_command(args)
+ action = getattr(args, "tools_action", None)
+ if action in ("list", "disable", "enable"):
+ from hermes_cli.tools_config import tools_disable_enable_command
+ tools_disable_enable_command(args)
+ else:
+ from hermes_cli.tools_config import tools_command
+ tools_command(args)
tools_parser.set_defaults(func=cmd_tools)
+ # =========================================================================
+ # mcp command โ manage MCP server connections
+ # =========================================================================
+ mcp_parser = subparsers.add_parser(
+ "mcp",
+ help="Manage MCP server connections",
+ description=(
+ "Add, remove, list, test, and configure MCP server connections.\n\n"
+ "MCP servers provide additional tools via the Model Context Protocol.\n"
+ "Use 'hermes mcp add' to connect to a new server with interactive\n"
+ "tool discovery. Run 'hermes mcp' with no subcommand to list servers."
+ ),
+ )
+ mcp_sub = mcp_parser.add_subparsers(dest="mcp_action")
+
+ mcp_add_p = mcp_sub.add_parser("add", help="Add an MCP server (discovery-first install)")
+ mcp_add_p.add_argument("name", help="Server name (used as config key)")
+ mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL")
+ mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)")
+ mcp_add_p.add_argument("--args", nargs="*", default=[], help="Arguments for stdio command")
+ mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
+
+ mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server")
+ mcp_rm_p.add_argument("name", help="Server name to remove")
+
+ mcp_sub.add_parser("list", aliases=["ls"], help="List configured MCP servers")
+
+ mcp_test_p = mcp_sub.add_parser("test", help="Test MCP server connection")
+ mcp_test_p.add_argument("name", help="Server name to test")
+
+ mcp_cfg_p = mcp_sub.add_parser("configure", aliases=["config"], help="Toggle tool selection")
+ mcp_cfg_p.add_argument("name", help="Server name to configure")
+
+ def cmd_mcp(args):
+ from hermes_cli.mcp_config import mcp_command
+ mcp_command(args)
+
+ mcp_parser.set_defaults(func=cmd_mcp)
+
# =========================================================================
# sessions command
# =========================================================================
@@ -2655,6 +3954,13 @@ def cmd_tools(args):
sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)")
+ def _confirm_prompt(prompt: str) -> bool:
+ """Prompt for y/N confirmation, safe against non-TTY environments."""
+ try:
+ return input(prompt).strip().lower() in ("y", "yes")
+ except (EOFError, KeyboardInterrupt):
+ return False
+
def cmd_sessions(args):
import json as _json
try:
@@ -2666,56 +3972,40 @@ def cmd_sessions(args):
action = args.sessions_action
+ # Hide third-party tool sessions by default, but honour explicit --source
+ _source = getattr(args, "source", None)
+ _exclude = None if _source else ["tool"]
+
if action == "list":
- sessions = db.list_sessions_rich(source=args.source, limit=args.limit)
+ sessions = db.list_sessions_rich(source=args.source, exclude_sources=_exclude, limit=args.limit)
if not sessions:
print("No sessions found.")
return
- from datetime import datetime
- import time as _time
-
- def _relative_time(ts):
- """Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
- if not ts:
- return "?"
- delta = _time.time() - ts
- if delta < 60:
- return "just now"
- elif delta < 3600:
- mins = int(delta / 60)
- return f"{mins}m ago"
- elif delta < 86400:
- hours = int(delta / 3600)
- return f"{hours}h ago"
- elif delta < 172800:
- return "yesterday"
- elif delta < 604800:
- days = int(delta / 86400)
- return f"{days}d ago"
- else:
- return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
-
has_titles = any(s.get("title") for s in sessions)
if has_titles:
- print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
- print("โ" * 100)
+ print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
+ print("โ" * 110)
else:
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
- print("โ" * 90)
+ print("โ" * 95)
for s in sessions:
last_active = _relative_time(s.get("last_active"))
preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48]
if has_titles:
- title = (s.get("title") or "โ")[:20]
- sid = s["id"][:20]
- print(f"{title:<22} {preview:<40} {last_active:<13} {sid}")
+ title = (s.get("title") or "โ")[:30]
+ sid = s["id"]
+ print(f"{title:<32} {preview:<40} {last_active:<13} {sid}")
else:
- sid = s["id"][:20]
+ sid = s["id"]
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
elif action == "export":
if args.session_id:
- data = db.export_session(args.session_id)
+ resolved_session_id = db.resolve_session_id(args.session_id)
+ if not resolved_session_id:
+ print(f"Session '{args.session_id}' not found.")
+ return
+ data = db.export_session(resolved_session_id)
if not data:
print(f"Session '{args.session_id}' not found.")
return
@@ -2730,13 +4020,16 @@ def _relative_time(ts):
print(f"Exported {len(sessions)} sessions to {args.output}")
elif action == "delete":
+ resolved_session_id = db.resolve_session_id(args.session_id)
+ if not resolved_session_id:
+ print(f"Session '{args.session_id}' not found.")
+ return
if not args.yes:
- confirm = input(f"Delete session '{args.session_id}' and all its messages? [y/N] ")
- if confirm.lower() not in ("y", "yes"):
+ if not _confirm_prompt(f"Delete session '{resolved_session_id}' and all its messages? [y/N] "):
print("Cancelled.")
return
- if db.delete_session(args.session_id):
- print(f"Deleted session '{args.session_id}'.")
+ if db.delete_session(resolved_session_id):
+ print(f"Deleted session '{resolved_session_id}'.")
else:
print(f"Session '{args.session_id}' not found.")
@@ -2744,18 +4037,21 @@ def _relative_time(ts):
days = args.older_than
source_msg = f" from '{args.source}'" if args.source else ""
if not args.yes:
- confirm = input(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] ")
- if confirm.lower() not in ("y", "yes"):
+ if not _confirm_prompt(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] "):
print("Cancelled.")
return
count = db.prune_sessions(older_than_days=days, source=args.source)
print(f"Pruned {count} session(s).")
elif action == "rename":
+ resolved_session_id = db.resolve_session_id(args.session_id)
+ if not resolved_session_id:
+ print(f"Session '{args.session_id}' not found.")
+ return
title = " ".join(args.title)
try:
- if db.set_session_title(args.session_id, title):
- print(f"Session '{args.session_id}' renamed to: {title}")
+ if db.set_session_title(resolved_session_id, title):
+ print(f"Session '{resolved_session_id}' renamed to: {title}")
else:
print(f"Session '{args.session_id}' not found.")
except ValueError as e:
@@ -2764,7 +4060,8 @@ def _relative_time(ts):
elif action == "browse":
limit = getattr(args, "limit", 50) or 50
source = getattr(args, "source", None)
- sessions = db.list_sessions_rich(source=source, limit=limit)
+ _browse_exclude = None if source else ["tool"]
+ sessions = db.list_sessions_rich(source=source, exclude_sources=_browse_exclude, limit=limit)
db.close()
if not sessions:
print("No sessions found.")
@@ -2937,6 +4234,27 @@ def cmd_claw(args):
help="Skip confirmation prompts"
)
uninstall_parser.set_defaults(func=cmd_uninstall)
+
+ # =========================================================================
+ # acp command
+ # =========================================================================
+ acp_parser = subparsers.add_parser(
+ "acp",
+ help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
+ description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
+ )
+
+ def cmd_acp(args):
+ """Launch Hermes Agent as an ACP server."""
+ try:
+ from acp_adapter.entry import main as acp_main
+ acp_main()
+ except ImportError:
+ print("ACP dependencies not installed.")
+ print("Install them with: pip install -e '.[acp]'")
+ sys.exit(1)
+
+ acp_parser.set_defaults(func=cmd_acp)
# =========================================================================
# Parse and execute
diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py
new file mode 100644
index 00000000000..025bfd627c4
--- /dev/null
+++ b/hermes_cli/mcp_config.py
@@ -0,0 +1,634 @@
+"""
+MCP Server Management CLI โ ``hermes mcp`` subcommand.
+
+Implements ``hermes mcp add/remove/list/test/configure`` for interactive
+MCP server lifecycle management (issue #690 Phase 2).
+
+Relies on tools/mcp_tool.py for connection/discovery and keeps
+configuration in ~/.hermes/config.yaml under the ``mcp_servers`` key.
+"""
+
+import asyncio
+import getpass
+import logging
+import os
+import re
+import time
+from typing import Any, Dict, List, Optional, Tuple
+
+from hermes_cli.config import (
+ load_config,
+ save_config,
+ get_env_value,
+ save_env_value,
+ get_hermes_home, # noqa: F401 โ used by test mocks
+)
+from hermes_cli.colors import Colors, color
+
+logger = logging.getLogger(__name__)
+
+
+# โโโ UI Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _info(text: str):
+ print(color(f" {text}", Colors.DIM))
+
+def _success(text: str):
+ print(color(f" โ {text}", Colors.GREEN))
+
+def _warning(text: str):
+ print(color(f" โ {text}", Colors.YELLOW))
+
+def _error(text: str):
+ print(color(f" โ {text}", Colors.RED))
+
+
+def _confirm(question: str, default: bool = True) -> bool:
+ default_str = "Y/n" if default else "y/N"
+ try:
+ val = input(color(f" {question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return default
+ if not val:
+ return default
+ return val in ("y", "yes")
+
+
+def _prompt(question: str, *, password: bool = False, default: str = "") -> str:
+ display = f" {question}"
+ if default:
+ display += f" [{default}]"
+ display += ": "
+ try:
+ if password:
+ value = getpass.getpass(color(display, Colors.YELLOW))
+ else:
+ value = input(color(display, Colors.YELLOW))
+ return value.strip() or default
+ except (KeyboardInterrupt, EOFError):
+ print()
+ return default
+
+
+# โโโ Config Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
+ """Return the ``mcp_servers`` dict from config, or empty dict."""
+ if config is None:
+ config = load_config()
+ servers = config.get("mcp_servers")
+ if not servers or not isinstance(servers, dict):
+ return {}
+ return servers
+
+
+def _save_mcp_server(name: str, server_config: dict):
+ """Add or update a server entry in config.yaml."""
+ config = load_config()
+ config.setdefault("mcp_servers", {})[name] = server_config
+ save_config(config)
+
+
+def _remove_mcp_server(name: str) -> bool:
+ """Remove a server from config.yaml. Returns True if it existed."""
+ config = load_config()
+ servers = config.get("mcp_servers", {})
+ if name not in servers:
+ return False
+ del servers[name]
+ if not servers:
+ config.pop("mcp_servers", None)
+ save_config(config)
+ return True
+
+
+def _env_key_for_server(name: str) -> str:
+ """Convert server name to an env-var key like ``MCP_MYSERVER_API_KEY``."""
+ return f"MCP_{name.upper().replace('-', '_')}_API_KEY"
+
+
+# โโโ Discovery (temporary connect) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def _probe_single_server(
+ name: str, config: dict, connect_timeout: float = 30
+) -> List[Tuple[str, str]]:
+ """Temporarily connect to one MCP server, list its tools, disconnect.
+
+ Returns list of ``(tool_name, description)`` tuples.
+ Raises on connection failure.
+ """
+ from tools.mcp_tool import (
+ _ensure_mcp_loop,
+ _run_on_mcp_loop,
+ _connect_server,
+ _stop_mcp_loop,
+ )
+
+ _ensure_mcp_loop()
+
+ tools_found: List[Tuple[str, str]] = []
+
+ async def _probe():
+ server = await asyncio.wait_for(
+ _connect_server(name, config), timeout=connect_timeout
+ )
+ for t in server._tools:
+ desc = getattr(t, "description", "") or ""
+ # Truncate long descriptions for display
+ if len(desc) > 80:
+ desc = desc[:77] + "..."
+ tools_found.append((t.name, desc))
+ await server.shutdown()
+
+ try:
+ _run_on_mcp_loop(_probe(), timeout=connect_timeout + 10)
+ except BaseException as exc:
+ raise _unwrap_exception_group(exc) from None
+ finally:
+ _stop_mcp_loop()
+
+ return tools_found
+
+
+def _unwrap_exception_group(exc: BaseException) -> Exception:
+ """Extract the root-cause exception from anyio TaskGroup wrappers.
+
+ The MCP SDK uses anyio task groups, which wrap errors in
+ ``BaseExceptionGroup`` / ``ExceptionGroup``. This makes error
+ messages opaque ("unhandled errors in a TaskGroup"). We unwrap
+ to surface the real cause (e.g. "401 Unauthorized").
+ """
+ while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
+ exc = exc.exceptions[0]
+ # Return a plain Exception so callers can catch normally
+ if isinstance(exc, Exception):
+ return exc
+ return RuntimeError(str(exc))
+
+
+# โโโ hermes mcp add โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def cmd_mcp_add(args):
+ """Add a new MCP server with discovery-first tool selection."""
+ name = args.name
+ url = getattr(args, "url", None)
+ command = getattr(args, "command", None)
+ cmd_args = getattr(args, "args", None) or []
+ auth_type = getattr(args, "auth", None)
+
+ # Validate transport
+ if not url and not command:
+ _error("Must specify --url or --command ")
+ _info("Examples:")
+ _info(' hermes mcp add ink --url "https://mcp.ml.ink/mcp"')
+ _info(' hermes mcp add github --command npx --args @modelcontextprotocol/server-github')
+ return
+
+ # Check if server already exists
+ existing = _get_mcp_servers()
+ if name in existing:
+ if not _confirm(f"Server '{name}' already exists. Overwrite?", default=False):
+ _info("Cancelled.")
+ return
+
+ # Build initial config
+ server_config: Dict[str, Any] = {}
+ if url:
+ server_config["url"] = url
+ else:
+ server_config["command"] = command
+ if cmd_args:
+ server_config["args"] = cmd_args
+
+ # โโ Authentication โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ if url and auth_type == "oauth":
+ print()
+ _info(f"Starting OAuth flow for '{name}'...")
+ oauth_ok = False
+ try:
+ from tools.mcp_oauth import build_oauth_auth
+ oauth_auth = build_oauth_auth(name, url)
+ if oauth_auth:
+ server_config["auth"] = "oauth"
+ _success("OAuth configured (tokens will be acquired on first connection)")
+ oauth_ok=True
+ else:
+ _warning("OAuth setup failed โ MCP SDK auth module not available")
+ except Exception as exc:
+ _warning(f"OAuth error: {exc}")
+
+ if not oauth_ok:
+ _info("This server may not support OAuth.")
+ if _confirm("Continue without authentication?", default=True):
+ # Don't store auth: oauth โ server doesn't support it
+ pass
+ else:
+ _info("Cancelled.")
+ return
+
+ elif url:
+ # Prompt for API key / Bearer token for HTTP servers
+ print()
+ _info(f"Connecting to {url}")
+ needs_auth = _confirm("Does this server require authentication?", default=True)
+ if needs_auth:
+ if auth_type == "header" or not auth_type:
+ env_key = _env_key_for_server(name)
+ existing_key = get_env_value(env_key)
+ if existing_key:
+ _success(f"{env_key}: already configured")
+ api_key = existing_key
+ else:
+ api_key = _prompt("API key / Bearer token", password=True)
+ if api_key:
+ save_env_value(env_key, api_key)
+ _success(f"Saved to ~/.hermes/.env as {env_key}")
+
+ # Set header with env var interpolation
+ if api_key or existing_key:
+ server_config["headers"] = {
+ "Authorization": f"Bearer ${{{env_key}}}"
+ }
+
+ # โโ Discovery: connect and list tools โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ print()
+ print(color(f" Connecting to '{name}'...", Colors.CYAN))
+
+ try:
+ tools = _probe_single_server(name, server_config)
+ except Exception as exc:
+ _error(f"Failed to connect: {exc}")
+ if _confirm("Save config anyway (you can test later)?", default=False):
+ server_config["enabled"] = False
+ _save_mcp_server(name, server_config)
+ _success(f"Saved '{name}' to config (disabled)")
+ _info("Fix the issue, then: hermes mcp test " + name)
+ return
+
+ if not tools:
+ _warning("Server connected but reported no tools.")
+ if _confirm("Save config anyway?", default=True):
+ _save_mcp_server(name, server_config)
+ _success(f"Saved '{name}' to config")
+ return
+
+ # โโ Tool selection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ print()
+ _success(f"Connected! Found {len(tools)} tool(s) from '{name}':")
+ print()
+ for tool_name, desc in tools:
+ short = desc[:60] + "..." if len(desc) > 60 else desc
+ print(f" {color(tool_name, Colors.GREEN):40s} {short}")
+ print()
+
+ # Ask: enable all, select, or cancel
+ try:
+ choice = input(
+ color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)
+ ).strip().lower()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ _info("Cancelled.")
+ return
+
+ if choice in ("n", "no"):
+ _info("Cancelled โ server not saved.")
+ return
+
+ if choice in ("s", "select"):
+ # Interactive tool selection
+ from hermes_cli.curses_ui import curses_checklist
+
+ labels = [f"{t[0]} โ {t[1]}" for t in tools]
+ pre_selected = set(range(len(tools)))
+
+ chosen = curses_checklist(
+ f"Select tools for '{name}'",
+ labels,
+ pre_selected,
+ )
+
+ if not chosen:
+ _info("No tools selected โ server not saved.")
+ return
+
+ chosen_names = [tools[i][0] for i in sorted(chosen)]
+ server_config.setdefault("tools", {})["include"] = chosen_names
+
+ tool_count = len(chosen_names)
+ total = len(tools)
+ else:
+ # Enable all (no filter needed โ default behaviour)
+ tool_count = len(tools)
+ total = len(tools)
+
+ # โโ Save โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ server_config["enabled"] = True
+ _save_mcp_server(name, server_config)
+
+ print()
+ _success(f"Saved '{name}' to ~/.hermes/config.yaml ({tool_count}/{total} tools enabled)")
+ _info("Start a new session to use these tools.")
+
+
+# โโโ hermes mcp remove โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def cmd_mcp_remove(args):
+ """Remove an MCP server from config."""
+ name = args.name
+ existing = _get_mcp_servers()
+
+ if name not in existing:
+ _error(f"Server '{name}' not found in config.")
+ servers = list(existing.keys())
+ if servers:
+ _info(f"Available servers: {', '.join(servers)}")
+ return
+
+ if not _confirm(f"Remove server '{name}'?", default=True):
+ _info("Cancelled.")
+ return
+
+ _remove_mcp_server(name)
+ _success(f"Removed '{name}' from config")
+
+ # Clean up OAuth tokens if they exist
+ try:
+ from tools.mcp_oauth import remove_oauth_tokens
+ remove_oauth_tokens(name)
+ _success("Cleaned up OAuth tokens")
+ except Exception:
+ pass
+
+
+# โโโ hermes mcp list โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def cmd_mcp_list(args=None):
+ """List all configured MCP servers."""
+ servers = _get_mcp_servers()
+
+ if not servers:
+ print()
+ _info("No MCP servers configured.")
+ print()
+ _info("Add one with:")
+ _info(' hermes mcp add --url ')
+ _info(' hermes mcp add --command --args ')
+ print()
+ return
+
+ print()
+ print(color(" MCP Servers:", Colors.CYAN + Colors.BOLD))
+ print()
+
+ # Table header
+ print(f" {'Name':<16} {'Transport':<30} {'Tools':<12} {'Status':<10}")
+ print(f" {'โ' * 16} {'โ' * 30} {'โ' * 12} {'โ' * 10}")
+
+ for name, cfg in servers.items():
+ # Transport info
+ if "url" in cfg:
+ url = cfg["url"]
+ # Truncate long URLs
+ if len(url) > 28:
+ url = url[:25] + "..."
+ transport = url
+ elif "command" in cfg:
+ cmd = cfg["command"]
+ cmd_args = cfg.get("args", [])
+ if isinstance(cmd_args, list) and cmd_args:
+ transport = f"{cmd} {' '.join(str(a) for a in cmd_args[:2])}"
+ else:
+ transport = cmd
+ if len(transport) > 28:
+ transport = transport[:25] + "..."
+ else:
+ transport = "?"
+
+ # Tool count
+ tools_cfg = cfg.get("tools", {})
+ if isinstance(tools_cfg, dict):
+ include = tools_cfg.get("include")
+ exclude = tools_cfg.get("exclude")
+ if include and isinstance(include, list):
+ tools_str = f"{len(include)} selected"
+ elif exclude and isinstance(exclude, list):
+ tools_str = f"-{len(exclude)} excluded"
+ else:
+ tools_str = "all"
+ else:
+ tools_str = "all"
+
+ # Enabled status
+ enabled = cfg.get("enabled", True)
+ if isinstance(enabled, str):
+ enabled = enabled.lower() in ("true", "1", "yes")
+ status = color("โ enabled", Colors.GREEN) if enabled else color("โ disabled", Colors.DIM)
+
+ print(f" {name:<16} {transport:<30} {tools_str:<12} {status}")
+
+ print()
+
+
+# โโโ hermes mcp test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def cmd_mcp_test(args):
+ """Test connection to an MCP server."""
+ name = args.name
+ servers = _get_mcp_servers()
+
+ if name not in servers:
+ _error(f"Server '{name}' not found in config.")
+ available = list(servers.keys())
+ if available:
+ _info(f"Available: {', '.join(available)}")
+ return
+
+ cfg = servers[name]
+ print()
+ print(color(f" Testing '{name}'...", Colors.CYAN))
+
+ # Show transport info
+ if "url" in cfg:
+ _info(f"Transport: HTTP โ {cfg['url']}")
+ else:
+ cmd = cfg.get("command", "?")
+ _info(f"Transport: stdio โ {cmd}")
+
+ # Show auth info (masked)
+ auth_type = cfg.get("auth", "")
+ headers = cfg.get("headers", {})
+ if auth_type == "oauth":
+ _info("Auth: OAuth 2.1 PKCE")
+ elif headers:
+ for k, v in headers.items():
+ if isinstance(v, str) and ("key" in k.lower() or "auth" in k.lower()):
+ # Mask the value
+ resolved = _interpolate_value(v)
+ if len(resolved) > 8:
+ masked = resolved[:4] + "***" + resolved[-4:]
+ else:
+ masked = "***"
+ print(f" {k}: {masked}")
+ else:
+ _info("Auth: none")
+
+ # Attempt connection
+ start = time.monotonic()
+ try:
+ tools = _probe_single_server(name, cfg)
+ elapsed_ms = (time.monotonic() - start) * 1000
+ except Exception as exc:
+ elapsed_ms = (time.monotonic() - start) * 1000
+ _error(f"Connection failed ({elapsed_ms:.0f}ms): {exc}")
+ return
+
+ _success(f"Connected ({elapsed_ms:.0f}ms)")
+ _success(f"Tools discovered: {len(tools)}")
+
+ if tools:
+ print()
+ for tool_name, desc in tools:
+ short = desc[:55] + "..." if len(desc) > 55 else desc
+ print(f" {color(tool_name, Colors.GREEN):36s} {short}")
+ print()
+
+
+def _interpolate_value(value: str) -> str:
+ """Resolve ``${ENV_VAR}`` references in a string."""
+ def _replace(m):
+ return os.getenv(m.group(1), "")
+ return re.sub(r"\$\{(\w+)\}", _replace, value)
+
+
+# โโโ hermes mcp configure โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def cmd_mcp_configure(args):
+ """Reconfigure which tools are enabled for an existing MCP server."""
+ name = args.name
+ servers = _get_mcp_servers()
+
+ if name not in servers:
+ _error(f"Server '{name}' not found in config.")
+ available = list(servers.keys())
+ if available:
+ _info(f"Available: {', '.join(available)}")
+ return
+
+ cfg = servers[name]
+
+ # Discover all available tools
+ print()
+ print(color(f" Connecting to '{name}' to discover tools...", Colors.CYAN))
+
+ try:
+ all_tools = _probe_single_server(name, cfg)
+ except Exception as exc:
+ _error(f"Failed to connect: {exc}")
+ return
+
+ if not all_tools:
+ _warning("Server reports no tools.")
+ return
+
+ # Determine which are currently enabled
+ tools_cfg = cfg.get("tools", {})
+ if isinstance(tools_cfg, dict):
+ include = tools_cfg.get("include")
+ exclude = tools_cfg.get("exclude")
+ else:
+ include = None
+ exclude = None
+
+ tool_names = [t[0] for t in all_tools]
+
+ if include and isinstance(include, list):
+ include_set = set(include)
+ pre_selected = {
+ i for i, tn in enumerate(tool_names) if tn in include_set
+ }
+ elif exclude and isinstance(exclude, list):
+ exclude_set = set(exclude)
+ pre_selected = {
+ i for i, tn in enumerate(tool_names) if tn not in exclude_set
+ }
+ else:
+ pre_selected = set(range(len(all_tools)))
+
+ currently = len(pre_selected)
+ total = len(all_tools)
+ _info(f"Currently {currently}/{total} tools enabled for '{name}'.")
+ print()
+
+ # Interactive checklist
+ from hermes_cli.curses_ui import curses_checklist
+
+ labels = [f"{t[0]} โ {t[1]}" for t in all_tools]
+
+ chosen = curses_checklist(
+ f"Select tools for '{name}'",
+ labels,
+ pre_selected,
+ )
+
+ if chosen == pre_selected:
+ _info("No changes made.")
+ return
+
+ # Update config
+ config = load_config()
+ server_entry = config.get("mcp_servers", {}).get(name, {})
+
+ if len(chosen) == total:
+ # All selected โ remove include/exclude (register all)
+ server_entry.pop("tools", None)
+ else:
+ chosen_names = [tool_names[i] for i in sorted(chosen)]
+ server_entry.setdefault("tools", {})
+ server_entry["tools"]["include"] = chosen_names
+ server_entry["tools"].pop("exclude", None)
+
+ config.setdefault("mcp_servers", {})[name] = server_entry
+ save_config(config)
+
+ new_count = len(chosen)
+ _success(f"Updated config: {new_count}/{total} tools enabled")
+ _info("Start a new session for changes to take effect.")
+
+
+# โโโ Dispatcher โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+def mcp_command(args):
+ """Main dispatcher for ``hermes mcp`` subcommands."""
+ action = getattr(args, "mcp_action", None)
+
+ handlers = {
+ "add": cmd_mcp_add,
+ "remove": cmd_mcp_remove,
+ "rm": cmd_mcp_remove,
+ "list": cmd_mcp_list,
+ "ls": cmd_mcp_list,
+ "test": cmd_mcp_test,
+ "configure": cmd_mcp_configure,
+ "config": cmd_mcp_configure,
+ }
+
+ handler = handlers.get(action)
+ if handler:
+ handler(args)
+ else:
+ # No subcommand โ show list
+ cmd_mcp_list()
+ print(color(" Commands:", Colors.CYAN))
+ _info("hermes mcp add --url Add an MCP server")
+ _info("hermes mcp add --command Add a stdio server")
+ _info("hermes mcp remove Remove a server")
+ _info("hermes mcp list List servers")
+ _info("hermes mcp test Test connection")
+ _info("hermes mcp configure Toggle tools")
+ print()
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
new file mode 100644
index 00000000000..499f140ed6a
--- /dev/null
+++ b/hermes_cli/model_switch.py
@@ -0,0 +1,232 @@
+"""Shared model-switching logic for CLI and gateway /model commands.
+
+Both the CLI (cli.py) and gateway (gateway/run.py) /model handlers
+share the same core pipeline:
+
+ parse_model_input โ is_custom detection โ auto-detect provider
+ โ credential resolution โ validate model โ return result
+
+This module extracts that shared pipeline into pure functions that
+return result objects. The callers handle all platform-specific
+concerns: state mutation, config persistence, output formatting.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ModelSwitchResult:
+ """Result of a model switch attempt."""
+
+ success: bool
+ new_model: str = ""
+ target_provider: str = ""
+ provider_changed: bool = False
+ api_key: str = ""
+ base_url: str = ""
+ persist: bool = False
+ error_message: str = ""
+ warning_message: str = ""
+ is_custom_target: bool = False
+ provider_label: str = ""
+
+
+@dataclass
+class CustomAutoResult:
+ """Result of switching to bare 'custom' provider with auto-detect."""
+
+ success: bool
+ model: str = ""
+ base_url: str = ""
+ api_key: str = ""
+ error_message: str = ""
+
+
+def switch_model(
+ raw_input: str,
+ current_provider: str,
+ current_base_url: str = "",
+ current_api_key: str = "",
+) -> ModelSwitchResult:
+ """Core model-switching pipeline shared between CLI and gateway.
+
+ Handles parsing, provider detection, credential resolution, and
+ model validation. Does NOT handle config persistence, state
+ mutation, or output formatting โ those are caller responsibilities.
+
+ Args:
+ raw_input: The user's model input (e.g. "claude-sonnet-4",
+ "zai:glm-5", "custom:local:qwen").
+ current_provider: The currently active provider.
+ current_base_url: The currently active base URL (used for
+ is_custom detection).
+ current_api_key: The currently active API key.
+
+ Returns:
+ ModelSwitchResult with all information the caller needs to
+ apply the switch and format output.
+ """
+ from hermes_cli.models import (
+ parse_model_input,
+ detect_provider_for_model,
+ validate_requested_model,
+ _PROVIDER_LABELS,
+ )
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+
+ # Step 1: Parse provider:model syntax
+ target_provider, new_model = parse_model_input(raw_input, current_provider)
+
+ # Step 2: Detect if we're currently on a custom endpoint
+ _base = current_base_url or ""
+ is_custom = current_provider == "custom" or (
+ "localhost" in _base or "127.0.0.1" in _base
+ )
+
+ # Step 3: Auto-detect provider when no explicit provider:model syntax
+ # was used. Skip for custom providers โ the model name might
+ # coincidentally match a known provider's catalog.
+ if target_provider == current_provider and not is_custom:
+ detected = detect_provider_for_model(new_model, current_provider)
+ if detected:
+ target_provider, new_model = detected
+
+ provider_changed = target_provider != current_provider
+
+ # Step 4: Resolve credentials for target provider
+ api_key = current_api_key
+ base_url = current_base_url
+ if provider_changed:
+ try:
+ runtime = resolve_runtime_provider(requested=target_provider)
+ api_key = runtime.get("api_key", "")
+ base_url = runtime.get("base_url", "")
+ except Exception as e:
+ provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
+ if target_provider == "custom":
+ return ModelSwitchResult(
+ success=False,
+ target_provider=target_provider,
+ error_message=(
+ "No custom endpoint configured. Set model.base_url "
+ "in config.yaml, or set OPENAI_BASE_URL in .env, "
+ "or run: hermes setup โ Custom OpenAI-compatible endpoint"
+ ),
+ )
+ return ModelSwitchResult(
+ success=False,
+ target_provider=target_provider,
+ error_message=(
+ f"Could not resolve credentials for provider "
+ f"'{provider_label}': {e}"
+ ),
+ )
+ else:
+ # Gateway also resolves for unchanged provider to get accurate
+ # base_url for validation probing.
+ try:
+ runtime = resolve_runtime_provider(requested=current_provider)
+ api_key = runtime.get("api_key", "")
+ base_url = runtime.get("base_url", "")
+ except Exception:
+ pass
+
+ # Step 5: Validate the model
+ try:
+ validation = validate_requested_model(
+ new_model,
+ target_provider,
+ api_key=api_key,
+ base_url=base_url,
+ )
+ except Exception:
+ validation = {
+ "accepted": True,
+ "persist": True,
+ "recognized": False,
+ "message": None,
+ }
+
+ if not validation.get("accepted"):
+ msg = validation.get("message", "Invalid model")
+ return ModelSwitchResult(
+ success=False,
+ new_model=new_model,
+ target_provider=target_provider,
+ error_message=msg,
+ )
+
+ # Step 6: Build result
+ provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
+ is_custom_target = target_provider == "custom" or (
+ base_url
+ and "openrouter.ai" not in (base_url or "")
+ and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or ""))
+ )
+
+ return ModelSwitchResult(
+ success=True,
+ new_model=new_model,
+ target_provider=target_provider,
+ provider_changed=provider_changed,
+ api_key=api_key,
+ base_url=base_url,
+ persist=bool(validation.get("persist")),
+ warning_message=validation.get("message") or "",
+ is_custom_target=is_custom_target,
+ provider_label=provider_label,
+ )
+
+
+def switch_to_custom_provider() -> CustomAutoResult:
+ """Handle bare '/model custom' โ resolve endpoint and auto-detect model.
+
+ Returns a result object; the caller handles persistence and output.
+ """
+ from hermes_cli.runtime_provider import (
+ resolve_runtime_provider,
+ _auto_detect_local_model,
+ )
+
+ try:
+ runtime = resolve_runtime_provider(requested="custom")
+ except Exception as e:
+ return CustomAutoResult(
+ success=False,
+ error_message=f"Could not resolve custom endpoint: {e}",
+ )
+
+ cust_base = runtime.get("base_url", "")
+ cust_key = runtime.get("api_key", "")
+
+ if not cust_base or "openrouter.ai" in cust_base:
+ return CustomAutoResult(
+ success=False,
+ error_message=(
+ "No custom endpoint configured. "
+ "Set model.base_url in config.yaml, or set OPENAI_BASE_URL "
+ "in .env, or run: hermes setup โ Custom OpenAI-compatible endpoint"
+ ),
+ )
+
+ detected_model = _auto_detect_local_model(cust_base)
+ if not detected_model:
+ return CustomAutoResult(
+ success=False,
+ base_url=cust_base,
+ api_key=cust_key,
+ error_message=(
+ f"Custom endpoint at {cust_base} is reachable but no single "
+ f"model was auto-detected. Specify the model explicitly: "
+ f"/model custom:"
+ ),
+ )
+
+ return CustomAutoResult(
+ success=True,
+ model=detected_model,
+ base_url=cust_base,
+ api_key=cust_key,
+ )
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index ff26a9d16c8..273230bbf60 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -8,44 +8,103 @@
from __future__ import annotations
import json
+import os
import urllib.request
import urllib.error
from difflib import get_close_matches
from typing import Any, Optional
+COPILOT_BASE_URL = "https://api.githubcopilot.com"
+COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
+COPILOT_EDITOR_VERSION = "vscode/1.104.1"
+COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
+COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
+
+# Backward-compatible aliases for the earlier GitHub Models-backed Copilot work.
+GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL
+GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL
+
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-opus-4.6", "recommended"),
("anthropic/claude-sonnet-4.5", ""),
- ("openai/gpt-5.4-pro", ""),
+ ("anthropic/claude-haiku-4.5", ""),
("openai/gpt-5.4", ""),
+ ("openai/gpt-5.4-mini", ""),
+ ("xiaomi/mimo-v2-pro", ""),
("openai/gpt-5.3-codex", ""),
("google/gemini-3-pro-preview", ""),
("google/gemini-3-flash-preview", ""),
("qwen/qwen3.5-plus-02-15", ""),
("qwen/qwen3.5-35b-a3b", ""),
("stepfun/step-3.5-flash", ""),
+ ("minimax/minimax-m2.7", ""),
+ ("minimax/minimax-m2.5", ""),
("z-ai/glm-5", ""),
+ ("z-ai/glm-5-turbo", ""),
("moonshotai/kimi-k2.5", ""),
- ("minimax/minimax-m2.5", ""),
+ ("x-ai/grok-4.20-beta", ""),
+ ("nvidia/nemotron-3-super-120b-a12b", ""),
+ ("nvidia/nemotron-3-super-120b-a12b:free", "free"),
+ ("arcee-ai/trinity-large-preview:free", "free"),
+ ("openai/gpt-5.4-pro", ""),
+ ("openai/gpt-5.4-nano", ""),
]
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
- "claude-opus-4-6",
- "claude-sonnet-4-6",
- "gpt-5.4",
- "gemini-3-flash",
- "gemini-3.0-pro-preview",
- "deepseek-v3.2",
+ "anthropic/claude-opus-4.6",
+ "anthropic/claude-sonnet-4.5",
+ "anthropic/claude-haiku-4.5",
+ "openai/gpt-5.4",
+ "openai/gpt-5.4-mini",
+ "xiaomi/mimo-v2-pro",
+ "openai/gpt-5.3-codex",
+ "google/gemini-3-pro-preview",
+ "google/gemini-3-flash-preview",
+ "qwen/qwen3.5-plus-02-15",
+ "qwen/qwen3.5-35b-a3b",
+ "stepfun/step-3.5-flash",
+ "minimax/minimax-m2.7",
+ "minimax/minimax-m2.5",
+ "z-ai/glm-5",
+ "z-ai/glm-5-turbo",
+ "moonshotai/kimi-k2.5",
+ "x-ai/grok-4.20-beta",
+ "nvidia/nemotron-3-super-120b-a12b",
+ "nvidia/nemotron-3-super-120b-a12b:free",
+ "arcee-ai/trinity-large-preview:free",
+ "openai/gpt-5.4-pro",
+ "openai/gpt-5.4-nano",
],
"openai-codex": [
+ "gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-mini",
"gpt-5.1-codex-max",
],
+ "copilot-acp": [
+ "copilot-acp",
+ ],
+ "copilot": [
+ "gpt-5.4",
+ "gpt-5.4-mini",
+ "gpt-5-mini",
+ "gpt-5.3-codex",
+ "gpt-5.2-codex",
+ "gpt-4.1",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "claude-opus-4.6",
+ "claude-sonnet-4.6",
+ "claude-sonnet-4.5",
+ "claude-haiku-4.5",
+ "gemini-2.5-pro",
+ "grok-code-fast-1",
+ ],
"zai": [
"glm-5",
+ "glm-5-turbo",
"glm-4.7",
"glm-4.5",
"glm-4.5-flash",
@@ -59,11 +118,15 @@
"kimi-k2-0905-preview",
],
"minimax": [
+ "MiniMax-M2.7",
+ "MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
],
"minimax-cn": [
+ "MiniMax-M2.7",
+ "MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
@@ -77,17 +140,120 @@
"claude-sonnet-4-20250514",
"claude-haiku-4-5-20251001",
],
+ "deepseek": [
+ "deepseek-chat",
+ "deepseek-reasoner",
+ ],
+ "opencode-zen": [
+ "gpt-5.4-pro",
+ "gpt-5.4",
+ "gpt-5.3-codex",
+ "gpt-5.3-codex-spark",
+ "gpt-5.2",
+ "gpt-5.2-codex",
+ "gpt-5.1",
+ "gpt-5.1-codex",
+ "gpt-5.1-codex-max",
+ "gpt-5.1-codex-mini",
+ "gpt-5",
+ "gpt-5-codex",
+ "gpt-5-nano",
+ "claude-opus-4-6",
+ "claude-opus-4-5",
+ "claude-opus-4-1",
+ "claude-sonnet-4-6",
+ "claude-sonnet-4-5",
+ "claude-sonnet-4",
+ "claude-haiku-4-5",
+ "claude-3-5-haiku",
+ "gemini-3.1-pro",
+ "gemini-3-pro",
+ "gemini-3-flash",
+ "minimax-m2.7",
+ "minimax-m2.5",
+ "minimax-m2.5-free",
+ "minimax-m2.1",
+ "glm-5",
+ "glm-4.7",
+ "glm-4.6",
+ "kimi-k2.5",
+ "kimi-k2-thinking",
+ "kimi-k2",
+ "qwen3-coder",
+ "big-pickle",
+ ],
+ "opencode-go": [
+ "glm-5",
+ "kimi-k2.5",
+ "minimax-m2.5",
+ ],
+ "ai-gateway": [
+ "anthropic/claude-opus-4.6",
+ "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-sonnet-4.5",
+ "anthropic/claude-haiku-4.5",
+ "openai/gpt-5",
+ "openai/gpt-4.1",
+ "openai/gpt-4.1-mini",
+ "google/gemini-3-pro-preview",
+ "google/gemini-3-flash",
+ "google/gemini-2.5-pro",
+ "google/gemini-2.5-flash",
+ "deepseek/deepseek-v3.2",
+ ],
+ "kilocode": [
+ "anthropic/claude-opus-4.6",
+ "anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.4",
+ "google/gemini-3-pro-preview",
+ "google/gemini-3-flash-preview",
+ ],
+ # Alibaba DashScope Coding platform (coding-intl) โ default endpoint.
+ # Supports Qwen models + third-party providers (GLM, Kimi, MiniMax).
+ # Users with classic DashScope keys should override DASHSCOPE_BASE_URL
+ # to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)
+ # or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
+ "alibaba": [
+ "qwen3.5-plus",
+ "qwen3-coder-plus",
+ "qwen3-coder-next",
+ # Third-party models available on coding-intl
+ "glm-5",
+ "glm-4.7",
+ "kimi-k2.5",
+ "MiniMax-M2.5",
+ ],
+ # Curated HF model list โ only agentic models that map to OpenRouter defaults.
+ "huggingface": [
+ "Qwen/Qwen3.5-397B-A17B",
+ "Qwen/Qwen3.5-35B-A3B",
+ "deepseek-ai/DeepSeek-V3.2",
+ "moonshotai/Kimi-K2.5",
+ "MiniMaxAI/MiniMax-M2.5",
+ "zai-org/GLM-5",
+ "XiaomiMiMo/MiMo-V2-Flash",
+ "moonshotai/Kimi-K2-Thinking",
+ ],
}
_PROVIDER_LABELS = {
"openrouter": "OpenRouter",
"openai-codex": "OpenAI Codex",
+ "copilot-acp": "GitHub Copilot ACP",
"nous": "Nous Portal",
+ "copilot": "GitHub Copilot",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"anthropic": "Anthropic",
+ "deepseek": "DeepSeek",
+ "opencode-zen": "OpenCode Zen",
+ "opencode-go": "OpenCode Go",
+ "ai-gateway": "AI Gateway",
+ "kilocode": "Kilo Code",
+ "alibaba": "Alibaba Cloud (DashScope)",
+ "huggingface": "Hugging Face",
"custom": "Custom endpoint",
}
@@ -96,12 +262,36 @@
"z-ai": "zai",
"z.ai": "zai",
"zhipu": "zai",
+ "github": "copilot",
+ "github-copilot": "copilot",
+ "github-models": "copilot",
+ "github-model": "copilot",
+ "github-copilot-acp": "copilot-acp",
+ "copilot-acp-agent": "copilot-acp",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
"claude-code": "anthropic",
+ "deep-seek": "deepseek",
+ "opencode": "opencode-zen",
+ "zen": "opencode-zen",
+ "go": "opencode-go",
+ "opencode-go-sub": "opencode-go",
+ "aigateway": "ai-gateway",
+ "vercel": "ai-gateway",
+ "vercel-ai-gateway": "ai-gateway",
+ "kilo": "kilocode",
+ "kilo-code": "kilocode",
+ "kilo-gateway": "kilocode",
+ "dashscope": "alibaba",
+ "aliyun": "alibaba",
+ "qwen": "alibaba",
+ "alibaba-cloud": "alibaba",
+ "hf": "huggingface",
+ "hugging-face": "huggingface",
+ "huggingface-hub": "huggingface",
}
@@ -134,8 +324,10 @@ def list_available_providers() -> list[dict[str, str]]:
"""
# Canonical providers in display order
_PROVIDER_ORDER = [
- "openrouter", "nous", "openai-codex",
- "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
+ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
+ "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "xgate", "kilocode", "anthropic", "alibaba",
+ "opencode-zen", "opencode-go",
+ "ai-gateway", "deepseek", "custom",
]
# Build reverse alias map
aliases_for: dict[str, list[str]] = {}
@@ -149,9 +341,15 @@ def list_available_providers() -> list[dict[str, str]]:
# Check if this provider has credentials available
has_creds = False
try:
- from hermes_cli.runtime_provider import resolve_runtime_provider
- runtime = resolve_runtime_provider(requested=pid)
- has_creds = bool(runtime.get("api_key"))
+ from hermes_cli.auth import get_auth_status, has_usable_secret
+ if pid == "custom":
+ custom_base_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
+ has_creds = bool(custom_base_url.strip())
+ elif pid == "openrouter":
+ has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
+ else:
+ status = get_auth_status(pid)
+ has_creds = bool(status.get("logged_in") or status.get("configured"))
except Exception:
pass
result.append({
@@ -186,10 +384,32 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
provider_part = stripped[:colon].strip().lower()
model_part = stripped[colon + 1:].strip()
if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES:
+ # Support custom:name:model triple syntax for named custom
+ # providers. ``custom:local:qwen`` โ ("custom:local", "qwen").
+ # Single colon ``custom:qwen`` โ ("custom", "qwen") as before.
+ if provider_part == "custom" and ":" in model_part:
+ second_colon = model_part.find(":")
+ custom_name = model_part[:second_colon].strip()
+ actual_model = model_part[second_colon + 1:].strip()
+ if custom_name and actual_model:
+ return (f"custom:{custom_name}", actual_model)
return (normalize_provider(provider_part), model_part)
return (current_provider, stripped)
+def _get_custom_base_url() -> str:
+ """Get the custom endpoint base_url from config.yaml."""
+ try:
+ from hermes_cli.config import load_config
+ config = load_config()
+ model_cfg = config.get("model", {})
+ if isinstance(model_cfg, dict):
+ return str(model_cfg.get("base_url", "")).strip()
+ except Exception:
+ pass
+ return ""
+
+
def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]]:
"""Return ``(model_id, description)`` tuples for a provider's model list.
@@ -211,6 +431,127 @@ def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]
return [(m, "") for m in models]
+def detect_provider_for_model(
+ model_name: str,
+ current_provider: str,
+) -> Optional[tuple[str, str]]:
+ """Auto-detect the best provider for a model name.
+
+ Returns ``(provider_id, model_name)`` โ the model name may be remapped
+ (e.g. bare ``deepseek-chat`` โ ``deepseek/deepseek-chat`` for OpenRouter).
+ Returns ``None`` when no confident match is found.
+
+ Priority:
+ 0. Bare provider name โ switch to that provider's default model
+ 1. Direct provider with credentials (highest)
+ 2. Direct provider without credentials โ remap to OpenRouter slug
+ 3. OpenRouter catalog match
+ """
+ name = (model_name or "").strip()
+ if not name:
+ return None
+
+ name_lower = name.lower()
+
+ # --- Step 0: bare provider name typed as model ---
+ # If someone types `/model nous` or `/model anthropic`, treat it as a
+ # provider switch and pick the first model from that provider's catalog.
+ # Skip "custom" and "openrouter" โ custom has no model catalog, and
+ # openrouter requires an explicit model name to be useful.
+ resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower)
+ if resolved_provider not in {"custom", "openrouter"}:
+ default_models = _PROVIDER_MODELS.get(resolved_provider, [])
+ if (
+ resolved_provider in _PROVIDER_LABELS
+ and default_models
+ and resolved_provider != normalize_provider(current_provider)
+ ):
+ return (resolved_provider, default_models[0])
+
+ # Aggregators list other providers' models โ never auto-switch TO them
+ _AGGREGATORS = {"nous", "openrouter"}
+
+ # If the model belongs to the current provider's catalog, don't suggest switching
+ current_models = _PROVIDER_MODELS.get(current_provider, [])
+ if any(name_lower == m.lower() for m in current_models):
+ return None
+
+ # --- Step 1: check static provider catalogs for a direct match ---
+ direct_match: Optional[str] = None
+ for pid, models in _PROVIDER_MODELS.items():
+ if pid == current_provider or pid in _AGGREGATORS:
+ continue
+ if any(name_lower == m.lower() for m in models):
+ direct_match = pid
+ break
+
+ if direct_match:
+ # Check if we have credentials for this provider
+ has_creds = False
+ try:
+ from hermes_cli.auth import PROVIDER_REGISTRY
+ pconfig = PROVIDER_REGISTRY.get(direct_match)
+ if pconfig:
+ import os
+ for env_var in pconfig.api_key_env_vars:
+ if os.getenv(env_var, "").strip():
+ has_creds = True
+ break
+ except Exception:
+ pass
+
+ if has_creds:
+ return (direct_match, name)
+
+ # No direct creds โ try to find this model on OpenRouter instead
+ or_slug = _find_openrouter_slug(name)
+ if or_slug:
+ return ("openrouter", or_slug)
+ # Still return the direct provider โ credential resolution will
+ # give a clear error rather than silently using the wrong provider
+ return (direct_match, name)
+
+ # --- Step 2: check OpenRouter catalog ---
+ # First try exact match (handles provider/model format)
+ or_slug = _find_openrouter_slug(name)
+ if or_slug:
+ if current_provider != "openrouter":
+ return ("openrouter", or_slug)
+ # Already on openrouter, just return the resolved slug
+ if or_slug != name:
+ return ("openrouter", or_slug)
+ return None # already on openrouter with matching name
+
+ return None
+
+
+def _find_openrouter_slug(model_name: str) -> Optional[str]:
+ """Find the full OpenRouter model slug for a bare or partial model name.
+
+ Handles:
+ - Exact match: ``anthropic/claude-opus-4.6`` โ as-is
+ - Bare name: ``deepseek-chat`` โ ``deepseek/deepseek-chat``
+ - Bare name: ``claude-opus-4.6`` โ ``anthropic/claude-opus-4.6``
+ """
+ name_lower = model_name.strip().lower()
+ if not name_lower:
+ return None
+
+ # Exact match (already has provider/ prefix)
+ for mid, _ in OPENROUTER_MODELS:
+ if name_lower == mid.lower():
+ return mid
+
+ # Try matching just the model part (after the /)
+ for mid, _ in OPENROUTER_MODELS:
+ if "/" in mid:
+ _, model_part = mid.split("/", 1)
+ if name_lower == model_part.lower():
+ return mid
+
+ return None
+
+
def normalize_provider(provider: Optional[str]) -> str:
"""Normalize provider aliases to Hermes' canonical provider ids.
@@ -222,6 +563,27 @@ def normalize_provider(provider: Optional[str]) -> str:
return _PROVIDER_ALIASES.get(normalized, normalized)
+def provider_label(provider: Optional[str]) -> str:
+ """Return a human-friendly label for a provider id or alias."""
+ original = (provider or "openrouter").strip()
+ normalized = original.lower()
+ if normalized == "auto":
+ return "Auto"
+ normalized = normalize_provider(normalized)
+ return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
+
+
+def _resolve_copilot_catalog_api_key() -> str:
+ """Best-effort GitHub token for fetching the Copilot model catalog."""
+ try:
+ from hermes_cli.auth import resolve_api_key_provider_credentials
+
+ creds = resolve_api_key_provider_credentials("copilot")
+ return str(creds.get("api_key") or "").strip()
+ except Exception:
+ return ""
+
+
def provider_model_ids(provider: Optional[str]) -> list[str]:
"""Return the best known model catalog for a provider.
@@ -235,13 +597,22 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
from hermes_cli.codex_models import get_codex_model_ids
return get_codex_model_ids()
+ if normalized in {"copilot", "copilot-acp"}:
+ try:
+ live = _fetch_github_models(_resolve_copilot_catalog_api_key())
+ if live:
+ return live
+ except Exception:
+ pass
+ if normalized == "copilot-acp":
+ return list(_PROVIDER_MODELS.get("copilot", []))
if normalized == "nous":
# Try live Nous Portal /models endpoint
try:
from hermes_cli.auth import fetch_nous_models, resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials()
if creds:
- live = fetch_nous_models(creds.get("api_key", ""), creds.get("base_url", ""))
+ live = fetch_nous_models(api_key=creds.get("api_key", ""), inference_base_url=creds.get("base_url", ""))
if live:
return live
except Exception:
@@ -250,6 +621,22 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
live = _fetch_anthropic_models()
if live:
return live
+ if normalized == "ai-gateway":
+ live = _fetch_ai_gateway_models()
+ if live:
+ return live
+ if normalized == "custom":
+ base_url = _get_custom_base_url()
+ if base_url:
+ # Try common API key env vars for custom endpoints
+ api_key = (
+ os.getenv("CUSTOM_API_KEY", "")
+ or os.getenv("OPENAI_API_KEY", "")
+ or os.getenv("OPENROUTER_API_KEY", "")
+ )
+ live = fetch_api_models(api_key, base_url)
+ if live:
+ return live
return list(_PROVIDER_MODELS.get(normalized, []))
@@ -271,7 +658,8 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
if _is_oauth_token(token):
headers["Authorization"] = f"Bearer {token}"
- headers["anthropic-beta"] = "oauth-2025-04-20"
+ from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
+ headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = token
@@ -296,34 +684,414 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
return None
-def fetch_api_models(
+def _payload_items(payload: Any) -> list[dict[str, Any]]:
+ if isinstance(payload, list):
+ return [item for item in payload if isinstance(item, dict)]
+ if isinstance(payload, dict):
+ data = payload.get("data", [])
+ if isinstance(data, list):
+ return [item for item in data if isinstance(item, dict)]
+ return []
+
+
+def _extract_model_ids(payload: Any) -> list[str]:
+ return [item.get("id", "") for item in _payload_items(payload) if item.get("id")]
+
+
+def copilot_default_headers() -> dict[str, str]:
+ """Standard headers for Copilot API requests.
+
+ Includes Openai-Intent and x-initiator headers that opencode and the
+ Copilot CLI send on every request.
+ """
+ try:
+ from hermes_cli.copilot_auth import copilot_request_headers
+ return copilot_request_headers(is_agent_turn=True)
+ except ImportError:
+ return {
+ "Editor-Version": COPILOT_EDITOR_VERSION,
+ "User-Agent": "HermesAgent/1.0",
+ "Openai-Intent": "conversation-edits",
+ "x-initiator": "agent",
+ }
+
+
+def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool:
+ model_id = str(item.get("id") or "").strip()
+ if not model_id:
+ return False
+
+ if item.get("model_picker_enabled") is False:
+ return False
+
+ capabilities = item.get("capabilities")
+ if isinstance(capabilities, dict):
+ model_type = str(capabilities.get("type") or "").strip().lower()
+ if model_type and model_type != "chat":
+ return False
+
+ supported_endpoints = item.get("supported_endpoints")
+ if isinstance(supported_endpoints, list):
+ normalized_endpoints = {
+ str(endpoint).strip()
+ for endpoint in supported_endpoints
+ if str(endpoint).strip()
+ }
+ if normalized_endpoints and not normalized_endpoints.intersection(
+ {"/chat/completions", "/responses", "/v1/messages"}
+ ):
+ return False
+
+ return True
+
+
+def fetch_github_model_catalog(
+ api_key: Optional[str] = None, timeout: float = 5.0
+) -> Optional[list[dict[str, Any]]]:
+ """Fetch the live GitHub Copilot model catalog for this account."""
+ attempts: list[dict[str, str]] = []
+ if api_key:
+ attempts.append({
+ **copilot_default_headers(),
+ "Authorization": f"Bearer {api_key}",
+ })
+ attempts.append(copilot_default_headers())
+
+ for headers in attempts:
+ req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode())
+ items = _payload_items(data)
+ models: list[dict[str, Any]] = []
+ seen_ids: set[str] = set()
+ for item in items:
+ if not _copilot_catalog_item_is_text_model(item):
+ continue
+ model_id = str(item.get("id") or "").strip()
+ if not model_id or model_id in seen_ids:
+ continue
+ seen_ids.add(model_id)
+ models.append(item)
+ if models:
+ return models
+ except Exception:
+ continue
+ return None
+
+
+def _is_github_models_base_url(base_url: Optional[str]) -> bool:
+ normalized = (base_url or "").strip().rstrip("/").lower()
+ return (
+ normalized.startswith(COPILOT_BASE_URL)
+ or normalized.startswith("https://models.github.ai/inference")
+ )
+
+
+def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
+ catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
+ if not catalog:
+ return None
+ return [item.get("id", "") for item in catalog if item.get("id")]
+
+
+_COPILOT_MODEL_ALIASES = {
+ "openai/gpt-5": "gpt-5-mini",
+ "openai/gpt-5-chat": "gpt-5-mini",
+ "openai/gpt-5-mini": "gpt-5-mini",
+ "openai/gpt-5-nano": "gpt-5-mini",
+ "openai/gpt-4.1": "gpt-4.1",
+ "openai/gpt-4.1-mini": "gpt-4.1",
+ "openai/gpt-4.1-nano": "gpt-4.1",
+ "openai/gpt-4o": "gpt-4o",
+ "openai/gpt-4o-mini": "gpt-4o-mini",
+ "openai/o1": "gpt-5.2",
+ "openai/o1-mini": "gpt-5-mini",
+ "openai/o1-preview": "gpt-5.2",
+ "openai/o3": "gpt-5.3-codex",
+ "openai/o3-mini": "gpt-5-mini",
+ "openai/o4-mini": "gpt-5-mini",
+ "anthropic/claude-opus-4.6": "claude-opus-4.6",
+ "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6",
+ "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5",
+ "anthropic/claude-haiku-4.5": "claude-haiku-4.5",
+}
+
+
+def _copilot_catalog_ids(
+ catalog: Optional[list[dict[str, Any]]] = None,
+ api_key: Optional[str] = None,
+) -> set[str]:
+ if catalog is None and api_key:
+ catalog = fetch_github_model_catalog(api_key=api_key)
+ if not catalog:
+ return set()
+ return {
+ str(item.get("id") or "").strip()
+ for item in catalog
+ if str(item.get("id") or "").strip()
+ }
+
+
+def normalize_copilot_model_id(
+ model_id: Optional[str],
+ *,
+ catalog: Optional[list[dict[str, Any]]] = None,
+ api_key: Optional[str] = None,
+) -> str:
+ raw = str(model_id or "").strip()
+ if not raw:
+ return ""
+
+ catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key)
+ alias = _COPILOT_MODEL_ALIASES.get(raw)
+ if alias:
+ return alias
+
+ candidates = [raw]
+ if "/" in raw:
+ candidates.append(raw.split("/", 1)[1].strip())
+
+ if raw.endswith("-mini"):
+ candidates.append(raw[:-5])
+ if raw.endswith("-nano"):
+ candidates.append(raw[:-5])
+ if raw.endswith("-chat"):
+ candidates.append(raw[:-5])
+
+ seen: set[str] = set()
+ for candidate in candidates:
+ if not candidate or candidate in seen:
+ continue
+ seen.add(candidate)
+ if candidate in _COPILOT_MODEL_ALIASES:
+ return _COPILOT_MODEL_ALIASES[candidate]
+ if candidate in catalog_ids:
+ return candidate
+
+ if "/" in raw:
+ return raw.split("/", 1)[1].strip()
+ return raw
+
+
+def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
+ raw = (model_id or "").strip().lower()
+ if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")):
+ return list(COPILOT_REASONING_EFFORTS_O_SERIES)
+ normalized = normalize_copilot_model_id(model_id).lower()
+ if normalized.startswith("gpt-5"):
+ return list(COPILOT_REASONING_EFFORTS_GPT5)
+ return []
+
+
+def _should_use_copilot_responses_api(model_id: str) -> bool:
+ """Decide whether a Copilot model should use the Responses API.
+
+ Replicates opencode's ``shouldUseCopilotResponsesApi`` logic:
+ GPT-5+ models use Responses API, except ``gpt-5-mini`` which uses
+ Chat Completions. All non-GPT models (Claude, Gemini, etc.) use
+ Chat Completions.
+ """
+ import re
+
+ match = re.match(r"^gpt-(\d+)", model_id)
+ if not match:
+ return False
+ major = int(match.group(1))
+ return major >= 5 and not model_id.startswith("gpt-5-mini")
+
+
+def copilot_model_api_mode(
+ model_id: Optional[str],
+ *,
+ catalog: Optional[list[dict[str, Any]]] = None,
+ api_key: Optional[str] = None,
+) -> str:
+ """Determine the API mode for a Copilot model.
+
+ Uses the model ID pattern (matching opencode's approach) as the
+ primary signal. Falls back to the catalog's ``supported_endpoints``
+ only for models not covered by the pattern check.
+ """
+ normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
+ if not normalized:
+ return "chat_completions"
+
+ # Primary: model ID pattern (matches opencode's shouldUseCopilotResponsesApi)
+ if _should_use_copilot_responses_api(normalized):
+ return "codex_responses"
+
+ # Secondary: check catalog for non-GPT-5 models (Claude via /v1/messages, etc.)
+ if catalog is None and api_key:
+ catalog = fetch_github_model_catalog(api_key=api_key)
+
+ if catalog:
+ catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
+ if isinstance(catalog_entry, dict):
+ supported_endpoints = {
+ str(endpoint).strip()
+ for endpoint in (catalog_entry.get("supported_endpoints") or [])
+ if str(endpoint).strip()
+ }
+ # For non-GPT-5 models, check if they only support messages API
+ if "/v1/messages" in supported_endpoints and "/chat/completions" not in supported_endpoints:
+ return "anthropic_messages"
+
+ return "chat_completions"
+
+
+def github_model_reasoning_efforts(
+ model_id: Optional[str],
+ *,
+ catalog: Optional[list[dict[str, Any]]] = None,
+ api_key: Optional[str] = None,
+) -> list[str]:
+ """Return supported reasoning-effort levels for a Copilot-visible model."""
+ normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
+ if not normalized:
+ return []
+
+ catalog_entry = None
+ if catalog is not None:
+ catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
+ elif api_key:
+ fetched_catalog = fetch_github_model_catalog(api_key=api_key)
+ if fetched_catalog:
+ catalog_entry = next((item for item in fetched_catalog if item.get("id") == normalized), None)
+
+ if catalog_entry is not None:
+ capabilities = catalog_entry.get("capabilities")
+ if isinstance(capabilities, dict):
+ supports = capabilities.get("supports")
+ if isinstance(supports, dict):
+ efforts = supports.get("reasoning_effort")
+ if isinstance(efforts, list):
+ normalized_efforts = [
+ str(effort).strip().lower()
+ for effort in efforts
+ if str(effort).strip()
+ ]
+ return list(dict.fromkeys(normalized_efforts))
+ return []
+ legacy_capabilities = {
+ str(capability).strip().lower()
+ for capability in catalog_entry.get("capabilities", [])
+ if str(capability).strip()
+ }
+ if "reasoning" not in legacy_capabilities:
+ return []
+
+ return _github_reasoning_efforts_for_model_id(str(model_id or normalized))
+
+
+def probe_api_models(
api_key: Optional[str],
base_url: Optional[str],
timeout: float = 5.0,
-) -> Optional[list[str]]:
- """Fetch the list of available model IDs from the provider's ``/models`` endpoint.
+) -> dict[str, Any]:
+ """Probe an OpenAI-compatible ``/models`` endpoint with light URL heuristics."""
+ normalized = (base_url or "").strip().rstrip("/")
+ if not normalized:
+ return {
+ "models": None,
+ "probed_url": None,
+ "resolved_base_url": "",
+ "suggested_base_url": None,
+ "used_fallback": False,
+ }
- Returns a list of model ID strings, or ``None`` if the endpoint could not
- be reached (network error, timeout, auth failure, etc.).
- """
- if not base_url:
- return None
+ if _is_github_models_base_url(normalized):
+ models = _fetch_github_models(api_key=api_key, timeout=timeout)
+ return {
+ "models": models,
+ "probed_url": COPILOT_MODELS_URL,
+ "resolved_base_url": COPILOT_BASE_URL,
+ "suggested_base_url": None,
+ "used_fallback": False,
+ }
- url = base_url.rstrip("/") + "/models"
+ if normalized.endswith("/v1"):
+ alternate_base = normalized[:-3].rstrip("/")
+ else:
+ alternate_base = normalized + "/v1"
+
+ candidates: list[tuple[str, bool]] = [(normalized, False)]
+ if alternate_base and alternate_base != normalized:
+ candidates.append((alternate_base, True))
+
+ tried: list[str] = []
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
+ if normalized.startswith(COPILOT_BASE_URL):
+ headers.update(copilot_default_headers())
+ for candidate_base, is_fallback in candidates:
+ url = candidate_base.rstrip("/") + "/models"
+ tried.append(url)
+ req = urllib.request.Request(url, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode())
+ return {
+ "models": [m.get("id", "") for m in data.get("data", [])],
+ "probed_url": url,
+ "resolved_base_url": candidate_base.rstrip("/"),
+ "suggested_base_url": alternate_base if alternate_base != candidate_base else normalized,
+ "used_fallback": is_fallback,
+ }
+ except Exception:
+ continue
+
+ return {
+ "models": None,
+ "probed_url": tried[-1] if tried else normalized.rstrip("/") + "/models",
+ "resolved_base_url": normalized,
+ "suggested_base_url": alternate_base if alternate_base != normalized else None,
+ "used_fallback": False,
+ }
+
+
+def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
+ """Fetch available language models with tool-use from AI Gateway."""
+ api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
+ if not api_key:
+ return None
+ base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip()
+ if not base_url:
+ from hermes_constants import AI_GATEWAY_BASE_URL
+ base_url = AI_GATEWAY_BASE_URL
+
+ url = base_url.rstrip("/") + "/models"
+ headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
- # Standard OpenAI format: {"data": [{"id": "model-name", ...}, ...]}
- return [m.get("id", "") for m in data.get("data", [])]
+ return [
+ m["id"]
+ for m in data.get("data", [])
+ if m.get("id")
+ and m.get("type") == "language"
+ and "tool-use" in (m.get("tags") or [])
+ ]
except Exception:
return None
+def fetch_api_models(
+ api_key: Optional[str],
+ base_url: Optional[str],
+ timeout: float = 5.0,
+) -> Optional[list[str]]:
+ """Fetch the list of available model IDs from the provider's ``/models`` endpoint.
+
+ Returns a list of model ID strings, or ``None`` if the endpoint could not
+ be reached (network error, timeout, auth failure, etc.).
+ """
+ return probe_api_models(api_key, base_url, timeout=timeout).get("models")
+
+
def validate_requested_model(
model_name: str,
provider: Optional[str],
@@ -347,6 +1115,12 @@ def validate_requested_model(
normalized = normalize_provider(provider)
if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url:
normalized = "custom"
+ requested_for_lookup = requested
+ if normalized == "copilot":
+ requested_for_lookup = normalize_copilot_model_id(
+ requested,
+ api_key=api_key,
+ ) or requested
if not requested:
return {
@@ -364,20 +1138,60 @@ def validate_requested_model(
"message": "Model names cannot contain spaces.",
}
- # Custom endpoints can serve any model โ skip validation
if normalized == "custom":
+ probe = probe_api_models(api_key, base_url)
+ api_models = probe.get("models")
+ if api_models is not None:
+ if requested_for_lookup in set(api_models):
+ return {
+ "accepted": True,
+ "persist": True,
+ "recognized": True,
+ "message": None,
+ }
+
+ suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
+ suggestion_text = ""
+ if suggestions:
+ suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
+
+ message = (
+ f"Note: `{requested}` was not found in this custom endpoint's model listing "
+ f"({probe.get('probed_url')}). It may still work if the server supports hidden or aliased models."
+ f"{suggestion_text}"
+ )
+ if probe.get("used_fallback"):
+ message += (
+ f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. "
+ f"Consider saving that as your base URL."
+ )
+
+ return {
+ "accepted": True,
+ "persist": True,
+ "recognized": False,
+ "message": message,
+ }
+
+ message = (
+ f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. "
+ f"Hermes will still save `{requested}`, but the endpoint should expose `/models` for verification."
+ )
+ if probe.get("suggested_base_url"):
+ message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
+
return {
"accepted": True,
"persist": True,
"recognized": False,
- "message": None,
+ "message": message,
}
# Probe the live API to check if the model actually exists
api_models = fetch_api_models(api_key, base_url)
if api_models is not None:
- if requested in set(api_models):
+ if requested_for_lookup in set(api_models):
# API confirmed the model exists
return {
"accepted": True,
diff --git a/hermes_cli/pairing.py b/hermes_cli/pairing.py
index ecd9f61fcfa..7e04da90237 100644
--- a/hermes_cli/pairing.py
+++ b/hermes_cli/pairing.py
@@ -72,10 +72,10 @@ def _cmd_approve(store, platform: str, code: str):
name = result.get("user_name", "")
display = f"{name} ({uid})" if name else uid
print(f"\n Approved! User {display} on {platform} can now use the bot~")
- print(f" They'll be recognized automatically on their next message.\n")
+ print(" They'll be recognized automatically on their next message.\n")
else:
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
- print(f" Run 'hermes pairing list' to see pending codes.\n")
+ print(" Run 'hermes pairing list' to see pending codes.\n")
def _cmd_revoke(store, platform: str, user_id: str):
diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py
new file mode 100644
index 00000000000..5e27535a0b7
--- /dev/null
+++ b/hermes_cli/plugins.py
@@ -0,0 +1,501 @@
+"""
+Hermes Plugin System
+====================
+
+Discovers, loads, and manages plugins from three sources:
+
+1. **User plugins** โ ``~/.hermes/plugins//``
+2. **Project plugins** โ ``./.hermes/plugins//`` (opt-in via
+ ``HERMES_ENABLE_PROJECT_PLUGINS``)
+3. **Pip plugins** โ packages that expose the ``hermes_agent.plugins``
+ entry-point group.
+
+Each directory plugin must contain a ``plugin.yaml`` manifest **and** an
+``__init__.py`` with a ``register(ctx)`` function.
+
+Lifecycle hooks
+---------------
+Plugins may register callbacks for any of the hooks in ``VALID_HOOKS``.
+The agent core calls ``invoke_hook(name, **kwargs)`` at the appropriate
+points.
+
+Tool registration
+-----------------
+``PluginContext.register_tool()`` delegates to ``tools.registry.register()``
+so plugin-defined tools appear alongside the built-in tools.
+"""
+
+from __future__ import annotations
+
+import importlib
+import importlib.metadata
+import importlib.util
+import logging
+import os
+import sys
+import types
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Set
+
+try:
+ import yaml
+except ImportError: # pragma: no cover โ yaml is optional at import time
+ yaml = None # type: ignore[assignment]
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+VALID_HOOKS: Set[str] = {
+ "pre_tool_call",
+ "post_tool_call",
+ "pre_llm_call",
+ "post_llm_call",
+ "on_session_start",
+ "on_session_end",
+}
+
+ENTRY_POINTS_GROUP = "hermes_agent.plugins"
+
+_NS_PARENT = "hermes_plugins"
+
+
+def _env_enabled(name: str) -> bool:
+ """Return True when an env var is set to a truthy opt-in value."""
+ return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+@dataclass
+class PluginManifest:
+ """Parsed representation of a plugin.yaml manifest."""
+
+ name: str
+ version: str = ""
+ description: str = ""
+ author: str = ""
+ requires_env: List[str] = field(default_factory=list)
+ provides_tools: List[str] = field(default_factory=list)
+ provides_hooks: List[str] = field(default_factory=list)
+ source: str = "" # "user", "project", or "entrypoint"
+ path: Optional[str] = None
+
+
+@dataclass
+class LoadedPlugin:
+ """Runtime state for a single loaded plugin."""
+
+ manifest: PluginManifest
+ module: Optional[types.ModuleType] = None
+ tools_registered: List[str] = field(default_factory=list)
+ hooks_registered: List[str] = field(default_factory=list)
+ enabled: bool = False
+ error: Optional[str] = None
+
+
+# ---------------------------------------------------------------------------
+# PluginContext โ handed to each plugin's ``register()`` function
+# ---------------------------------------------------------------------------
+
+class PluginContext:
+ """Facade given to plugins so they can register tools and hooks."""
+
+ def __init__(self, manifest: PluginManifest, manager: "PluginManager"):
+ self.manifest = manifest
+ self._manager = manager
+
+ # -- tool registration --------------------------------------------------
+
+ def register_tool(
+ self,
+ name: str,
+ toolset: str,
+ schema: dict,
+ handler: Callable,
+ check_fn: Callable | None = None,
+ requires_env: list | None = None,
+ is_async: bool = False,
+ description: str = "",
+ emoji: str = "",
+ ) -> None:
+ """Register a tool in the global registry **and** track it as plugin-provided."""
+ from tools.registry import registry
+
+ registry.register(
+ name=name,
+ toolset=toolset,
+ schema=schema,
+ handler=handler,
+ check_fn=check_fn,
+ requires_env=requires_env,
+ is_async=is_async,
+ description=description,
+ emoji=emoji,
+ )
+ self._manager._plugin_tool_names.add(name)
+ logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
+
+ # -- hook registration --------------------------------------------------
+
+ def register_hook(self, hook_name: str, callback: Callable) -> None:
+ """Register a lifecycle hook callback.
+
+ Unknown hook names produce a warning but are still stored so
+ forward-compatible plugins don't break.
+ """
+ if hook_name not in VALID_HOOKS:
+ logger.warning(
+ "Plugin '%s' registered unknown hook '%s' "
+ "(valid: %s)",
+ self.manifest.name,
+ hook_name,
+ ", ".join(sorted(VALID_HOOKS)),
+ )
+ self._manager._hooks.setdefault(hook_name, []).append(callback)
+ logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
+
+
+# ---------------------------------------------------------------------------
+# PluginManager
+# ---------------------------------------------------------------------------
+
+class PluginManager:
+ """Central manager that discovers, loads, and invokes plugins."""
+
+ def __init__(self) -> None:
+ self._plugins: Dict[str, LoadedPlugin] = {}
+ self._hooks: Dict[str, List[Callable]] = {}
+ self._plugin_tool_names: Set[str] = set()
+ self._discovered: bool = False
+
+ # -----------------------------------------------------------------------
+ # Public
+ # -----------------------------------------------------------------------
+
+ def discover_and_load(self) -> None:
+ """Scan all plugin sources and load each plugin found."""
+ if self._discovered:
+ return
+ self._discovered = True
+
+ manifests: List[PluginManifest] = []
+
+ # 1. User plugins (~/.hermes/plugins/)
+ hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
+ user_dir = Path(hermes_home) / "plugins"
+ manifests.extend(self._scan_directory(user_dir, source="user"))
+
+ # 2. Project plugins (./.hermes/plugins/)
+ if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
+ project_dir = Path.cwd() / ".hermes" / "plugins"
+ manifests.extend(self._scan_directory(project_dir, source="project"))
+
+ # 3. Pip / entry-point plugins
+ manifests.extend(self._scan_entry_points())
+
+ # Load each manifest
+ for manifest in manifests:
+ self._load_plugin(manifest)
+
+ if manifests:
+ logger.info(
+ "Plugin discovery complete: %d found, %d enabled",
+ len(self._plugins),
+ sum(1 for p in self._plugins.values() if p.enabled),
+ )
+
+ # -----------------------------------------------------------------------
+ # Directory scanning
+ # -----------------------------------------------------------------------
+
+ def _scan_directory(self, path: Path, source: str) -> List[PluginManifest]:
+ """Read ``plugin.yaml`` manifests from subdirectories of *path*."""
+ manifests: List[PluginManifest] = []
+ if not path.is_dir():
+ return manifests
+
+ for child in sorted(path.iterdir()):
+ if not child.is_dir():
+ continue
+ manifest_file = child / "plugin.yaml"
+ if not manifest_file.exists():
+ manifest_file = child / "plugin.yml"
+ if not manifest_file.exists():
+ logger.debug("Skipping %s (no plugin.yaml)", child)
+ continue
+
+ try:
+ if yaml is None:
+ logger.warning("PyYAML not installed โ cannot load %s", manifest_file)
+ continue
+ data = yaml.safe_load(manifest_file.read_text()) or {}
+ manifest = PluginManifest(
+ name=data.get("name", child.name),
+ version=str(data.get("version", "")),
+ description=data.get("description", ""),
+ author=data.get("author", ""),
+ requires_env=data.get("requires_env", []),
+ provides_tools=data.get("provides_tools", []),
+ provides_hooks=data.get("provides_hooks", []),
+ source=source,
+ path=str(child),
+ )
+ manifests.append(manifest)
+ except Exception as exc:
+ logger.warning("Failed to parse %s: %s", manifest_file, exc)
+
+ return manifests
+
+ # -----------------------------------------------------------------------
+ # Entry-point scanning
+ # -----------------------------------------------------------------------
+
+ def _scan_entry_points(self) -> List[PluginManifest]:
+ """Check ``importlib.metadata`` for pip-installed plugins."""
+ manifests: List[PluginManifest] = []
+ try:
+ eps = importlib.metadata.entry_points()
+ # Python 3.12+ returns a SelectableGroups; earlier returns dict
+ if hasattr(eps, "select"):
+ group_eps = eps.select(group=ENTRY_POINTS_GROUP)
+ elif isinstance(eps, dict):
+ group_eps = eps.get(ENTRY_POINTS_GROUP, [])
+ else:
+ group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
+
+ for ep in group_eps:
+ manifest = PluginManifest(
+ name=ep.name,
+ source="entrypoint",
+ path=ep.value,
+ )
+ manifests.append(manifest)
+ except Exception as exc:
+ logger.debug("Entry-point scan failed: %s", exc)
+
+ return manifests
+
+ # -----------------------------------------------------------------------
+ # Loading
+ # -----------------------------------------------------------------------
+
+ def _load_plugin(self, manifest: PluginManifest) -> None:
+ """Import a plugin module and call its ``register(ctx)`` function."""
+ loaded = LoadedPlugin(manifest=manifest)
+
+ try:
+ if manifest.source in ("user", "project"):
+ module = self._load_directory_module(manifest)
+ else:
+ module = self._load_entrypoint_module(manifest)
+
+ loaded.module = module
+
+ # Call register()
+ register_fn = getattr(module, "register", None)
+ if register_fn is None:
+ loaded.error = "no register() function"
+ logger.warning("Plugin '%s' has no register() function", manifest.name)
+ else:
+ ctx = PluginContext(manifest, self)
+ register_fn(ctx)
+ loaded.tools_registered = [
+ t for t in self._plugin_tool_names
+ if t not in {
+ n
+ for name, p in self._plugins.items()
+ for n in p.tools_registered
+ }
+ ]
+ loaded.hooks_registered = list(
+ {
+ h
+ for h, cbs in self._hooks.items()
+ if cbs # non-empty
+ }
+ - {
+ h
+ for name, p in self._plugins.items()
+ for h in p.hooks_registered
+ }
+ )
+ loaded.enabled = True
+
+ except Exception as exc:
+ loaded.error = str(exc)
+ logger.warning("Failed to load plugin '%s': %s", manifest.name, exc)
+
+ self._plugins[manifest.name] = loaded
+
+ def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
+ """Import a directory-based plugin as ``hermes_plugins.``."""
+ plugin_dir = Path(manifest.path) # type: ignore[arg-type]
+ init_file = plugin_dir / "__init__.py"
+ if not init_file.exists():
+ raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
+
+ # Ensure the namespace parent package exists
+ if _NS_PARENT not in sys.modules:
+ ns_pkg = types.ModuleType(_NS_PARENT)
+ ns_pkg.__path__ = [] # type: ignore[attr-defined]
+ ns_pkg.__package__ = _NS_PARENT
+ sys.modules[_NS_PARENT] = ns_pkg
+
+ module_name = f"{_NS_PARENT}.{manifest.name.replace('-', '_')}"
+ spec = importlib.util.spec_from_file_location(
+ module_name,
+ init_file,
+ submodule_search_locations=[str(plugin_dir)],
+ )
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Cannot create module spec for {init_file}")
+
+ module = importlib.util.module_from_spec(spec)
+ module.__package__ = module_name
+ module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ return module
+
+ def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
+ """Load a pip-installed plugin via its entry-point reference."""
+ eps = importlib.metadata.entry_points()
+ if hasattr(eps, "select"):
+ group_eps = eps.select(group=ENTRY_POINTS_GROUP)
+ elif isinstance(eps, dict):
+ group_eps = eps.get(ENTRY_POINTS_GROUP, [])
+ else:
+ group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
+
+ for ep in group_eps:
+ if ep.name == manifest.name:
+ return ep.load()
+
+ raise ImportError(
+ f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
+ )
+
+ # -----------------------------------------------------------------------
+ # Hook invocation
+ # -----------------------------------------------------------------------
+
+ def invoke_hook(self, hook_name: str, **kwargs: Any) -> None:
+ """Call all registered callbacks for *hook_name*.
+
+ Each callback is wrapped in its own try/except so a misbehaving
+ plugin cannot break the core agent loop.
+ """
+ callbacks = self._hooks.get(hook_name, [])
+ for cb in callbacks:
+ try:
+ cb(**kwargs)
+ except Exception as exc:
+ logger.warning(
+ "Hook '%s' callback %s raised: %s",
+ hook_name,
+ getattr(cb, "__name__", repr(cb)),
+ exc,
+ )
+
+ # -----------------------------------------------------------------------
+ # Introspection
+ # -----------------------------------------------------------------------
+
+ def list_plugins(self) -> List[Dict[str, Any]]:
+ """Return a list of info dicts for all discovered plugins."""
+ result: List[Dict[str, Any]] = []
+ for name, loaded in sorted(self._plugins.items()):
+ result.append(
+ {
+ "name": name,
+ "version": loaded.manifest.version,
+ "description": loaded.manifest.description,
+ "source": loaded.manifest.source,
+ "enabled": loaded.enabled,
+ "tools": len(loaded.tools_registered),
+ "hooks": len(loaded.hooks_registered),
+ "error": loaded.error,
+ }
+ )
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Module-level singleton & convenience functions
+# ---------------------------------------------------------------------------
+
+_plugin_manager: Optional[PluginManager] = None
+
+
+def get_plugin_manager() -> PluginManager:
+ """Return (and lazily create) the global PluginManager singleton."""
+ global _plugin_manager
+ if _plugin_manager is None:
+ _plugin_manager = PluginManager()
+ return _plugin_manager
+
+
+def discover_plugins() -> None:
+ """Discover and load all plugins (idempotent)."""
+ get_plugin_manager().discover_and_load()
+
+
+def invoke_hook(hook_name: str, **kwargs: Any) -> None:
+ """Invoke a lifecycle hook on all loaded plugins."""
+ get_plugin_manager().invoke_hook(hook_name, **kwargs)
+
+
+def get_plugin_tool_names() -> Set[str]:
+ """Return the set of tool names registered by plugins."""
+ return get_plugin_manager()._plugin_tool_names
+
+
+def get_plugin_toolsets() -> List[tuple]:
+ """Return plugin toolsets as ``(key, label, description)`` tuples.
+
+ Used by the ``hermes tools`` TUI so plugin-provided toolsets appear
+ alongside the built-in ones and can be toggled on/off per platform.
+ """
+ manager = get_plugin_manager()
+ if not manager._plugin_tool_names:
+ return []
+
+ try:
+ from tools.registry import registry
+ except Exception:
+ return []
+
+ # Group plugin tool names by their toolset
+ toolset_tools: Dict[str, List[str]] = {}
+ toolset_plugin: Dict[str, LoadedPlugin] = {}
+ for tool_name in manager._plugin_tool_names:
+ entry = registry._tools.get(tool_name)
+ if not entry:
+ continue
+ ts = entry.toolset
+ toolset_tools.setdefault(ts, []).append(entry.name)
+
+ # Map toolsets back to the plugin that registered them
+ for _name, loaded in manager._plugins.items():
+ for tool_name in loaded.tools_registered:
+ entry = registry._tools.get(tool_name)
+ if entry and entry.toolset in toolset_tools:
+ toolset_plugin.setdefault(entry.toolset, loaded)
+
+ result = []
+ for ts_key in sorted(toolset_tools):
+ plugin = toolset_plugin.get(ts_key)
+ label = f"๐ {ts_key.replace('_', ' ').title()}"
+ if plugin and plugin.manifest.description:
+ desc = plugin.manifest.description
+ else:
+ desc = ", ".join(sorted(toolset_tools[ts_key]))
+ result.append((ts_key, label, desc))
+
+ return result
diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py
new file mode 100644
index 00000000000..e20c1e1b0bd
--- /dev/null
+++ b/hermes_cli/plugins_cmd.py
@@ -0,0 +1,446 @@
+"""``hermes plugins`` CLI subcommand โ install, update, remove, and list plugins.
+
+Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
+Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
+
+After install, if the plugin ships an ``after-install.md`` file it is
+rendered with Rich Markdown. Otherwise a default confirmation is shown.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Minimum manifest version this installer understands.
+# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
+# future breaking changes to the manifest schema bump this.
+_SUPPORTED_MANIFEST_VERSION = 1
+
+
+def _plugins_dir() -> Path:
+ """Return the user plugins directory, creating it if needed."""
+ hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
+ plugins = Path(hermes_home) / "plugins"
+ plugins.mkdir(parents=True, exist_ok=True)
+ return plugins
+
+
+def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
+ """Validate a plugin name and return the safe target path inside *plugins_dir*.
+
+ Raises ``ValueError`` if the name contains path-traversal sequences or would
+ resolve outside the plugins directory.
+ """
+ if not name:
+ raise ValueError("Plugin name must not be empty.")
+
+ # Reject obvious traversal characters
+ for bad in ("/", "\\", ".."):
+ if bad in name:
+ raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")
+
+ target = (plugins_dir / name).resolve()
+ plugins_resolved = plugins_dir.resolve()
+
+ if (
+ not str(target).startswith(str(plugins_resolved) + os.sep)
+ and target != plugins_resolved
+ ):
+ raise ValueError(
+ f"Invalid plugin name '{name}': resolves outside the plugins directory."
+ )
+
+ return target
+
+
+def _resolve_git_url(identifier: str) -> str:
+ """Turn an identifier into a cloneable Git URL.
+
+ Accepted formats:
+ - Full URL: https://github.com/owner/repo.git
+ - Full URL: git@github.com:owner/repo.git
+ - Full URL: ssh://git@github.com/owner/repo.git
+ - Shorthand: owner/repo โ https://github.com/owner/repo.git
+
+ NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
+ security warning at install time.
+ """
+ # Already a URL
+ if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
+ return identifier
+
+ # owner/repo shorthand
+ parts = identifier.strip("/").split("/")
+ if len(parts) == 2:
+ owner, repo = parts
+ return f"https://github.com/{owner}/{repo}.git"
+
+ raise ValueError(
+ f"Invalid plugin identifier: '{identifier}'. "
+ "Use a Git URL or owner/repo shorthand."
+ )
+
+
+def _repo_name_from_url(url: str) -> str:
+ """Extract the repo name from a Git URL for the plugin directory name."""
+ # Strip trailing .git and slashes
+ name = url.rstrip("/")
+ if name.endswith(".git"):
+ name = name[:-4]
+ # Get last path component
+ name = name.rsplit("/", 1)[-1]
+ # Handle ssh-style urls: git@github.com:owner/repo
+ if ":" in name:
+ name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1]
+ return name
+
+
+def _read_manifest(plugin_dir: Path) -> dict:
+ """Read plugin.yaml and return the parsed dict, or empty dict."""
+ manifest_file = plugin_dir / "plugin.yaml"
+ if not manifest_file.exists():
+ return {}
+ try:
+ import yaml
+
+ with open(manifest_file) as f:
+ return yaml.safe_load(f) or {}
+ except Exception as e:
+ logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e)
+ return {}
+
+
+def _copy_example_files(plugin_dir: Path, console) -> None:
+ """Copy any .example files to their real names if they don't already exist.
+
+ For example, ``config.yaml.example`` becomes ``config.yaml``.
+ Skips files that already exist to avoid overwriting user config on reinstall.
+ """
+ for example_file in plugin_dir.glob("*.example"):
+ real_name = example_file.stem # e.g. "config.yaml" from "config.yaml.example"
+ real_path = plugin_dir / real_name
+ if not real_path.exists():
+ try:
+ shutil.copy2(example_file, real_path)
+ console.print(
+ f"[dim] Created {real_name} from {example_file.name}[/dim]"
+ )
+ except OSError as e:
+ console.print(
+ f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}"
+ )
+
+
+def _display_after_install(plugin_dir: Path, identifier: str) -> None:
+ """Show after-install.md if it exists, otherwise a default message."""
+ from rich.console import Console
+ from rich.markdown import Markdown
+ from rich.panel import Panel
+
+ console = Console()
+ after_install = plugin_dir / "after-install.md"
+
+ if after_install.exists():
+ content = after_install.read_text(encoding="utf-8")
+ md = Markdown(content)
+ console.print()
+ console.print(Panel(md, border_style="green", expand=False))
+ console.print()
+ else:
+ console.print()
+ console.print(
+ Panel(
+ f"[green bold]Plugin installed:[/] {identifier}\n"
+ f"[dim]Location:[/] {plugin_dir}",
+ border_style="green",
+ title="โ Installed",
+ expand=False,
+ )
+ )
+ console.print()
+
+
+def _display_removed(name: str, plugins_dir: Path) -> None:
+ """Show confirmation after removing a plugin."""
+ from rich.console import Console
+
+ console = Console()
+ console.print()
+ console.print(f"[red]โ[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}")
+ console.print()
+
+
+def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
+ """Return the plugin path if it exists, or exit with an error listing installed plugins."""
+ target = _sanitize_plugin_name(name, plugins_dir)
+ if not target.exists():
+ installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)"
+ console.print(
+ f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}.\n"
+ f"Installed plugins: {installed}"
+ )
+ sys.exit(1)
+ return target
+
+
+# ---------------------------------------------------------------------------
+# Commands
+# ---------------------------------------------------------------------------
+
+
+def cmd_install(identifier: str, force: bool = False) -> None:
+ """Install a plugin from a Git URL or owner/repo shorthand."""
+ import tempfile
+ from rich.console import Console
+
+ console = Console()
+
+ try:
+ git_url = _resolve_git_url(identifier)
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ sys.exit(1)
+
+ # Warn about insecure / local URL schemes
+ if git_url.startswith("http://") or git_url.startswith("file://"):
+ console.print(
+ "[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
+ "Consider using https:// or git@ for production installs."
+ )
+
+ plugins_dir = _plugins_dir()
+
+ # Clone into a temp directory first so we can read plugin.yaml for the name
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_target = Path(tmp) / "plugin"
+ console.print(f"[dim]Cloning {git_url}...[/dim]")
+
+ try:
+ result = subprocess.run(
+ ["git", "clone", "--depth", "1", git_url, str(tmp_target)],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ except FileNotFoundError:
+ console.print("[red]Error:[/red] git is not installed or not in PATH.")
+ sys.exit(1)
+ except subprocess.TimeoutExpired:
+ console.print("[red]Error:[/red] Git clone timed out after 60 seconds.")
+ sys.exit(1)
+
+ if result.returncode != 0:
+ console.print(
+ f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}"
+ )
+ sys.exit(1)
+
+ # Read manifest
+ manifest = _read_manifest(tmp_target)
+ plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
+
+ # Sanitize plugin name against path traversal
+ try:
+ target = _sanitize_plugin_name(plugin_name, plugins_dir)
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ sys.exit(1)
+
+ # Check manifest_version compatibility
+ mv = manifest.get("manifest_version")
+ if mv is not None:
+ try:
+ mv_int = int(mv)
+ except (ValueError, TypeError):
+ console.print(
+ f"[red]Error:[/red] Plugin '{plugin_name}' has invalid "
+ f"manifest_version '{mv}' (expected an integer)."
+ )
+ sys.exit(1)
+ if mv_int > _SUPPORTED_MANIFEST_VERSION:
+ 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]hermes update[/bold] to get a newer installer."
+ )
+ sys.exit(1)
+
+ if target.exists():
+ if not force:
+ console.print(
+ f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
+ f"Use [bold]--force[/bold] to remove and reinstall, or "
+ f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest."
+ )
+ sys.exit(1)
+ console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
+ shutil.rmtree(target)
+
+ # Move from temp to final location
+ shutil.move(str(tmp_target), str(target))
+
+ # Validate it looks like a plugin
+ if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists():
+ console.print(
+ f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml "
+ f"or __init__.py. It may not be a valid Hermes plugin."
+ )
+
+ # Copy .example files to their real names (e.g. config.yaml.example โ config.yaml)
+ _copy_example_files(target, console)
+
+ _display_after_install(target, identifier)
+
+ console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
+ console.print("[dim] hermes gateway restart[/dim]")
+ console.print()
+
+
+def cmd_update(name: str) -> None:
+ """Update an installed plugin by pulling latest from its git remote."""
+ from rich.console import Console
+
+ console = Console()
+ plugins_dir = _plugins_dir()
+
+ try:
+ target = _require_installed_plugin(name, plugins_dir, console)
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ sys.exit(1)
+
+ if not (target / ".git").exists():
+ console.print(
+ f"[red]Error:[/red] Plugin '{name}' was not installed from git "
+ f"(no .git directory). Cannot update."
+ )
+ sys.exit(1)
+
+ console.print(f"[dim]Updating {name}...[/dim]")
+
+ try:
+ result = subprocess.run(
+ ["git", "pull", "--ff-only"],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ cwd=str(target),
+ )
+ except FileNotFoundError:
+ console.print("[red]Error:[/red] git is not installed or not in PATH.")
+ sys.exit(1)
+ except subprocess.TimeoutExpired:
+ console.print("[red]Error:[/red] Git pull timed out after 60 seconds.")
+ sys.exit(1)
+
+ if result.returncode != 0:
+ console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}")
+ sys.exit(1)
+
+ # Copy any new .example files
+ _copy_example_files(target, console)
+
+ output = result.stdout.strip()
+ if "Already up to date" in output:
+ console.print(
+ f"[green]โ[/green] Plugin [bold]{name}[/bold] is already up to date."
+ )
+ else:
+ console.print(f"[green]โ[/green] Plugin [bold]{name}[/bold] updated.")
+ console.print(f"[dim]{output}[/dim]")
+
+
+def cmd_remove(name: str) -> None:
+ """Remove an installed plugin by name."""
+ from rich.console import Console
+
+ console = Console()
+ plugins_dir = _plugins_dir()
+
+ try:
+ target = _require_installed_plugin(name, plugins_dir, console)
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ sys.exit(1)
+
+ shutil.rmtree(target)
+ _display_removed(name, plugins_dir)
+
+
+def cmd_list() -> None:
+ """List installed plugins."""
+ from rich.console import Console
+ from rich.table import Table
+
+ try:
+ import yaml
+ except ImportError:
+ yaml = None
+
+ console = Console()
+ plugins_dir = _plugins_dir()
+
+ dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
+ if not dirs:
+ console.print("[dim]No plugins installed.[/dim]")
+ console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
+ return
+
+ table = Table(title="Installed Plugins", show_lines=False)
+ table.add_column("Name", style="bold")
+ table.add_column("Version", style="dim")
+ table.add_column("Description")
+ table.add_column("Source", style="dim")
+
+ for d in dirs:
+ manifest_file = d / "plugin.yaml"
+ name = d.name
+ version = ""
+ description = ""
+ source = "local"
+
+ if manifest_file.exists() and yaml:
+ try:
+ with open(manifest_file) as f:
+ manifest = yaml.safe_load(f) or {}
+ name = manifest.get("name", d.name)
+ version = manifest.get("version", "")
+ description = manifest.get("description", "")
+ except Exception:
+ pass
+
+ # Check if it's a git repo (installed via hermes plugins install)
+ if (d / ".git").exists():
+ source = "git"
+
+ table.add_row(name, str(version), description, source)
+
+ console.print()
+ console.print(table)
+ console.print()
+
+
+def plugins_command(args) -> None:
+ """Dispatch hermes plugins subcommands."""
+ action = getattr(args, "plugins_action", None)
+
+ if action == "install":
+ cmd_install(args.identifier, force=getattr(args, "force", False))
+ elif action == "update":
+ cmd_update(args.name)
+ elif action in ("remove", "rm", "uninstall"):
+ cmd_remove(args.name)
+ elif action in ("list", "ls") or action is None:
+ cmd_list()
+ else:
+ from rich.console import Console
+
+ Console().print(f"[red]Unknown plugins action: {action}[/red]")
+ sys.exit(1)
diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py
index 062558cad3f..c77a9d9dd8c 100644
--- a/hermes_cli/runtime_provider.py
+++ b/hermes_cli/runtime_provider.py
@@ -5,6 +5,7 @@
import os
from typing import Any, Dict, Optional
+from hermes_cli import auth as auth_mod
from hermes_cli.auth import (
AuthError,
PROVIDER_REGISTRY,
@@ -13,38 +14,200 @@
resolve_nous_runtime_credentials,
resolve_codex_runtime_credentials,
resolve_api_key_provider_credentials,
+ resolve_external_process_provider_credentials,
+ has_usable_secret,
)
from hermes_cli.config import load_config
from hermes_constants import OPENROUTER_BASE_URL
+def _normalize_custom_provider_name(value: str) -> str:
+ return value.strip().lower().replace(" ", "-")
+
+
+def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
+ """Auto-detect api_mode from the resolved base URL.
+
+ Direct api.openai.com endpoints need the Responses API for GPT-5.x
+ tool calls with reasoning (chat/completions returns 400).
+ """
+ normalized = (base_url or "").strip().lower().rstrip("/")
+ if "api.openai.com" in normalized and "openrouter" not in normalized:
+ return "codex_responses"
+ return None
+
+
+def _auto_detect_local_model(base_url: str) -> str:
+ """Query a local server for its model name when only one model is loaded."""
+ if not base_url:
+ return ""
+ try:
+ import requests
+ url = base_url.rstrip("/")
+ if not url.endswith("/v1"):
+ url += "/v1"
+ resp = requests.get(url + "/models", timeout=5)
+ if resp.ok:
+ models = resp.json().get("data", [])
+ if len(models) == 1:
+ model_id = models[0].get("id", "")
+ if model_id:
+ return model_id
+ except Exception:
+ pass
+ return ""
+
+
def _get_model_config() -> Dict[str, Any]:
config = load_config()
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
- return dict(model_cfg)
+ cfg = dict(model_cfg)
+ default = cfg.get("default", "").strip()
+ base_url = cfg.get("base_url", "").strip()
+ is_local = "localhost" in base_url or "127.0.0.1" in base_url
+ is_fallback = not default or default == "anthropic/claude-opus-4.6"
+ if is_local and is_fallback and base_url:
+ detected = _auto_detect_local_model(base_url)
+ if detected:
+ cfg["default"] = detected
+ return cfg
if isinstance(model_cfg, str) and model_cfg.strip():
return {"default": model_cfg.strip()}
return {}
+def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
+ configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
+ if configured_mode:
+ return configured_mode
+
+ model_name = str(model_cfg.get("default") or "").strip()
+ if not model_name:
+ return "chat_completions"
+
+ try:
+ from hermes_cli.models import copilot_model_api_mode
+
+ return copilot_model_api_mode(model_name, api_key=api_key)
+ except Exception:
+ return "chat_completions"
+
+
+_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"}
+
+
+def _parse_api_mode(raw: Any) -> Optional[str]:
+ """Validate an api_mode value from config. Returns None if invalid."""
+ if isinstance(raw, str):
+ normalized = raw.strip().lower()
+ if normalized in _VALID_API_MODES:
+ return normalized
+ return None
+
+
def resolve_requested_provider(requested: Optional[str] = None) -> str:
- """Resolve provider request from explicit arg, env, then config."""
+ """Resolve provider request from explicit arg, config, then env."""
if requested and requested.strip():
return requested.strip().lower()
- env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
- if env_provider:
- return env_provider
-
model_cfg = _get_model_config()
cfg_provider = model_cfg.get("provider")
if isinstance(cfg_provider, str) and cfg_provider.strip():
return cfg_provider.strip().lower()
+ # Prefer the persisted config selection over any stale shell/.env
+ # provider override so chat uses the endpoint the user last saved.
+ env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
+ if env_provider:
+ return env_provider
+
return "auto"
+def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
+ requested_norm = _normalize_custom_provider_name(requested_provider or "")
+ if not requested_norm or requested_norm == "custom":
+ return None
+
+ # Raw names should only map to custom providers when they are not already
+ # valid built-in providers or aliases. Explicit menu keys like
+ # ``custom:local`` always target the saved custom provider.
+ if requested_norm == "auto":
+ return None
+ if not requested_norm.startswith("custom:"):
+ try:
+ auth_mod.resolve_provider(requested_norm)
+ except AuthError:
+ pass
+ else:
+ return None
+
+ config = load_config()
+ custom_providers = config.get("custom_providers")
+ if not isinstance(custom_providers, list):
+ return None
+
+ for entry in custom_providers:
+ if not isinstance(entry, dict):
+ continue
+ name = entry.get("name")
+ base_url = entry.get("base_url")
+ if not isinstance(name, str) or not isinstance(base_url, str):
+ continue
+ name_norm = _normalize_custom_provider_name(name)
+ menu_key = f"custom:{name_norm}"
+ if requested_norm not in {name_norm, menu_key}:
+ continue
+ result = {
+ "name": name.strip(),
+ "base_url": base_url.strip(),
+ "api_key": str(entry.get("api_key", "") or "").strip(),
+ }
+ api_mode = _parse_api_mode(entry.get("api_mode"))
+ if api_mode:
+ result["api_mode"] = api_mode
+ return result
+
+ return None
+
+
+def _resolve_named_custom_runtime(
+ *,
+ requested_provider: str,
+ explicit_api_key: Optional[str] = None,
+ explicit_base_url: Optional[str] = None,
+) -> Optional[Dict[str, Any]]:
+ custom_provider = _get_named_custom_provider(requested_provider)
+ if not custom_provider:
+ return None
+
+ base_url = (
+ (explicit_base_url or "").strip()
+ or custom_provider.get("base_url", "")
+ ).rstrip("/")
+ if not base_url:
+ return None
+
+ api_key_candidates = [
+ (explicit_api_key or "").strip(),
+ str(custom_provider.get("api_key", "") or "").strip(),
+ os.getenv("OPENAI_API_KEY", "").strip(),
+ os.getenv("OPENROUTER_API_KEY", "").strip(),
+ ]
+ api_key = next((candidate for candidate in api_key_candidates if has_usable_secret(candidate)), "")
+
+ return {
+ "provider": "custom",
+ "api_mode": custom_provider.get("api_mode")
+ or _detect_api_mode_for_url(base_url)
+ or "chat_completions",
+ "base_url": base_url,
+ "api_key": api_key,
+ "source": f"custom_provider:{custom_provider.get('name', requested_provider)}",
+ }
+
+
def _resolve_openrouter_runtime(
*,
requested_provider: str,
@@ -54,6 +217,12 @@ def _resolve_openrouter_runtime(
model_cfg = _get_model_config()
cfg_base_url = model_cfg.get("base_url") if isinstance(model_cfg.get("base_url"), str) else ""
cfg_provider = model_cfg.get("provider") if isinstance(model_cfg.get("provider"), str) else ""
+ cfg_api_key = ""
+ for k in ("api_key", "api"):
+ v = model_cfg.get(k)
+ if isinstance(v, str) and v.strip():
+ cfg_api_key = v.strip()
+ break
requested_norm = (requested_provider or "").strip().lower()
cfg_provider = cfg_provider.strip().lower()
@@ -61,20 +230,24 @@ def _resolve_openrouter_runtime(
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
use_config_base_url = False
- if requested_norm == "auto":
- if cfg_base_url.strip() and not explicit_base_url and not env_openai_base_url:
- if not cfg_provider or cfg_provider == "auto":
+ if cfg_base_url.strip() and not explicit_base_url:
+ if requested_norm == "auto":
+ if (not cfg_provider or cfg_provider == "auto") and not env_openai_base_url:
use_config_base_url = True
+ elif requested_norm == "custom" and cfg_provider == "custom":
+ # provider: custom โ use base_url from config (Fixes #1760).
+ use_config_base_url = True
# When the user explicitly requested the openrouter provider, skip
# OPENAI_BASE_URL โ it typically points to a custom / non-OpenRouter
# endpoint and would prevent switching back to OpenRouter (#874).
skip_openai_base = requested_norm == "openrouter"
+ # For custom, prefer config base_url over env so config.yaml is honored (#1760).
base_url = (
(explicit_base_url or "").strip()
- or ("" if skip_openai_base else env_openai_base_url)
or (cfg_base_url.strip() if use_config_base_url else "")
+ or ("" if skip_openai_base else env_openai_base_url)
or env_openrouter_base_url
or OPENROUTER_BASE_URL
).rstrip("/")
@@ -86,25 +259,39 @@ def _resolve_openrouter_runtime(
# provider (issues #420, #560).
_is_openrouter_url = "openrouter.ai" in base_url
if _is_openrouter_url:
- api_key = (
- explicit_api_key
- or os.getenv("OPENROUTER_API_KEY")
- or os.getenv("OPENAI_API_KEY")
- or ""
- )
+ api_key_candidates = [
+ explicit_api_key,
+ os.getenv("OPENROUTER_API_KEY"),
+ os.getenv("OPENAI_API_KEY"),
+ ]
else:
- api_key = (
- explicit_api_key
- or os.getenv("OPENAI_API_KEY")
- or os.getenv("OPENROUTER_API_KEY")
- or ""
- )
+ # Custom endpoint: use api_key from config when using config base_url (#1760).
+ api_key_candidates = [
+ explicit_api_key,
+ (cfg_api_key if use_config_base_url else ""),
+ os.getenv("OPENAI_API_KEY"),
+ os.getenv("OPENROUTER_API_KEY"),
+ ]
+ api_key = next(
+ (str(candidate or "").strip() for candidate in api_key_candidates if has_usable_secret(candidate)),
+ "",
+ )
source = "explicit" if (explicit_api_key or explicit_base_url) else "env/config"
+ # When "custom" was explicitly requested, preserve that as the provider
+ # name instead of silently relabeling to "openrouter" (#2562).
+ # Also provide a placeholder API key for local servers that don't require
+ # authentication โ the OpenAI SDK requires a non-empty api_key string.
+ effective_provider = "custom" if requested_norm == "custom" else "openrouter"
+ if effective_provider == "custom" and not api_key and not _is_openrouter_url:
+ api_key = "no-key-required"
+
return {
- "provider": "openrouter",
- "api_mode": "chat_completions",
+ "provider": effective_provider,
+ "api_mode": _parse_api_mode(model_cfg.get("api_mode"))
+ or _detect_api_mode_for_url(base_url)
+ or "chat_completions",
"base_url": base_url,
"api_key": api_key,
"source": source,
@@ -120,6 +307,15 @@ def resolve_runtime_provider(
"""Resolve runtime provider credentials for agent execution."""
requested_provider = resolve_requested_provider(requested)
+ custom_runtime = _resolve_named_custom_runtime(
+ requested_provider=requested_provider,
+ explicit_api_key=explicit_api_key,
+ explicit_base_url=explicit_base_url,
+ )
+ if custom_runtime:
+ custom_runtime["requested_provider"] = requested_provider
+ return custom_runtime
+
provider = resolve_provider(
requested_provider,
explicit_api_key=explicit_api_key,
@@ -153,19 +349,41 @@ def resolve_runtime_provider(
"requested_provider": requested_provider,
}
+ if provider == "copilot-acp":
+ creds = resolve_external_process_provider_credentials(provider)
+ return {
+ "provider": "copilot-acp",
+ "api_mode": "chat_completions",
+ "base_url": creds.get("base_url", "").rstrip("/"),
+ "api_key": creds.get("api_key", ""),
+ "command": creds.get("command", ""),
+ "args": list(creds.get("args") or []),
+ "source": creds.get("source", "process"),
+ "requested_provider": requested_provider,
+ }
+
# Anthropic (native Messages API)
if provider == "anthropic":
from agent.anthropic_adapter import resolve_anthropic_token
token = resolve_anthropic_token()
if not token:
raise AuthError(
- "No Anthropic credentials found. Set ANTHROPIC_API_KEY, "
+ "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, "
"run 'claude setup-token', or authenticate with 'claude /login'."
)
+ # Allow base URL override from config.yaml model.base_url, but only
+ # when the configured provider is anthropic โ otherwise a non-Anthropic
+ # base_url (e.g. Codex endpoint) would leak into Anthropic requests.
+ model_cfg = _get_model_config()
+ cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
+ cfg_base_url = ""
+ if cfg_provider == "anthropic":
+ cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
+ base_url = cfg_base_url or "https://api.anthropic.com"
return {
"provider": "anthropic",
"api_mode": "anthropic_messages",
- "base_url": "https://api.anthropic.com",
+ "base_url": base_url,
"api_key": token,
"source": "env",
"requested_provider": requested_provider,
@@ -175,12 +393,36 @@ def resolve_runtime_provider(
pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig and pconfig.auth_type == "api_key":
creds = resolve_api_key_provider_credentials(provider)
+ model_cfg = _get_model_config()
+ base_url = creds.get("base_url", "").rstrip("/")
+ api_mode = "chat_completions"
+ if provider == "copilot":
+ api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", ""))
+ else:
+ # Check explicit api_mode from model config first
+ configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
+ if configured_mode:
+ api_mode = configured_mode
+ # Auto-detect Anthropic-compatible endpoints by URL convention
+ # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
+ elif base_url.rstrip("/").endswith("/anthropic"):
+ api_mode = "anthropic_messages"
+ # MiniMax providers always use Anthropic Messages API.
+ # Auto-correct stale /v1 URLs (from old .env or config) to /anthropic.
+ elif provider in ("minimax", "minimax-cn"):
+ api_mode = "anthropic_messages"
+ if base_url.rstrip("/").endswith("/v1"):
+ base_url = base_url.rstrip("/")[:-3] + "/anthropic"
return {
"provider": provider,
- "api_mode": "chat_completions",
- "base_url": creds.get("base_url", "").rstrip("/"),
+ "api_mode": api_mode,
+ "base_url": base_url,
"api_key": creds.get("api_key", ""),
"source": creds.get("source", "env"),
+ "request_headers_resolver": creds.get("request_headers_resolver"),
+ "request_headers_key": creds.get("request_headers_key"),
+ "payment_adapter": creds.get("payment_adapter"),
+ "payment_config": creds.get("payment_config"),
"requested_provider": requested_provider,
}
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index 405036acc73..e2336f174af 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -4,9 +4,9 @@
Modular wizard with independently-runnable sections:
1. Model & Provider โ choose your AI provider and model
2. Terminal Backend โ where your agent runs commands
- 3. Messaging Platforms โ connect Telegram, Discord, etc.
- 4. Tools โ configure TTS, web search, image generation, etc.
- 5. Agent Settings โ iterations, compression, session reset
+ 3. Agent Settings โ iterations, compression, session reset
+ 4. Messaging Platforms โ connect Telegram, Discord, etc.
+ 5. Tools โ configure TTS, web search, image generation, etc.
Config files are stored in ~/.hermes/ for easy access.
"""
@@ -55,13 +55,92 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
# Default model lists per provider โ used as fallback when the live
# /models endpoint can't be reached.
_DEFAULT_PROVIDER_MODELS = {
+ "copilot-acp": [
+ "copilot-acp",
+ ],
+ "copilot": [
+ "gpt-5.4",
+ "gpt-5.4-mini",
+ "gpt-5-mini",
+ "gpt-5.3-codex",
+ "gpt-5.2-codex",
+ "gpt-4.1",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "claude-opus-4.6",
+ "claude-sonnet-4.6",
+ "claude-sonnet-4.5",
+ "claude-haiku-4.5",
+ "gemini-2.5-pro",
+ "grok-code-fast-1",
+ ],
"zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
- "minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
- "minimax-cn": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
+ "minimax": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
+ "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
+ "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
+ "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
+ "huggingface": [
+ "Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
+ "Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
+ "deepseek-ai/DeepSeek-V3.2", "moonshotai/Kimi-K2.5",
+ ],
}
+def _current_reasoning_effort(config: Dict[str, Any]) -> str:
+ agent_cfg = config.get("agent")
+ if isinstance(agent_cfg, dict):
+ return str(agent_cfg.get("reasoning_effort") or "").strip().lower()
+ return ""
+
+
+def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
+ agent_cfg = config.get("agent")
+ if not isinstance(agent_cfg, dict):
+ agent_cfg = {}
+ config["agent"] = agent_cfg
+ agent_cfg["reasoning_effort"] = effort
+
+
+def _setup_copilot_reasoning_selection(
+ config: Dict[str, Any],
+ model_id: str,
+ prompt_choice,
+ *,
+ catalog: Optional[list[dict[str, Any]]] = None,
+ api_key: str = "",
+) -> None:
+ from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id
+
+ normalized_model = normalize_copilot_model_id(
+ model_id,
+ catalog=catalog,
+ api_key=api_key,
+ ) or model_id
+ efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key)
+ if not efforts:
+ return
+
+ current_effort = _current_reasoning_effort(config)
+ choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"]
+
+ if current_effort == "none":
+ default_idx = len(efforts)
+ elif current_effort in efforts:
+ default_idx = efforts.index(current_effort)
+ elif "medium" in efforts:
+ default_idx = efforts.index("medium")
+ else:
+ default_idx = len(choices) - 1
+
+ effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx)
+ if effort_idx < len(efforts):
+ _set_reasoning_effort(config, efforts[effort_idx])
+ elif effort_idx == len(efforts):
+ _set_reasoning_effort(config, "none")
+
+
def _setup_provider_model_selection(config, provider_id, current_model, prompt_choice, prompt_fn):
"""Model selection for API-key providers with live /models detection.
@@ -69,29 +148,60 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
hardcoded default list with a warning if the endpoint is unreachable.
Always offers a 'Custom model' escape hatch.
"""
- from hermes_cli.auth import PROVIDER_REGISTRY
+ from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
from hermes_cli.config import get_env_value
- from hermes_cli.models import fetch_api_models
+ from hermes_cli.models import (
+ copilot_model_api_mode,
+ fetch_api_models,
+ fetch_github_model_catalog,
+ normalize_copilot_model_id,
+ )
pconfig = PROVIDER_REGISTRY[provider_id]
+ is_copilot_catalog_provider = provider_id in {"copilot", "copilot-acp"}
# Resolve API key and base URL for the probe
- api_key = ""
- for ev in pconfig.api_key_env_vars:
- api_key = get_env_value(ev) or os.getenv(ev, "")
- if api_key:
- break
- base_url_env = pconfig.base_url_env_var or ""
- base_url = (get_env_value(base_url_env) if base_url_env else "") or pconfig.inference_base_url
+ if is_copilot_catalog_provider:
+ api_key = ""
+ if provider_id == "copilot":
+ creds = resolve_api_key_provider_credentials(provider_id)
+ api_key = creds.get("api_key", "")
+ base_url = creds.get("base_url", "") or pconfig.inference_base_url
+ else:
+ try:
+ creds = resolve_api_key_provider_credentials("copilot")
+ api_key = creds.get("api_key", "")
+ except Exception:
+ pass
+ base_url = pconfig.inference_base_url
+ catalog = fetch_github_model_catalog(api_key)
+ current_model = normalize_copilot_model_id(
+ current_model,
+ catalog=catalog,
+ api_key=api_key,
+ ) or current_model
+ else:
+ api_key = ""
+ for ev in pconfig.api_key_env_vars:
+ api_key = get_env_value(ev) or os.getenv(ev, "")
+ if api_key:
+ break
+ base_url_env = pconfig.base_url_env_var or ""
+ base_url = (get_env_value(base_url_env) if base_url_env else "") or pconfig.inference_base_url
+ catalog = None
# Try live /models endpoint
- live_models = fetch_api_models(api_key, base_url)
+ if is_copilot_catalog_provider and catalog:
+ live_models = [item.get("id", "") for item in catalog if item.get("id")]
+ else:
+ live_models = fetch_api_models(api_key, base_url)
if live_models:
provider_models = live_models
print_info(f"Found {len(live_models)} model(s) from {pconfig.name} API")
else:
- provider_models = _DEFAULT_PROVIDER_MODELS.get(provider_id, [])
+ fallback_provider_id = "copilot" if provider_id == "copilot-acp" else provider_id
+ provider_models = _DEFAULT_PROVIDER_MODELS.get(fallback_provider_id, [])
if provider_models:
print_warning(
f"Could not auto-detect models from {pconfig.name} API โ showing defaults.\n"
@@ -105,13 +215,57 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
keep_idx = len(model_choices) - 1
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
+ selected_model = current_model
+
if model_idx < len(provider_models):
- _set_default_model(config, provider_models[model_idx])
+ selected_model = provider_models[model_idx]
+ if is_copilot_catalog_provider:
+ selected_model = normalize_copilot_model_id(
+ selected_model,
+ catalog=catalog,
+ api_key=api_key,
+ ) or selected_model
+ _set_default_model(config, selected_model)
elif model_idx == len(provider_models):
custom = prompt_fn("Enter model name")
if custom:
- _set_default_model(config, custom)
- # else: keep current
+ if is_copilot_catalog_provider:
+ selected_model = normalize_copilot_model_id(
+ custom,
+ catalog=catalog,
+ api_key=api_key,
+ ) or custom
+ else:
+ selected_model = custom
+ _set_default_model(config, selected_model)
+ else:
+ # "Keep current" selected โ validate it's compatible with the new
+ # provider. OpenRouter-formatted names (containing "/") won't work
+ # on direct-API providers and would silently break the gateway.
+ if "/" in (current_model or "") and provider_models:
+ print_warning(
+ f"Current model \"{current_model}\" looks like an OpenRouter model "
+ f"and won't work with {pconfig.name}. "
+ f"Switching to {provider_models[0]}."
+ )
+ selected_model = provider_models[0]
+ _set_default_model(config, provider_models[0])
+
+ if provider_id == "copilot" and selected_model:
+ model_cfg = _model_config_dict(config)
+ model_cfg["api_mode"] = copilot_model_api_mode(
+ selected_model,
+ catalog=catalog,
+ api_key=api_key,
+ )
+ config["model"] = model_cfg
+ _setup_copilot_reasoning_selection(
+ config,
+ selected_model,
+ prompt_choice,
+ catalog=catalog,
+ api_key=api_key,
+ )
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
@@ -134,7 +288,6 @@ def _sync_model_from_disk(config: Dict[str, Any]) -> None:
save_env_value,
get_env_value,
ensure_hermes_home,
- DEFAULT_CONFIG,
)
from hermes_cli.colors import Colors, color
@@ -166,6 +319,36 @@ def print_error(text: str):
print(color(f"โ {text}", Colors.RED))
+def is_interactive_stdin() -> bool:
+ """Return True when stdin looks like a usable interactive TTY."""
+ stdin = getattr(sys, "stdin", None)
+ if stdin is None:
+ return False
+ try:
+ return bool(stdin.isatty())
+ except Exception:
+ return False
+
+
+def print_noninteractive_setup_guidance(reason: str | None = None) -> None:
+ """Print guidance for headless/non-interactive setup flows."""
+ print()
+ print(color("โ Hermes Setup โ Non-interactive mode", Colors.CYAN, Colors.BOLD))
+ print()
+ if reason:
+ print_info(reason)
+ print_info("The interactive wizard cannot be used here.")
+ print()
+ print_info("Configure Hermes using environment variables or config commands:")
+ print_info(" hermes config set model.provider custom")
+ print_info(" hermes config set model.base_url http://localhost:8080/v1")
+ print_info(" hermes config set model.default your-model-name")
+ print()
+ print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.")
+ print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.")
+ print()
+
+
def prompt(question: str, default: str = None, password: bool = False) -> str:
"""Prompt for input with optional default."""
if default:
@@ -187,54 +370,86 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
sys.exit(1)
+def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int:
+ """Single-select menu using curses to avoid simple_term_menu rendering bugs."""
+ try:
+ import curses
+ result_holder = [default]
+
+ def _curses_menu(stdscr):
+ curses.curs_set(0)
+ if curses.has_colors():
+ curses.start_color()
+ curses.use_default_colors()
+ curses.init_pair(1, curses.COLOR_GREEN, -1)
+ curses.init_pair(2, curses.COLOR_YELLOW, -1)
+ cursor = default
+
+ while True:
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+ try:
+ stdscr.addnstr(
+ 0,
+ 0,
+ question,
+ max_x - 1,
+ curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0),
+ )
+ except curses.error:
+ pass
+
+ for i, choice in enumerate(choices):
+ y = i + 2
+ if y >= max_y - 1:
+ break
+ arrow = "โ" if i == cursor else " "
+ line = f" {arrow} {choice}"
+ attr = curses.A_NORMAL
+ if i == cursor:
+ attr = curses.A_BOLD
+ if curses.has_colors():
+ attr |= curses.color_pair(1)
+ try:
+ stdscr.addnstr(y, 0, line, max_x - 1, attr)
+ except curses.error:
+ pass
+
+ stdscr.refresh()
+ key = stdscr.getch()
+ if key in (curses.KEY_UP, ord("k")):
+ cursor = (cursor - 1) % len(choices)
+ elif key in (curses.KEY_DOWN, ord("j")):
+ cursor = (cursor + 1) % len(choices)
+ elif key in (curses.KEY_ENTER, 10, 13):
+ result_holder[0] = cursor
+ return
+ elif key in (27, ord("q")):
+ return
+
+ curses.wrapper(_curses_menu)
+ return result_holder[0]
+ except Exception:
+ return -1
+
+
+
def prompt_choice(question: str, choices: list, default: int = 0) -> int:
"""Prompt for a choice from a list with arrow key navigation.
Escape keeps the current default (skips the question).
Ctrl+C exits the wizard.
"""
- print(color(question, Colors.YELLOW))
-
- # Try to use interactive menu if available
- try:
- from simple_term_menu import TerminalMenu
- import re
-
- # Strip emoji characters โ simple_term_menu miscalculates visual
- # width of emojis, causing duplicated/garbled lines on redraw.
- _emoji_re = re.compile(
- "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f"
- "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+",
- flags=re.UNICODE,
- )
- menu_choices = [f" {_emoji_re.sub('', choice).strip()}" for choice in choices]
-
- print_info(" โ/โ Navigate Enter Select Esc Skip Ctrl+C Exit")
-
- terminal_menu = TerminalMenu(
- menu_choices,
- cursor_index=default,
- menu_cursor="โ ",
- menu_cursor_style=("fg_green", "bold"),
- menu_highlight_style=("fg_green",),
- cycle_cursor=True,
- clear_screen=False,
- )
-
- idx = terminal_menu.show()
- if idx is None: # User pressed Escape โ keep current value
- print_info(f" Skipped (keeping current)")
+ idx = _curses_prompt_choice(question, choices, default)
+ if idx >= 0:
+ if idx == default:
+ print_info(" Skipped (keeping current)")
print()
return default
- print() # Add newline after selection
+ print()
return idx
- except (ImportError, NotImplementedError):
- pass
- except Exception as e:
- print(f" (Interactive menu unavailable: {e})")
-
- # Fallback to number-based selection (simple_term_menu doesn't support Windows)
+ print(color(question, Colors.YELLOW))
for i, choice in enumerate(choices):
marker = "โ" if i == default else "โ"
if i == default:
@@ -304,84 +519,15 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
if pre_selected is None:
pre_selected = []
- print(color(title, Colors.YELLOW))
- print_info(" SPACE Toggle ENTER Confirm ESC Skip Ctrl+C Exit")
- print()
-
- try:
- from simple_term_menu import TerminalMenu
- import re
-
- # Strip emoji characters from menu labels โ simple_term_menu miscalculates
- # visual width of emojis on macOS, causing duplicated/garbled lines.
- _emoji_re = re.compile(
- "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f"
- "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+",
- flags=re.UNICODE,
- )
- menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items]
-
- # Map pre-selected indices to the actual menu entry strings
- preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)]
-
- terminal_menu = TerminalMenu(
- menu_items,
- multi_select=True,
- show_multi_select_hint=False,
- multi_select_cursor="[โ] ",
- multi_select_select_on_accept=False,
- multi_select_empty_ok=True,
- preselected_entries=preselected if preselected else None,
- menu_cursor="โ ",
- menu_cursor_style=("fg_green", "bold"),
- menu_highlight_style=("fg_green",),
- cycle_cursor=True,
- clear_screen=False,
- )
-
- terminal_menu.show()
+ from hermes_cli.curses_ui import curses_checklist
- if terminal_menu.chosen_menu_entries is None:
- print_info(" Skipped (keeping current)")
- return list(pre_selected)
-
- selected = list(terminal_menu.chosen_menu_indices or [])
- return selected
-
- except (ImportError, NotImplementedError):
- # Fallback: numbered toggle interface (simple_term_menu doesn't support Windows)
- selected = set(pre_selected)
-
- while True:
- for i, item in enumerate(items):
- marker = color("[โ]", Colors.GREEN) if i in selected else "[ ]"
- print(f" {marker} {i + 1}. {item}")
- print()
-
- try:
- value = input(
- color(" Toggle # (or Enter to confirm): ", Colors.DIM)
- ).strip()
- if not value:
- break
- idx = int(value) - 1
- if 0 <= idx < len(items):
- if idx in selected:
- selected.discard(idx)
- else:
- selected.add(idx)
- else:
- print_error(f"Enter a number between 1 and {len(items)}")
- except ValueError:
- print_error("Enter a number")
- except (KeyboardInterrupt, EOFError):
- print()
- return []
-
- # Clear and redraw (simple approach)
- print()
-
- return sorted(selected)
+ chosen = curses_checklist(
+ title,
+ items,
+ set(pre_selected),
+ cancel_returns=set(pre_selected),
+ )
+ return sorted(chosen)
def _prompt_api_key(var: dict):
@@ -407,9 +553,9 @@ def _prompt_api_key(var: dict):
if value:
save_env_value(var["name"], value)
- print_success(f" โ Saved")
+ print_success(" โ Saved")
else:
- print_warning(f" Skipped (configure later with 'hermes setup')")
+ print_warning(" Skipped (configure later with 'hermes setup')")
def _print_setup_summary(config: dict, hermes_home):
@@ -420,19 +566,30 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status = []
- # OpenRouter (required for vision, moa)
- if get_env_value("OPENROUTER_API_KEY"):
+ # Vision โ use the same runtime resolver as the actual vision tools
+ try:
+ from agent.auxiliary_client import get_available_vision_backends
+
+ _vision_backends = get_available_vision_backends()
+ except Exception:
+ _vision_backends = []
+
+ if _vision_backends:
tool_status.append(("Vision (image analysis)", True, None))
+ else:
+ tool_status.append(("Vision (image analysis)", False, "run 'hermes setup' to configure"))
+
+ # Mixture of Agents โ requires OpenRouter specifically (calls multiple models)
+ if get_env_value("OPENROUTER_API_KEY"):
tool_status.append(("Mixture of Agents", True, None))
else:
- tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY"))
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
- # Firecrawl (web tools)
- if get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"):
+ # Web tools (Parallel, Firecrawl, or Tavily)
+ if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
tool_status.append(("Web Search & Extract", True, None))
else:
- tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY"))
+ tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
# Browser tools (local Chromium or Browserbase cloud)
import shutil
@@ -464,6 +621,16 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"):
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
+ elif tts_provider == "neutts":
+ try:
+ import importlib.util
+ neutts_ok = importlib.util.find_spec("neutts") is not None
+ except Exception:
+ neutts_ok = False
+ if neutts_ok:
+ tool_status.append(("Text-to-Speech (NeuTTS local)", True, None))
+ else:
+ tool_status.append(("Text-to-Speech (NeuTTS โ not installed)", False, "run 'hermes setup tts'"))
else:
tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
@@ -562,10 +729,10 @@ def _print_setup_summary(config: dict, hermes_home):
print(
f" {color('hermes config edit', Colors.GREEN)} Open config in your editor"
)
- print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}")
- print(f" Set a specific value")
+ print(f" {color('hermes config set ', Colors.GREEN)}")
+ print(" Set a specific value")
print()
- print(f" Or edit the files directly:")
+ print(" Or edit the files directly:")
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
print()
@@ -593,13 +760,13 @@ def _prompt_container_resources(config: dict):
print_info(" Persistent filesystem keeps files between sessions.")
print_info(" Set to 'no' for ephemeral sandboxes that reset each time.")
persist_str = prompt(
- f" Persist filesystem across sessions? (yes/no)", persist_label
+ " Persist filesystem across sessions? (yes/no)", persist_label
)
terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1")
# CPU
current_cpu = terminal.get("container_cpu", 1)
- cpu_str = prompt(f" CPU cores", str(current_cpu))
+ cpu_str = prompt(" CPU cores", str(current_cpu))
try:
terminal["container_cpu"] = float(cpu_str)
except ValueError:
@@ -607,7 +774,7 @@ def _prompt_container_resources(config: dict):
# Memory
current_mem = terminal.get("container_memory", 5120)
- mem_str = prompt(f" Memory in MB (5120 = 5GB)", str(current_mem))
+ mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
try:
terminal["container_memory"] = int(mem_str)
except ValueError:
@@ -615,7 +782,7 @@ def _prompt_container_resources(config: dict):
# Disk
current_disk = terminal.get("container_disk", 51200)
- disk_str = prompt(f" Disk in MB (51200 = 50GB)", str(current_disk))
+ disk_str = prompt(" Disk in MB (51200 = 50GB)", str(current_disk))
try:
terminal["container_disk"] = int(disk_str)
except ValueError:
@@ -635,17 +802,16 @@ def setup_model_provider(config: dict):
"""Configure the inference provider and default model."""
from hermes_cli.auth import (
get_active_provider,
- get_provider_auth_state,
PROVIDER_REGISTRY,
- format_auth_error,
- AuthError,
fetch_nous_models,
resolve_nous_runtime_credentials,
_update_config_for_provider,
_login_openai_codex,
- get_codex_auth_status,
+ resolve_codex_runtime_credentials,
DEFAULT_CODEX_BASE_URL,
detect_external_credentials,
+ get_auth_status,
+ resolve_api_key_provider_credentials,
)
print_header("Inference Provider")
@@ -655,6 +821,14 @@ def setup_model_provider(config: dict):
existing_or = get_env_value("OPENROUTER_API_KEY")
active_oauth = get_active_provider()
existing_custom = get_env_value("OPENAI_BASE_URL")
+ copilot_status = get_auth_status("copilot")
+ copilot_acp_status = get_auth_status("copilot-acp")
+
+ model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
+ current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None
+ if current_config_provider == "auto":
+ current_config_provider = None
+ current_config_base_url = str(model_cfg.get("base_url") or "").strip()
# Detect credentials from other CLI tools
detected_creds = detect_external_credentials()
@@ -668,10 +842,28 @@ def setup_model_provider(config: dict):
print()
# Detect if any provider is already configured
- has_any_provider = bool(active_oauth or existing_custom or existing_or)
+ has_any_provider = bool(
+ current_config_provider
+ or active_oauth
+ or existing_custom
+ or existing_or
+ or copilot_status.get("logged_in")
+ or copilot_acp_status.get("logged_in")
+ )
# Build "keep current" label
- if active_oauth and active_oauth in PROVIDER_REGISTRY:
+ if current_config_provider == "custom":
+ custom_label = current_config_base_url or existing_custom
+ keep_label = (
+ f"Keep current (Custom: {custom_label})"
+ if custom_label
+ else "Keep current (Custom)"
+ )
+ elif current_config_provider == "openrouter":
+ keep_label = "Keep current (OpenRouter)"
+ elif current_config_provider and current_config_provider in PROVIDER_REGISTRY:
+ keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})"
+ elif active_oauth and active_oauth in PROVIDER_REGISTRY:
keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})"
elif existing_custom:
keep_label = f"Keep current (Custom: {existing_custom})"
@@ -681,21 +873,30 @@ def setup_model_provider(config: dict):
keep_label = None # No provider configured โ don't show "Keep current"
provider_choices = [
+ "OpenRouter API key (100+ models, pay-per-use)",
"Login with Nous Portal (Nous Research subscription โ OAuth)",
"Login with OpenAI Codex",
- "OpenRouter API key (100+ models, pay-per-use)",
"Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)",
"Z.AI / GLM (Zhipu AI models)",
"Kimi / Moonshot (Kimi coding models)",
"MiniMax (global endpoint)",
"MiniMax China (mainland China endpoint)",
+ "Kilo Code (Kilo Gateway API)",
"Anthropic (Claude models โ API key or Claude Code subscription)",
+ "AI Gateway (Vercel โ 200+ models, pay-per-use)",
+ "Alibaba Cloud / DashScope (Qwen models via Anthropic-compatible API)",
+ "OpenCode Zen (35+ curated models, pay-as-you-go)",
+ "OpenCode Go (open models, $10/month subscription)",
+ "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)",
+ "GitHub Copilot ACP (spawns `copilot --acp --stdio`)",
+ "xgate (ai.xgate.run inference endpoint)",
+ "Hugging Face Inference Providers (20+ open models)",
]
if keep_label:
provider_choices.append(keep_label)
# Default to "Keep current" if a provider exists, otherwise OpenRouter (most common)
- default_provider = len(provider_choices) - 1 if has_any_provider else 2
+ default_provider = len(provider_choices) - 1 if has_any_provider else 0
if not has_any_provider:
print_warning("An inference provider is required for Hermes to work.")
@@ -709,9 +910,65 @@ def setup_model_provider(config: dict):
selected_provider = (
None # "nous", "openai-codex", "openrouter", "custom", or None (keep)
)
+ selected_base_url = None # deferred until after model selection
nous_models = [] # populated if Nous login succeeds
- if provider_idx == 0: # Nous Portal (OAuth)
+ if provider_idx == 0: # OpenRouter
+ selected_provider = "openrouter"
+ print()
+ print_header("OpenRouter API Key")
+ print_info("OpenRouter provides access to 100+ models from multiple providers.")
+ print_info("Get your API key at: https://openrouter.ai/keys")
+
+ if existing_or:
+ print_info(f"Current: {existing_or[:8]}... (configured)")
+ if prompt_yes_no("Update OpenRouter API key?", False):
+ api_key = prompt(" OpenRouter API key", password=True)
+ if api_key:
+ save_env_value("OPENROUTER_API_KEY", api_key)
+ print_success("OpenRouter API key updated")
+ else:
+ api_key = prompt(" OpenRouter API key", password=True)
+ if api_key:
+ save_env_value("OPENROUTER_API_KEY", api_key)
+ print_success("OpenRouter API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear any custom endpoint if switching to OpenRouter
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+
+ # Update config.yaml and deactivate any OAuth provider so the
+ # resolver doesn't keep returning the old provider (e.g. Codex).
+ try:
+ from hermes_cli.auth import deactivate_provider
+
+ deactivate_provider()
+ except Exception:
+ pass
+ import yaml
+
+ config_path = (
+ Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml"
+ )
+ try:
+ disk_cfg = {}
+ if config_path.exists():
+ disk_cfg = yaml.safe_load(config_path.read_text()) or {}
+ model_section = disk_cfg.get("model", {})
+ if isinstance(model_section, str):
+ model_section = {"default": model_section}
+ model_section["provider"] = "openrouter"
+ model_section.pop("base_url", None) # OpenRouter uses default URL
+ disk_cfg["model"] = model_section
+ config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False))
+ _set_model_provider(config, "openrouter")
+ except Exception as e:
+ logger.debug("Could not save provider to config.yaml: %s", e)
+
+ elif provider_idx == 1: # Nous Portal (OAuth)
selected_provider = "nous"
print()
print_header("Nous Portal Login")
@@ -720,7 +977,7 @@ def setup_model_provider(config: dict):
print()
try:
- from hermes_cli.auth import _login_nous, ProviderConfig
+ from hermes_cli.auth import _login_nous
import argparse
mock_args = argparse.Namespace(
@@ -759,7 +1016,7 @@ def setup_model_provider(config: dict):
print_info("You can try again later with: hermes model")
selected_provider = None
- elif provider_idx == 1: # OpenAI Codex
+ elif provider_idx == 2: # OpenAI Codex
selected_provider = "openai-codex"
print()
print_header("OpenAI Codex Login")
@@ -785,129 +1042,22 @@ def setup_model_provider(config: dict):
print_info("You can try again later with: hermes model")
selected_provider = None
- elif provider_idx == 2: # OpenRouter
- selected_provider = "openrouter"
- print()
- print_header("OpenRouter API Key")
- print_info("OpenRouter provides access to 100+ models from multiple providers.")
- print_info("Get your API key at: https://openrouter.ai/keys")
-
- if existing_or:
- print_info(f"Current: {existing_or[:8]}... (configured)")
- if prompt_yes_no("Update OpenRouter API key?", False):
- api_key = prompt(" OpenRouter API key", password=True)
- if api_key:
- save_env_value("OPENROUTER_API_KEY", api_key)
- print_success("OpenRouter API key updated")
- else:
- api_key = prompt(" OpenRouter API key", password=True)
- if api_key:
- save_env_value("OPENROUTER_API_KEY", api_key)
- print_success("OpenRouter API key saved")
- else:
- print_warning("Skipped - agent won't work without an API key")
-
- # Clear any custom endpoint if switching to OpenRouter
- if existing_custom:
- save_env_value("OPENAI_BASE_URL", "")
- save_env_value("OPENAI_API_KEY", "")
-
- # Update config.yaml and deactivate any OAuth provider so the
- # resolver doesn't keep returning the old provider (e.g. Codex).
- try:
- from hermes_cli.auth import deactivate_provider
-
- deactivate_provider()
- except Exception:
- pass
- import yaml
-
- config_path = (
- Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml"
- )
- try:
- disk_cfg = {}
- if config_path.exists():
- disk_cfg = yaml.safe_load(config_path.read_text()) or {}
- model_section = disk_cfg.get("model", {})
- if isinstance(model_section, str):
- model_section = {"default": model_section}
- model_section["provider"] = "openrouter"
- model_section.pop("base_url", None) # OpenRouter uses default URL
- disk_cfg["model"] = model_section
- config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False))
- _set_model_provider(config, "openrouter")
- except Exception as e:
- logger.debug("Could not save provider to config.yaml: %s", e)
-
elif provider_idx == 3: # Custom endpoint
selected_provider = "custom"
print()
print_header("Custom OpenAI-Compatible Endpoint")
print_info("Works with any API that follows OpenAI's chat completions spec")
+ print()
- current_url = get_env_value("OPENAI_BASE_URL") or ""
- current_key = get_env_value("OPENAI_API_KEY")
- _raw_model = config.get("model", "")
- current_model = (
- _raw_model.get("default", "")
- if isinstance(_raw_model, dict)
- else (_raw_model or "")
- )
-
- if current_url:
- print_info(f" Current URL: {current_url}")
- if current_key:
- print_info(f" Current key: {current_key[:8]}... (configured)")
-
- base_url = prompt(
- " API base URL (e.g., https://api.example.com/v1)", current_url
- )
- api_key = prompt(" API key", password=True)
- model_name = prompt(" Model name (e.g., gpt-4, claude-3-opus)", current_model)
-
- if base_url:
- save_env_value("OPENAI_BASE_URL", base_url)
- if api_key:
- save_env_value("OPENAI_API_KEY", api_key)
- if model_name:
- _set_default_model(config, model_name)
-
- try:
- from hermes_cli.auth import deactivate_provider
-
- deactivate_provider()
- except Exception:
- pass
-
- # Save provider and base_url to config.yaml so the gateway and CLI
- # both resolve the correct provider without relying on env-var heuristics.
- if base_url:
- import yaml
-
- config_path = (
- Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
- / "config.yaml"
- )
- try:
- disk_cfg = {}
- if config_path.exists():
- disk_cfg = yaml.safe_load(config_path.read_text()) or {}
- model_section = disk_cfg.get("model", {})
- if isinstance(model_section, str):
- model_section = {"default": model_section}
- model_section["provider"] = "custom"
- model_section["base_url"] = base_url.rstrip("/")
- if model_name:
- model_section["default"] = model_name
- disk_cfg["model"] = model_section
- config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False))
- except Exception as e:
- logger.debug("Could not save provider to config.yaml: %s", e)
-
- _set_model_provider(config, "custom", base_url)
-
- print_success("Custom endpoint configured")
+ # Reuse the shared custom endpoint flow from `hermes model`.
+ # This handles: URL/key/model/context-length prompts, endpoint probing,
+ # env saving, config.yaml updates, and custom_providers persistence.
+ from hermes_cli.main import _model_flow_custom
+ _model_flow_custom(config)
+ # _model_flow_custom handles model selection, config, env vars,
+ # and custom_providers. Keep selected_provider = "custom" so
+ # the model selection step below is skipped (line 1631 check)
+ # but vision and TTS setup still run.
elif provider_idx == 4: # Z.AI / GLM
selected_provider = "zai"
@@ -967,8 +1117,8 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
- _update_config_for_provider("zai", zai_base_url)
_set_model_provider(config, "zai", zai_base_url)
+ selected_base_url = zai_base_url
elif provider_idx == 5: # Kimi / Moonshot
selected_provider = "kimi-coding"
@@ -1000,8 +1150,8 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
- _update_config_for_provider("kimi-coding", pconfig.inference_base_url)
_set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
elif provider_idx == 6: # MiniMax
selected_provider = "minimax"
@@ -1022,151 +1172,516 @@ def setup_model_provider(config: dict):
save_env_value("MINIMAX_API_KEY", api_key)
print_success("MiniMax API key updated")
else:
- api_key = prompt(" MiniMax API key", password=True)
+ api_key = prompt(" MiniMax API key", password=True)
+ if api_key:
+ save_env_value("MINIMAX_API_KEY", api_key)
+ print_success("MiniMax API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "minimax", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 7: # MiniMax China
+ selected_provider = "minimax-cn"
+ print()
+ print_header("MiniMax China API Key")
+ pconfig = PROVIDER_REGISTRY["minimax-cn"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info(f"Base URL: {pconfig.inference_base_url}")
+ print_info("Get your API key at: https://platform.minimaxi.com/")
+ print()
+
+ existing_key = get_env_value("MINIMAX_CN_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" MiniMax CN API key", password=True)
+ if api_key:
+ save_env_value("MINIMAX_CN_API_KEY", api_key)
+ print_success("MiniMax CN API key updated")
+ else:
+ api_key = prompt(" MiniMax CN API key", password=True)
+ if api_key:
+ save_env_value("MINIMAX_CN_API_KEY", api_key)
+ print_success("MiniMax CN API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 8: # Kilo Code
+ selected_provider = "kilocode"
+ print()
+ print_header("Kilo Code API Key")
+ pconfig = PROVIDER_REGISTRY["kilocode"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info(f"Base URL: {pconfig.inference_base_url}")
+ print_info("Get your API key at: https://kilo.ai")
+ print()
+
+ existing_key = get_env_value("KILOCODE_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" Kilo Code API key", password=True)
+ if api_key:
+ save_env_value("KILOCODE_API_KEY", api_key)
+ print_success("Kilo Code API key updated")
+ else:
+ api_key = prompt(" Kilo Code API key", password=True)
+ if api_key:
+ save_env_value("KILOCODE_API_KEY", api_key)
+ print_success("Kilo Code API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "kilocode", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 9: # Anthropic
+ selected_provider = "anthropic"
+ print()
+ print_header("Anthropic Authentication")
+ from hermes_cli.auth import PROVIDER_REGISTRY
+ from hermes_cli.config import save_anthropic_api_key, save_anthropic_oauth_token
+ pconfig = PROVIDER_REGISTRY["anthropic"]
+
+ # Check ALL credential sources
+ import os as _os
+ from agent.anthropic_adapter import (
+ read_claude_code_credentials, is_claude_code_token_valid,
+ run_oauth_setup_token,
+ )
+ cc_creds = read_claude_code_credentials()
+ cc_valid = bool(cc_creds and is_claude_code_token_valid(cc_creds))
+
+ existing_key = (
+ get_env_value("ANTHROPIC_TOKEN")
+ or get_env_value("ANTHROPIC_API_KEY")
+ or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
+ )
+
+ has_creds = bool(existing_key) or cc_valid
+ needs_auth = not has_creds
+
+ if has_creds:
+ if existing_key:
+ print_info(f"Current credentials: {existing_key[:12]}...")
+ elif cc_valid:
+ print_success("Found valid Claude Code credentials (auto-detected)")
+
+ auth_choices = [
+ "Use existing credentials",
+ "Reauthenticate (new OAuth login)",
+ "Cancel",
+ ]
+ choice_idx = prompt_choice("What would you like to do?", auth_choices, 0)
+ if choice_idx == 1:
+ needs_auth = True
+ elif choice_idx == 2:
+ pass # fall through to provider config
+
+ if needs_auth:
+ auth_choices = [
+ "Claude Pro/Max subscription (OAuth login)",
+ "Anthropic API key (pay-per-token)",
+ ]
+ auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
+
+ if auth_idx == 0:
+ # OAuth setup-token flow
+ try:
+ print()
+ print_info("Running 'claude setup-token' โ follow the prompts below.")
+ print_info("A browser window will open for you to authorize access.")
+ print()
+ token = run_oauth_setup_token()
+ if token:
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
+ print_success("OAuth credentials saved")
+ else:
+ # Subprocess completed but no token auto-detected
+ print()
+ token = prompt("Paste setup-token here (if displayed above)", password=True)
+ if token:
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
+ print_success("Setup-token saved")
+ else:
+ print_warning("Skipped โ agent won't work without credentials")
+ except FileNotFoundError:
+ print()
+ print_info("The 'claude' CLI is required for OAuth login.")
+ print()
+ print_info("To install: npm install -g @anthropic-ai/claude-code")
+ print_info("Then run: claude setup-token")
+ print_info("Or paste an existing setup-token below:")
+ print()
+ token = prompt("Setup-token (sk-ant-oat-...)", password=True)
+ if token:
+ save_anthropic_oauth_token(token, save_fn=save_env_value)
+ print_success("Setup-token saved")
+ else:
+ print_warning("Skipped โ install Claude Code and re-run setup")
+ else:
+ print()
+ print_info("Get an API key at: https://console.anthropic.com/settings/keys")
+ print()
+ api_key = prompt("API key (sk-ant-...)", password=True)
+ if api_key:
+ save_anthropic_api_key(api_key, save_fn=save_env_value)
+ print_success("API key saved")
+ else:
+ print_warning("Skipped โ agent won't work without credentials")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ # Don't save base_url for Anthropic โ resolve_runtime_provider()
+ # always hardcodes it. Stale base_urls contaminate other providers.
+ _set_model_provider(config, "anthropic")
+ selected_base_url = ""
+
+ elif provider_idx == 10: # AI Gateway
+ selected_provider = "ai-gateway"
+ print()
+ print_header("AI Gateway API Key")
+ pconfig = PROVIDER_REGISTRY["ai-gateway"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info("Get your API key at: https://vercel.com/docs/ai-gateway")
+ print()
+
+ existing_key = get_env_value("AI_GATEWAY_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" AI Gateway API key", password=True)
+ if api_key:
+ save_env_value("AI_GATEWAY_API_KEY", api_key)
+ print_success("AI Gateway API key updated")
+ else:
+ api_key = prompt(" AI Gateway API key", password=True)
+ if api_key:
+ save_env_value("AI_GATEWAY_API_KEY", api_key)
+ print_success("AI Gateway API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6")
+ _set_model_provider(config, "ai-gateway", pconfig.inference_base_url)
+
+ elif provider_idx == 11: # Alibaba Cloud / DashScope
+ selected_provider = "alibaba"
+ print()
+ print_header("Alibaba Cloud / DashScope API Key")
+ pconfig = PROVIDER_REGISTRY["alibaba"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info("Get your API key at: https://modelstudio.console.alibabacloud.com/")
+ print()
+
+ existing_key = get_env_value("DASHSCOPE_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ new_key = prompt(" DashScope API key", password=True)
+ if new_key:
+ save_env_value("DASHSCOPE_API_KEY", new_key)
+ print_success("DashScope API key updated")
+ else:
+ new_key = prompt(" DashScope API key", password=True)
+ if new_key:
+ save_env_value("DASHSCOPE_API_KEY", new_key)
+ print_success("DashScope API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _update_config_for_provider("alibaba", pconfig.inference_base_url, default_model="qwen3.5-plus")
+ _set_model_provider(config, "alibaba", pconfig.inference_base_url)
+
+ elif provider_idx == 12: # OpenCode Zen
+ selected_provider = "opencode-zen"
+ print()
+ print_header("OpenCode Zen API Key")
+ pconfig = PROVIDER_REGISTRY["opencode-zen"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info(f"Base URL: {pconfig.inference_base_url}")
+ print_info("Get your API key at: https://opencode.ai/auth")
+ print()
+
+ existing_key = get_env_value("OPENCODE_ZEN_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" OpenCode Zen API key", password=True)
+ if api_key:
+ save_env_value("OPENCODE_ZEN_API_KEY", api_key)
+ print_success("OpenCode Zen API key updated")
+ else:
+ api_key = prompt(" OpenCode Zen API key", password=True)
+ if api_key:
+ save_env_value("OPENCODE_ZEN_API_KEY", api_key)
+ print_success("OpenCode Zen API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "opencode-zen", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 13: # OpenCode Go
+ selected_provider = "opencode-go"
+ print()
+ print_header("OpenCode Go API Key")
+ pconfig = PROVIDER_REGISTRY["opencode-go"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info(f"Base URL: {pconfig.inference_base_url}")
+ print_info("Get your API key at: https://opencode.ai/auth")
+ print()
+
+ existing_key = get_env_value("OPENCODE_GO_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" OpenCode Go API key", password=True)
+ if api_key:
+ save_env_value("OPENCODE_GO_API_KEY", api_key)
+ print_success("OpenCode Go API key updated")
+ else:
+ api_key = prompt(" OpenCode Go API key", password=True)
+ if api_key:
+ save_env_value("OPENCODE_GO_API_KEY", api_key)
+ print_success("OpenCode Go API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "opencode-go", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 14: # GitHub Copilot
+ selected_provider = "copilot"
+ print()
+ print_header("GitHub Copilot")
+ pconfig = PROVIDER_REGISTRY["copilot"]
+ print_info("Hermes can use GITHUB_TOKEN, GH_TOKEN, or your gh CLI login.")
+ print_info(f"Base URL: {pconfig.inference_base_url}")
+ print()
+
+ copilot_creds = resolve_api_key_provider_credentials("copilot")
+ source = copilot_creds.get("source", "")
+ token = copilot_creds.get("api_key", "")
+ if token:
+ if source in ("GITHUB_TOKEN", "GH_TOKEN"):
+ print_info(f"Current: {token[:8]}... ({source})")
+ elif source == "gh auth token":
+ print_info("Current: authenticated via `gh auth token`")
+ else:
+ print_info("Current: GitHub token configured")
+ else:
+ api_key = prompt(" GitHub token", password=True)
if api_key:
- save_env_value("MINIMAX_API_KEY", api_key)
- print_success("MiniMax API key saved")
+ save_env_value("GITHUB_TOKEN", api_key)
+ print_success("GitHub token saved")
else:
- print_warning("Skipped - agent won't work without an API key")
+ print_warning("Skipped - agent won't work without a GitHub token or gh auth login")
- # Clear custom endpoint vars if switching
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
- _update_config_for_provider("minimax", pconfig.inference_base_url)
- _set_model_provider(config, "minimax", pconfig.inference_base_url)
+ _set_model_provider(config, "copilot", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
- elif provider_idx == 7: # MiniMax China
- selected_provider = "minimax-cn"
+ elif provider_idx == 15: # GitHub Copilot ACP
+ selected_provider = "copilot-acp"
print()
- print_header("MiniMax China API Key")
- pconfig = PROVIDER_REGISTRY["minimax-cn"]
+ print_header("GitHub Copilot ACP")
+ pconfig = PROVIDER_REGISTRY["copilot-acp"]
+ print_info("Hermes will start `copilot --acp --stdio` for each request.")
+ print_info("Use HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH to override the command.")
+ print_info(f"Base marker: {pconfig.inference_base_url}")
+ print()
+
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _set_model_provider(config, "copilot-acp", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ elif provider_idx == 16: # xgate
+ selected_provider = "xgate"
+ print()
+ print_header("xgate (Surface Only)")
+ pconfig = PROVIDER_REGISTRY["xgate"]
print_info(f"Provider: {pconfig.name}")
print_info(f"Base URL: {pconfig.inference_base_url}")
- print_info("Get your API key at: https://platform.minimaxi.com/")
+ print_info("This PR only adds the named provider surface and default base URL.")
+ print_info("Header-based auth lands in follow-up PR 2 and is required for the default hosted xgate flow.")
+ print_info("Only configure an API key here if your xgate deployment already accepts one directly.")
print()
- existing_key = get_env_value("MINIMAX_CN_API_KEY")
+ existing_key = get_env_value("XGATE_API_KEY")
if existing_key:
print_info(f"Current: {existing_key[:8]}... (configured)")
if prompt_yes_no("Update API key?", False):
- api_key = prompt(" MiniMax CN API key", password=True)
+ api_key = prompt_text("xgate API key", password=True)
if api_key:
- save_env_value("MINIMAX_CN_API_KEY", api_key)
- print_success("MiniMax CN API key updated")
+ save_env_value("XGATE_API_KEY", api_key)
+ print_success("xgate API key updated")
else:
- api_key = prompt(" MiniMax CN API key", password=True)
+ api_key = prompt_text("xgate API key", password=True)
if api_key:
- save_env_value("MINIMAX_CN_API_KEY", api_key)
- print_success("MiniMax CN API key saved")
+ save_env_value("XGATE_API_KEY", api_key)
+ print_success("xgate API key saved")
else:
- print_warning("Skipped - agent won't work without an API key")
+ print_info("Skipped - the hosted xgate auth path is added in follow-up PR 2")
- # Clear custom endpoint vars if switching
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
- _update_config_for_provider("minimax-cn", pconfig.inference_base_url)
- _set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
+ _set_model_provider(config, "xgate", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
- elif provider_idx == 8: # Anthropic
- selected_provider = "anthropic"
+ elif provider_idx == 17: # Hugging Face Inference Providers
+ selected_provider = "huggingface"
+ print()
+ print_header("Hugging Face API Token")
+ pconfig = PROVIDER_REGISTRY["huggingface"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info("Get your token at: https://huggingface.co/settings/tokens")
+ print_info("Required permission: 'Make calls to Inference Providers'")
print()
- print_header("Anthropic Authentication")
- from hermes_cli.auth import PROVIDER_REGISTRY
- pconfig = PROVIDER_REGISTRY["anthropic"]
-
- # Check for Claude Code credential auto-discovery
- from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
- cc_creds = read_claude_code_credentials()
- if cc_creds and is_claude_code_token_valid(cc_creds):
- print_success("Found valid Claude Code credentials (~/.claude/.credentials.json)")
- if prompt_yes_no("Use these credentials?", True):
- print_success("Using Claude Code subscription credentials")
- else:
- cc_creds = None
-
- existing_key = get_env_value("ANTHROPIC_API_KEY") or get_env_value("ANTHROPIC_TOKEN")
-
- if not (cc_creds and is_claude_code_token_valid(cc_creds)):
- if existing_key:
- print_info(f"Current credentials: {existing_key[:12]}...")
- if not prompt_yes_no("Update credentials?", False):
- # User wants to keep existing โ skip auth prompt entirely
- existing_key = "KEEP" # truthy sentinel to skip auth choice
-
- if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
- auth_choices = [
- "Claude Pro/Max subscription (setup-token)",
- "Anthropic API key (pay-per-token)",
- ]
- auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
-
- if auth_idx == 0:
- print()
- print_info("To get a setup-token from your Claude subscription:")
- print_info(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
- print_info(" 2. Run: claude setup-token")
- print_info(" 3. Open the URL it prints in your browser")
- print_info(" 4. Log in and click \"Authorize\"")
- print_info(" 5. Paste the auth code back into Claude Code")
- print_info(" 6. Copy the resulting sk-ant-oat01-... token")
- print()
- token = prompt("Paste setup-token here", password=True)
- if token:
- save_env_value("ANTHROPIC_API_KEY", token)
- print_success("Setup-token saved")
- else:
- print_warning("Skipped โ agent won't work without credentials")
- else:
- print()
- print_info("Get an API key at: https://console.anthropic.com/settings/keys")
- print()
- api_key = prompt("API key (sk-ant-api03-...)", password=True)
- if api_key:
- save_env_value("ANTHROPIC_API_KEY", api_key)
- print_success("API key saved")
- else:
- print_warning("Skipped โ agent won't work without credentials")
- # Clear custom endpoint vars if switching
- if existing_custom:
+ api_key = prompt(" HF Token", password=True)
+ if api_key:
+ save_env_value("HF_TOKEN", api_key)
+ # Clear OpenRouter env vars to prevent routing confusion
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
- _update_config_for_provider("anthropic", pconfig.inference_base_url)
- _set_model_provider(config, "anthropic", pconfig.inference_base_url)
-
- # else: provider_idx == 9 (Keep current) โ only shown when a provider already exists
-
- # โโ OpenRouter API Key for tools (if not already set) โโ
- # Tools (vision, web, MoA) use OpenRouter independently of the main provider.
- # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen.
- if selected_provider in (
- "nous",
- "openai-codex",
- "custom",
- "zai",
- "kimi-coding",
- "minimax",
- "minimax-cn",
- "anthropic",
- ) and not get_env_value("OPENROUTER_API_KEY"):
- print()
- print_header("OpenRouter API Key (for tools)")
- print_info("Tools like vision analysis, web search, and MoA use OpenRouter")
- print_info("independently of your main inference provider.")
- print_info("Get your API key at: https://openrouter.ai/keys")
+ _set_model_provider(config, "huggingface", pconfig.inference_base_url)
+ selected_base_url = pconfig.inference_base_url
+
+ # else: provider_idx == 18 (Keep current) โ only shown when a provider already exists
+ # Normalize "keep current" to an explicit provider so downstream logic
+ # doesn't fall back to the generic OpenRouter/static-model path.
+ if selected_provider is None:
+ if current_config_provider:
+ selected_provider = current_config_provider
+ elif active_oauth and active_oauth in PROVIDER_REGISTRY:
+ selected_provider = active_oauth
+ elif existing_custom:
+ selected_provider = "custom"
+ elif existing_or:
+ selected_provider = "openrouter"
+
+ # โโ Vision & Image Analysis Setup โโ
+ # Keep setup aligned with the actual runtime resolver the vision tools use.
+ try:
+ from agent.auxiliary_client import get_available_vision_backends
+
+ _vision_backends = set(get_available_vision_backends())
+ except Exception:
+ _vision_backends = set()
+
+ _vision_needs_setup = not bool(_vision_backends)
+
+ if selected_provider in _vision_backends:
+ # If the user just selected a backend Hermes can already use for
+ # vision, treat it as covered. Auth/setup failure returns earlier.
+ _vision_needs_setup = False
+
+ if _vision_needs_setup:
+ _prov_names = {
+ "nous-api": "Nous Portal API key",
+ "copilot": "GitHub Copilot",
+ "copilot-acp": "GitHub Copilot ACP",
+ "zai": "Z.AI / GLM",
+ "kimi-coding": "Kimi / Moonshot",
+ "minimax": "MiniMax",
+ "minimax-cn": "MiniMax CN",
+ "anthropic": "Anthropic",
+ "ai-gateway": "AI Gateway",
+ "custom": "your custom endpoint",
+ }
+ _prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
- api_key = prompt(
- " OpenRouter API key (optional, press Enter to skip)", password=True
- )
- if api_key:
- save_env_value("OPENROUTER_API_KEY", api_key)
- print_success("OpenRouter API key saved (for tools)")
+ print()
+ print_header("Vision & Image Analysis (optional)")
+ print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
+ print_info("doesn't currently provide one Hermes can auto-use for vision,")
+ print_info("so choose a backend now or skip and configure later.")
+ print()
+
+ _vision_choices = [
+ "OpenRouter โ uses Gemini (free tier at openrouter.ai/keys)",
+ "OpenAI-compatible endpoint โ base URL, API key, and vision model",
+ "Skip for now",
+ ]
+ _vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)
+
+ if _vision_idx == 0: # OpenRouter
+ _or_key = prompt(" OpenRouter API key", password=True).strip()
+ if _or_key:
+ save_env_value("OPENROUTER_API_KEY", _or_key)
+ print_success("OpenRouter key saved โ vision will use Gemini")
+ else:
+ print_info("Skipped โ vision won't be available")
+ elif _vision_idx == 1: # OpenAI-compatible endpoint
+ _base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
+ _api_key_label = " API key"
+ if "api.openai.com" in _base_url.lower():
+ _api_key_label = " OpenAI API key"
+ _oai_key = prompt(_api_key_label, password=True).strip()
+ if _oai_key:
+ save_env_value("OPENAI_API_KEY", _oai_key)
+ save_env_value("OPENAI_BASE_URL", _base_url)
+ if "api.openai.com" in _base_url.lower():
+ _oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
+ _vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
+ _vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
+ _selected_vision_model = (
+ _oai_vision_models[_vm_idx]
+ if _vm_idx < len(_oai_vision_models)
+ else "gpt-4o-mini"
+ )
+ else:
+ _selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
+ save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
+ print_success(
+ f"Vision configured with {_base_url}"
+ + (f" ({_selected_vision_model})" if _selected_vision_model else "")
+ )
+ else:
+ print_info("Skipped โ vision won't be available")
else:
- print_info(
- "Skipped - some tools (vision, web scraping) won't work without this"
- )
+ print_info("Skipped โ add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
# โโ Model Selection (adapts based on provider) โโ
if selected_provider != "custom": # Custom already prompted for model name
@@ -1219,7 +1734,15 @@ def setup_model_provider(config: dict):
elif selected_provider == "openai-codex":
from hermes_cli.codex_models import get_codex_model_ids
- codex_models = get_codex_model_ids()
+ codex_token = None
+ try:
+ codex_creds = resolve_codex_runtime_credentials()
+ codex_token = codex_creds.get("api_key")
+ except Exception as exc:
+ logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc)
+
+ codex_models = get_codex_model_ids(access_token=codex_token)
+
model_choices = codex_models + [f"Keep current ({current_model})"]
default_codex = 0
if current_model in codex_models:
@@ -1238,61 +1761,19 @@ def setup_model_provider(config: dict):
_set_default_model(config, custom)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
_set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
- elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn"):
+ elif selected_provider == "copilot-acp":
+ _setup_provider_model_selection(
+ config, selected_provider, current_model,
+ prompt_choice, prompt,
+ )
+ model_cfg = _model_config_dict(config)
+ model_cfg["api_mode"] = "chat_completions"
+ config["model"] = model_cfg
+ elif selected_provider in ("copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "xgate", "kilocode", "ai-gateway", "opencode-zen", "opencode-go", "alibaba", "huggingface"):
_setup_provider_model_selection(
config, selected_provider, current_model,
prompt_choice, prompt,
)
- if is_coding_plan:
- zai_models = ["glm-4.7", "glm-4.5", "glm-4.5-flash"]
- else:
- zai_models = ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"]
- model_choices = list(zai_models)
- model_choices.append("Custom model")
- model_choices.append(f"Keep current ({current_model})")
-
- keep_idx = len(model_choices) - 1
- model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
-
- if model_idx < len(zai_models):
- _set_default_model(config, zai_models[model_idx])
- elif model_idx == len(zai_models):
- custom = prompt("Enter model name")
- if custom:
- _set_default_model(config, custom)
- # else: keep current
- elif selected_provider == "kimi-coding":
- kimi_models = ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"]
- model_choices = list(kimi_models)
- model_choices.append("Custom model")
- model_choices.append(f"Keep current ({current_model})")
-
- keep_idx = len(model_choices) - 1
- model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
-
- if model_idx < len(kimi_models):
- _set_default_model(config, kimi_models[model_idx])
- elif model_idx == len(kimi_models):
- custom = prompt("Enter model name")
- if custom:
- _set_default_model(config, custom)
- # else: keep current
- elif selected_provider in ("minimax", "minimax-cn"):
- minimax_models = ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]
- model_choices = list(minimax_models)
- model_choices.append("Custom model")
- model_choices.append(f"Keep current ({current_model})")
-
- keep_idx = len(model_choices) - 1
- model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
-
- if model_idx < len(minimax_models):
- _set_default_model(config, minimax_models[model_idx])
- elif model_idx == len(minimax_models):
- custom = prompt("Enter model name")
- if custom:
- _set_default_model(config, custom)
- # else: keep current
elif selected_provider == "anthropic":
# Try live model list first, fall back to static
from hermes_cli.models import provider_model_ids
@@ -1346,7 +1827,171 @@ def setup_model_provider(config: dict):
)
print_success(f"Model set to: {_display}")
+ # Write provider+base_url to config.yaml only after model selection is complete.
+ # This prevents a race condition where the gateway picks up a new provider
+ # before the model name has been updated to match.
+ if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "xgate", "kilocode", "anthropic", "huggingface") and selected_base_url is not None:
+ _update_config_for_provider(selected_provider, selected_base_url)
+
+ save_config(config)
+
+ # Offer TTS provider selection at the end of model setup
+ _setup_tts_provider(config)
+
+
+# =============================================================================
+# Section 1b: TTS Provider Configuration
+# =============================================================================
+
+
+def _check_espeak_ng() -> bool:
+ """Check if espeak-ng is installed."""
+ import shutil
+ return shutil.which("espeak-ng") is not None or shutil.which("espeak") is not None
+
+
+def _install_neutts_deps() -> bool:
+ """Install NeuTTS dependencies with user approval. Returns True on success."""
+ import subprocess
+ import sys
+
+ # Check espeak-ng
+ if not _check_espeak_ng():
+ print()
+ print_warning("NeuTTS requires espeak-ng for phonemization.")
+ if sys.platform == "darwin":
+ print_info("Install with: brew install espeak-ng")
+ elif sys.platform == "win32":
+ print_info("Install with: choco install espeak-ng")
+ else:
+ print_info("Install with: sudo apt install espeak-ng")
+ print()
+ if prompt_yes_no("Install espeak-ng now?", True):
+ try:
+ if sys.platform == "darwin":
+ subprocess.run(["brew", "install", "espeak-ng"], check=True)
+ elif sys.platform == "win32":
+ subprocess.run(["choco", "install", "espeak-ng", "-y"], check=True)
+ else:
+ subprocess.run(["sudo", "apt", "install", "-y", "espeak-ng"], check=True)
+ print_success("espeak-ng installed")
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
+ print_warning(f"Could not install espeak-ng automatically: {e}")
+ print_info("Please install it manually and re-run setup.")
+ return False
+ else:
+ print_warning("espeak-ng is required for NeuTTS. Install it manually before using NeuTTS.")
+
+ # Install neutts Python package
+ print()
+ print_info("Installing neutts Python package...")
+ print_info("This will also download the TTS model (~300MB) on first use.")
+ print()
+ try:
+ subprocess.run(
+ [sys.executable, "-m", "pip", "install", "-U", "neutts[all]", "--quiet"],
+ check=True, timeout=300,
+ )
+ print_success("neutts installed successfully")
+ return True
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
+ print_error(f"Failed to install neutts: {e}")
+ print_info("Try manually: python -m pip install -U neutts[all]")
+ return False
+
+
+def _setup_tts_provider(config: dict):
+ """Interactive TTS provider selection with install flow for NeuTTS."""
+ tts_config = config.get("tts", {})
+ current_provider = tts_config.get("provider", "edge")
+
+ provider_labels = {
+ "edge": "Edge TTS",
+ "elevenlabs": "ElevenLabs",
+ "openai": "OpenAI TTS",
+ "neutts": "NeuTTS",
+ }
+ current_label = provider_labels.get(current_provider, current_provider)
+
+ print()
+ print_header("Text-to-Speech Provider (optional)")
+ print_info(f"Current: {current_label}")
+ print()
+
+ choices = [
+ "Edge TTS (free, cloud-based, no setup needed)",
+ "ElevenLabs (premium quality, needs API key)",
+ "OpenAI TTS (good quality, needs API key)",
+ "NeuTTS (local on-device, free, ~300MB model download)",
+ f"Keep current ({current_label})",
+ ]
+ idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1)
+
+ if idx == 4: # Keep current
+ return
+
+ providers = ["edge", "elevenlabs", "openai", "neutts"]
+ selected = providers[idx]
+
+ if selected == "neutts":
+ # Check if already installed
+ try:
+ import importlib.util
+ already_installed = importlib.util.find_spec("neutts") is not None
+ except Exception:
+ already_installed = False
+
+ if already_installed:
+ print_success("NeuTTS is already installed")
+ else:
+ print()
+ print_info("NeuTTS requires:")
+ print_info(" โข Python package: neutts (~50MB install + ~300MB model on first use)")
+ print_info(" โข System package: espeak-ng (phonemizer)")
+ print()
+ if prompt_yes_no("Install NeuTTS dependencies now?", True):
+ if not _install_neutts_deps():
+ print_warning("NeuTTS installation incomplete. Falling back to Edge TTS.")
+ selected = "edge"
+ else:
+ print_info("Skipping install. Set tts.provider to 'neutts' after installing manually.")
+ selected = "edge"
+
+ elif selected == "elevenlabs":
+ existing = get_env_value("ELEVENLABS_API_KEY")
+ if not existing:
+ print()
+ api_key = prompt("ElevenLabs API key", password=True)
+ if api_key:
+ save_env_value("ELEVENLABS_API_KEY", api_key)
+ print_success("ElevenLabs API key saved")
+ else:
+ print_warning("No API key provided. Falling back to Edge TTS.")
+ selected = "edge"
+
+ elif selected == "openai":
+ existing = get_env_value("VOICE_TOOLS_OPENAI_KEY")
+ if not existing:
+ print()
+ api_key = prompt("OpenAI API key for TTS", password=True)
+ if api_key:
+ save_env_value("VOICE_TOOLS_OPENAI_KEY", api_key)
+ print_success("OpenAI TTS API key saved")
+ else:
+ print_warning("No API key provided. Falling back to Edge TTS.")
+ selected = "edge"
+
+ # Save the selection
+ if "tts" not in config:
+ config["tts"] = {}
+ config["tts"]["provider"] = selected
save_config(config)
+ print_success(f"TTS provider set to: {provider_labels.get(selected, selected)}")
+
+
+def setup_tts(config: dict):
+ """Standalone TTS setup (for 'hermes setup tts')."""
+ _setup_tts_provider(config)
# =============================================================================
@@ -1447,7 +2092,7 @@ def setup_terminal_backend(config: dict):
# Docker image
current_image = config.get("terminal", {}).get(
- "docker_image", "python:3.11-slim"
+ "docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
image = prompt(" Docker image", current_image)
config["terminal"]["docker_image"] = image
@@ -1469,7 +2114,7 @@ def setup_terminal_backend(config: dict):
print_info(f"Found: {sing_bin}")
current_image = config.get("terminal", {}).get(
- "singularity_image", "docker://python:3.11-slim"
+ "singularity_image", "docker://nikolaik/python-nodejs:python3.11-nodejs20"
)
image = prompt(" Container image", current_image)
config["terminal"]["singularity_image"] = image
@@ -1671,7 +2316,7 @@ def setup_agent_settings(config: dict):
)
print_info("Maximum tool-calling iterations per conversation.")
print_info("Higher = more complex tasks, but costs more tokens.")
- print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.")
+ print_info("Default is 90, which works for most tasks. Use 150+ for open exploration.")
max_iter_str = prompt("Max iterations", current_max)
try:
@@ -1713,7 +2358,7 @@ def setup_agent_settings(config: dict):
config.setdefault("compression", {})["enabled"] = True
- current_threshold = config.get("compression", {}).get("threshold", 0.85)
+ current_threshold = config.get("compression", {}).get("threshold", 0.50)
threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold))
try:
threshold = float(threshold_str)
@@ -1723,7 +2368,7 @@ def setup_agent_settings(config: dict):
pass
print_success(
- f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}"
+ f"Context compression threshold set to {config['compression'].get('threshold', 0.50)}"
)
# โโ Session Reset Policy โโ
@@ -1938,7 +2583,17 @@ def setup_gateway(config: dict):
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
)
if allowed_users:
- save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
+ # Clean up common prefixes (user:123, <@123>, <@!123>)
+ cleaned_ids = []
+ for uid in allowed_users.replace(" ", "").split(","):
+ uid = uid.strip()
+ if uid.startswith("<@") and uid.endswith(">"):
+ uid = uid.lstrip("<@!").rstrip(">")
+ if uid.lower().startswith("user:"):
+ uid = uid[5:]
+ if uid:
+ cleaned_ids.append(uid)
+ save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
print_success("Discord allowlist configured")
else:
print_info(
@@ -1973,8 +2628,18 @@ def setup_gateway(config: dict):
)
allowed_users = prompt("Allowed user IDs (comma-separated)")
if allowed_users:
+ # Clean up common prefixes (user:123, <@123>, <@!123>)
+ cleaned_ids = []
+ for uid in allowed_users.replace(" ", "").split(","):
+ uid = uid.strip()
+ if uid.startswith("<@") and uid.endswith(">"):
+ uid = uid.lstrip("<@!").rstrip(">")
+ if uid.lower().startswith("user:"):
+ uid = uid[5:]
+ if uid:
+ cleaned_ids.append(uid)
save_env_value(
- "DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")
+ "DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)
)
print_success("Discord allowlist configured")
@@ -1994,20 +2659,22 @@ def setup_gateway(config: dict):
print_info(" โข Create an App-Level Token with 'connections:write' scope")
print_info(" 3. Add Bot Token Scopes: Features โ OAuth & Permissions")
print_info(" Required scopes: chat:write, app_mentions:read,")
- print_info(" channels:history, channels:read, groups:history,")
- print_info(" im:history, im:read, im:write, users:read, files:write")
+ print_info(" channels:history, channels:read, im:history,")
+ print_info(" im:read, im:write, users:read, files:write")
+ print_info(" Optional for private channels: groups:history")
print_info(" 4. Subscribe to Events: Features โ Event Subscriptions โ Enable")
- print_info(" Required events: message.im, message.channels,")
- print_info(" message.groups, app_mention")
- print_warning(" โ Without message.channels/message.groups events,")
- print_warning(" the bot will ONLY work in DMs, not channels!")
+ print_info(" Required events: message.im, message.channels, app_mention")
+ print_info(" Optional for private channels: message.groups")
+ print_warning(" โ Without message.channels the bot will ONLY work in DMs,")
+ print_warning(" not public channels.")
print_info(" 5. Install to Workspace: Settings โ Install App")
+ print_info(" 6. Reinstall the app after any scope or event changes")
print_info(
- " 6. After installing, invite the bot to channels: /invite @YourBot"
+ " 7. After installing, invite the bot to channels: /invite @YourBot"
)
print()
print_info(
- " Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack"
+ " Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/"
)
print()
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
@@ -2025,16 +2692,132 @@ def setup_gateway(config: dict):
)
print()
allowed_users = prompt(
- "Allowed user IDs (comma-separated, leave empty for open access)"
+ "Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)"
)
if allowed_users:
save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("Slack allowlist configured")
+ else:
+ print_warning(
+ "โ ๏ธ No Slack allowlist set - unpaired users will be denied by default."
+ )
+ print_info(
+ " Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access."
+ )
+
+ # โโ Matrix โโ
+ existing_matrix = get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD")
+ if existing_matrix:
+ print_info("Matrix: already configured")
+ if prompt_yes_no("Reconfigure Matrix?", False):
+ existing_matrix = None
+
+ if not existing_matrix and prompt_yes_no("Set up Matrix?", False):
+ print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
+ print_info(" 1. Create a bot user on your homeserver, or use your own account")
+ print_info(" 2. Get an access token from Element, or provide user ID + password")
+ print()
+ homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
+ if homeserver:
+ save_env_value("MATRIX_HOMESERVER", homeserver.rstrip("/"))
+
+ print()
+ print_info("Auth: provide an access token (recommended), or user ID + password.")
+ token = prompt("Access token (leave empty for password login)", password=True)
+ if token:
+ save_env_value("MATRIX_ACCESS_TOKEN", token)
+ user_id = prompt("User ID (@bot:server โ optional, will be auto-detected)")
+ if user_id:
+ save_env_value("MATRIX_USER_ID", user_id)
+ print_success("Matrix access token saved")
+ else:
+ user_id = prompt("User ID (@bot:server)")
+ if user_id:
+ save_env_value("MATRIX_USER_ID", user_id)
+ password = prompt("Password", password=True)
+ if password:
+ save_env_value("MATRIX_PASSWORD", password)
+ print_success("Matrix credentials saved")
+
+ if token or get_env_value("MATRIX_PASSWORD"):
+ # E2EE
+ print()
+ if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False):
+ save_env_value("MATRIX_ENCRYPTION", "true")
+ print_success("E2EE enabled")
+ print_info(" Requires: pip install 'matrix-nio[e2e]'")
+
+ # Allowed users
+ print()
+ print_info("๐ Security: Restrict who can use your bot")
+ print_info(" Matrix user IDs look like @username:server")
+ print()
+ allowed_users = prompt(
+ "Allowed user IDs (comma-separated, leave empty for open access)"
+ )
+ if allowed_users:
+ save_env_value("MATRIX_ALLOWED_USERS", allowed_users.replace(" ", ""))
+ print_success("Matrix allowlist configured")
+ else:
+ print_info(
+ "โ ๏ธ No allowlist set - anyone who can message the bot can use it!"
+ )
+
+ # Home room
+ print()
+ print_info("๐ฌ Home Room: where Hermes delivers cron job results and notifications.")
+ print_info(" Room IDs look like !abc123:server (shown in Element room settings)")
+ print_info(" You can also set this later by typing /set-home in a Matrix room.")
+ home_room = prompt("Home room ID (leave empty to set later with /set-home)")
+ if home_room:
+ save_env_value("MATRIX_HOME_ROOM", home_room)
+
+ # โโ Mattermost โโ
+ existing_mattermost = get_env_value("MATTERMOST_TOKEN")
+ if existing_mattermost:
+ print_info("Mattermost: already configured")
+ if prompt_yes_no("Reconfigure Mattermost?", False):
+ existing_mattermost = None
+
+ if not existing_mattermost and prompt_yes_no("Set up Mattermost?", False):
+ print_info("Works with any self-hosted Mattermost instance.")
+ print_info(" 1. In Mattermost: Integrations โ Bot Accounts โ Add Bot Account")
+ print_info(" 2. Copy the bot token")
+ print()
+ mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)")
+ if mm_url:
+ save_env_value("MATTERMOST_URL", mm_url.rstrip("/"))
+ token = prompt("Bot token", password=True)
+ if token:
+ save_env_value("MATTERMOST_TOKEN", token)
+ print_success("Mattermost token saved")
+
+ # Allowed users
+ print()
+ print_info("๐ Security: Restrict who can use your bot")
+ print_info(" To find your user ID: click your avatar โ Profile")
+ print_info(" or use the API: GET /api/v4/users/me")
+ print()
+ allowed_users = prompt(
+ "Allowed user IDs (comma-separated, leave empty for open access)"
+ )
+ if allowed_users:
+ save_env_value("MATTERMOST_ALLOWED_USERS", allowed_users.replace(" ", ""))
+ print_success("Mattermost allowlist configured")
else:
print_info(
- "โ ๏ธ No allowlist set - anyone in your workspace can use the bot!"
+ "โ ๏ธ No allowlist set - anyone who can message the bot can use it!"
)
+ # Home channel
+ print()
+ print_info("๐ฌ Home Channel: where Hermes delivers cron job results and notifications.")
+ print_info(" To get a channel ID: click channel name โ View Info โ copy the ID")
+ print_info(" You can also set this later by typing /set-home in a Mattermost channel.")
+ home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
+ if home_channel:
+ save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
+
# โโ WhatsApp โโ
existing_whatsapp = get_env_value("WHATSAPP_ENABLED")
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
@@ -2047,12 +2830,71 @@ def setup_gateway(config: dict):
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
print_info("or personal self-chat) and pair via QR code.")
+ # โโ Webhooks โโ
+ existing_webhook = get_env_value("WEBHOOK_ENABLED")
+ if existing_webhook:
+ print_info("Webhooks: already configured")
+ if prompt_yes_no("Reconfigure webhooks?", False):
+ existing_webhook = None
+
+ if not existing_webhook and prompt_yes_no("Set up webhooks? (GitHub, GitLab, etc.)", False):
+ print()
+ print_warning(
+ "โ Webhook and SMS platforms require exposing gateway ports to the"
+ )
+ print_warning(
+ " internet. For security, run the gateway in a sandboxed environment"
+ )
+ print_warning(
+ " (Docker, VM, etc.) to limit blast radius from prompt injection."
+ )
+ print()
+ print_info(
+ " Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/"
+ )
+ print()
+
+ port = prompt("Webhook port (default 8644)")
+ if port:
+ try:
+ save_env_value("WEBHOOK_PORT", str(int(port)))
+ print_success(f"Webhook port set to {port}")
+ except ValueError:
+ print_warning("Invalid port number, using default 8644")
+
+ secret = prompt("Global HMAC secret (shared across all routes)", password=True)
+ if secret:
+ save_env_value("WEBHOOK_SECRET", secret)
+ print_success("Webhook secret saved")
+ else:
+ print_warning("No secret set โ you must configure per-route secrets in config.yaml")
+
+ save_env_value("WEBHOOK_ENABLED", "true")
+ print()
+ print_success("Webhooks enabled! Next steps:")
+ print_info(" 1. Define webhook routes in ~/.hermes/config.yaml")
+ print_info(" 2. Point your service (GitHub, GitLab, etc.) at:")
+ print_info(" http://your-server:8644/webhooks/")
+ print()
+ print_info(
+ " Route configuration guide:"
+ )
+ print_info(
+ " https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes"
+ )
+ print()
+ print_info(" Open config in your editor: hermes config edit")
+
# โโ Gateway Service Setup โโ
any_messaging = (
get_env_value("TELEGRAM_BOT_TOKEN")
or get_env_value("DISCORD_BOT_TOKEN")
or get_env_value("SLACK_BOT_TOKEN")
+ or get_env_value("MATTERMOST_TOKEN")
+ or get_env_value("MATRIX_ACCESS_TOKEN")
+ or get_env_value("MATRIX_PASSWORD")
or get_env_value("WHATSAPP_ENABLED")
+ or get_env_value("WEBHOOK_ENABLED")
)
if any_messaging:
print()
@@ -2092,7 +2934,9 @@ def setup_gateway(config: dict):
from hermes_cli.gateway import (
_is_service_installed,
_is_service_running,
- systemd_install,
+ has_conflicting_systemd_units,
+ install_linux_gateway_from_setup,
+ print_systemd_scope_conflict_warning,
systemd_start,
systemd_restart,
launchd_install,
@@ -2104,6 +2948,10 @@ def setup_gateway(config: dict):
service_running = _is_service_running()
print()
+ if _is_linux and has_conflicting_systemd_units():
+ print_systemd_scope_conflict_warning()
+ print()
+
if service_running:
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
try:
@@ -2129,15 +2977,18 @@ def setup_gateway(config: dict):
True,
):
try:
+ installed_scope = None
+ did_install = False
if _is_linux:
- systemd_install(force=False)
+ installed_scope, did_install = install_linux_gateway_from_setup(force=False)
else:
launchd_install(force=False)
+ did_install = True
print()
- if prompt_yes_no(" Start the service now?", True):
+ if did_install and prompt_yes_no(" Start the service now?", True):
try:
if _is_linux:
- systemd_start()
+ systemd_start(system=installed_scope == "system")
elif _is_macos:
launchd_start()
except Exception as e:
@@ -2147,6 +2998,8 @@ def setup_gateway(config: dict):
print_info(" You can try manually: hermes gateway install")
else:
print_info(" You can install later: hermes gateway install")
+ if _is_linux:
+ print_info(" Or as a boot-time service: sudo hermes gateway install --system")
print_info(" Or run in foreground: hermes gateway")
else:
print_info("Start the gateway to bring your bots online:")
@@ -2175,6 +3028,95 @@ def setup_tools(config: dict, first_install: bool = False):
tools_command(first_install=first_install, config=config)
+# =============================================================================
+# Post-Migration Section Skip Logic
+# =============================================================================
+
+
+def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]:
+ """Return a short summary if a setup section is already configured, else None.
+
+ Used after OpenClaw migration to detect which sections can be skipped.
+ ``get_env_value`` is the module-level import from hermes_cli.config
+ so that test patches on ``setup_mod.get_env_value`` take effect.
+ """
+ if section_key == "model":
+ has_key = bool(
+ get_env_value("OPENROUTER_API_KEY")
+ or get_env_value("OPENAI_API_KEY")
+ or get_env_value("ANTHROPIC_API_KEY")
+ )
+ if not has_key:
+ # Check for OAuth providers
+ try:
+ from hermes_cli.auth import get_active_provider
+ if get_active_provider():
+ has_key = True
+ except Exception:
+ pass
+ if not has_key:
+ return None
+ model = config.get("model")
+ if isinstance(model, str) and model.strip():
+ return model.strip()
+ if isinstance(model, dict):
+ return str(model.get("default") or model.get("model") or "configured")
+ return "configured"
+
+ elif section_key == "terminal":
+ backend = config.get("terminal", {}).get("backend", "local")
+ return f"backend: {backend}"
+
+ elif section_key == "agent":
+ max_turns = config.get("agent", {}).get("max_turns", 90)
+ return f"max turns: {max_turns}"
+
+ elif section_key == "gateway":
+ platforms = []
+ if get_env_value("TELEGRAM_BOT_TOKEN"):
+ platforms.append("Telegram")
+ if get_env_value("DISCORD_BOT_TOKEN"):
+ platforms.append("Discord")
+ if get_env_value("SLACK_BOT_TOKEN"):
+ platforms.append("Slack")
+ if get_env_value("WHATSAPP_PHONE_NUMBER_ID"):
+ platforms.append("WhatsApp")
+ if get_env_value("SIGNAL_ACCOUNT"):
+ platforms.append("Signal")
+ if platforms:
+ return ", ".join(platforms)
+ return None # No platforms configured โ section must run
+
+ elif section_key == "tools":
+ tools = []
+ if get_env_value("ELEVENLABS_API_KEY"):
+ tools.append("TTS/ElevenLabs")
+ if get_env_value("BROWSERBASE_API_KEY"):
+ tools.append("Browser")
+ if get_env_value("FIRECRAWL_API_KEY"):
+ tools.append("Firecrawl")
+ if tools:
+ return ", ".join(tools)
+ return None
+
+ return None
+
+
+def _skip_configured_section(
+ config: dict, section_key: str, label: str
+) -> bool:
+ """Show an already-configured section summary and offer to skip.
+
+ Returns True if the user chose to skip, False if the section should run.
+ """
+ summary = _get_section_config_summary(config, section_key)
+ if not summary:
+ return False
+ print()
+ print_success(f" {label}: {summary}")
+ return not prompt_yes_no(f" Reconfigure {label.lower()}?", default=False)
+
+
# =============================================================================
# OpenClaw Migration
# =============================================================================
@@ -2246,7 +3188,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
target_root=hermes_home.resolve(),
execute=True,
workspace_target=None,
- overwrite=False,
+ overwrite=True,
migrate_secrets=True,
output_dir=None,
selected_options=selected,
@@ -2289,6 +3231,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
SETUP_SECTIONS = [
("model", "Model & Provider", setup_model_provider),
+ ("tts", "Text-to-Speech", setup_tts),
("terminal", "Terminal Backend", setup_terminal_backend),
("gateway", "Messaging Platforms (Gateway)", setup_gateway),
("tools", "Tools", setup_tools),
@@ -2307,11 +3250,26 @@ def run_setup_wizard(args):
hermes setup tools โ just tool configuration
hermes setup agent โ just agent settings
"""
+ from hermes_cli.config import is_managed, managed_error
+ if is_managed():
+ managed_error("run setup wizard")
+ return
ensure_hermes_home()
config = load_config()
hermes_home = get_hermes_home()
+ # Detect non-interactive environments (headless SSH, Docker, CI/CD)
+ non_interactive = getattr(args, 'non_interactive', False)
+ if not non_interactive and not is_interactive_stdin():
+ non_interactive = True
+
+ if non_interactive:
+ print_noninteractive_setup_guidance(
+ "Running in a non-interactive environment (no TTY detected)."
+ )
+ return
+
# Check if a specific section was requested
section = getattr(args, "section", None)
if section:
@@ -2386,6 +3344,8 @@ def run_setup_wizard(args):
)
)
+ migration_ran = False
+
if is_existing:
# โโ Returning User Menu โโ
print()
@@ -2425,12 +3385,17 @@ def run_setup_wizard(args):
print_info("Exiting. Run 'hermes setup' again when ready.")
return
elif 3 <= choice <= 7:
- # Individual section
- section_idx = choice - 3
- _, label, func = SETUP_SECTIONS[section_idx]
- func(config)
- save_config(config)
- _print_setup_summary(config, hermes_home)
+ # Individual section โ map by key, not by position.
+ # SETUP_SECTIONS includes TTS but the returning-user menu skips it,
+ # so positional indexing (choice - 3) would dispatch the wrong section.
+ _RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"]
+ section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
+ section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
+ if section:
+ _, label, func = section
+ func(config)
+ save_config(config)
+ _print_setup_summary(config, hermes_home)
return
else:
# โโ First-Time Setup โโ
@@ -2438,9 +3403,9 @@ def run_setup_wizard(args):
print_info("We'll walk you through:")
print_info(" 1. Model & Provider โ choose your AI provider and model")
print_info(" 2. Terminal Backend โ where your agent runs commands")
- print_info(" 3. Messaging Platforms โ connect Telegram, Discord, etc.")
- print_info(" 4. Tools โ configure TTS, web search, image generation, etc.")
- print_info(" 5. Agent Settings โ iterations, compression, session reset")
+ print_info(" 3. Agent Settings โ iterations, compression, session reset")
+ print_info(" 4. Messaging Platforms โ connect Telegram, Discord, etc.")
+ print_info(" 5. Tools โ configure TTS, web search, image generation, etc.")
print()
print_info("Press Enter to begin, or Ctrl+C to exit.")
try:
@@ -2450,7 +3415,8 @@ def run_setup_wizard(args):
return
# Offer OpenClaw migration before configuration begins
- if _offer_openclaw_migration(hermes_home):
+ migration_ran = _offer_openclaw_migration(hermes_home)
+ if migration_ran:
# Reload config in case migration wrote to it
config = load_config()
@@ -2463,20 +3429,31 @@ def run_setup_wizard(args):
print()
print_info("You can edit these files directly or use 'hermes config edit'")
+ if migration_ran:
+ print()
+ print_info("Settings were imported from OpenClaw.")
+ print_info("Each section below will show what was imported โ press Enter to keep,")
+ print_info("or choose to reconfigure if needed.")
+
# Section 1: Model & Provider
- setup_model_provider(config)
+ if not (migration_ran and _skip_configured_section(config, "model", "Model & Provider")):
+ setup_model_provider(config)
# Section 2: Terminal Backend
- setup_terminal_backend(config)
+ if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
+ setup_terminal_backend(config)
# Section 3: Agent Settings
- setup_agent_settings(config)
+ if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
+ setup_agent_settings(config)
# Section 4: Messaging Platforms
- setup_gateway(config)
+ if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
+ setup_gateway(config)
# Section 5: Tools
- setup_tools(config, first_install=not is_existing)
+ if not (migration_ran and _skip_configured_section(config, "tools", "Tools")):
+ setup_tools(config, first_install=not is_existing)
# Save and show summary
save_config(config)
@@ -2489,7 +3466,6 @@ def _run_quick_setup(config: dict, hermes_home):
get_missing_env_vars,
get_missing_config_fields,
check_config_version,
- migrate_config,
)
print()
@@ -2628,9 +3604,9 @@ def _run_quick_setup(config: dict, hermes_home):
value = prompt(f" {var.get('prompt', var['name'])}")
if value:
save_env_value(var["name"], value)
- print_success(f" โ Saved")
+ print_success(" โ Saved")
else:
- print_warning(f" Skipped")
+ print_warning(" Skipped")
print()
# Handle missing config fields
diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py
index 808b61762d6..d1d8d50a378 100644
--- a/hermes_cli/skills_config.py
+++ b/hermes_cli/skills_config.py
@@ -11,7 +11,7 @@
telegram: [skill-c]
cli: []
"""
-from typing import Dict, List, Optional, Set
+from typing import List, Optional, Set
from hermes_cli.config import load_config, save_config
from hermes_cli.colors import Colors, color
diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py
index e39b098a2ee..62f91ce9a48 100644
--- a/hermes_cli/skills_hub.py
+++ b/hermes_cli/skills_hub.py
@@ -13,7 +13,7 @@
import json
import shutil
from pathlib import Path
-from typing import Optional
+from typing import Any, Dict, Optional
from rich.console import Console
from rich.panel import Panel
@@ -76,6 +76,70 @@ def _resolve_short_name(name: str, sources, console: Console) -> str:
return ""
+def _format_extra_metadata_lines(extra: Dict[str, Any]) -> list[str]:
+ lines: list[str] = []
+ if not extra:
+ return lines
+
+ if extra.get("repo_url"):
+ lines.append(f"[bold]Repo:[/] {extra['repo_url']}")
+ if extra.get("detail_url"):
+ lines.append(f"[bold]Detail Page:[/] {extra['detail_url']}")
+ if extra.get("index_url"):
+ lines.append(f"[bold]Index:[/] {extra['index_url']}")
+ if extra.get("endpoint"):
+ lines.append(f"[bold]Endpoint:[/] {extra['endpoint']}")
+ if extra.get("install_command"):
+ lines.append(f"[bold]Install Command:[/] {extra['install_command']}")
+ if extra.get("installs") is not None:
+ lines.append(f"[bold]Installs:[/] {extra['installs']}")
+ if extra.get("weekly_installs"):
+ lines.append(f"[bold]Weekly Installs:[/] {extra['weekly_installs']}")
+
+ security = extra.get("security_audits")
+ if isinstance(security, dict) and security:
+ ordered = ", ".join(f"{name}={status}" for name, status in sorted(security.items()))
+ lines.append(f"[bold]Security:[/] {ordered}")
+
+ return lines
+
+
+def _resolve_source_meta_and_bundle(identifier: str, sources):
+ """Resolve metadata and bundle for a specific identifier."""
+ meta = None
+ bundle = None
+ matched_source = None
+
+ for src in sources:
+ if meta is None:
+ try:
+ meta = src.inspect(identifier)
+ if meta:
+ matched_source = src
+ except Exception:
+ meta = None
+ try:
+ bundle = src.fetch(identifier)
+ except Exception:
+ bundle = None
+ if bundle:
+ matched_source = src
+ if meta is None:
+ try:
+ meta = src.inspect(identifier)
+ except Exception:
+ meta = None
+ break
+
+ return meta, bundle, matched_source
+
+
+def _derive_category_from_install_path(install_path: str) -> str:
+ path = Path(install_path)
+ parent = str(path.parent)
+ return "" if parent == "." else parent
+
+
def do_search(query: str, source: str = "all", limit: int = 10,
console: Optional[Console] = None) -> None:
"""Search registries and display results as a Rich table."""
@@ -122,7 +186,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
Official skills are always shown first, regardless of source filter.
"""
from tools.skills_hub import (
- GitHubAuth, create_source_router, OptionalSkillSource, SkillMeta,
+ GitHubAuth, create_source_router,
)
# Clamp page_size to safe range
@@ -136,7 +200,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Collect results from all (or filtered) sources
# Use empty query to get everything; per-source limits prevent overload
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
- _PER_SOURCE_LIMIT = {"official": 100, "github": 100, "clawhub": 50,
+ _PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
"claude-marketplace": 50, "lobehub": 50}
all_results: list = []
@@ -240,7 +304,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
def do_install(identifier: str, category: str = "", force: bool = False,
- console: Optional[Console] = None) -> None:
+ console: Optional[Console] = None, skip_confirm: bool = False) -> None:
"""Fetch, quarantine, scan, confirm, and install a skill."""
from tools.skills_hub import (
GitHubAuth, create_source_router, ensure_hub_dirs,
@@ -263,11 +327,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print(f"\n[bold]Fetching:[/] {identifier}")
- bundle = None
- for src in sources:
- bundle = src.fetch(identifier)
- if bundle:
- break
+ meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources)
if not bundle:
c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.\n")
@@ -288,13 +348,17 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print("Use --force to reinstall.\n")
return
+ extra_metadata = dict(getattr(meta, "extra", {}) or {})
+ extra_metadata.update(getattr(bundle, "metadata", {}) or {})
+
# Quarantine the bundle
q_path = quarantine_bundle(bundle)
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
# Scan
c.print("[bold]Running security scan...[/]")
- result = scan_skill(q_path, source=identifier)
+ scan_source = getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or identifier
+ result = scan_skill(q_path, source=scan_source)
c.print(format_scan_report(result))
# Check install policy
@@ -309,8 +373,14 @@ def do_install(identifier: str, category: str = "", force: bool = False,
f"{len(result.findings)}_findings")
return
+ if extra_metadata:
+ metadata_lines = _format_extra_metadata_lines(extra_metadata)
+ if metadata_lines:
+ c.print(Panel("\n".join(metadata_lines), title="Upstream Metadata", border_style="blue"))
+
# Confirm with user โ show appropriate warning based on source
- if not force:
+ # skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
+ if not force and not skip_confirm:
c.print()
if bundle.source == "official":
c.print(Panel(
@@ -347,6 +417,13 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
+ # Invalidate the skills prompt cache so the new skill appears immediately
+ try:
+ from agent.prompt_builder import clear_skills_system_prompt_cache
+ clear_skills_system_prompt_cache(clear_snapshot=True)
+ except Exception:
+ pass
+
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
"""Preview a skill's SKILL.md content without installing."""
@@ -361,23 +438,12 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
if not identifier:
return
- meta = None
- for src in sources:
- meta = src.inspect(identifier)
- if meta:
- break
+ meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources)
if not meta:
c.print(f"[bold red]Error:[/] Could not find '{identifier}' in any source.\n")
return
- # Also fetch full content for preview
- bundle = None
- for src in sources:
- bundle = src.fetch(identifier)
- if bundle:
- break
-
c.print()
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(meta.trust_level, "dim")
trust_label = "official" if meta.source == "official" else meta.trust_level
@@ -391,11 +457,14 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
]
if meta.tags:
info_lines.append(f"[bold]Tags:[/] {', '.join(meta.tags)}")
+ info_lines.extend(_format_extra_metadata_lines(meta.extra))
c.print(Panel("\n".join(info_lines), title=f"Skill: {meta.name}"))
if bundle and "SKILL.md" in bundle.files:
content = bundle.files["SKILL.md"]
+ if isinstance(content, bytes):
+ content = content.decode("utf-8", errors="replace")
# Show first 50 lines as preview
lines = content.split("\n")
preview = "\n".join(lines[:50])
@@ -464,6 +533,49 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No
)
+def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
+ """Check hub-installed skills for upstream updates."""
+ from tools.skills_hub import check_for_skill_updates
+
+ c = console or _console
+ results = check_for_skill_updates(name=name)
+ if not results:
+ c.print("[dim]No hub-installed skills to check.[/]\n")
+ return
+
+ table = Table(title="Skill Updates")
+ table.add_column("Name", style="bold cyan")
+ table.add_column("Source", style="dim")
+ table.add_column("Status", style="dim")
+
+ for entry in results:
+ table.add_row(entry.get("name", ""), entry.get("source", ""), entry.get("status", ""))
+
+ c.print(table)
+ update_count = sum(1 for entry in results if entry.get("status") == "update_available")
+ c.print(f"[dim]{update_count} update(s) available across {len(results)} checked skill(s)[/]\n")
+
+
+def do_update(name: Optional[str] = None, console: Optional[Console] = None) -> None:
+ """Update hub-installed skills with upstream changes."""
+ from tools.skills_hub import HubLockFile, check_for_skill_updates
+
+ c = console or _console
+ lock = HubLockFile()
+ updates = [entry for entry in check_for_skill_updates(name=name) if entry.get("status") == "update_available"]
+ if not updates:
+ c.print("[dim]No updates available.[/]\n")
+ return
+
+ for entry in updates:
+ installed = lock.get_installed(entry["name"])
+ category = _derive_category_from_install_path(installed.get("install_path", "")) if installed else ""
+ c.print(f"[bold]Updating:[/] {entry['name']}")
+ do_install(entry["identifier"], category=category, force=True, console=c)
+
+ c.print(f"[bold green]Updated {len(updates)} skill(s).[/]\n")
+
+
def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> None:
"""Re-run security scan on installed hub skills."""
from tools.skills_hub import HubLockFile, SKILLS_DIR
@@ -497,24 +609,32 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
c.print()
-def do_uninstall(name: str, console: Optional[Console] = None) -> None:
+def do_uninstall(name: str, console: Optional[Console] = None,
+ skip_confirm: bool = False) -> None:
"""Remove a hub-installed skill with confirmation."""
from tools.skills_hub import uninstall_skill
c = console or _console
- c.print(f"\n[bold]Uninstall '{name}'?[/]")
- try:
- answer = input("Confirm [y/N]: ").strip().lower()
- except (EOFError, KeyboardInterrupt):
- answer = "n"
- if answer not in ("y", "yes"):
- c.print("[dim]Cancelled.[/]\n")
- return
+ # skip_confirm bypasses the prompt (needed in TUI mode where input() hangs)
+ if not skip_confirm:
+ c.print(f"\n[bold]Uninstall '{name}'?[/]")
+ try:
+ answer = input("Confirm [y/N]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ answer = "n"
+ if answer not in ("y", "yes"):
+ c.print("[dim]Cancelled.[/]\n")
+ return
success, msg = uninstall_skill(name)
if success:
c.print(f"[bold green]{msg}[/]\n")
+ try:
+ from agent.prompt_builder import clear_skills_system_prompt_cache
+ clear_skills_system_prompt_cache(clear_snapshot=True)
+ except Exception:
+ pass
else:
c.print(f"[bold red]Error:[/] {msg}\n")
@@ -535,7 +655,8 @@ def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> No
table.add_column("Repo", style="bold cyan")
table.add_column("Path", style="dim")
for t in taps:
- table.add_row(t["repo"], t.get("path", "skills/"))
+ label = t.get("repo") or t.get("name") or t.get("path", "unknown")
+ table.add_row(label, t.get("path", "skills/"))
c.print(table)
c.print()
@@ -822,11 +943,16 @@ def skills_command(args) -> None:
elif action == "search":
do_search(args.query, source=args.source, limit=args.limit)
elif action == "install":
- do_install(args.identifier, category=args.category, force=args.force)
+ do_install(args.identifier, category=args.category, force=args.force,
+ skip_confirm=getattr(args, "yes", False))
elif action == "inspect":
do_inspect(args.identifier)
elif action == "list":
do_list(source_filter=args.source)
+ elif action == "check":
+ do_check(name=getattr(args, "name", None))
+ elif action == "update":
+ do_update(name=getattr(args, "name", None))
elif action == "audit":
do_audit(name=getattr(args, "name", None))
elif action == "uninstall":
@@ -853,7 +979,7 @@ def skills_command(args) -> None:
return
do_tap(tap_action, repo=repo)
else:
- _console.print("Usage: hermes skills [browse|search|install|inspect|list|audit|uninstall|publish|snapshot|tap]\n")
+ _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|publish|snapshot|tap]\n")
_console.print("Run 'hermes skills --help' for details.\n")
@@ -872,6 +998,8 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
/skills inspect openai/skills/skill-creator
/skills list
/skills list --source hub
+ /skills check
+ /skills update
/skills audit
/skills audit my-skill
/skills uninstall my-skill
@@ -920,7 +1048,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
elif action == "search":
if not args:
- c.print("[bold red]Usage:[/] /skills search [--source github] [--limit N]\n")
+ c.print("[bold red]Usage:[/] /skills search [--source skills-sh|well-known|github|official] [--limit N]\n")
return
source = "all"
limit = 10
@@ -943,15 +1071,19 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
elif action == "install":
if not args:
- c.print("[bold red]Usage:[/] /skills install [--category ] [--force]\n")
+ c.print("[bold red]Usage:[/] /skills install [--category ] [--force|--yes]\n")
return
identifier = args[0]
category = ""
+ # --yes / -y bypasses confirmation prompt (needed in TUI mode)
+ # --force handles reinstall override
+ skip_confirm = any(flag in args for flag in ("--yes", "-y"))
force = "--force" in args
for i, a in enumerate(args):
if a == "--category" and i + 1 < len(args):
category = args[i + 1]
- do_install(identifier, category=category, force=force, console=c)
+ do_install(identifier, category=category, force=force,
+ skip_confirm=skip_confirm, console=c)
elif action == "inspect":
if not args:
@@ -967,15 +1099,24 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
source_filter = args[idx + 1]
do_list(source_filter=source_filter, console=c)
+ elif action == "check":
+ name = args[0] if args else None
+ do_check(name=name, console=c)
+
+ elif action == "update":
+ name = args[0] if args else None
+ do_update(name=name, console=c)
+
elif action == "audit":
name = args[0] if args else None
do_audit(name=name, console=c)
elif action == "uninstall":
if not args:
- c.print("[bold red]Usage:[/] /skills uninstall \n")
+ c.print("[bold red]Usage:[/] /skills uninstall [--yes]\n")
return
- do_uninstall(args[0], console=c)
+ skip_confirm = any(flag in args for flag in ("--yes", "-y"))
+ do_uninstall(args[0], console=c, skip_confirm=skip_confirm)
elif action == "publish":
if not args:
@@ -1029,6 +1170,8 @@ def _print_skills_help(console: Console) -> None:
" [cyan]install[/] Install a skill (with security scan)\n"
" [cyan]inspect[/] Preview a skill without installing\n"
" [cyan]list[/] [--source hub|builtin|local] List installed skills\n"
+ " [cyan]check[/] [name] Check hub skills for upstream updates\n"
+ " [cyan]update[/] [name] Update hub skills with upstream changes\n"
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
" [cyan]uninstall[/] Remove a hub-installed skill\n"
" [cyan]publish[/] --repo Publish a skill to GitHub via PR\n"
diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py
index 6b9cb3c86f3..62fac0eafac 100644
--- a/hermes_cli/skin_engine.py
+++ b/hermes_cli/skin_engine.py
@@ -60,6 +60,12 @@
# Tool prefix: character for tool output lines (default: โ)
tool_prefix: "โ"
+ # Tool emojis: override the default emoji for any tool (used in spinners & progress)
+ tool_emojis:
+ terminal: "โ" # Override terminal tool emoji
+ web_search: "๐ฎ" # Override web_search tool emoji
+ # Any tool not listed here uses its registry default
+
USAGE
=====
@@ -95,6 +101,8 @@
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
+from hermes_constants import get_hermes_home
+
logger = logging.getLogger(__name__)
@@ -111,6 +119,7 @@ class SkinConfig:
spinner: Dict[str, Any] = field(default_factory=dict)
branding: Dict[str, str] = field(default_factory=dict)
tool_prefix: str = "โ"
+ tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides
banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
@@ -344,12 +353,12 @@ def get_branding(self, key: str, fallback: str = "") -> str:
"help_header": "(ฮจ) Available Commands",
},
"tool_prefix": "โ",
- "banner_logo": """[bold #B8E8FF]โโโโโโโ โโโโโโโ โโโโโโโโโโโโโโโโโโ โโโโโโโโ โโโโโโโ โโโโ โโโ โโโโโโ โโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ[/]
-[bold #97D6FF]โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโ[/]
-[#75C1F6]โโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโ โโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโ โโโโโโ โโโ โโโ[/]
-[#4FA2E0]โโโโโโโ โโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโ โโโ[/]
-[#2E7CC7]โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ[/]
-[#1B4F95]โโโ โโโโโโโ โโโโโโโโโโโโโโโโโโ โโโโโโโโ โโโโโโโ โโโ โโโโโ โโโ โโโ โโโโโโโ โโโโโโโโโโโ โโโโโ โโโ[/]""",
+ "banner_logo": """[bold #B8E8FF]โโโโโโโ โโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโ โโโโ โโโ โโโโโโ โโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ[/]
+[bold #97D6FF]โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโ[/]
+[#75C1F6]โโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโ โโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโ โโโโโโ โโโ โโโ[/]
+[#4FA2E0]โโโโโโโ โโโ โโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโ โโโ[/]
+[#2E7CC7]โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ[/]
+[#1B4F95]โโโ โโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโ โโโ โโโโโ โโโ โโโ โโโโโโโ โโโโโโโโโโโ โโโโโ โโโ[/]""",
"banner_hero": """[#2A6FB9]โ โ โ โ โ โ โ โ โ โ โ โขโฃโกโ โ โ โ โ โ โ โ โ โ โ [/]
[#5DB8F5]โ โ โ โ โ โ โ โ โ โฃ โฃพโฃฟโฃทโฃโ โ โ โ โ โ โ โ โ [/]
[#5DB8F5]โ โ โ โ โ โ โ โข โฃฟโ โ ฮจโ โ นโฃฟโกโ โ โ โ โ โ โ [/]
@@ -506,8 +515,7 @@ def get_branding(self, key: str, fallback: str = "") -> str:
def _skins_dir() -> Path:
"""User skins directory."""
- home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- return home / "skins"
+ return get_hermes_home() / "skins"
def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]:
@@ -541,6 +549,7 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
spinner=spinner,
branding=branding,
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "โ")),
+ tool_emojis=data.get("tool_emojis", {}),
banner_logo=data.get("banner_logo", ""),
banner_hero=data.get("banner_hero", ""),
)
@@ -628,3 +637,88 @@ def init_skin_from_config(config: dict) -> None:
set_active_skin(skin_name.strip())
else:
set_active_skin("default")
+
+
+# =============================================================================
+# Convenience helpers for CLI modules
+# =============================================================================
+
+
+def get_active_prompt_symbol(fallback: str = "โฏ ") -> str:
+ """Get the interactive prompt symbol from the active skin."""
+ try:
+ return get_active_skin().get_branding("prompt_symbol", fallback)
+ except Exception:
+ return fallback
+
+
+
+def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
+ """Get the /help header from the active skin."""
+ try:
+ return get_active_skin().get_branding("help_header", fallback)
+ except Exception:
+ return fallback
+
+
+
+def get_active_goodbye(fallback: str = "Goodbye! โ") -> str:
+ """Get the goodbye line from the active skin."""
+ try:
+ return get_active_skin().get_branding("goodbye", fallback)
+ except Exception:
+ return fallback
+
+
+
+def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
+ """Return prompt_toolkit style overrides derived from the active skin.
+
+ These are layered on top of the CLI's base TUI style so /skin can refresh
+ the live prompt_toolkit UI immediately without rebuilding the app.
+ """
+ try:
+ skin = get_active_skin()
+ except Exception:
+ return {}
+
+ prompt = skin.get_color("prompt", "#FFF8DC")
+ input_rule = skin.get_color("input_rule", "#CD7F32")
+ title = skin.get_color("banner_title", "#FFD700")
+ text = skin.get_color("banner_text", prompt)
+ dim = skin.get_color("banner_dim", "#555555")
+ label = skin.get_color("ui_label", title)
+ warn = skin.get_color("ui_warn", "#FF8C00")
+ error = skin.get_color("ui_error", "#FF6B6B")
+
+ return {
+ "input-area": prompt,
+ "placeholder": f"{dim} italic",
+ "prompt": prompt,
+ "prompt-working": f"{dim} italic",
+ "hint": f"{dim} italic",
+ "input-rule": input_rule,
+ "image-badge": f"{label} bold",
+ "completion-menu": f"bg:#1a1a2e {text}",
+ "completion-menu.completion": f"bg:#1a1a2e {text}",
+ "completion-menu.completion.current": f"bg:#333355 {title}",
+ "completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
+ "completion-menu.meta.completion.current": f"bg:#333355 {label}",
+ "clarify-border": input_rule,
+ "clarify-title": f"{title} bold",
+ "clarify-question": f"{text} bold",
+ "clarify-choice": dim,
+ "clarify-selected": f"{title} bold",
+ "clarify-active-other": f"{title} italic",
+ "clarify-countdown": input_rule,
+ "sudo-prompt": f"{error} bold",
+ "sudo-border": input_rule,
+ "sudo-title": f"{error} bold",
+ "sudo-text": text,
+ "approval-border": input_rule,
+ "approval-title": f"{warn} bold",
+ "approval-desc": f"{text} bold",
+ "approval-cmd": f"{dim} italic",
+ "approval-choice": dim,
+ "approval-selected": f"{title} bold",
+ }
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index 971dad47fad..174c3c7dc41 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -11,8 +11,11 @@
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
+from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
-from hermes_cli.config import get_env_path, get_env_value
+from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
+from hermes_cli.models import provider_label
+from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_constants import OPENROUTER_MODELS_URL
def check_mark(ok: bool) -> str:
@@ -48,6 +51,32 @@ def _format_iso_timestamp(value) -> str:
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
+def _configured_model_label(config: dict) -> str:
+ """Return the configured default model from config.yaml."""
+ model_cfg = config.get("model")
+ if isinstance(model_cfg, dict):
+ model = (model_cfg.get("default") or model_cfg.get("name") or "").strip()
+ elif isinstance(model_cfg, str):
+ model = model_cfg.strip()
+ else:
+ model = ""
+ return model or "(not set)"
+
+
+def _effective_provider_label() -> str:
+ """Return the provider label matching current CLI runtime resolution."""
+ requested = resolve_requested_provider()
+ try:
+ effective = resolve_provider(requested)
+ except AuthError:
+ effective = requested or "auto"
+
+ if effective == "openrouter" and get_env_value("OPENAI_BASE_URL"):
+ effective = "custom"
+
+ return provider_label(effective)
+
+
def show_status(args):
"""Show status of all Hermes Agent components."""
show_all = getattr(args, 'all', False)
@@ -68,6 +97,14 @@ def show_status(args):
env_path = get_env_path()
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
+
+ try:
+ config = load_config()
+ except Exception:
+ config = {}
+
+ print(f" Model: {_configured_model_label(config)}")
+ print(f" Provider: {_effective_provider_label()}")
# =========================================================================
# API Keys
@@ -77,13 +114,14 @@ def show_status(args):
keys = {
"OpenRouter": "OPENROUTER_API_KEY",
- "Anthropic": "ANTHROPIC_API_KEY",
"OpenAI": "OPENAI_API_KEY",
"Z.AI/GLM": "GLM_API_KEY",
"Kimi": "KIMI_API_KEY",
"MiniMax": "MINIMAX_API_KEY",
"MiniMax-CN": "MINIMAX_CN_API_KEY",
+ "xgate": "XGATE_API_KEY",
"Firecrawl": "FIRECRAWL_API_KEY",
+ "Tavily": "TAVILY_API_KEY",
"Browserbase": "BROWSERBASE_API_KEY", # Optional โ local browser works without this
"FAL": "FAL_KEY",
"Tinker": "TINKER_API_KEY",
@@ -98,6 +136,14 @@ def show_status(args):
display = redact_key(value) if not show_all else value
print(f" {name:<12} {check_mark(has_key)} {display}")
+ anthropic_value = (
+ get_env_value("ANTHROPIC_TOKEN")
+ or get_env_value("ANTHROPIC_API_KEY")
+ or ""
+ )
+ anthropic_display = redact_key(anthropic_value) if not show_all else anthropic_value
+ print(f" {'Anthropic':<12} {check_mark(bool(anthropic_value))} {anthropic_display}")
+
# =========================================================================
# Auth Providers (OAuth)
# =========================================================================
@@ -152,6 +198,7 @@ def show_status(args):
"Kimi / Moonshot": ("KIMI_API_KEY",),
"MiniMax": ("MINIMAX_API_KEY",),
"MiniMax (China)": ("MINIMAX_CN_API_KEY",),
+ "xgate": ("XGATE_API_KEY",),
}
for pname, env_vars in apikey_providers.items():
key_val = ""
@@ -174,7 +221,6 @@ def show_status(args):
# Fall back to config file value when env var isn't set
# (hermes status doesn't go through cli.py's config loading)
try:
- from hermes_cli.config import load_config
_cfg = load_config()
terminal_env = _cfg.get("terminal", {}).get("backend", "local")
except Exception:
@@ -209,6 +255,7 @@ def show_status(args):
"Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"),
"Slack": ("SLACK_BOT_TOKEN", None),
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
+ "SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
@@ -232,14 +279,19 @@ def show_status(args):
print(color("โ Gateway Service", Colors.CYAN, Colors.BOLD))
if sys.platform.startswith('linux'):
+ try:
+ from hermes_cli.gateway import get_service_name
+ _gw_svc = get_service_name()
+ except Exception:
+ _gw_svc = "hermes-gateway"
result = subprocess.run(
- ["systemctl", "--user", "is-active", "hermes-gateway"],
+ ["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(f" Manager: systemd (user)")
+ print(" Manager: systemd (user)")
elif sys.platform == 'darwin':
result = subprocess.run(
@@ -249,10 +301,10 @@ def show_status(args):
)
is_loaded = result.returncode == 0
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
- print(f" Manager: launchd")
+ print(" Manager: launchd")
else:
print(f" Status: {color('N/A', Colors.DIM)}")
- print(f" Manager: (not supported on this platform)")
+ print(" Manager: (not supported on this platform)")
# =========================================================================
# Cron Jobs
@@ -260,7 +312,7 @@ def show_status(args):
print()
print(color("โ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
- jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json"
+ jobs_file = get_hermes_home() / "cron" / "jobs.json"
if jobs_file.exists():
import json
try:
@@ -270,9 +322,9 @@ def show_status(args):
enabled_jobs = [j for j in jobs if j.get("enabled", True)]
print(f" Jobs: {len(enabled_jobs)} active, {len(jobs)} total")
except Exception:
- print(f" Jobs: (error reading jobs file)")
+ print(" Jobs: (error reading jobs file)")
else:
- print(f" Jobs: 0")
+ print(" Jobs: 0")
# =========================================================================
# Sessions
@@ -280,7 +332,7 @@ def show_status(args):
print()
print(color("โ Sessions", Colors.CYAN, Colors.BOLD))
- sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json"
+ sessions_file = get_hermes_home() / "sessions" / "sessions.json"
if sessions_file.exists():
import json
try:
@@ -288,9 +340,9 @@ def show_status(args):
data = json.load(f)
print(f" Active: {len(data)} session(s)")
except Exception:
- print(f" Active: (error reading sessions file)")
+ print(" Active: (error reading sessions file)")
else:
- print(f" Active: 0")
+ print(" Active: 0")
# =========================================================================
# Deep checks
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index cb9b9965759..35758cd155c 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -13,11 +13,9 @@
from pathlib import Path
from typing import Dict, List, Optional, Set
-import os
from hermes_cli.config import (
load_config, save_config, get_env_value, save_env_value,
- get_hermes_home,
)
from hermes_cli.colors import Colors, color
@@ -91,7 +89,7 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
("session_search", "๐ Session Search", "search past conversations"),
("clarify", "โ Clarifying Questions", "clarify"),
("delegation", "๐ฅ Task Delegation", "delegate_task"),
- ("cronjob", "โฐ Cron Jobs", "schedule, list, remove"),
+ ("cronjob", "โฐ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("rl", "๐งช RL Training", "Tinker-Atropos training tools"),
("homeassistant", "๐ Home Assistant", "smart home device control"),
]
@@ -101,6 +99,32 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
# but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl"}
+
+def _get_effective_configurable_toolsets():
+ """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets.
+
+ Plugin toolsets are appended at the end so they appear after the
+ built-in toolsets in the TUI checklist.
+ """
+ result = list(CONFIGURABLE_TOOLSETS)
+ try:
+ from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
+ discover_plugins() # idempotent โ ensures plugins are loaded
+ result.extend(get_plugin_toolsets())
+ except Exception:
+ pass
+ return result
+
+
+def _get_plugin_toolset_keys() -> set:
+ """Return the set of toolset keys provided by plugins."""
+ try:
+ from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
+ discover_plugins() # idempotent โ ensures plugins are loaded
+ return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
+ except Exception:
+ return set()
+
# Platform display config
PLATFORMS = {
"cli": {"label": "๐ฅ๏ธ CLI", "default_toolset": "hermes-cli"},
@@ -109,7 +133,11 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
"slack": {"label": "๐ผ Slack", "default_toolset": "hermes-slack"},
"whatsapp": {"label": "๐ฑ WhatsApp", "default_toolset": "hermes-whatsapp"},
"signal": {"label": "๐ก Signal", "default_toolset": "hermes-signal"},
+ "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"},
+ "api_server": {"label": "๐ API Server", "default_toolset": "hermes-api-server"},
}
@@ -150,19 +178,37 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
"web": {
"name": "Web Search & Extract",
"setup_title": "Select Search Provider",
- "setup_note": "A free DuckDuckGo search skill is also included โ skip this if you don't need Firecrawl.",
+ "setup_note": "A free DuckDuckGo search skill is also included โ skip this if you don't need a premium provider.",
"icon": "๐",
"providers": [
{
"name": "Firecrawl Cloud",
- "tag": "Recommended - hosted service",
+ "tag": "Hosted service - search, extract, and crawl",
+ "web_backend": "firecrawl",
"env_vars": [
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
],
},
+ {
+ "name": "Parallel",
+ "tag": "AI-native search and extract",
+ "web_backend": "parallel",
+ "env_vars": [
+ {"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"},
+ ],
+ },
+ {
+ "name": "Tavily",
+ "tag": "AI-native search, extract, and crawl",
+ "web_backend": "tavily",
+ "env_vars": [
+ {"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
+ ],
+ },
{
"name": "Firecrawl Self-Hosted",
"tag": "Free - run your own instance",
+ "web_backend": "firecrawl",
"env_vars": [
{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
],
@@ -190,6 +236,7 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
"name": "Local Browser",
"tag": "Free headless Chromium (no API key needed)",
"env_vars": [],
+ "browser_provider": None,
"post_setup": "browserbase", # Same npm install for agent-browser
},
{
@@ -199,6 +246,16 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
],
+ "browser_provider": "browserbase",
+ "post_setup": "browserbase",
+ },
+ {
+ "name": "Browser Use",
+ "tag": "Cloud browser with remote execution",
+ "env_vars": [
+ {"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
+ ],
+ "browser_provider": "browser-use",
"post_setup": "browserbase",
},
],
@@ -326,9 +383,31 @@ def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = Non
return summary
-def _get_platform_tools(config: dict, platform: str) -> Set[str]:
+def _parse_enabled_flag(value, default: bool = True) -> bool:
+ """Parse bool-like config values used by tool/platform settings."""
+ if value is None:
+ 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
+
+
+def _get_platform_tools(
+ config: dict,
+ platform: str,
+ *,
+ include_default_mcp_servers: bool = True,
+) -> Set[str]:
"""Resolve which individual toolset names are enabled for a platform."""
- from toolsets import resolve_toolset, TOOLSETS
+ from toolsets import resolve_toolset
platform_toolsets = config.get("platform_toolsets", {})
toolset_names = platform_toolsets.get(platform)
@@ -337,39 +416,140 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]:
default_ts = PLATFORMS[platform]["default_toolset"]
toolset_names = [default_ts]
- # Resolve to individual tool names, then map back to which
- # configurable toolsets are covered
- all_tool_names = set()
- for ts_name in toolset_names:
- all_tool_names.update(resolve_toolset(ts_name))
+ configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
- # Map individual tool names back to configurable toolset keys
- enabled_toolsets = set()
- for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
- ts_tools = set(resolve_toolset(ts_key))
- if ts_tools and ts_tools.issubset(all_tool_names):
- enabled_toolsets.add(ts_key)
+ # If the saved list contains any configurable keys directly, the user
+ # has explicitly configured this platform โ use direct membership.
+ # This avoids the subset-inference bug where composite toolsets like
+ # "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled
+ # toolsets to re-appear as enabled.
+ has_explicit_config = any(ts in configurable_keys for ts in toolset_names)
+
+ if has_explicit_config:
+ enabled_toolsets = {ts for ts in toolset_names if ts in configurable_keys}
+ else:
+ # No explicit config โ fall back to resolving composite toolset names
+ # (e.g. "hermes-cli") to individual tool names and reverse-mapping.
+ all_tool_names = set()
+ for ts_name in toolset_names:
+ all_tool_names.update(resolve_toolset(ts_name))
+
+ enabled_toolsets = set()
+ for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
+ ts_tools = set(resolve_toolset(ts_key))
+ if ts_tools and ts_tools.issubset(all_tool_names):
+ enabled_toolsets.add(ts_key)
+
+ # Plugin toolsets: enabled by default unless explicitly disabled.
+ # A plugin toolset is "known" for a platform once `hermes tools`
+ # has been saved for that platform (tracked via known_plugin_toolsets).
+ # Unknown plugins default to enabled; known-but-absent = disabled.
+ plugin_ts_keys = _get_plugin_toolset_keys()
+ if plugin_ts_keys:
+ known_map = config.get("known_plugin_toolsets", {})
+ known_for_platform = set(known_map.get(platform, []))
+ for pts in plugin_ts_keys:
+ if pts in toolset_names:
+ # Explicitly listed in config โ enabled
+ enabled_toolsets.add(pts)
+ elif pts not in known_for_platform:
+ # New plugin not yet seen by hermes tools โ default enabled
+ enabled_toolsets.add(pts)
+ # else: known but not in config = user disabled it
+
+ # Preserve any explicit non-configurable toolset entries (for example,
+ # custom toolsets or MCP server names saved in platform_toolsets).
+ platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
+ explicit_passthrough = {
+ ts
+ for ts in toolset_names
+ if ts not in configurable_keys
+ and ts not in plugin_ts_keys
+ and ts not in platform_default_keys
+ }
+
+ # MCP servers are expected to be available on all platforms by default.
+ # If the platform explicitly lists one or more MCP server names, treat that
+ # as an allowlist. Otherwise include every globally enabled MCP server.
+ mcp_servers = config.get("mcp_servers", {})
+ enabled_mcp_servers = {
+ name
+ for name, server_cfg in mcp_servers.items()
+ if isinstance(server_cfg, dict)
+ and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
+ }
+ explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
+ enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
+ if include_default_mcp_servers:
+ if explicit_mcp_servers:
+ enabled_toolsets.update(explicit_mcp_servers)
+ else:
+ enabled_toolsets.update(enabled_mcp_servers)
+ else:
+ enabled_toolsets.update(explicit_mcp_servers)
return enabled_toolsets
def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]):
- """Save the selected toolset keys for a platform to config."""
+ """Save the selected toolset keys for a platform to config.
+
+ Preserves any non-configurable toolset entries (like MCP server names)
+ that were already in the config for this platform.
+ """
config.setdefault("platform_toolsets", {})
- config["platform_toolsets"][platform] = sorted(enabled_toolset_keys)
+
+ # Get the set of all configurable toolset keys (built-in + plugin)
+ configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
+ plugin_keys = _get_plugin_toolset_keys()
+ configurable_keys |= plugin_keys
+
+ # Also exclude platform default toolsets (hermes-cli, hermes-telegram, etc.)
+ # These are "super" toolsets that resolve to ALL tools, so preserving them
+ # would silently override the user's unchecked selections on the next read.
+ platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
+
+ # Get existing toolsets for this platform
+ existing_toolsets = config.get("platform_toolsets", {}).get(platform, [])
+ if not isinstance(existing_toolsets, list):
+ existing_toolsets = []
+
+ # Preserve any entries that are NOT configurable toolsets and NOT platform
+ # defaults (i.e. only MCP server names should be preserved)
+ preserved_entries = {
+ entry for entry in existing_toolsets
+ if entry not in configurable_keys and entry not in platform_default_keys
+ }
+
+ # Merge preserved entries with new enabled toolsets
+ config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries)
+
+ # Track which plugin toolsets are "known" for this platform so we can
+ # distinguish "new plugin, default enabled" from "user disabled it".
+ if plugin_keys:
+ config.setdefault("known_plugin_toolsets", {})
+ config["known_plugin_toolsets"][platform] = sorted(plugin_keys)
+
save_config(config)
def _toolset_has_keys(ts_key: str) -> bool:
"""Check if a toolset's required API keys are configured."""
+ if ts_key == "vision":
+ try:
+ from agent.auxiliary_client import resolve_vision_provider_client
+
+ _provider, client, _model = resolve_vision_provider_client()
+ return client is not None
+ except Exception:
+ return False
+
# Check TOOL_CATEGORIES first (provider-aware)
cat = TOOL_CATEGORIES.get(ts_key)
if cat:
- for provider in cat["providers"]:
+ for provider in cat.get("providers", []):
env_vars = provider.get("env_vars", [])
- if not env_vars:
- return True # Free provider (e.g., Edge TTS)
- if all(get_env_value(v["key"]) for v in env_vars):
+ if env_vars and all(get_env_value(e["key"]) for e in env_vars):
return True
return False
@@ -467,15 +647,17 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
from hermes_cli.curses_ui import curses_checklist
+ effective = _get_effective_configurable_toolsets()
+
labels = []
- for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
+ for ts_key, ts_label, ts_desc in effective:
suffix = ""
if not _toolset_has_keys(ts_key) and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
suffix = " [no API key]"
labels.append(f"{ts_label} ({ts_desc}){suffix}")
pre_selected = {
- i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)
+ i for i, (ts_key, _, _) in enumerate(effective)
if ts_key in enabled
}
@@ -485,7 +667,7 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str
pre_selected,
cancel_returns=pre_selected,
)
- return {CONFIGURABLE_TOOLSETS[i][0] for i in chosen}
+ return {effective[i][0] for i in chosen}
# โโโ Provider-Aware Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -535,7 +717,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
# Multiple providers - let user choose
print()
# Use custom title if provided (e.g. "Select Search Provider")
- title = cat.get("setup_title", f"Choose a provider")
+ title = cat.get("setup_title", "Choose a provider")
print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN))
if cat.get("setup_note"):
_print_info(f" {cat['setup_note']}")
@@ -548,10 +730,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
configured = ""
env_vars = p.get("env_vars", [])
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
+ if _is_provider_active(p, config):
configured = " [active]"
elif not env_vars:
- configured = " [active]" if config.get("tts", {}).get("provider", "edge") == p.get("tts_provider", "") else ""
+ configured = ""
else:
configured = " [configured]"
provider_choices.append(f"{p['name']}{tag}{configured}")
@@ -560,15 +742,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
provider_choices.append("Skip โ keep defaults / configure later")
# Detect current provider as default
- default_idx = 0
- for i, p in enumerate(providers):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
- default_idx = i
- break
- env_vars = p.get("env_vars", [])
- if env_vars and all(get_env_value(v["key"]) for v in env_vars):
- default_idx = i
- break
+ default_idx = _detect_active_provider_index(providers, config)
provider_idx = _prompt_choice(f" {title}:", provider_choices, default_idx)
@@ -580,6 +754,31 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
_configure_provider(providers[provider_idx], config)
+def _is_provider_active(provider: dict, config: dict) -> bool:
+ """Check if a provider entry matches the currently active config."""
+ if provider.get("tts_provider"):
+ return config.get("tts", {}).get("provider") == provider["tts_provider"]
+ if "browser_provider" in provider:
+ current = config.get("browser", {}).get("cloud_provider")
+ return provider["browser_provider"] == current
+ if provider.get("web_backend"):
+ current = config.get("web", {}).get("backend")
+ return current == provider["web_backend"]
+ return False
+
+
+def _detect_active_provider_index(providers: list, config: dict) -> int:
+ """Return the index of the currently active provider, or 0."""
+ for i, p in enumerate(providers):
+ if _is_provider_active(p, config):
+ return i
+ # Fallback: env vars present โ likely configured
+ env_vars = p.get("env_vars", [])
+ if env_vars and all(get_env_value(v["key"]) for v in env_vars):
+ return i
+ return 0
+
+
def _configure_provider(provider: dict, config: dict):
"""Configure a single provider - prompt for API keys and set config."""
env_vars = provider.get("env_vars", [])
@@ -588,6 +787,20 @@ def _configure_provider(provider: dict, config: dict):
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
+ # Set browser cloud provider in config if applicable
+ if "browser_provider" in provider:
+ bp = provider["browser_provider"]
+ if bp:
+ config.setdefault("browser", {})["cloud_provider"] = bp
+ _print_success(f" Browser cloud provider set to: {bp}")
+ else:
+ config.get("browser", {}).pop("cloud_provider", None)
+
+ # Set web search backend in config if applicable
+ if provider.get("web_backend"):
+ config.setdefault("web", {})["backend"] = provider["web_backend"]
+ _print_success(f" Web backend set to: {provider['web_backend']}")
+
if not env_vars:
_print_success(f" {provider['name']} - no configuration needed!")
return
@@ -613,9 +826,9 @@ def _configure_provider(provider: dict, config: dict):
if value:
save_env_value(var["key"], value)
- _print_success(f" Saved")
+ _print_success(" Saved")
else:
- _print_warning(f" Skipped")
+ _print_warning(" Skipped")
all_configured = False
# Run post-setup hooks if needed
@@ -628,6 +841,39 @@ def _configure_provider(provider: dict, config: dict):
def _configure_simple_requirements(ts_key: str):
"""Simple fallback for toolsets that just need env vars (no provider selection)."""
+ if ts_key == "vision":
+ if _toolset_has_keys("vision"):
+ return
+ print()
+ print(color(" Vision / Image Analysis requires a multimodal backend:", Colors.YELLOW))
+ choices = [
+ "OpenRouter โ uses Gemini",
+ "OpenAI-compatible endpoint โ base URL, API key, and vision model",
+ "Skip",
+ ]
+ idx = _prompt_choice(" Configure vision backend", choices, 2)
+ if idx == 0:
+ _print_info(" Get key at: https://openrouter.ai/keys")
+ value = _prompt(" OPENROUTER_API_KEY", password=True)
+ if value and value.strip():
+ save_env_value("OPENROUTER_API_KEY", value.strip())
+ _print_success(" Saved")
+ else:
+ _print_warning(" Skipped")
+ elif idx == 1:
+ base_url = _prompt(" OPENAI_BASE_URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
+ key_label = " OPENAI_API_KEY" if "api.openai.com" in base_url.lower() else " API key"
+ api_key = _prompt(key_label, password=True)
+ if api_key and api_key.strip():
+ save_env_value("OPENAI_BASE_URL", base_url)
+ save_env_value("OPENAI_API_KEY", api_key.strip())
+ if "api.openai.com" in base_url.lower():
+ save_env_value("AUXILIARY_VISION_MODEL", "gpt-4o-mini")
+ _print_success(" Saved")
+ else:
+ _print_warning(" Skipped")
+ return
+
requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
if not requirements:
return
@@ -636,7 +882,7 @@ def _configure_simple_requirements(ts_key: str):
if not missing:
return
- ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
+ ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
print()
print(color(f" {ts_label} requires configuration:", Colors.YELLOW))
@@ -646,16 +892,16 @@ def _configure_simple_requirements(ts_key: str):
value = _prompt(f" {var}", password=True)
if value and value.strip():
save_env_value(var, value.strip())
- _print_success(f" Saved")
+ _print_success(" Saved")
else:
- _print_warning(f" Skipped")
+ _print_warning(" Skipped")
def _reconfigure_tool(config: dict):
"""Let user reconfigure an existing tool's provider or API key."""
# Build list of configurable tools that are currently set up
configurable = []
- for ts_key, ts_label, _ in CONFIGURABLE_TOOLSETS:
+ for ts_key, ts_label, _ in _get_effective_configurable_toolsets():
cat = TOOL_CATEGORIES.get(ts_key)
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
if cat or reqs:
@@ -707,7 +953,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
configured = ""
env_vars = p.get("env_vars", [])
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
+ if _is_provider_active(p, config):
configured = " [active]"
elif not env_vars:
configured = ""
@@ -715,15 +961,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
configured = " [configured]"
provider_choices.append(f"{p['name']}{tag}{configured}")
- default_idx = 0
- for i, p in enumerate(providers):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
- default_idx = i
- break
- env_vars = p.get("env_vars", [])
- if env_vars and all(get_env_value(v["key"]) for v in env_vars):
- default_idx = i
- break
+ default_idx = _detect_active_provider_index(providers, config)
provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx)
_reconfigure_provider(providers[provider_idx], config)
@@ -737,6 +975,20 @@ def _reconfigure_provider(provider: dict, config: dict):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
_print_success(f" TTS provider set to: {provider['tts_provider']}")
+ if "browser_provider" in provider:
+ bp = provider["browser_provider"]
+ if bp:
+ config.setdefault("browser", {})["cloud_provider"] = bp
+ _print_success(f" Browser cloud provider set to: {bp}")
+ else:
+ config.get("browser", {}).pop("cloud_provider", None)
+ _print_success(" Browser set to local mode")
+
+ # Set web search backend in config if applicable
+ if provider.get("web_backend"):
+ config.setdefault("web", {})["backend"] = provider["web_backend"]
+ _print_success(f" Web backend set to: {provider['web_backend']}")
+
if not env_vars:
_print_success(f" {provider['name']} - no configuration needed!")
return
@@ -752,9 +1004,9 @@ def _reconfigure_provider(provider: dict, config: dict):
value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val)
if value and value.strip():
save_env_value(var["key"], value.strip())
- _print_success(f" Updated")
+ _print_success(" Updated")
else:
- _print_info(f" Kept current")
+ _print_info(" Kept current")
def _reconfigure_simple_requirements(ts_key: str):
@@ -763,7 +1015,7 @@ def _reconfigure_simple_requirements(ts_key: str):
if not requirements:
return
- ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
+ ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
print()
print(color(f" {ts_label}:", Colors.CYAN))
@@ -776,9 +1028,9 @@ def _reconfigure_simple_requirements(ts_key: str):
value = _prompt(f" {var} (Enter to keep current)", password=True)
if value and value.strip():
save_env_value(var, value.strip())
- _print_success(f" Updated")
+ _print_success(" Updated")
else:
- _print_info(f" Kept current")
+ _print_info(" Kept current")
# โโโ Main Entry Point โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -802,7 +1054,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Non-interactive summary mode for CLI usage
if getattr(args, "summary", False):
- total = len(CONFIGURABLE_TOOLSETS)
+ total = len(_get_effective_configurable_toolsets())
print(color("โ Tool Summary", Colors.CYAN, Colors.BOLD))
print()
summary = _platform_toolset_summary(config, enabled_platforms)
@@ -813,7 +1065,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print(color(f" {pinfo['label']}", Colors.BOLD) + color(f" ({count}/{total})", Colors.DIM))
if enabled:
for ts_key in sorted(enabled):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
print(color(f" โ {label}", Colors.GREEN))
else:
print(color(" (none enabled)", Colors.DIM))
@@ -828,7 +1080,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
if first_install:
for pkey in enabled_platforms:
pinfo = PLATFORMS[pkey]
- current_enabled = _get_platform_tools(config, pkey)
+ current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
# Uncheck toolsets that should be off by default
checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS
@@ -840,11 +1092,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
removed = current_enabled - new_enabled
if added:
for ts in sorted(added):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" + {label}", Colors.GREEN))
if removed:
for ts in sorted(removed):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" - {label}", Colors.RED))
# Walk through ALL selected tools that have provider options or
@@ -860,7 +1112,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print()
print(color(f" Configuring {len(to_configure)} tool(s):", Colors.YELLOW))
for ts_key in to_configure:
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
print(color(f" โข {label}", Colors.DIM))
print(color(" You can skip any tool you don't need right now.", Colors.DIM))
print()
@@ -880,21 +1132,28 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
platform_keys = []
for pkey in enabled_platforms:
pinfo = PLATFORMS[pkey]
- current = _get_platform_tools(config, pkey)
+ current = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
count = len(current)
- total = len(CONFIGURABLE_TOOLSETS)
+ total = len(_get_effective_configurable_toolsets())
platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)")
platform_keys.append(pkey)
if len(platform_keys) > 1:
platform_choices.append("Configure all platforms (global)")
platform_choices.append("Reconfigure an existing tool's provider or API key")
+
+ # Show MCP option if any MCP servers are configured
+ _has_mcp = bool(config.get("mcp_servers"))
+ if _has_mcp:
+ platform_choices.append("Configure MCP server tools")
+
platform_choices.append("Done")
# Index offsets for the extra options after per-platform entries
_global_idx = len(platform_keys) if len(platform_keys) > 1 else -1
_reconfig_idx = len(platform_keys) + (1 if len(platform_keys) > 1 else 0)
- _done_idx = _reconfig_idx + 1
+ _mcp_idx = (_reconfig_idx + 1) if _has_mcp else -1
+ _done_idx = _reconfig_idx + (2 if _has_mcp else 1)
while True:
idx = _prompt_choice("Select an option:", platform_choices, default=0)
@@ -909,26 +1168,32 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print()
continue
+ # "Configure MCP tools" selected
+ if idx == _mcp_idx:
+ _configure_mcp_tools_interactive(config)
+ print()
+ continue
+
# "Configure all platforms (global)" selected
if idx == _global_idx:
# Use the union of all platforms' current tools as the starting state
all_current = set()
for pk in platform_keys:
- all_current |= _get_platform_tools(config, pk)
+ all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False)
new_enabled = _prompt_toolset_checklist("All platforms", all_current)
if new_enabled != all_current:
for pk in platform_keys:
- prev = _get_platform_tools(config, pk)
+ prev = _get_platform_tools(config, pk, include_default_mcp_servers=False)
added = new_enabled - prev
removed = prev - new_enabled
pinfo_inner = PLATFORMS[pk]
if added or removed:
print(color(f" {pinfo_inner['label']}:", Colors.DIM))
for ts in sorted(added):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" + {label}", Colors.GREEN))
for ts in sorted(removed):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" - {label}", Colors.RED))
# Configure API keys for newly enabled tools
for ts_key in sorted(added):
@@ -940,8 +1205,8 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print(color(" โ Saved configuration for all platforms", Colors.GREEN))
# Update choice labels
for ci, pk in enumerate(platform_keys):
- new_count = len(_get_platform_tools(config, pk))
- total = len(CONFIGURABLE_TOOLSETS)
+ new_count = len(_get_platform_tools(config, pk, include_default_mcp_servers=False))
+ total = len(_get_effective_configurable_toolsets())
platform_choices[ci] = f"Configure {PLATFORMS[pk]['label']} ({new_count}/{total} enabled)"
else:
print(color(" No changes", Colors.DIM))
@@ -952,7 +1217,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
pinfo = PLATFORMS[pkey]
# Get current enabled toolsets for this platform
- current_enabled = _get_platform_tools(config, pkey)
+ current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
# Show checklist
new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled)
@@ -963,11 +1228,11 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
if added:
for ts in sorted(added):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" + {label}", Colors.GREEN))
if removed:
for ts in sorted(removed):
- label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
+ label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" - {label}", Colors.RED))
# Configure newly enabled toolsets that need API keys
@@ -985,11 +1250,268 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print()
# Update the choice label with new count
- new_count = len(_get_platform_tools(config, pkey))
- total = len(CONFIGURABLE_TOOLSETS)
+ new_count = len(_get_platform_tools(config, pkey, include_default_mcp_servers=False))
+ total = len(_get_effective_configurable_toolsets())
platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)"
print()
print(color(" Tool configuration saved to ~/.hermes/config.yaml", Colors.DIM))
print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM))
print()
+
+
+# โโโ MCP Tools Interactive Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _configure_mcp_tools_interactive(config: dict):
+ """Probe MCP servers for available tools and let user toggle them on/off.
+
+ Connects to each configured MCP server, discovers tools, then shows
+ a per-server curses checklist. Writes changes back as ``tools.exclude``
+ entries in config.yaml.
+ """
+ from hermes_cli.curses_ui import curses_checklist
+
+ mcp_servers = config.get("mcp_servers") or {}
+ if not mcp_servers:
+ _print_info("No MCP servers configured.")
+ return
+
+ # Count enabled servers
+ enabled_names = [
+ k for k, v in mcp_servers.items()
+ if v.get("enabled", True) not in (False, "false", "0", "no", "off")
+ ]
+ if not enabled_names:
+ _print_info("All MCP servers are disabled.")
+ return
+
+ print()
+ print(color(" Discovering tools from MCP servers...", Colors.YELLOW))
+ print(color(f" Connecting to {len(enabled_names)} server(s): {', '.join(enabled_names)}", Colors.DIM))
+
+ try:
+ from tools.mcp_tool import probe_mcp_server_tools
+ server_tools = probe_mcp_server_tools()
+ except Exception as exc:
+ _print_error(f"Failed to probe MCP servers: {exc}")
+ return
+
+ if not server_tools:
+ _print_warning("Could not discover tools from any MCP server.")
+ _print_info("Check that server commands/URLs are correct and dependencies are installed.")
+ return
+
+ # Report discovery results
+ failed = [n for n in enabled_names if n not in server_tools]
+ if failed:
+ for name in failed:
+ _print_warning(f" Could not connect to '{name}'")
+
+ total_tools = sum(len(tools) for tools in server_tools.values())
+ print(color(f" Found {total_tools} tool(s) across {len(server_tools)} server(s)", Colors.GREEN))
+ print()
+
+ any_changes = False
+
+ for server_name, tools in server_tools.items():
+ if not tools:
+ _print_info(f" {server_name}: no tools found")
+ continue
+
+ srv_cfg = mcp_servers.get(server_name, {})
+ tools_cfg = srv_cfg.get("tools") or {}
+ include_list = tools_cfg.get("include") or []
+ exclude_list = tools_cfg.get("exclude") or []
+
+ # Build checklist labels
+ labels = []
+ for tool_name, description in tools:
+ desc_short = description[:70] + "..." if len(description) > 70 else description
+ if desc_short:
+ labels.append(f"{tool_name} ({desc_short})")
+ else:
+ labels.append(tool_name)
+
+ # Determine which tools are currently enabled
+ pre_selected: Set[int] = set()
+ tool_names = [t[0] for t in tools]
+ for i, tool_name in enumerate(tool_names):
+ if include_list:
+ # Include mode: only included tools are selected
+ if tool_name in include_list:
+ pre_selected.add(i)
+ elif exclude_list:
+ # Exclude mode: everything except excluded
+ if tool_name not in exclude_list:
+ pre_selected.add(i)
+ else:
+ # No filter: all enabled
+ pre_selected.add(i)
+
+ chosen = curses_checklist(
+ f"MCP Server: {server_name} ({len(tools)} tools)",
+ labels,
+ pre_selected,
+ cancel_returns=pre_selected,
+ )
+
+ if chosen == pre_selected:
+ _print_info(f" {server_name}: no changes")
+ continue
+
+ # Compute new exclude list based on unchecked tools
+ new_exclude = [tool_names[i] for i in range(len(tool_names)) if i not in chosen]
+
+ # Update config
+ srv_cfg = mcp_servers.setdefault(server_name, {})
+ tools_cfg = srv_cfg.setdefault("tools", {})
+
+ if new_exclude:
+ tools_cfg["exclude"] = new_exclude
+ # Remove include if present โ we're switching to exclude mode
+ tools_cfg.pop("include", None)
+ else:
+ # All tools enabled โ clear filters
+ tools_cfg.pop("exclude", None)
+ tools_cfg.pop("include", None)
+
+ enabled_count = len(chosen)
+ disabled_count = len(tools) - enabled_count
+ _print_success(
+ f" {server_name}: {enabled_count} enabled, {disabled_count} disabled"
+ )
+ any_changes = True
+
+ if any_changes:
+ save_config(config)
+ print()
+ print(color(" โ MCP tool configuration saved", Colors.GREEN))
+ else:
+ print(color(" No changes to MCP tools", Colors.DIM))
+
+
+# โโโ Non-interactive disable/enable โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str):
+ """Add or remove built-in toolsets for a platform."""
+ enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False)
+ if action == "disable":
+ updated = enabled - set(toolset_names)
+ else:
+ updated = enabled | set(toolset_names)
+ _save_platform_tools(config, platform, updated)
+
+
+def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]:
+ """Add or remove specific MCP tools from a server's exclude list.
+
+ Returns the set of server names that were not found in config.
+ """
+ failed_servers: Set[str] = set()
+ mcp_servers = config.get("mcp_servers") or {}
+
+ for target in targets:
+ server_name, tool_name = target.split(":", 1)
+ if server_name not in mcp_servers:
+ failed_servers.add(server_name)
+ continue
+ tools_cfg = mcp_servers[server_name].setdefault("tools", {})
+ exclude = list(tools_cfg.get("exclude") or [])
+ if action == "disable":
+ if tool_name not in exclude:
+ exclude.append(tool_name)
+ else:
+ exclude = [t for t in exclude if t != tool_name]
+ tools_cfg["exclude"] = exclude
+
+ return failed_servers
+
+
+def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"):
+ """Print a summary of enabled/disabled toolsets and MCP tool filters."""
+ effective = _get_effective_configurable_toolsets()
+ builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
+
+ print(f"Built-in toolsets ({platform}):")
+ for ts_key, label, _ in effective:
+ if ts_key not in builtin_keys:
+ continue
+ status = (color("โ enabled", Colors.GREEN) if ts_key in enabled_toolsets
+ else color("โ disabled", Colors.RED))
+ print(f" {status} {ts_key} {color(label, Colors.DIM)}")
+
+ # Plugin toolsets
+ plugin_entries = [(k, l) for k, l, _ in effective if k not in builtin_keys]
+ if plugin_entries:
+ print()
+ print(f"Plugin toolsets ({platform}):")
+ for ts_key, label in plugin_entries:
+ status = (color("โ enabled", Colors.GREEN) if ts_key in enabled_toolsets
+ else color("โ disabled", Colors.RED))
+ print(f" {status} {ts_key} {color(label, Colors.DIM)}")
+
+ if mcp_servers:
+ print()
+ print("MCP servers:")
+ for srv_name, srv_cfg in mcp_servers.items():
+ tools_cfg = srv_cfg.get("tools") or {}
+ exclude = tools_cfg.get("exclude") or []
+ include = tools_cfg.get("include") or []
+ if include:
+ _print_info(f"{srv_name} [include only: {', '.join(include)}]")
+ elif exclude:
+ _print_info(f"{srv_name} [excluded: {color(', '.join(exclude), Colors.YELLOW)}]")
+ else:
+ _print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}")
+
+
+def tools_disable_enable_command(args):
+ """Enable, disable, or list tools for a platform.
+
+ Built-in toolsets use plain names (e.g. ``web``, ``memory``).
+ MCP tools use ``server:tool`` notation (e.g. ``github:create_issue``).
+ """
+ action = args.tools_action
+ platform = getattr(args, "platform", "cli")
+ config = load_config()
+
+ if platform not in PLATFORMS:
+ _print_error(f"Unknown platform '{platform}'. Valid: {', '.join(PLATFORMS)}")
+ return
+
+ if action == "list":
+ _print_tools_list(_get_platform_tools(config, platform, include_default_mcp_servers=False),
+ config.get("mcp_servers") or {}, platform)
+ return
+
+ targets: List[str] = args.names
+ toolset_targets = [t for t in targets if ":" not in t]
+ mcp_targets = [t for t in targets if ":" in t]
+
+ valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
+ unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets]
+ if unknown_toolsets:
+ for name in unknown_toolsets:
+ _print_error(f"Unknown toolset '{name}'")
+ toolset_targets = [t for t in toolset_targets if t in valid_toolsets]
+
+ if toolset_targets:
+ _apply_toolset_change(config, platform, toolset_targets, action)
+
+ failed_servers: Set[str] = set()
+ if mcp_targets:
+ failed_servers = _apply_mcp_change(config, mcp_targets, action)
+ for srv in failed_servers:
+ _print_error(f"MCP server '{srv}' not found in config")
+
+ save_config(config)
+
+ successful = [
+ t for t in targets
+ if t not in unknown_toolsets and (":" not in t or t.split(":")[0] not in failed_servers)
+ ]
+ if successful:
+ verb = "Disabled" if action == "disable" else "Enabled"
+ _print_success(f"{verb}: {', '.join(successful)}")
diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py
index d70405ce312..4a068b04ba3 100644
--- a/hermes_cli/uninstall.py
+++ b/hermes_cli/uninstall.py
@@ -7,11 +7,11 @@
"""
import os
-import sys
import shutil
import subprocess
from pathlib import Path
-from typing import Optional
+
+from hermes_constants import get_hermes_home
from hermes_cli.colors import Colors, color
@@ -33,11 +33,6 @@ def get_project_root() -> Path:
return Path(__file__).parent.parent.resolve()
-def get_hermes_home() -> Path:
- """Get the Hermes home directory (~/.hermes)."""
- return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
-
-
def find_shell_configs() -> list:
"""Find shell configuration files that might have PATH entries."""
home = Path.home()
@@ -133,7 +128,13 @@ def uninstall_gateway_service():
if platform.system() != "Linux":
return False
- service_file = Path.home() / ".config" / "systemd" / "user" / "hermes-gateway.service"
+ try:
+ from hermes_cli.gateway import get_service_name
+ svc_name = get_service_name()
+ except Exception:
+ svc_name = "hermes-gateway"
+
+ service_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service"
if not service_file.exists():
return False
@@ -141,14 +142,14 @@ def uninstall_gateway_service():
try:
# Stop the service
subprocess.run(
- ["systemctl", "--user", "stop", "hermes-gateway"],
+ ["systemctl", "--user", "stop", svc_name],
capture_output=True,
check=False
)
# Disable the service
subprocess.run(
- ["systemctl", "--user", "disable", "hermes-gateway"],
+ ["systemctl", "--user", "disable", svc_name],
capture_output=True,
check=False
)
@@ -272,7 +273,7 @@ def run_uninstall(args):
log_info("No wrapper script found")
# 4. Remove installation directory (code)
- log_info(f"Removing installation directory...")
+ log_info("Removing installation directory...")
# Check if we're running from within the install dir
# We need to be careful here
diff --git a/hermes_constants.py b/hermes_constants.py
index a81af04d3da..518472023f8 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -4,9 +4,47 @@
without risk of circular imports.
"""
+import os
+from pathlib import Path
+
+
+def get_hermes_home() -> Path:
+ """Return the Hermes home directory (default: ~/.hermes).
+
+ Reads HERMES_HOME env var, falls back to ~/.hermes.
+ This is the single source of truth โ all other copies should import this.
+ """
+ return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+
+
+VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")
+
+
+def parse_reasoning_effort(effort: str) -> dict | None:
+ """Parse a reasoning effort level into a config dict.
+
+ Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
+ Returns None when the input is empty or unrecognized (caller uses default).
+ Returns {"enabled": False} for "none".
+ Returns {"enabled": True, "effort": } for valid effort levels.
+ """
+ if not effort or not effort.strip():
+ return None
+ effort = effort.strip().lower()
+ if effort == "none":
+ return {"enabled": False}
+ if effort in VALID_REASONING_EFFORTS:
+ return {"enabled": True, "effort": effort}
+ return None
+
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
+AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
+AI_GATEWAY_MODELS_URL = f"{AI_GATEWAY_BASE_URL}/models"
+AI_GATEWAY_CHAT_URL = f"{AI_GATEWAY_BASE_URL}/chat/completions"
+
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
NOUS_API_CHAT_URL = f"{NOUS_API_BASE_URL}/chat/completions"
diff --git a/hermes_state.py b/hermes_state.py
index 84c3bf44abb..af74ed6ff78 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -15,17 +15,24 @@
"""
import json
+import logging
import os
+import random
import re
import sqlite3
+import threading
import time
from pathlib import Path
-from typing import Dict, Any, List, Optional
+from hermes_constants import get_hermes_home
+from typing import Any, Callable, Dict, List, Optional, TypeVar
+logger = logging.getLogger(__name__)
-DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
+T = TypeVar("T")
-SCHEMA_VERSION = 4
+DEFAULT_DB_PATH = get_hermes_home() / "state.db"
+
+SCHEMA_VERSION = 6
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
@@ -47,6 +54,17 @@
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
+ cache_read_tokens INTEGER DEFAULT 0,
+ cache_write_tokens INTEGER DEFAULT 0,
+ reasoning_tokens INTEGER DEFAULT 0,
+ billing_provider TEXT,
+ billing_base_url TEXT,
+ billing_mode TEXT,
+ estimated_cost_usd REAL,
+ actual_cost_usd REAL,
+ cost_status TEXT,
+ cost_source TEXT,
+ pricing_version TEXT,
title TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@@ -61,7 +79,10 @@
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
- finish_reason TEXT
+ finish_reason TEXT,
+ reasoning TEXT,
+ reasoning_details TEXT,
+ codex_reasoning_items TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
@@ -100,14 +121,38 @@ class SessionDB:
single writer via WAL mode). Each method opens its own cursor.
"""
+ # โโ Write-contention tuning โโ
+ # With multiple hermes processes (gateway + CLI sessions + worktree agents)
+ # all sharing one state.db, WAL write-lock contention causes visible TUI
+ # freezes. SQLite's built-in busy handler uses a deterministic sleep
+ # schedule that causes convoy effects under high concurrency.
+ #
+ # Instead, we keep the SQLite timeout short (1s) and handle retries at the
+ # application level with random jitter, which naturally staggers competing
+ # writers and avoids the convoy.
+ _WRITE_MAX_RETRIES = 15
+ _WRITE_RETRY_MIN_S = 0.020 # 20ms
+ _WRITE_RETRY_MAX_S = 0.150 # 150ms
+ # Attempt a PASSIVE WAL checkpoint every N successful writes.
+ _CHECKPOINT_EVERY_N_WRITES = 50
+
def __init__(self, db_path: Path = None):
self.db_path = db_path or DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._lock = threading.Lock()
+ self._write_count = 0
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
- timeout=10.0,
+ # Short timeout โ application-level retry with random jitter
+ # handles contention instead of sitting in SQLite's internal
+ # busy handler for up to 30s.
+ timeout=1.0,
+ # Autocommit mode: Python's default isolation_level="" auto-starts
+ # transactions on DML, which conflicts with our explicit
+ # BEGIN IMMEDIATE. None = we manage transactions ourselves.
+ isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
@@ -115,6 +160,96 @@ def __init__(self, db_path: Path = None):
self._init_schema()
+ # โโ Core write helper โโ
+
+ def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
+ """Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
+
+ *fn* receives the connection and should perform INSERT/UPDATE/DELETE
+ statements. The caller must NOT call ``commit()`` โ that's handled
+ here after *fn* returns.
+
+ BEGIN IMMEDIATE acquires the WAL write lock at transaction start
+ (not at commit time), so lock contention surfaces immediately.
+ On ``database is locked``, we release the Python lock, sleep a
+ random 20-150ms, and retry โ breaking the convoy pattern that
+ SQLite's built-in deterministic backoff creates.
+
+ Returns whatever *fn* returns.
+ """
+ last_err: Optional[Exception] = None
+ for attempt in range(self._WRITE_MAX_RETRIES):
+ try:
+ with self._lock:
+ self._conn.execute("BEGIN IMMEDIATE")
+ try:
+ result = fn(self._conn)
+ self._conn.commit()
+ except BaseException:
+ try:
+ self._conn.rollback()
+ except Exception:
+ pass
+ raise
+ # Success โ periodic best-effort checkpoint.
+ self._write_count += 1
+ if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
+ self._try_wal_checkpoint()
+ return result
+ except sqlite3.OperationalError as exc:
+ err_msg = str(exc).lower()
+ if "locked" in err_msg or "busy" in err_msg:
+ last_err = exc
+ if attempt < self._WRITE_MAX_RETRIES - 1:
+ jitter = random.uniform(
+ self._WRITE_RETRY_MIN_S,
+ self._WRITE_RETRY_MAX_S,
+ )
+ time.sleep(jitter)
+ continue
+ # Non-lock error or retries exhausted โ propagate.
+ raise
+ # Retries exhausted (shouldn't normally reach here).
+ raise last_err or sqlite3.OperationalError(
+ "database is locked after max retries"
+ )
+
+ def _try_wal_checkpoint(self) -> None:
+ """Best-effort PASSIVE WAL checkpoint. Never blocks, never raises.
+
+ Flushes committed WAL frames back into the main DB file for any
+ frames that no other connection currently needs. Keeps the WAL
+ from growing unbounded when many processes hold persistent
+ connections.
+ """
+ try:
+ with self._lock:
+ result = self._conn.execute(
+ "PRAGMA wal_checkpoint(PASSIVE)"
+ ).fetchone()
+ if result and result[1] > 0:
+ logger.debug(
+ "WAL checkpoint: %d/%d pages checkpointed",
+ result[2], result[1],
+ )
+ except Exception:
+ pass # Best effort โ never fatal.
+
+ def close(self):
+ """Close the database connection.
+
+ Attempts a PASSIVE WAL checkpoint first so that exiting processes
+ help keep the WAL file from growing unbounded.
+ """
+ with self._lock:
+ if self._conn:
+ try:
+ self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
+ except Exception:
+ pass
+ self._conn.close()
+ self._conn = None
+
def _init_schema(self):
"""Create tables and FTS if they don't exist, run migrations."""
cursor = self._conn.cursor()
@@ -152,6 +287,49 @@ def _init_schema(self):
except sqlite3.OperationalError:
pass # Index already exists
cursor.execute("UPDATE schema_version SET version = 4")
+ if current_version < 5:
+ new_columns = [
+ ("cache_read_tokens", "INTEGER DEFAULT 0"),
+ ("cache_write_tokens", "INTEGER DEFAULT 0"),
+ ("reasoning_tokens", "INTEGER DEFAULT 0"),
+ ("billing_provider", "TEXT"),
+ ("billing_base_url", "TEXT"),
+ ("billing_mode", "TEXT"),
+ ("estimated_cost_usd", "REAL"),
+ ("actual_cost_usd", "REAL"),
+ ("cost_status", "TEXT"),
+ ("cost_source", "TEXT"),
+ ("pricing_version", "TEXT"),
+ ]
+ for name, column_type in new_columns:
+ try:
+ # name and column_type come from the hardcoded tuple above,
+ # not user input. Double-quote identifier escaping is applied
+ # as defense-in-depth; SQLite DDL cannot be parameterized.
+ safe_name = name.replace('"', '""')
+ cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
+ except sqlite3.OperationalError:
+ pass
+ cursor.execute("UPDATE schema_version SET version = 5")
+ if current_version < 6:
+ # v6: add reasoning columns to messages table โ preserves assistant
+ # reasoning text and structured reasoning_details across gateway
+ # session turns. Without these, reasoning chains are lost on
+ # session reload, breaking multi-turn reasoning continuity for
+ # providers that replay reasoning (OpenRouter, OpenAI, Nous).
+ for col_name, col_type in [
+ ("reasoning", "TEXT"),
+ ("reasoning_details", "TEXT"),
+ ("codex_reasoning_items", "TEXT"),
+ ]:
+ try:
+ safe = col_name.replace('"', '""')
+ cursor.execute(
+ f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
+ )
+ except sqlite3.OperationalError:
+ pass # Column already exists
+ cursor.execute("UPDATE schema_version SET version = 6")
# Unique title index โ always ensure it exists (safe to run after migrations
# since the title column is guaranteed to exist at this point)
@@ -173,9 +351,10 @@ def _init_schema(self):
def close(self):
"""Close the database connection."""
- if self._conn:
- self._conn.close()
- self._conn = None
+ with self._lock:
+ if self._conn:
+ self._conn.close()
+ self._conn = None
# =========================================================================
# Session lifecycle
@@ -192,61 +371,265 @@ def create_session(
parent_session_id: str = None,
) -> str:
"""Create a new session record. Returns the session_id."""
- self._conn.execute(
- """INSERT INTO sessions (id, source, user_id, model, model_config,
- system_prompt, parent_session_id, started_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- session_id,
- source,
- user_id,
- model,
- json.dumps(model_config) if model_config else None,
- system_prompt,
- parent_session_id,
- time.time(),
- ),
- )
- self._conn.commit()
+ def _do(conn):
+ conn.execute(
+ """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
+ system_prompt, parent_session_id, started_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ session_id,
+ source,
+ user_id,
+ model,
+ json.dumps(model_config) if model_config else None,
+ system_prompt,
+ parent_session_id,
+ time.time(),
+ ),
+ )
+ self._execute_write(_do)
return session_id
def end_session(self, session_id: str, end_reason: str) -> None:
"""Mark a session as ended."""
- self._conn.execute(
- "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?",
- (time.time(), end_reason, session_id),
- )
- self._conn.commit()
+ def _do(conn):
+ conn.execute(
+ "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?",
+ (time.time(), end_reason, session_id),
+ )
+ self._execute_write(_do)
+
+ def reopen_session(self, session_id: str) -> None:
+ """Clear ended_at/end_reason so a session can be resumed."""
+ def _do(conn):
+ conn.execute(
+ "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
+ (session_id,),
+ )
+ self._execute_write(_do)
def update_system_prompt(self, session_id: str, system_prompt: str) -> None:
"""Store the full assembled system prompt snapshot."""
- self._conn.execute(
- "UPDATE sessions SET system_prompt = ? WHERE id = ?",
- (system_prompt, session_id),
- )
- self._conn.commit()
+ def _do(conn):
+ conn.execute(
+ "UPDATE sessions SET system_prompt = ? WHERE id = ?",
+ (system_prompt, session_id),
+ )
+ self._execute_write(_do)
def update_token_counts(
- self, session_id: str, input_tokens: int = 0, output_tokens: int = 0
+ self,
+ session_id: str,
+ input_tokens: int = 0,
+ output_tokens: int = 0,
+ model: str = None,
+ cache_read_tokens: int = 0,
+ cache_write_tokens: int = 0,
+ reasoning_tokens: int = 0,
+ estimated_cost_usd: Optional[float] = None,
+ actual_cost_usd: Optional[float] = None,
+ cost_status: Optional[str] = None,
+ cost_source: Optional[str] = None,
+ pricing_version: Optional[str] = None,
+ billing_provider: Optional[str] = None,
+ billing_base_url: Optional[str] = None,
+ billing_mode: Optional[str] = None,
+ absolute: bool = False,
) -> None:
- """Increment token counters on a session."""
- self._conn.execute(
- """UPDATE sessions SET
- input_tokens = input_tokens + ?,
- output_tokens = output_tokens + ?
- WHERE id = ?""",
- (input_tokens, output_tokens, session_id),
+ """Update token counters and backfill model if not already set.
+
+ When *absolute* is False (default), values are **incremented** โ use
+ this for per-API-call deltas (CLI path).
+
+ When *absolute* is True, values are **set directly** โ use this when
+ the caller already holds cumulative totals (gateway path, where the
+ cached agent accumulates across messages).
+ """
+ if absolute:
+ sql = """UPDATE sessions SET
+ input_tokens = ?,
+ output_tokens = ?,
+ cache_read_tokens = ?,
+ cache_write_tokens = ?,
+ reasoning_tokens = ?,
+ estimated_cost_usd = COALESCE(?, 0),
+ actual_cost_usd = CASE
+ WHEN ? IS NULL THEN actual_cost_usd
+ ELSE ?
+ END,
+ cost_status = COALESCE(?, cost_status),
+ cost_source = COALESCE(?, cost_source),
+ pricing_version = COALESCE(?, pricing_version),
+ billing_provider = COALESCE(billing_provider, ?),
+ billing_base_url = COALESCE(billing_base_url, ?),
+ billing_mode = COALESCE(billing_mode, ?),
+ model = COALESCE(model, ?)
+ WHERE id = ?"""
+ else:
+ sql = """UPDATE sessions SET
+ input_tokens = input_tokens + ?,
+ output_tokens = output_tokens + ?,
+ cache_read_tokens = cache_read_tokens + ?,
+ cache_write_tokens = cache_write_tokens + ?,
+ reasoning_tokens = reasoning_tokens + ?,
+ estimated_cost_usd = COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0),
+ actual_cost_usd = CASE
+ WHEN ? IS NULL THEN actual_cost_usd
+ ELSE COALESCE(actual_cost_usd, 0) + ?
+ END,
+ cost_status = COALESCE(?, cost_status),
+ cost_source = COALESCE(?, cost_source),
+ pricing_version = COALESCE(?, pricing_version),
+ billing_provider = COALESCE(billing_provider, ?),
+ billing_base_url = COALESCE(billing_base_url, ?),
+ billing_mode = COALESCE(billing_mode, ?),
+ model = COALESCE(model, ?)
+ WHERE id = ?"""
+ params = (
+ input_tokens,
+ output_tokens,
+ cache_read_tokens,
+ cache_write_tokens,
+ reasoning_tokens,
+ estimated_cost_usd,
+ actual_cost_usd,
+ actual_cost_usd,
+ cost_status,
+ cost_source,
+ pricing_version,
+ billing_provider,
+ billing_base_url,
+ billing_mode,
+ model,
+ session_id,
)
- self._conn.commit()
+ def _do(conn):
+ conn.execute(sql, params)
+ self._execute_write(_do)
+
+ def ensure_session(
+ self,
+ session_id: str,
+ source: str = "unknown",
+ model: str = None,
+ ) -> None:
+ """Ensure a session row exists, creating it with minimal metadata if absent.
+
+ Used by _flush_messages_to_session_db to recover from a failed
+ create_session() call (e.g. transient SQLite lock at agent startup).
+ INSERT OR IGNORE is safe to call even when the row already exists.
+ """
+ def _do(conn):
+ conn.execute(
+ """INSERT OR IGNORE INTO sessions
+ (id, source, model, started_at)
+ VALUES (?, ?, ?, ?)""",
+ (session_id, source, model, time.time()),
+ )
+ self._execute_write(_do)
+
+ def set_token_counts(
+ self,
+ session_id: str,
+ input_tokens: int = 0,
+ output_tokens: int = 0,
+ model: str = None,
+ cache_read_tokens: int = 0,
+ cache_write_tokens: int = 0,
+ reasoning_tokens: int = 0,
+ estimated_cost_usd: Optional[float] = None,
+ actual_cost_usd: Optional[float] = None,
+ cost_status: Optional[str] = None,
+ cost_source: Optional[str] = None,
+ pricing_version: Optional[str] = None,
+ billing_provider: Optional[str] = None,
+ billing_base_url: Optional[str] = None,
+ billing_mode: Optional[str] = None,
+ ) -> None:
+ """Set token counters to absolute values (not increment).
+
+ Use this when the caller provides cumulative totals from a completed
+ conversation run (e.g. the gateway, where the cached agent's
+ session_prompt_tokens already reflects the running total).
+ """
+ def _do(conn):
+ conn.execute(
+ """UPDATE sessions SET
+ input_tokens = ?,
+ output_tokens = ?,
+ cache_read_tokens = ?,
+ cache_write_tokens = ?,
+ reasoning_tokens = ?,
+ estimated_cost_usd = ?,
+ actual_cost_usd = CASE
+ WHEN ? IS NULL THEN actual_cost_usd
+ ELSE ?
+ END,
+ cost_status = COALESCE(?, cost_status),
+ cost_source = COALESCE(?, cost_source),
+ pricing_version = COALESCE(?, pricing_version),
+ billing_provider = COALESCE(billing_provider, ?),
+ billing_base_url = COALESCE(billing_base_url, ?),
+ billing_mode = COALESCE(billing_mode, ?),
+ model = COALESCE(model, ?)
+ WHERE id = ?""",
+ (
+ input_tokens,
+ output_tokens,
+ cache_read_tokens,
+ cache_write_tokens,
+ reasoning_tokens,
+ estimated_cost_usd,
+ actual_cost_usd,
+ actual_cost_usd,
+ cost_status,
+ cost_source,
+ pricing_version,
+ billing_provider,
+ billing_base_url,
+ billing_mode,
+ model,
+ session_id,
+ ),
+ )
+ self._execute_write(_do)
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get a session by ID."""
- cursor = self._conn.execute(
- "SELECT * FROM sessions WHERE id = ?", (session_id,)
- )
- row = cursor.fetchone()
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
+ )
+ row = cursor.fetchone()
return dict(row) if row else None
+ def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
+ """Resolve an exact or uniquely prefixed session ID to the full ID.
+
+ Returns the exact ID when it exists. Otherwise treats the input as a
+ prefix and returns the single matching session ID if the prefix is
+ unambiguous. Returns None for no matches or ambiguous prefixes.
+ """
+ exact = self.get_session(session_id_or_prefix)
+ if exact:
+ return exact["id"]
+
+ escaped = (
+ session_id_or_prefix
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_")
+ )
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
+ (f"{escaped}%",),
+ )
+ matches = [row["id"] for row in cursor.fetchall()]
+ if len(matches) == 1:
+ return matches[0]
+ return None
+
# Maximum length for session titles
MAX_TITLE_LENGTH = 100
@@ -267,8 +650,6 @@ def sanitize_title(title: Optional[str]) -> Optional[str]:
if not title:
return None
- import re
-
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
# normalized to spaces by the whitespace collapsing step below
@@ -305,38 +686,42 @@ def set_session_title(self, session_id: str, title: str) -> bool:
Empty/whitespace-only strings are normalized to None (clearing the title).
"""
title = self.sanitize_title(title)
- if title:
- # Check uniqueness (allow the same session to keep its own title)
- cursor = self._conn.execute(
- "SELECT id FROM sessions WHERE title = ? AND id != ?",
+ def _do(conn):
+ if title:
+ # Check uniqueness (allow the same session to keep its own title)
+ cursor = conn.execute(
+ "SELECT id FROM sessions WHERE title = ? AND id != ?",
+ (title, session_id),
+ )
+ conflict = cursor.fetchone()
+ if conflict:
+ raise ValueError(
+ f"Title '{title}' is already in use by session {conflict['id']}"
+ )
+ cursor = conn.execute(
+ "UPDATE sessions SET title = ? WHERE id = ?",
(title, session_id),
)
- conflict = cursor.fetchone()
- if conflict:
- raise ValueError(
- f"Title '{title}' is already in use by session {conflict['id']}"
- )
- cursor = self._conn.execute(
- "UPDATE sessions SET title = ? WHERE id = ?",
- (title, session_id),
- )
- self._conn.commit()
- return cursor.rowcount > 0
+ return cursor.rowcount
+ rowcount = self._execute_write(_do)
+ return rowcount > 0
def get_session_title(self, session_id: str) -> Optional[str]:
"""Get the title for a session, or None."""
- cursor = self._conn.execute(
- "SELECT title FROM sessions WHERE id = ?", (session_id,)
- )
- row = cursor.fetchone()
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT title FROM sessions WHERE id = ?", (session_id,)
+ )
+ row = cursor.fetchone()
return row["title"] if row else None
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
- cursor = self._conn.execute(
- "SELECT * FROM sessions WHERE title = ?", (title,)
- )
- row = cursor.fetchone()
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT * FROM sessions WHERE title = ?", (title,)
+ )
+ row = cursor.fetchone()
return dict(row) if row else None
def resolve_session_by_title(self, title: str) -> Optional[str]:
@@ -353,12 +738,13 @@ def resolve_session_by_title(self, title: str) -> Optional[str]:
# Also search for numbered variants: "title #2", "title #3", etc.
# Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
- cursor = self._conn.execute(
- "SELECT id, title, started_at FROM sessions "
- "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
- (f"{escaped} #%",),
- )
- numbered = cursor.fetchall()
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT id, title, started_at FROM sessions "
+ "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
+ (f"{escaped} #%",),
+ )
+ numbered = cursor.fetchall()
if numbered:
# Return the most recent numbered variant
@@ -373,7 +759,6 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
Strips any existing " #N" suffix to find the base name, then finds
the highest existing number and increments.
"""
- import re
# Strip existing #N suffix to find the true base
match = re.match(r'^(.*?) #(\d+)$', base_title)
if match:
@@ -384,11 +769,12 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
# Find all existing numbered variants
# Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
- cursor = self._conn.execute(
- "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
- (base, f"{escaped} #%"),
- )
- existing = [row["title"] for row in cursor.fetchall()]
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
+ (base, f"{escaped} #%"),
+ )
+ existing = [row["title"] for row in cursor.fetchall()]
if not existing:
return base # No conflict, use the base name as-is
@@ -405,6 +791,7 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
def list_sessions_rich(
self,
source: str = None,
+ exclude_sources: List[str] = None,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
@@ -416,7 +803,18 @@ def list_sessions_rich(
Uses a single query with correlated subqueries instead of N+2 queries.
"""
- source_clause = "WHERE s.source = ?" if source else ""
+ where_clauses = []
+ params = []
+
+ if source:
+ where_clauses.append("s.source = ?")
+ params.append(source)
+ if exclude_sources:
+ placeholders = ",".join("?" for _ in exclude_sources)
+ where_clauses.append(f"s.source NOT IN ({placeholders})")
+ params.extend(exclude_sources)
+
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
query = f"""
SELECT s.*,
COALESCE(
@@ -431,14 +829,16 @@ def list_sessions_rich(
s.started_at
) AS last_active
FROM sessions s
- {source_clause}
+ {where_sql}
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?
"""
- params = (source, limit, offset) if source else (limit, offset)
- cursor = self._conn.execute(query, params)
+ params.extend([limit, offset])
+ with self._lock:
+ cursor = self._conn.execute(query, params)
+ rows = cursor.fetchall()
sessions = []
- for row in cursor.fetchall():
+ for row in rows:
s = dict(row)
# Build the preview from the raw substring
raw = s.pop("_preview_raw", "").strip()
@@ -465,6 +865,9 @@ def append_message(
tool_call_id: str = None,
token_count: int = None,
finish_reason: str = None,
+ reasoning: str = None,
+ reasoning_details: Any = None,
+ codex_reasoning_items: Any = None,
) -> int:
"""
Append a message to a session. Returns the message row ID.
@@ -472,52 +875,69 @@ def append_message(
Also increments the session's message_count (and tool_call_count
if role is 'tool' or tool_calls is present).
"""
- cursor = self._conn.execute(
- """INSERT INTO messages (session_id, role, content, tool_call_id,
- tool_calls, tool_name, timestamp, token_count, finish_reason)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (
- session_id,
- role,
- content,
- tool_call_id,
- json.dumps(tool_calls) if tool_calls else None,
- tool_name,
- time.time(),
- token_count,
- finish_reason,
- ),
+ # Serialize structured fields to JSON before entering the write txn
+ reasoning_details_json = (
+ json.dumps(reasoning_details)
+ if reasoning_details else None
)
- msg_id = cursor.lastrowid
+ codex_items_json = (
+ json.dumps(codex_reasoning_items)
+ if codex_reasoning_items else None
+ )
+ tool_calls_json = json.dumps(tool_calls) if tool_calls else None
- # Update counters
- # Count actual tool calls from the tool_calls list (not from tool responses).
- # A single assistant message can contain multiple parallel tool calls.
+ # Pre-compute tool call count
num_tool_calls = 0
if tool_calls is not None:
num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1
- if num_tool_calls > 0:
- self._conn.execute(
- """UPDATE sessions SET message_count = message_count + 1,
- tool_call_count = tool_call_count + ? WHERE id = ?""",
- (num_tool_calls, session_id),
- )
- else:
- self._conn.execute(
- "UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
- (session_id,),
+
+ def _do(conn):
+ cursor = conn.execute(
+ """INSERT INTO messages (session_id, role, content, tool_call_id,
+ tool_calls, tool_name, timestamp, token_count, finish_reason,
+ reasoning, reasoning_details, codex_reasoning_items)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ session_id,
+ role,
+ content,
+ tool_call_id,
+ tool_calls_json,
+ tool_name,
+ time.time(),
+ token_count,
+ finish_reason,
+ reasoning,
+ reasoning_details_json,
+ codex_items_json,
+ ),
)
+ msg_id = cursor.lastrowid
+
+ # Update counters
+ if num_tool_calls > 0:
+ conn.execute(
+ """UPDATE sessions SET message_count = message_count + 1,
+ tool_call_count = tool_call_count + ? WHERE id = ?""",
+ (num_tool_calls, session_id),
+ )
+ else:
+ conn.execute(
+ "UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
+ (session_id,),
+ )
+ return msg_id
- self._conn.commit()
- return msg_id
+ return self._execute_write(_do)
def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
"""Load all messages for a session, ordered by timestamp."""
- cursor = self._conn.execute(
- "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id",
- (session_id,),
- )
- rows = cursor.fetchall()
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id",
+ (session_id,),
+ )
+ rows = cursor.fetchall()
result = []
for row in rows:
msg = dict(row)
@@ -534,13 +954,16 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
Load messages in the OpenAI conversation format (role + content dicts).
Used by the gateway to restore conversation history.
"""
- cursor = self._conn.execute(
- "SELECT role, content, tool_call_id, tool_calls, tool_name "
- "FROM messages WHERE session_id = ? ORDER BY timestamp, id",
- (session_id,),
- )
+ with self._lock:
+ cursor = self._conn.execute(
+ "SELECT role, content, tool_call_id, tool_calls, tool_name, "
+ "reasoning, reasoning_details, codex_reasoning_items "
+ "FROM messages WHERE session_id = ? ORDER BY timestamp, id",
+ (session_id,),
+ )
+ rows = cursor.fetchall()
messages = []
- for row in cursor.fetchall():
+ for row in rows:
msg = {"role": row["role"], "content": row["content"]}
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
@@ -551,6 +974,22 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
msg["tool_calls"] = json.loads(row["tool_calls"])
except (json.JSONDecodeError, TypeError):
pass
+ # Restore reasoning fields on assistant messages so providers
+ # that replay reasoning (OpenRouter, OpenAI, Nous) receive
+ # coherent multi-turn reasoning context.
+ if row["role"] == "assistant":
+ if row["reasoning"]:
+ msg["reasoning"] = row["reasoning"]
+ if row["reasoning_details"]:
+ try:
+ msg["reasoning_details"] = json.loads(row["reasoning_details"])
+ except (json.JSONDecodeError, TypeError):
+ pass
+ if row["codex_reasoning_items"]:
+ try:
+ msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
+ except (json.JSONDecodeError, TypeError):
+ pass
messages.append(msg)
return messages
@@ -567,27 +1006,52 @@ def _sanitize_fts5_query(query: str) -> str:
``NOT``) have special meaning. Passing raw user input directly to
MATCH can cause ``sqlite3.OperationalError``.
- Strategy: strip characters that are only meaningful as FTS5 operators
- and would otherwise cause syntax errors. This preserves normal keyword
- search while preventing crashes on inputs like ``C++``, ``"unterminated``,
- or ``hello AND``.
+ Strategy:
+ - Preserve properly paired quoted phrases (``"exact phrase"``)
+ - Strip unmatched FTS5-special characters that would cause errors
+ - Wrap unquoted hyphenated terms in quotes so FTS5 matches them
+ as exact phrases instead of splitting on the hyphen
"""
- # Remove FTS5-special characters that are not useful in keyword search
- sanitized = re.sub(r'[+{}()"^]', " ", query)
- # Collapse repeated * (e.g. "***") into a single one, and remove
- # leading * (prefix-only matching requires at least one char before *)
+ # Step 1: Extract balanced double-quoted phrases and protect them
+ # from further processing via numbered placeholders.
+ _quoted_parts: list = []
+
+ def _preserve_quoted(m: re.Match) -> str:
+ _quoted_parts.append(m.group(0))
+ return f"\x00Q{len(_quoted_parts) - 1}\x00"
+
+ sanitized = re.sub(r'"[^"]*"', _preserve_quoted, query)
+
+ # Step 2: Strip remaining (unmatched) FTS5-special characters
+ sanitized = re.sub(r'[+{}()\"^]', " ", sanitized)
+
+ # Step 3: Collapse repeated * (e.g. "***") into a single one,
+ # and remove leading * (prefix-only needs at least one char before *)
sanitized = re.sub(r"\*+", "*", sanitized)
sanitized = re.sub(r"(^|\s)\*", r"\1", sanitized)
- # Remove dangling boolean operators at start/end that would cause
- # syntax errors (e.g. "hello AND" or "OR world")
+
+ # Step 4: Remove dangling boolean operators at start/end that would
+ # cause syntax errors (e.g. "hello AND" or "OR world")
sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip())
sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip())
+
+ # Step 5: Wrap unquoted hyphenated terms (e.g. ``chat-send``) in
+ # double quotes. FTS5's tokenizer splits on hyphens, turning
+ # ``chat-send`` into ``chat AND send``. Quoting preserves the
+ # intended phrase match.
+ sanitized = re.sub(r"\b(\w+(?:-\w+)+)\b", r'"\1"', sanitized)
+
+ # Step 6: Restore preserved quoted phrases
+ for i, quoted in enumerate(_quoted_parts):
+ sanitized = sanitized.replace(f"\x00Q{i}\x00", quoted)
+
return sanitized.strip()
def search_messages(
self,
query: str,
source_filter: List[str] = None,
+ exclude_sources: List[str] = None,
role_filter: List[str] = None,
limit: int = 20,
offset: int = 0,
@@ -611,16 +1075,19 @@ def search_messages(
if not query:
return []
- if source_filter is None:
- source_filter = ["cli", "telegram", "discord", "whatsapp", "slack"]
-
# Build WHERE clauses dynamically
where_clauses = ["messages_fts MATCH ?"]
params: list = [query]
- source_placeholders = ",".join("?" for _ in source_filter)
- where_clauses.append(f"s.source IN ({source_placeholders})")
- params.extend(source_filter)
+ if source_filter is not None:
+ source_placeholders = ",".join("?" for _ in source_filter)
+ where_clauses.append(f"s.source IN ({source_placeholders})")
+ params.extend(source_filter)
+
+ if exclude_sources is not None:
+ exclude_placeholders = ",".join("?" for _ in exclude_sources)
+ where_clauses.append(f"s.source NOT IN ({exclude_placeholders})")
+ params.extend(exclude_sources)
if role_filter:
role_placeholders = ",".join("?" for _ in role_filter)
@@ -650,31 +1117,35 @@ def search_messages(
LIMIT ? OFFSET ?
"""
- try:
- cursor = self._conn.execute(sql, params)
- except sqlite3.OperationalError:
- # FTS5 query syntax error despite sanitization โ return empty
- return []
- matches = [dict(row) for row in cursor.fetchall()]
-
- # Add surrounding context (1 message before + after each match)
+ with self._lock:
+ try:
+ cursor = self._conn.execute(sql, params)
+ except sqlite3.OperationalError:
+ # FTS5 query syntax error despite sanitization โ return empty
+ return []
+ matches = [dict(row) for row in cursor.fetchall()]
+
+ # Add surrounding context (1 message before + after each match).
+ # Done outside the lock so we don't hold it across N sequential queries.
for match in matches:
try:
- ctx_cursor = self._conn.execute(
- """SELECT role, content FROM messages
- WHERE session_id = ? AND id >= ? - 1 AND id <= ? + 1
- ORDER BY id""",
- (match["session_id"], match["id"], match["id"]),
- )
- context_msgs = [
- {"role": r["role"], "content": (r["content"] or "")[:200]}
- for r in ctx_cursor.fetchall()
- ]
+ with self._lock:
+ ctx_cursor = self._conn.execute(
+ """SELECT role, content FROM messages
+ WHERE session_id = ? AND id >= ? - 1 AND id <= ? + 1
+ ORDER BY id""",
+ (match["session_id"], match["id"], match["id"]),
+ )
+ context_msgs = [
+ {"role": r["role"], "content": (r["content"] or "")[:200]}
+ for r in ctx_cursor.fetchall()
+ ]
match["context"] = context_msgs
except Exception:
match["context"] = []
- # Remove full content from result (snippet is enough, saves tokens)
+ # Remove full content from result (snippet is enough, saves tokens)
+ for match in matches:
match.pop("content", None)
return matches
@@ -686,17 +1157,18 @@ def search_sessions(
offset: int = 0,
) -> List[Dict[str, Any]]:
"""List sessions, optionally filtered by source."""
- if source:
- cursor = self._conn.execute(
- "SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
- (source, limit, offset),
- )
- else:
- cursor = self._conn.execute(
- "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
- (limit, offset),
- )
- return [dict(row) for row in cursor.fetchall()]
+ with self._lock:
+ if source:
+ cursor = self._conn.execute(
+ "SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
+ (source, limit, offset),
+ )
+ else:
+ cursor = self._conn.execute(
+ "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
+ (limit, offset),
+ )
+ return [dict(row) for row in cursor.fetchall()]
# =========================================================================
# Utility
@@ -704,23 +1176,25 @@ def search_sessions(
def session_count(self, source: str = None) -> int:
"""Count sessions, optionally filtered by source."""
- if source:
- cursor = self._conn.execute(
- "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
- )
- else:
- cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
- return cursor.fetchone()[0]
+ with self._lock:
+ if source:
+ cursor = self._conn.execute(
+ "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
+ )
+ else:
+ cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
+ return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int:
"""Count messages, optionally for a specific session."""
- if session_id:
- cursor = self._conn.execute(
- "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
- )
- else:
- cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
- return cursor.fetchone()[0]
+ with self._lock:
+ if session_id:
+ cursor = self._conn.execute(
+ "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
+ )
+ else:
+ cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
+ return cursor.fetchone()[0]
# =========================================================================
# Export and cleanup
@@ -748,51 +1222,53 @@ def export_all(self, source: str = None) -> List[Dict[str, Any]]:
def clear_messages(self, session_id: str) -> None:
"""Delete all messages for a session and reset its counters."""
- self._conn.execute(
- "DELETE FROM messages WHERE session_id = ?", (session_id,)
- )
- self._conn.execute(
- "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
- (session_id,),
- )
- self._conn.commit()
+ def _do(conn):
+ conn.execute(
+ "DELETE FROM messages WHERE session_id = ?", (session_id,)
+ )
+ conn.execute(
+ "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
+ (session_id,),
+ )
+ self._execute_write(_do)
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages. Returns True if found."""
- cursor = self._conn.execute(
- "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
- )
- if cursor.fetchone()[0] == 0:
- return False
- self._conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
- self._conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
- self._conn.commit()
- return True
+ def _do(conn):
+ cursor = conn.execute(
+ "SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
+ )
+ if cursor.fetchone()[0] == 0:
+ return False
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
+ conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
+ return True
+ return self._execute_write(_do)
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
"""
Delete sessions older than N days. Returns count of deleted sessions.
Only prunes ended sessions (not active ones).
"""
- import time as _time
- cutoff = _time.time() - (older_than_days * 86400)
-
- if source:
- cursor = self._conn.execute(
- """SELECT id FROM sessions
- WHERE started_at < ? AND ended_at IS NOT NULL AND source = ?""",
- (cutoff, source),
- )
- else:
- cursor = self._conn.execute(
- "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
- (cutoff,),
- )
- session_ids = [row["id"] for row in cursor.fetchall()]
+ cutoff = time.time() - (older_than_days * 86400)
+
+ def _do(conn):
+ if source:
+ cursor = conn.execute(
+ """SELECT id FROM sessions
+ WHERE started_at < ? AND ended_at IS NOT NULL AND source = ?""",
+ (cutoff, source),
+ )
+ else:
+ cursor = conn.execute(
+ "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
+ (cutoff,),
+ )
+ session_ids = [row["id"] for row in cursor.fetchall()]
- for sid in session_ids:
- self._conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
- self._conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
+ for sid in session_ids:
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
+ conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
+ return len(session_ids)
- self._conn.commit()
- return len(session_ids)
+ return self._execute_write(_do)
diff --git a/hermes_time.py b/hermes_time.py
index 98879d2e149..4ec8dfe004a 100644
--- a/hermes_time.py
+++ b/hermes_time.py
@@ -15,8 +15,9 @@
import logging
import os
-from datetime import datetime, timezone as _tz
+from datetime import datetime
from pathlib import Path
+from hermes_constants import get_hermes_home
from typing import Optional
logger = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ def _resolve_timezone_name() -> str:
# 2. config.yaml ``timezone`` key
try:
import yaml
- hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
+ hermes_home = get_hermes_home()
config_path = hermes_home / "config.yaml"
if config_path.exists():
with open(config_path) as f:
diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py
new file mode 100644
index 00000000000..78a0d4b7806
--- /dev/null
+++ b/honcho_integration/cli.py
@@ -0,0 +1,780 @@
+"""CLI commands for Honcho integration management.
+
+Handles: hermes honcho setup | status | sessions | map | peer
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from pathlib import Path
+
+from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
+
+HOST = "hermes"
+
+
+def _config_path() -> Path:
+ """Return the active Honcho config path (instance-local or global)."""
+ return resolve_config_path()
+
+
+def _read_config() -> dict:
+ path = _config_path()
+ if path.exists():
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ return {}
+
+
+def _write_config(cfg: dict, path: Path | None = None) -> None:
+ 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",
+ encoding="utf-8",
+ )
+
+
+def _resolve_api_key(cfg: dict) -> str:
+ """Resolve API key with host -> root -> env fallback."""
+ host_key = ((cfg.get("hosts") or {}).get(HOST) or {}).get("apiKey")
+ return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
+
+
+def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
+ suffix = f" [{default}]" if default else ""
+ sys.stdout.write(f" {label}{suffix}: ")
+ sys.stdout.flush()
+ if secret:
+ if sys.stdin.isatty():
+ import getpass
+ val = getpass.getpass(prompt="")
+ else:
+ # Non-TTY (piped input, test runners) โ read plaintext
+ val = sys.stdin.readline().strip()
+ else:
+ val = sys.stdin.readline().strip()
+ return val or (default or "")
+
+
+def _ensure_sdk_installed() -> bool:
+ """Check honcho-ai is importable; offer to install if not. Returns True if ready."""
+ try:
+ import honcho # noqa: F401
+ return True
+ except ImportError:
+ pass
+
+ print(" honcho-ai is not installed.")
+ answer = _prompt("Install it now? (honcho-ai>=2.0.1)", default="y")
+ if answer.lower() not in ("y", "yes"):
+ print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
+ return False
+
+ import subprocess
+ print(" Installing honcho-ai...", flush=True)
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ print(" Installed.\n")
+ return True
+ else:
+ print(f" Install failed:\n{result.stderr.strip()}")
+ print(" Run manually: pip install 'honcho-ai>=2.0.1'\n")
+ return False
+
+
+def cmd_setup(args) -> None:
+ """Interactive Honcho setup wizard."""
+ cfg = _read_config()
+
+ active_path = _config_path()
+ print("\nHoncho memory setup\n" + "โ" * 40)
+ print(" Honcho gives Hermes persistent cross-session memory.")
+ 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():
+ return
+
+ # All writes go to hosts.hermes โ root keys are managed by the user
+ # or the honcho CLI only.
+ hosts = cfg.setdefault("hosts", {})
+ hermes_host = hosts.setdefault(HOST, {})
+
+ # API key โ shared credential, lives at root so all hosts can read it
+ current_key = cfg.get("apiKey", "")
+ masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
+ print(f" Current API key: {masked}")
+ new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
+ if new_key:
+ cfg["apiKey"] = new_key
+
+ effective_key = cfg.get("apiKey", "")
+ if not effective_key:
+ print("\n No API key configured. Get your API key at https://app.honcho.dev")
+ print(" Run 'hermes honcho setup' again once you have a key.\n")
+ return
+
+ # Peer name
+ current_peer = hermes_host.get("peerName") or cfg.get("peerName", "")
+ new_peer = _prompt("Your name (user peer)", default=current_peer or os.getenv("USER", "user"))
+ if new_peer:
+ hermes_host["peerName"] = new_peer
+
+ current_workspace = hermes_host.get("workspace") or cfg.get("workspace", "hermes")
+ new_workspace = _prompt("Workspace ID", default=current_workspace)
+ if new_workspace:
+ hermes_host["workspace"] = new_workspace
+
+ hermes_host.setdefault("aiPeer", HOST)
+
+ # Memory mode
+ current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
+ print("\n Memory mode options:")
+ print(" hybrid โ write to both Honcho and local MEMORY.md (default)")
+ print(" honcho โ Honcho only, skip MEMORY.md writes")
+ new_mode = _prompt("Memory mode", default=current_mode)
+ if new_mode in ("hybrid", "honcho"):
+ hermes_host["memoryMode"] = new_mode
+ else:
+ hermes_host["memoryMode"] = "hybrid"
+
+ # Write frequency
+ current_wf = str(hermes_host.get("writeFrequency") or cfg.get("writeFrequency", "async"))
+ print("\n Write frequency options:")
+ print(" async โ background thread, no token cost (recommended)")
+ print(" turn โ sync write after every turn")
+ print(" session โ batch write at session end only")
+ print(" N โ write every N turns (e.g. 5)")
+ new_wf = _prompt("Write frequency", default=current_wf)
+ try:
+ hermes_host["writeFrequency"] = int(new_wf)
+ except (ValueError, TypeError):
+ hermes_host["writeFrequency"] = new_wf if new_wf in ("async", "turn", "session") else "async"
+
+ # Recall mode
+ _raw_recall = hermes_host.get("recallMode") or cfg.get("recallMode", "hybrid")
+ current_recall = "hybrid" if _raw_recall not in ("hybrid", "context", "tools") else _raw_recall
+ print("\n Recall mode options:")
+ print(" hybrid โ auto-injected context + Honcho tools available (default)")
+ print(" context โ auto-injected context only, Honcho tools hidden")
+ print(" tools โ Honcho tools only, no auto-injected context")
+ new_recall = _prompt("Recall mode", default=current_recall)
+ if new_recall in ("hybrid", "context", "tools"):
+ hermes_host["recallMode"] = new_recall
+
+ # Session strategy
+ current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory")
+ print("\n Session strategy options:")
+ print(" per-directory โ one session per working directory (default)")
+ print(" per-session โ new Honcho session each run, named by Hermes session ID")
+ print(" per-repo โ one session per git repository (uses repo root name)")
+ print(" global โ single session across all directories")
+ new_strat = _prompt("Session strategy", default=current_strat)
+ if new_strat in ("per-session", "per-repo", "per-directory", "global"):
+ hermes_host["sessionStrategy"] = new_strat
+
+ hermes_host.setdefault("enabled", True)
+ hermes_host.setdefault("saveMessages", True)
+
+ _write_config(cfg)
+ print(f"\n Config written to {active_path}")
+
+ # Test connection
+ print(" Testing connection... ", end="", flush=True)
+ try:
+ from honcho_integration.client import HonchoClientConfig, get_honcho_client, reset_honcho_client
+ reset_honcho_client()
+ hcfg = HonchoClientConfig.from_global_config()
+ get_honcho_client(hcfg)
+ print("OK")
+ except Exception as e:
+ print(f"FAILED\n Error: {e}")
+ return
+
+ print("\n Honcho is ready.")
+ print(f" Session: {hcfg.resolve_session_name()}")
+ print(f" Workspace: {hcfg.workspace_id}")
+ print(f" Peer: {hcfg.peer_name}")
+ _mode_str = hcfg.memory_mode
+ if hcfg.peer_memory_modes:
+ overrides = ", ".join(f"{k}={v}" for k, v in hcfg.peer_memory_modes.items())
+ _mode_str = f"{hcfg.memory_mode} (peers: {overrides})"
+ print(f" Mode: {_mode_str}")
+ print(f" Frequency: {hcfg.write_frequency}")
+ print("\n Honcho tools available in chat:")
+ print(" honcho_context โ ask Honcho a question about you (LLM-synthesized)")
+ print(" honcho_search โ semantic search over your history (no LLM)")
+ print(" honcho_profile โ your peer card, key facts (no LLM)")
+ print(" honcho_conclude โ persist a user fact to Honcho memory (no LLM)")
+ print("\n Other commands:")
+ print(" hermes honcho status โ show full config")
+ print(" hermes honcho mode โ show or change memory mode")
+ print(" hermes honcho tokens โ show or set token budgets")
+ print(" hermes honcho identity โ seed or show AI peer identity")
+ print(" hermes honcho map โ map this directory to a session name\n")
+
+
+def cmd_status(args) -> None:
+ """Show current Honcho config and connection status."""
+ try:
+ import honcho # noqa: F401
+ except ImportError:
+ print(" honcho-ai is not installed. Run: hermes honcho setup\n")
+ return
+
+ cfg = _read_config()
+
+ active_path = _config_path()
+
+ if not cfg:
+ print(f" No Honcho config found at {active_path}")
+ print(" Run 'hermes honcho setup' to configure.\n")
+ return
+
+ try:
+ from honcho_integration.client import HonchoClientConfig, get_honcho_client
+ hcfg = HonchoClientConfig.from_global_config()
+ except Exception as e:
+ print(f" Config error: {e}\n")
+ return
+
+ api_key = hcfg.api_key or ""
+ masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set")
+
+ print("\nHoncho status\n" + "โ" * 40)
+ print(f" Enabled: {hcfg.enabled}")
+ print(f" API key: {masked}")
+ print(f" Workspace: {hcfg.workspace_id}")
+ print(f" Host: {hcfg.host}")
+ print(f" Config path: {active_path}")
+ 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()}")
+ print(f" Recall mode: {hcfg.recall_mode}")
+ print(f" Memory mode: {hcfg.memory_mode}")
+ if hcfg.peer_memory_modes:
+ print(" Per-peer modes:")
+ for peer, mode in hcfg.peer_memory_modes.items():
+ print(f" {peer}: {mode}")
+ print(f" Write freq: {hcfg.write_frequency}")
+
+ if hcfg.enabled and hcfg.api_key:
+ print("\n Connection... ", end="", flush=True)
+ try:
+ get_honcho_client(hcfg)
+ print("OK\n")
+ except Exception as e:
+ print(f"FAILED ({e})\n")
+ else:
+ reason = "disabled" if not hcfg.enabled else "no API key"
+ print(f"\n Not connected ({reason})\n")
+
+
+def cmd_sessions(args) -> None:
+ """List known directory โ session name mappings."""
+ cfg = _read_config()
+ sessions = cfg.get("sessions", {})
+
+ if not sessions:
+ print(" No session mappings configured.\n")
+ print(" Add one with: hermes honcho map ")
+ print(f" Or edit {_config_path()} directly.\n")
+ return
+
+ cwd = os.getcwd()
+ print(f"\nHoncho session mappings ({len(sessions)})\n" + "โ" * 40)
+ for path, name in sorted(sessions.items()):
+ marker = " โ" if path == cwd else ""
+ print(f" {name:<30} {path}{marker}")
+ print()
+
+
+def cmd_map(args) -> None:
+ """Map current directory to a Honcho session name."""
+ if not args.session_name:
+ cmd_sessions(args)
+ return
+
+ cwd = os.getcwd()
+ session_name = args.session_name.strip()
+
+ if not session_name:
+ print(" Session name cannot be empty.\n")
+ return
+
+ import re
+ sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', session_name).strip('-')
+ if sanitized != session_name:
+ print(f" Session name sanitized to: {sanitized}")
+ session_name = sanitized
+
+ cfg = _read_config()
+ cfg.setdefault("sessions", {})[cwd] = session_name
+ _write_config(cfg)
+ print(f" Mapped {cwd}\n โ {session_name}\n")
+
+
+def cmd_peer(args) -> None:
+ """Show or update peer names and dialectic reasoning level."""
+ cfg = _read_config()
+ changed = False
+
+ user_name = getattr(args, "user", None)
+ ai_name = getattr(args, "ai", None)
+ reasoning = getattr(args, "reasoning", None)
+
+ REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
+
+ if user_name is None and ai_name is None and reasoning is None:
+ # Show current values
+ hosts = cfg.get("hosts", {})
+ hermes = hosts.get(HOST, {})
+ user = hermes.get('peerName') or cfg.get('peerName') or '(not set)'
+ ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST
+ lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
+ max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
+ print("\nHoncho peers\n" + "โ" * 40)
+ print(f" User peer: {user}")
+ print(" Your identity in Honcho. Messages you send build this peer's card.")
+ print(f" AI peer: {ai}")
+ print(" Hermes' identity in Honcho. Seed with 'hermes honcho identity '.")
+ print(" Dialectic calls ask this peer questions to warm session context.")
+ print()
+ print(f" Dialectic reasoning: {lvl} ({', '.join(REASONING_LEVELS)})")
+ print(f" Dialectic cap: {max_chars} chars\n")
+ return
+
+ if user_name is not None:
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["peerName"] = user_name.strip()
+ changed = True
+ print(f" User peer โ {user_name.strip()}")
+
+ if ai_name is not None:
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["aiPeer"] = ai_name.strip()
+ changed = True
+ print(f" AI peer โ {ai_name.strip()}")
+
+ if reasoning is not None:
+ if reasoning not in REASONING_LEVELS:
+ print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
+ return
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning
+ changed = True
+ print(f" Dialectic reasoning level โ {reasoning}")
+
+ if changed:
+ _write_config(cfg)
+ print(f" Saved to {_config_path()}\n")
+
+
+def cmd_mode(args) -> None:
+ """Show or set the memory mode."""
+ MODES = {
+ "hybrid": "write to both Honcho and local MEMORY.md (default)",
+ "honcho": "Honcho only โ MEMORY.md writes disabled",
+ }
+ cfg = _read_config()
+ mode_arg = getattr(args, "mode", None)
+
+ if mode_arg is None:
+ current = (
+ (cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode")
+ or cfg.get("memoryMode")
+ or "hybrid"
+ )
+ print("\nHoncho memory mode\n" + "โ" * 40)
+ for m, desc in MODES.items():
+ marker = " โ" if m == current else ""
+ print(f" {m:<8} {desc}{marker}")
+ print("\n Set with: hermes honcho mode [hybrid|honcho]\n")
+ return
+
+ if mode_arg not in MODES:
+ print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
+ return
+
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg
+ _write_config(cfg)
+ print(f" Memory mode โ {mode_arg} ({MODES[mode_arg]})\n")
+
+
+def cmd_tokens(args) -> None:
+ """Show or set token budget settings."""
+ cfg = _read_config()
+ hosts = cfg.get("hosts", {})
+ hermes = hosts.get(HOST, {})
+
+ context = getattr(args, "context", None)
+ dialectic = getattr(args, "dialectic", None)
+
+ if context is None and dialectic is None:
+ ctx_tokens = hermes.get("contextTokens") or cfg.get("contextTokens") or "(Honcho default)"
+ d_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
+ d_level = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
+ print("\nHoncho budgets\n" + "โ" * 40)
+ print()
+ print(f" Context {ctx_tokens} tokens")
+ print(" Raw memory retrieval. Honcho returns stored facts/history about")
+ print(" the user and session, injected directly into the system prompt.")
+ print()
+ print(f" Dialectic {d_chars} chars, reasoning: {d_level}")
+ print(" AI-to-AI inference. Hermes asks Honcho's AI peer a question")
+ print(" (e.g. \"what were we working on?\") and Honcho runs its own model")
+ print(" to synthesize an answer. Used for first-turn session continuity.")
+ print(" Level controls how much reasoning Honcho spends on the answer.")
+ print("\n Set with: hermes honcho tokens [--context N] [--dialectic N]\n")
+ return
+
+ changed = False
+ if context is not None:
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["contextTokens"] = context
+ print(f" context tokens โ {context}")
+ changed = True
+ if dialectic is not None:
+ cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticMaxChars"] = dialectic
+ print(f" dialectic cap โ {dialectic} chars")
+ changed = True
+
+ if changed:
+ _write_config(cfg)
+ print(f" Saved to {_config_path()}\n")
+
+
+def cmd_identity(args) -> None:
+ """Seed AI peer identity or show both peer representations."""
+ cfg = _read_config()
+ if not _resolve_api_key(cfg):
+ print(" No API key configured. Run 'hermes honcho setup' first.\n")
+ return
+
+ file_path = getattr(args, "file", None)
+ show = getattr(args, "show", False)
+
+ try:
+ from honcho_integration.client import HonchoClientConfig, get_honcho_client
+ from honcho_integration.session import HonchoSessionManager
+ hcfg = HonchoClientConfig.from_global_config()
+ client = get_honcho_client(hcfg)
+ mgr = HonchoSessionManager(honcho=client, config=hcfg)
+ session_key = hcfg.resolve_session_name()
+ mgr.get_or_create(session_key)
+ except Exception as e:
+ print(f" Honcho connection failed: {e}\n")
+ return
+
+ if show:
+ # โโ User peer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ user_card = mgr.get_peer_card(session_key)
+ print(f"\nUser peer ({hcfg.peer_name or 'not set'})\n" + "โ" * 40)
+ if user_card:
+ for fact in user_card:
+ print(f" {fact}")
+ else:
+ print(" No user peer card yet. Send a few messages to build one.")
+
+ # โโ AI peer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ ai_rep = mgr.get_ai_representation(session_key)
+ print(f"\nAI peer ({hcfg.ai_peer})\n" + "โ" * 40)
+ if ai_rep.get("representation"):
+ print(ai_rep["representation"])
+ elif ai_rep.get("card"):
+ print(ai_rep["card"])
+ else:
+ print(" No representation built yet.")
+ print(" Run 'hermes honcho identity ' to seed one.")
+ print()
+ return
+
+ if not file_path:
+ print("\nHoncho identity management\n" + "โ" * 40)
+ print(f" User peer: {hcfg.peer_name or 'not set'}")
+ print(f" AI peer: {hcfg.ai_peer}")
+ print()
+ print(" hermes honcho identity --show โ show both peer representations")
+ print(" hermes honcho identity โ seed AI peer from SOUL.md or any .md/.txt\n")
+ return
+
+ from pathlib import Path
+ p = Path(file_path).expanduser()
+ if not p.exists():
+ print(f" File not found: {p}\n")
+ return
+
+ content = p.read_text(encoding="utf-8").strip()
+ if not content:
+ print(f" File is empty: {p}\n")
+ return
+
+ source = p.name
+ ok = mgr.seed_ai_identity(session_key, content, source=source)
+ if ok:
+ print(f" Seeded AI peer identity from {p.name} into session '{session_key}'")
+ print(f" Honcho will incorporate this into {hcfg.ai_peer}'s representation over time.\n")
+ else:
+ print(" Failed to seed identity. Check logs for details.\n")
+
+
+def cmd_migrate(args) -> None:
+ """Step-by-step migration guide: OpenClaw native memory โ Hermes + Honcho."""
+ from pathlib import Path
+
+ # โโ Detect OpenClaw native memory files โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ cwd = Path(os.getcwd())
+ openclaw_home = Path.home() / ".openclaw"
+
+ # User peer: facts about the user
+ user_file_names = ["USER.md", "MEMORY.md"]
+ # AI peer: agent identity / configuration
+ agent_file_names = ["SOUL.md", "IDENTITY.md", "AGENTS.md", "TOOLS.md", "BOOTSTRAP.md"]
+
+ user_files: list[Path] = []
+ agent_files: list[Path] = []
+ for name in user_file_names:
+ for d in [cwd, openclaw_home]:
+ p = d / name
+ if p.exists() and p not in user_files:
+ user_files.append(p)
+ for name in agent_file_names:
+ for d in [cwd, openclaw_home]:
+ p = d / name
+ if p.exists() and p not in agent_files:
+ agent_files.append(p)
+
+ cfg = _read_config()
+ has_key = bool(_resolve_api_key(cfg))
+
+ print("\nHoncho migration: OpenClaw native memory โ Hermes\n" + "โ" * 50)
+ print()
+ print(" OpenClaw's native memory stores context in local markdown files")
+ print(" (USER.md, MEMORY.md, SOUL.md, ...) and injects them via QMD search.")
+ print(" Honcho replaces that with a cloud-backed, LLM-observable memory layer:")
+ print(" context is retrieved semantically, injected automatically each turn,")
+ print(" and enriched by a dialectic reasoning layer that builds over time.")
+ print()
+
+ # โโ Step 1: Honcho account โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print("Step 1 Create a Honcho account")
+ print()
+ if has_key:
+ masked = f"...{cfg['apiKey'][-8:]}" if len(cfg["apiKey"]) > 8 else "set"
+ print(f" Honcho API key already configured: {masked}")
+ print(" Skip to Step 2.")
+ else:
+ print(" Honcho is a cloud memory service that gives Hermes persistent memory")
+ print(" across sessions. You need an API key to use it.")
+ print()
+ print(" 1. Get your API key at https://app.honcho.dev")
+ print(" 2. Run: hermes honcho setup")
+ print(" Paste the key when prompted.")
+ print()
+ answer = _prompt(" Run 'hermes honcho setup' now?", default="y")
+ if answer.lower() in ("y", "yes"):
+ cmd_setup(args)
+ cfg = _read_config()
+ has_key = bool(cfg.get("apiKey", ""))
+ else:
+ print()
+ print(" Run 'hermes honcho setup' when ready, then re-run this walkthrough.")
+
+ # โโ Step 2: Detected files โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print()
+ print("Step 2 Detected OpenClaw memory files")
+ print()
+ if user_files or agent_files:
+ if user_files:
+ print(f" User memory ({len(user_files)} file(s)) โ will go to Honcho user peer:")
+ for f in user_files:
+ print(f" {f}")
+ if agent_files:
+ print(f" Agent identity ({len(agent_files)} file(s)) โ will go to Honcho AI peer:")
+ for f in agent_files:
+ print(f" {f}")
+ else:
+ print(" No OpenClaw native memory files found in cwd or ~/.openclaw/.")
+ print(" If your files are elsewhere, copy them here before continuing,")
+ print(" or seed them manually: hermes honcho identity ")
+
+ # โโ Step 3: Migrate user memory โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print()
+ print("Step 3 Migrate user memory files โ Honcho user peer")
+ print()
+ print(" USER.md and MEMORY.md contain facts about you that the agent should")
+ print(" remember across sessions. Honcho will store these under your user peer")
+ print(" and inject relevant excerpts into the system prompt automatically.")
+ print()
+ if user_files:
+ print(f" Found: {', '.join(f.name for f in user_files)}")
+ print()
+ print(" These are picked up automatically the first time you run 'hermes'")
+ print(" with Honcho configured and no prior session history.")
+ print(" (Hermes calls migrate_memory_files() on first session init.)")
+ print()
+ print(" If you want to migrate them now without starting a session:")
+ for f in user_files:
+ print(" hermes honcho migrate โ this step handles it interactively")
+ if has_key:
+ answer = _prompt(" Upload user memory files to Honcho now?", default="y")
+ if answer.lower() in ("y", "yes"):
+ try:
+ from honcho_integration.client import (
+ HonchoClientConfig,
+ get_honcho_client,
+ reset_honcho_client,
+ )
+ from honcho_integration.session import HonchoSessionManager
+
+ reset_honcho_client()
+ hcfg = HonchoClientConfig.from_global_config()
+ client = get_honcho_client(hcfg)
+ mgr = HonchoSessionManager(honcho=client, config=hcfg)
+ session_key = hcfg.resolve_session_name()
+ mgr.get_or_create(session_key)
+ # Upload from each directory that had user files
+ dirs_with_files = set(str(f.parent) for f in user_files)
+ any_uploaded = False
+ for d in dirs_with_files:
+ if mgr.migrate_memory_files(session_key, d):
+ any_uploaded = True
+ if any_uploaded:
+ print(f" Uploaded user memory files from: {', '.join(dirs_with_files)}")
+ else:
+ print(" Nothing uploaded (files may already be migrated or empty).")
+ except Exception as e:
+ print(f" Failed: {e}")
+ else:
+ print(" Run 'hermes honcho setup' first, then re-run this step.")
+ else:
+ print(" No user memory files detected. Nothing to migrate here.")
+
+ # โโ Step 4: Seed AI identity โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print()
+ print("Step 4 Seed AI identity files โ Honcho AI peer")
+ print()
+ print(" SOUL.md, IDENTITY.md, AGENTS.md, TOOLS.md, BOOTSTRAP.md define the")
+ print(" agent's character, capabilities, and behavioral rules. In OpenClaw")
+ print(" these are injected via file search at prompt-build time.")
+ print()
+ print(" In Hermes, they are seeded once into Honcho's AI peer through the")
+ print(" observation pipeline. Honcho builds a representation from them and")
+ print(" from every subsequent assistant message (observe_me=True). Over time")
+ print(" the representation reflects actual behavior, not just declaration.")
+ print()
+ if agent_files:
+ print(f" Found: {', '.join(f.name for f in agent_files)}")
+ print()
+ if has_key:
+ answer = _prompt(" Seed AI identity from all detected files now?", default="y")
+ if answer.lower() in ("y", "yes"):
+ try:
+ from honcho_integration.client import (
+ HonchoClientConfig,
+ get_honcho_client,
+ reset_honcho_client,
+ )
+ from honcho_integration.session import HonchoSessionManager
+
+ reset_honcho_client()
+ hcfg = HonchoClientConfig.from_global_config()
+ client = get_honcho_client(hcfg)
+ mgr = HonchoSessionManager(honcho=client, config=hcfg)
+ session_key = hcfg.resolve_session_name()
+ mgr.get_or_create(session_key)
+ for f in agent_files:
+ content = f.read_text(encoding="utf-8").strip()
+ if content:
+ ok = mgr.seed_ai_identity(session_key, content, source=f.name)
+ status = "seeded" if ok else "failed"
+ print(f" {f.name}: {status}")
+ except Exception as e:
+ print(f" Failed: {e}")
+ else:
+ print(" Run 'hermes honcho setup' first, then seed manually:")
+ for f in agent_files:
+ print(f" hermes honcho identity {f}")
+ else:
+ print(" No agent identity files detected.")
+ print(" To seed manually: hermes honcho identity ")
+
+ # โโ Step 5: What changes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print()
+ print("Step 5 What changes vs. OpenClaw native memory")
+ print()
+ print(" Storage")
+ print(" OpenClaw: markdown files on disk, searched via QMD at prompt-build time.")
+ print(" Hermes: cloud-backed Honcho peers. Files can stay on disk as source")
+ print(" of truth; Honcho holds the live representation.")
+ print()
+ print(" Context injection")
+ print(" OpenClaw: file excerpts injected synchronously before each LLM call.")
+ print(" Hermes: Honcho context fetched async at turn end, injected next turn.")
+ print(" First turn has no Honcho context; subsequent turns are loaded.")
+ print()
+ print(" Memory growth")
+ print(" OpenClaw: you edit files manually to update memory.")
+ print(" Hermes: Honcho observes every message and updates representations")
+ print(" automatically. Files become the seed, not the live store.")
+ print()
+ print(" Honcho tools (available to the agent during conversation)")
+ print(" honcho_context โ ask Honcho a question, get a synthesized answer (LLM)")
+ print(" honcho_search โ semantic search over stored context (no LLM)")
+ print(" honcho_profile โ fast peer card snapshot (no LLM)")
+ print(" honcho_conclude โ write a conclusion/fact back to memory (no LLM)")
+ print()
+ print(" Session naming")
+ print(" OpenClaw: no persistent session concept โ files are global.")
+ print(" Hermes: per-session by default โ each run gets its own session")
+ print(" Map a custom name: hermes honcho map ")
+
+ # โโ Step 6: Next steps โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ print()
+ print("Step 6 Next steps")
+ print()
+ if not has_key:
+ print(" 1. hermes honcho setup โ configure API key (required)")
+ print(" 2. hermes honcho migrate โ re-run this walkthrough")
+ else:
+ print(" 1. hermes honcho status โ verify Honcho connection")
+ print(" 2. hermes โ start a session")
+ print(" (user memory files auto-uploaded on first turn if not done above)")
+ print(" 3. hermes honcho identity --show โ verify AI peer representation")
+ print(" 4. hermes honcho tokens โ tune context and dialectic budgets")
+ print(" 5. hermes honcho mode โ view or change memory mode")
+ print()
+
+
+def honcho_command(args) -> None:
+ """Route honcho subcommands."""
+ sub = getattr(args, "honcho_command", None)
+ if sub == "setup" or sub is None:
+ cmd_setup(args)
+ elif sub == "status":
+ cmd_status(args)
+ elif sub == "sessions":
+ cmd_sessions(args)
+ elif sub == "map":
+ cmd_map(args)
+ elif sub == "peer":
+ cmd_peer(args)
+ elif sub == "mode":
+ cmd_mode(args)
+ elif sub == "tokens":
+ cmd_tokens(args)
+ elif sub == "identity":
+ cmd_identity(args)
+ elif sub == "migrate":
+ cmd_migrate(args)
+ else:
+ print(f" Unknown honcho command: {sub}")
+ print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate\n")
diff --git a/honcho_integration/client.py b/honcho_integration/client.py
index 054569df94c..385974d12f0 100644
--- a/honcho_integration/client.py
+++ b/honcho_integration/client.py
@@ -1,7 +1,9 @@
"""Honcho client initialization and configuration.
-Reads the global ~/.honcho/config.json when available, falling back
-to environment variables.
+Resolution order for config file:
+ 1. $HERMES_HOME/honcho.json (instance-local, enables isolated Hermes instances)
+ 2. ~/.honcho/config.json (global, shared across all Honcho-enabled apps)
+ 3. Environment variables (HONCHO_API_KEY, HONCHO_ENVIRONMENT)
Resolution order for host-specific settings:
1. Explicit host block fields (always win)
@@ -16,6 +18,8 @@
import logging
from dataclasses import dataclass, field
from pathlib import Path
+
+from hermes_constants import get_hermes_home
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
@@ -27,6 +31,53 @@
HOST = "hermes"
+def resolve_config_path() -> Path:
+ """Return the active Honcho config path.
+
+ Checks $HERMES_HOME/honcho.json first (instance-local), then falls back
+ to ~/.honcho/config.json (global). Returns the global path if neither
+ exists (for first-time setup writes).
+ """
+ local_path = get_hermes_home() / "honcho.json"
+ if local_path.exists():
+ return local_path
+ return GLOBAL_CONFIG_PATH
+
+
+_RECALL_MODE_ALIASES = {"auto": "hybrid"}
+_VALID_RECALL_MODES = {"hybrid", "context", "tools"}
+
+
+def _normalize_recall_mode(val: str) -> str:
+ """Normalize legacy recall mode values (e.g. 'auto' โ 'hybrid')."""
+ val = _RECALL_MODE_ALIASES.get(val, val)
+ return val if val in _VALID_RECALL_MODES else "hybrid"
+
+
+def _resolve_memory_mode(
+ global_val: str | dict,
+ host_val: str | dict | None,
+) -> dict:
+ """Parse memoryMode (string or object) into memory_mode + peer_memory_modes.
+
+ Resolution order: host-level wins over global.
+ String form: applies as the default for all peers.
+ Object form: { "default": "hybrid", "hermes": "honcho", ... }
+ "default" key sets the fallback; other keys are per-peer overrides.
+ """
+ # Pick the winning value (host beats global)
+ val = host_val if host_val is not None else global_val
+
+ if isinstance(val, dict):
+ default = val.get("default", "hybrid")
+ overrides = {k: v for k, v in val.items() if k != "default"}
+ else:
+ default = str(val) if val else "hybrid"
+ overrides = {}
+
+ return {"memory_mode": default, "peer_memory_modes": overrides}
+
+
@dataclass
class HonchoClientConfig:
"""Configuration for Honcho client, resolved for a specific host."""
@@ -35,6 +86,8 @@ class HonchoClientConfig:
workspace_id: str = "hermes"
api_key: str | None = None
environment: str = "production"
+ # Optional base URL for self-hosted Honcho (overrides environment mapping)
+ base_url: str | None = None
# Identity
peer_name: str | None = None
ai_peer: str = "hermes"
@@ -42,23 +95,56 @@ class HonchoClientConfig:
# Toggles
enabled: bool = False
save_messages: bool = True
+ # memoryMode: default for all peers. "hybrid" / "honcho"
+ memory_mode: str = "hybrid"
+ # Per-peer overrides โ any named Honcho peer. Override memory_mode when set.
+ # Config object form: "memoryMode": { "default": "hybrid", "hermes": "honcho" }
+ peer_memory_modes: dict[str, str] = field(default_factory=dict)
+
+ def peer_memory_mode(self, peer_name: str) -> str:
+ """Return the effective memory mode for a named peer.
+
+ Resolution: per-peer override โ global memory_mode default.
+ """
+ return self.peer_memory_modes.get(peer_name, self.memory_mode)
+ # Write frequency: "async" (background thread), "turn" (sync per turn),
+ # "session" (flush on session end), or int (every N turns)
+ write_frequency: str | int = "async"
# Prefetch budget
context_tokens: int | None = None
+ # Dialectic (peer.chat) settings
+ # reasoning_level: "minimal" | "low" | "medium" | "high" | "max"
+ # Used as the default; prefetch_dialectic may bump it dynamically.
+ dialectic_reasoning_level: str = "low"
+ # Max chars of dialectic result to inject into Hermes system prompt
+ dialectic_max_chars: int = 600
+ # Recall mode: how memory retrieval works when Honcho is active.
+ # "hybrid" โ auto-injected context + Honcho tools available (model decides)
+ # "context" โ auto-injected context only, Honcho tools removed
+ # "tools" โ Honcho tools only, no auto-injected context
+ recall_mode: str = "hybrid"
# Session resolution
session_strategy: str = "per-directory"
session_peer_prefix: bool = False
sessions: dict[str, str] = field(default_factory=dict)
# Raw global config for anything else consumers need
raw: dict[str, Any] = field(default_factory=dict)
+ # True when Honcho was explicitly configured for this host (hosts.hermes
+ # block exists or enabled was set explicitly), vs auto-enabled from a
+ # stray HONCHO_API_KEY env var.
+ explicitly_configured: bool = False
@classmethod
def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig:
"""Create config from environment variables (fallback)."""
+ api_key = os.environ.get("HONCHO_API_KEY")
+ base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
return cls(
workspace_id=workspace_id,
- api_key=os.environ.get("HONCHO_API_KEY"),
+ api_key=api_key,
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
- enabled=True,
+ base_url=base_url,
+ enabled=bool(api_key or base_url),
)
@classmethod
@@ -67,11 +153,11 @@ def from_global_config(
host: str = HOST,
config_path: Path | None = None,
) -> HonchoClientConfig:
- """Create config from ~/.honcho/config.json.
+ """Create config from the resolved Honcho config path.
- Falls back to environment variables if the file doesn't exist.
+ Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
"""
- path = config_path or GLOBAL_CONFIG_PATH
+ path = config_path or resolve_config_path()
if not path.exists():
logger.debug("No global Honcho config at %s, falling back to env", path)
return cls.from_env()
@@ -83,6 +169,9 @@ def from_global_config(
return cls.from_env()
host_block = (raw.get("hosts") or {}).get(host, {})
+ # A hosts.hermes block or explicit enabled flag means the user
+ # intentionally configured Honcho for this host.
+ _explicitly_configured = bool(host_block) or raw.get("enabled") is True
# Explicit host block fields win, then flat/global, then defaults
workspace = (
@@ -97,53 +186,172 @@ def from_global_config(
)
linked_hosts = host_block.get("linkedHosts", [])
- api_key = raw.get("apiKey") or os.environ.get("HONCHO_API_KEY")
+ api_key = (
+ host_block.get("apiKey")
+ or raw.get("apiKey")
+ or os.environ.get("HONCHO_API_KEY")
+ )
+
+ environment = (
+ host_block.get("environment")
+ or raw.get("environment", "production")
+ )
+
+ base_url = (
+ raw.get("baseUrl")
+ or os.environ.get("HONCHO_BASE_URL", "").strip()
+ or None
+ )
- # Auto-enable when API key is present (unless explicitly disabled)
- # This matches user expectations: setting an API key should activate the feature.
- explicit_enabled = raw.get("enabled")
- if explicit_enabled is None:
- # Not explicitly set in config -> auto-enable if API key exists
- enabled = bool(api_key)
+ # Auto-enable when API key or base_url is present (unless explicitly disabled)
+ # Host-level enabled wins, then root-level, then auto-enable if key/url exists.
+ host_enabled = host_block.get("enabled")
+ root_enabled = raw.get("enabled")
+ if host_enabled is not None:
+ enabled = host_enabled
+ elif root_enabled is not None:
+ enabled = root_enabled
else:
- # Respect explicit setting
- enabled = explicit_enabled
+ # Not explicitly set anywhere -> auto-enable if API key or base_url exists
+ enabled = bool(api_key or base_url)
+
+ # write_frequency: accept int or string
+ raw_wf = (
+ host_block.get("writeFrequency")
+ or raw.get("writeFrequency")
+ or "async"
+ )
+ try:
+ write_frequency: str | int = int(raw_wf)
+ except (TypeError, ValueError):
+ write_frequency = str(raw_wf)
+
+ # saveMessages: host wins (None-aware since False is valid)
+ host_save = host_block.get("saveMessages")
+ save_messages = host_save if host_save is not None else raw.get("saveMessages", True)
+
+ # sessionStrategy / sessionPeerPrefix: host first, root fallback
+ session_strategy = (
+ host_block.get("sessionStrategy")
+ or raw.get("sessionStrategy", "per-directory")
+ )
+ host_prefix = host_block.get("sessionPeerPrefix")
+ session_peer_prefix = (
+ host_prefix if host_prefix is not None
+ else raw.get("sessionPeerPrefix", False)
+ )
return cls(
host=host,
workspace_id=workspace,
api_key=api_key,
- environment=raw.get("environment", "production"),
- peer_name=raw.get("peerName"),
+ environment=environment,
+ base_url=base_url,
+ peer_name=host_block.get("peerName") or raw.get("peerName"),
ai_peer=ai_peer,
linked_hosts=linked_hosts,
enabled=enabled,
- save_messages=raw.get("saveMessages", True),
- context_tokens=raw.get("contextTokens") or host_block.get("contextTokens"),
- session_strategy=raw.get("sessionStrategy", "per-directory"),
- session_peer_prefix=raw.get("sessionPeerPrefix", False),
+ save_messages=save_messages,
+ **_resolve_memory_mode(
+ raw.get("memoryMode", "hybrid"),
+ host_block.get("memoryMode"),
+ ),
+ write_frequency=write_frequency,
+ context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"),
+ dialectic_reasoning_level=(
+ host_block.get("dialecticReasoningLevel")
+ or raw.get("dialecticReasoningLevel")
+ or "low"
+ ),
+ dialectic_max_chars=int(
+ host_block.get("dialecticMaxChars")
+ or raw.get("dialecticMaxChars")
+ or 600
+ ),
+ recall_mode=_normalize_recall_mode(
+ host_block.get("recallMode")
+ or raw.get("recallMode")
+ or "hybrid"
+ ),
+ session_strategy=session_strategy,
+ session_peer_prefix=session_peer_prefix,
sessions=raw.get("sessions", {}),
raw=raw,
+ explicitly_configured=_explicitly_configured,
)
- def resolve_session_name(self, cwd: str | None = None) -> str | None:
- """Resolve session name for a directory.
+ @staticmethod
+ def _git_repo_name(cwd: str) -> str | None:
+ """Return the git repo root directory name, or None if not in a repo."""
+ import subprocess
- Checks manual overrides first, then derives from directory name.
+ try:
+ root = subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ capture_output=True, text=True, cwd=cwd, timeout=5,
+ )
+ if root.returncode == 0:
+ return Path(root.stdout.strip()).name
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+ return None
+
+ def resolve_session_name(
+ self,
+ cwd: str | None = None,
+ session_title: str | None = None,
+ session_id: str | None = None,
+ ) -> str | None:
+ """Resolve Honcho session name.
+
+ Resolution order:
+ 1. Manual directory override from sessions map
+ 2. Hermes session title (from /title command)
+ 3. per-session strategy โ Hermes session_id ({timestamp}_{hex})
+ 4. per-repo strategy โ git repo root directory name
+ 5. per-directory strategy โ directory basename
+ 6. global strategy โ workspace name
"""
+ import re
+
if not cwd:
cwd = os.getcwd()
- # Manual override
+ # Manual override always wins
manual = self.sessions.get(cwd)
if manual:
return manual
- # Derive from directory basename
- base = Path(cwd).name
- if self.session_peer_prefix and self.peer_name:
- return f"{self.peer_name}-{base}"
- return base
+ # /title mid-session remap
+ if session_title:
+ sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', session_title).strip('-')
+ if sanitized:
+ if self.session_peer_prefix and self.peer_name:
+ return f"{self.peer_name}-{sanitized}"
+ return sanitized
+
+ # per-session: inherit Hermes session_id (new Honcho session each run)
+ if self.session_strategy == "per-session" and session_id:
+ if self.session_peer_prefix and self.peer_name:
+ return f"{self.peer_name}-{session_id}"
+ return session_id
+
+ # per-repo: one Honcho session per git repository
+ if self.session_strategy == "per-repo":
+ base = self._git_repo_name(cwd) or Path(cwd).name
+ if self.session_peer_prefix and self.peer_name:
+ return f"{self.peer_name}-{base}"
+ return base
+
+ # per-directory: one Honcho session per working directory (default)
+ if self.session_strategy in ("per-directory", "per-session"):
+ base = Path(cwd).name
+ if self.session_peer_prefix and self.peer_name:
+ return f"{self.peer_name}-{base}"
+ return base
+
+ # global: single session across all directories
+ return self.workspace_id
def get_linked_workspaces(self) -> list[str]:
"""Resolve linked host keys to workspace names."""
@@ -174,11 +382,12 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
if config is None:
config = HonchoClientConfig.from_global_config()
- if not config.api_key:
+ if not config.api_key and not config.base_url:
raise ValueError(
- "Honcho API key not found. Set it in ~/.honcho/config.json "
- "or the HONCHO_API_KEY environment variable. "
- "Get an API key from https://app.honcho.dev"
+ "Honcho API key not found. "
+ "Get your API key at https://app.honcho.dev, "
+ "then run 'hermes honcho setup' or set HONCHO_API_KEY. "
+ "For local instances, set HONCHO_BASE_URL instead."
)
try:
@@ -189,13 +398,34 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"Install it with: pip install honcho-ai"
)
- logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
-
- _honcho_client = Honcho(
- workspace_id=config.workspace_id,
- api_key=config.api_key,
- environment=config.environment,
- )
+ # Allow config.yaml honcho.base_url to override the SDK's environment
+ # mapping, enabling remote self-hosted Honcho deployments without
+ # requiring the server to live on localhost.
+ resolved_base_url = config.base_url
+ if not resolved_base_url:
+ try:
+ from hermes_cli.config import load_config
+ hermes_cfg = load_config()
+ honcho_cfg = hermes_cfg.get("honcho", {})
+ if isinstance(honcho_cfg, dict):
+ resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
+ except Exception:
+ pass
+
+ if resolved_base_url:
+ logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
+ else:
+ logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
+
+ kwargs: dict = {
+ "workspace_id": config.workspace_id,
+ "api_key": config.api_key,
+ "environment": config.environment,
+ }
+ if resolved_base_url:
+ kwargs["base_url"] = resolved_base_url
+
+ _honcho_client = Honcho(**kwargs)
return _honcho_client
diff --git a/honcho_integration/session.py b/honcho_integration/session.py
index a384b429ddd..23b96d1cb19 100644
--- a/honcho_integration/session.py
+++ b/honcho_integration/session.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+import queue
import re
import logging
+import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, TYPE_CHECKING
@@ -15,6 +17,9 @@
logger = logging.getLogger(__name__)
+# Sentinel to signal the async writer thread to shut down
+_ASYNC_SHUTDOWN = object()
+
@dataclass
class HonchoSession:
@@ -80,7 +85,8 @@ def __init__(
Args:
honcho: Optional Honcho client. If not provided, uses the singleton.
context_tokens: Max tokens for context() calls (None = Honcho default).
- config: HonchoClientConfig from global config (provides peer_name, ai_peer, etc.).
+ config: HonchoClientConfig from global config (provides peer_name, ai_peer,
+ write_frequency, memory_mode, etc.).
"""
self._honcho = honcho
self._context_tokens = context_tokens
@@ -89,6 +95,34 @@ def __init__(
self._peers_cache: dict[str, Any] = {}
self._sessions_cache: dict[str, Any] = {}
+ # Write frequency state
+ write_frequency = (config.write_frequency if config else "async")
+ self._write_frequency = write_frequency
+ self._turn_counter: int = 0
+
+ # Prefetch caches: session_key โ last result (consumed once per turn)
+ self._context_cache: dict[str, dict] = {}
+ self._dialectic_cache: dict[str, str] = {}
+ self._prefetch_cache_lock = threading.Lock()
+ self._dialectic_reasoning_level: str = (
+ config.dialectic_reasoning_level if config else "low"
+ )
+ self._dialectic_max_chars: int = (
+ config.dialectic_max_chars if config else 600
+ )
+
+ # Async write queue โ started lazily on first enqueue
+ self._async_queue: queue.Queue | None = None
+ self._async_thread: threading.Thread | None = None
+ if write_frequency == "async":
+ self._async_queue = queue.Queue()
+ self._async_thread = threading.Thread(
+ target=self._async_writer_loop,
+ name="honcho-async-writer",
+ daemon=True,
+ )
+ self._async_thread.start()
+
@property
def honcho(self) -> Honcho:
"""Get the Honcho client, initializing if needed."""
@@ -125,10 +159,12 @@ def _get_or_create_honcho_session(
session = self.honcho.session(session_id)
- # Configure peer observation settings
+ # Configure peer observation settings.
+ # observe_me=True for AI peer so Honcho watches what the agent says
+ # and builds its representation over time โ enabling identity formation.
from honcho.session import SessionPeerConfig
user_config = SessionPeerConfig(observe_me=True, observe_others=True)
- ai_config = SessionPeerConfig(observe_me=False, observe_others=True)
+ ai_config = SessionPeerConfig(observe_me=True, observe_others=True)
session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)])
@@ -234,16 +270,11 @@ def get_or_create(self, key: str) -> HonchoSession:
self._cache[key] = session
return session
- def save(self, session: HonchoSession) -> None:
- """
- Save messages to Honcho.
-
- Syncs only new (unsynced) messages from the local cache.
- """
+ def _flush_session(self, session: HonchoSession) -> bool:
+ """Internal: write unsynced messages to Honcho synchronously."""
if not session.messages:
- return
+ return True
- # Get the Honcho session and peers
user_peer = self._get_or_create_peer(session.user_peer_id)
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
honcho_session = self._sessions_cache.get(session.honcho_session_id)
@@ -253,11 +284,9 @@ def save(self, session: HonchoSession) -> None:
session.honcho_session_id, user_peer, assistant_peer
)
- # Only send new messages (those without a '_synced' flag)
new_messages = [m for m in session.messages if not m.get("_synced")]
-
if not new_messages:
- return
+ return True
honcho_messages = []
for msg in new_messages:
@@ -269,13 +298,106 @@ def save(self, session: HonchoSession) -> None:
for msg in new_messages:
msg["_synced"] = True
logger.debug("Synced %d messages to Honcho for %s", len(honcho_messages), session.key)
+ self._cache[session.key] = session
+ return True
except Exception as e:
for msg in new_messages:
msg["_synced"] = False
logger.error("Failed to sync messages to Honcho: %s", e)
+ self._cache[session.key] = session
+ return False
+
+ def _async_writer_loop(self) -> None:
+ """Background daemon thread: drains the async write queue."""
+ while True:
+ try:
+ item = self._async_queue.get(timeout=5)
+ if item is _ASYNC_SHUTDOWN:
+ break
+
+ first_error: Exception | None = None
+ try:
+ success = self._flush_session(item)
+ except Exception as e:
+ success = False
+ first_error = e
+
+ if success:
+ continue
+
+ if first_error is not None:
+ logger.warning("Honcho async write failed, retrying once: %s", first_error)
+ else:
+ logger.warning("Honcho async write failed, retrying once")
+
+ import time as _time
+ _time.sleep(2)
+
+ try:
+ retry_success = self._flush_session(item)
+ except Exception as e2:
+ logger.error("Honcho async write retry failed, dropping batch: %s", e2)
+ continue
+
+ if not retry_success:
+ logger.error("Honcho async write retry failed, dropping batch")
+ except queue.Empty:
+ continue
+ except Exception as e:
+ logger.error("Honcho async writer error: %s", e)
- # Update cache
- self._cache[session.key] = session
+ def save(self, session: HonchoSession) -> None:
+ """Save messages to Honcho, respecting write_frequency.
+
+ write_frequency modes:
+ "async" โ enqueue for background thread (zero blocking, zero token cost)
+ "turn" โ flush synchronously every turn
+ "session" โ defer until flush_session() is called explicitly
+ N (int) โ flush every N turns
+ """
+ self._turn_counter += 1
+ wf = self._write_frequency
+
+ if wf == "async":
+ if self._async_queue is not None:
+ self._async_queue.put(session)
+ elif wf == "turn":
+ self._flush_session(session)
+ elif wf == "session":
+ # Accumulate; caller must call flush_all() at session end
+ pass
+ elif isinstance(wf, int) and wf > 0:
+ if self._turn_counter % wf == 0:
+ self._flush_session(session)
+
+ def flush_all(self) -> None:
+ """Flush all pending unsynced messages for all cached sessions.
+
+ Called at session end for "session" write_frequency, or to force
+ a sync before process exit regardless of mode.
+ """
+ for session in list(self._cache.values()):
+ try:
+ self._flush_session(session)
+ except Exception as e:
+ logger.error("Honcho flush_all error for %s: %s", session.key, e)
+
+ # Drain async queue synchronously if it exists
+ if self._async_queue is not None:
+ while not self._async_queue.empty():
+ try:
+ item = self._async_queue.get_nowait()
+ if item is not _ASYNC_SHUTDOWN:
+ self._flush_session(item)
+ except queue.Empty:
+ break
+
+ def shutdown(self) -> None:
+ """Gracefully shut down the async writer thread."""
+ if self._async_queue is not None and self._async_thread is not None:
+ self.flush_all()
+ self._async_queue.put(_ASYNC_SHUTDOWN)
+ self._async_thread.join(timeout=10)
def delete(self, key: str) -> bool:
"""Delete a session from local cache."""
@@ -305,49 +427,163 @@ def new_session(self, key: str) -> HonchoSession:
# get_or_create will create a fresh session
session = self.get_or_create(new_key)
- # Cache under both original key and timestamped key
+ # Cache under the original key so callers find it by the expected name
self._cache[key] = session
- self._cache[new_key] = session
logger.info("Created new session for %s (honcho: %s)", key, session.honcho_session_id)
return session
- def get_user_context(self, session_key: str, query: str) -> str:
+ _REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
+
+ def _dynamic_reasoning_level(self, query: str) -> str:
+ """
+ Pick a reasoning level based on message complexity.
+
+ Uses the configured default as a floor; bumps up for longer or
+ more complex messages so Honcho applies more inference where it matters.
+
+ < 120 chars โ default (typically "low")
+ 120โ400 chars โ one level above default (cap at "high")
+ > 400 chars โ two levels above default (cap at "high")
+
+ "max" is never selected automatically โ reserve it for explicit config.
+ """
+ levels = self._REASONING_LEVELS
+ default_idx = levels.index(self._dialectic_reasoning_level) if self._dialectic_reasoning_level in levels else 1
+ n = len(query)
+ if n < 120:
+ bump = 0
+ elif n < 400:
+ bump = 1
+ else:
+ bump = 2
+ # Cap at "high" (index 3) for auto-selection
+ idx = min(default_idx + bump, 3)
+ return levels[idx]
+
+ def dialectic_query(
+ self, session_key: str, query: str,
+ reasoning_level: str | None = None,
+ peer: str = "user",
+ ) -> str:
"""
- Query Honcho's dialectic chat for user context.
+ Query Honcho's dialectic endpoint about a peer.
+
+ Runs an LLM on Honcho's backend against the target peer's full
+ representation. Higher latency than context() โ call async via
+ prefetch_dialectic() to avoid blocking the response.
Args:
- session_key: The session key to get context for.
- query: Natural language question about the user.
+ session_key: The session key to query against.
+ query: Natural language question.
+ reasoning_level: Override the config default. If None, uses
+ _dynamic_reasoning_level(query).
+ peer: Which peer to query โ "user" (default) or "ai".
Returns:
- Honcho's response about the user.
+ Honcho's synthesized answer, or empty string on failure.
"""
session = self._cache.get(session_key)
if not session:
- return "No session found for this context."
+ return ""
- user_peer = self._get_or_create_peer(session.user_peer_id)
+ peer_id = session.assistant_peer_id if peer == "ai" else session.user_peer_id
+ target_peer = self._get_or_create_peer(peer_id)
+ level = reasoning_level or self._dynamic_reasoning_level(query)
try:
- return user_peer.chat(query)
+ result = target_peer.chat(query, reasoning_level=level) or ""
+ # Apply Hermes-side char cap before caching
+ if result and self._dialectic_max_chars and len(result) > self._dialectic_max_chars:
+ result = result[:self._dialectic_max_chars].rsplit(" ", 1)[0] + " โฆ"
+ return result
except Exception as e:
- logger.error("Failed to get user context from Honcho: %s", e)
- return f"Unable to retrieve user context: {e}"
+ logger.warning("Honcho dialectic query failed: %s", e)
+ return ""
+
+ def prefetch_dialectic(self, session_key: str, query: str) -> None:
+ """
+ Fire a dialectic_query in a background thread, caching the result.
+
+ Non-blocking. The result is available via pop_dialectic_result()
+ on the next call (typically the following turn). Reasoning level
+ is selected dynamically based on query complexity.
+
+ Args:
+ session_key: The session key to query against.
+ query: The user's current message, used as the query.
+ """
+ def _run():
+ result = self.dialectic_query(session_key, query)
+ if result:
+ self.set_dialectic_result(session_key, result)
+
+ t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True)
+ t.start()
+
+ def set_dialectic_result(self, session_key: str, result: str) -> None:
+ """Store a prefetched dialectic result in a thread-safe way."""
+ if not result:
+ return
+ with self._prefetch_cache_lock:
+ self._dialectic_cache[session_key] = result
+
+ def pop_dialectic_result(self, session_key: str) -> str:
+ """
+ Return and clear the cached dialectic result for this session.
+
+ Returns empty string if no result is ready yet.
+ """
+ with self._prefetch_cache_lock:
+ return self._dialectic_cache.pop(session_key, "")
+
+ def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
+ """
+ Fire get_prefetch_context in a background thread, caching the result.
+
+ Non-blocking. Consumed next turn via pop_context_result(). This avoids
+ a synchronous HTTP round-trip blocking every response.
+ """
+ def _run():
+ result = self.get_prefetch_context(session_key, user_message)
+ if result:
+ self.set_context_result(session_key, result)
+
+ t = threading.Thread(target=_run, name="honcho-context-prefetch", daemon=True)
+ t.start()
+
+ def set_context_result(self, session_key: str, result: dict[str, str]) -> None:
+ """Store a prefetched context result in a thread-safe way."""
+ if not result:
+ return
+ with self._prefetch_cache_lock:
+ self._context_cache[session_key] = result
+
+ def pop_context_result(self, session_key: str) -> dict[str, str]:
+ """
+ Return and clear the cached context result for this session.
+
+ Returns empty dict if no result is ready yet (first turn).
+ """
+ with self._prefetch_cache_lock:
+ return self._context_cache.pop(session_key, {})
def get_prefetch_context(self, session_key: str, user_message: str | None = None) -> dict[str, str]:
"""
- Pre-fetch user context using Honcho's context() method.
+ Pre-fetch user and AI peer context from Honcho.
- Single API call that returns the user's representation
- and peer card, using semantic search based on the user's message.
+ Fetches peer_representation and peer_card for both peers. search_query
+ is intentionally omitted โ it would only affect additional excerpts
+ that this code does not consume, and passing the raw message exposes
+ conversation content in server access logs.
Args:
session_key: The session key to get context for.
- user_message: The user's message for semantic search.
+ user_message: Unused; kept for call-site compatibility.
Returns:
- Dictionary with 'representation' and 'card' keys.
+ Dictionary with 'representation', 'card', 'ai_representation',
+ and 'ai_card' keys.
"""
session = self._cache.get(session_key)
if not session:
@@ -357,23 +593,35 @@ def get_prefetch_context(self, session_key: str, user_message: str | None = None
if not honcho_session:
return {}
+ result: dict[str, str] = {}
try:
ctx = honcho_session.context(
summary=False,
tokens=self._context_tokens,
peer_target=session.user_peer_id,
- search_query=user_message,
+ peer_perspective=session.assistant_peer_id,
)
- # peer_card is list[str] in SDK v2, join for prompt injection
card = ctx.peer_card or []
- card_str = "\n".join(card) if isinstance(card, list) else str(card)
- return {
- "representation": ctx.peer_representation or "",
- "card": card_str,
- }
+ result["representation"] = ctx.peer_representation or ""
+ result["card"] = "\n".join(card) if isinstance(card, list) else str(card)
except Exception as e:
- logger.warning("Failed to fetch context from Honcho: %s", e)
- return {}
+ logger.warning("Failed to fetch user context from Honcho: %s", e)
+
+ # Also fetch AI peer's own representation so Hermes knows itself.
+ try:
+ ai_ctx = honcho_session.context(
+ summary=False,
+ tokens=self._context_tokens,
+ peer_target=session.assistant_peer_id,
+ peer_perspective=session.user_peer_id,
+ )
+ ai_card = ai_ctx.peer_card or []
+ result["ai_representation"] = ai_ctx.peer_representation or ""
+ result["ai_card"] = "\n".join(ai_card) if isinstance(ai_card, list) else str(ai_card)
+ except Exception as e:
+ logger.debug("Failed to fetch AI peer context from Honcho: %s", e)
+
+ return result
def migrate_local_history(self, session_key: str, messages: list[dict[str, Any]]) -> bool:
"""
@@ -388,21 +636,17 @@ def migrate_local_history(self, session_key: str, messages: list[dict[str, Any]]
Returns:
True if upload succeeded, False otherwise.
"""
- sanitized = self._sanitize_id(session_key)
- honcho_session = self._sessions_cache.get(sanitized)
+ session = self._cache.get(session_key)
+ if not session:
+ logger.warning("No local session cached for '%s', skipping migration", session_key)
+ return False
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
if not honcho_session:
logger.warning("No Honcho session cached for '%s', skipping migration", session_key)
return False
- # Resolve user peer for attribution
- parts = session_key.split(":", 1)
- channel = parts[0] if len(parts) > 1 else "default"
- chat_id = parts[1] if len(parts) > 1 else session_key
- user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")
- user_peer = self._peers_cache.get(user_peer_id)
- if not user_peer:
- logger.warning("No user peer cached for '%s', skipping migration", user_peer_id)
- return False
+ user_peer = self._get_or_create_peer(session.user_peer_id)
content_bytes = self._format_migration_transcript(session_key, messages)
first_ts = messages[0].get("timestamp") if messages else None
@@ -471,29 +715,45 @@ def migrate_memory_files(self, session_key: str, memory_dir: str) -> bool:
if not memory_path.exists():
return False
- sanitized = self._sanitize_id(session_key)
- honcho_session = self._sessions_cache.get(sanitized)
+ session = self._cache.get(session_key)
+ if not session:
+ logger.warning("No local session cached for '%s', skipping memory migration", session_key)
+ return False
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
if not honcho_session:
logger.warning("No Honcho session cached for '%s', skipping memory migration", session_key)
return False
- # Resolve user peer for attribution
- parts = session_key.split(":", 1)
- channel = parts[0] if len(parts) > 1 else "default"
- chat_id = parts[1] if len(parts) > 1 else session_key
- user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}")
- user_peer = self._peers_cache.get(user_peer_id)
- if not user_peer:
- logger.warning("No user peer cached for '%s', skipping memory migration", user_peer_id)
- return False
+ user_peer = self._get_or_create_peer(session.user_peer_id)
+ assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
uploaded = False
files = [
- ("MEMORY.md", "consolidated_memory.md", "Long-term agent notes and preferences"),
- ("USER.md", "user_profile.md", "User profile and preferences"),
+ (
+ "MEMORY.md",
+ "consolidated_memory.md",
+ "Long-term agent notes and preferences",
+ user_peer,
+ "user",
+ ),
+ (
+ "USER.md",
+ "user_profile.md",
+ "User profile and preferences",
+ user_peer,
+ "user",
+ ),
+ (
+ "SOUL.md",
+ "agent_soul.md",
+ "Agent persona and identity configuration",
+ assistant_peer,
+ "ai",
+ ),
]
- for filename, upload_name, description in files:
+ for filename, upload_name, description, target_peer, target_kind in files:
filepath = memory_path / filename
if not filepath.exists():
continue
@@ -515,16 +775,209 @@ def migrate_memory_files(self, session_key: str, memory_dir: str) -> bool:
try:
honcho_session.upload_file(
file=(upload_name, wrapped.encode("utf-8"), "text/plain"),
- peer=user_peer,
- metadata={"source": "local_memory", "original_file": filename},
+ peer=target_peer,
+ metadata={
+ "source": "local_memory",
+ "original_file": filename,
+ "target_peer": target_kind,
+ },
+ )
+ logger.info(
+ "Uploaded %s to Honcho for %s (%s peer)",
+ filename,
+ session_key,
+ target_kind,
)
- logger.info("Uploaded %s to Honcho for %s", filename, session_key)
uploaded = True
except Exception as e:
logger.error("Failed to upload %s to Honcho: %s", filename, e)
return uploaded
+ def get_peer_card(self, session_key: str) -> list[str]:
+ """
+ Fetch the user peer's card โ a curated list of key facts.
+
+ Fast, no LLM reasoning. Returns raw structured facts Honcho has
+ inferred about the user (name, role, preferences, patterns).
+ Empty list if unavailable.
+ """
+ session = self._cache.get(session_key)
+ if not session:
+ return []
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+ if not honcho_session:
+ return []
+
+ try:
+ ctx = honcho_session.context(
+ summary=False,
+ tokens=200,
+ peer_target=session.user_peer_id,
+ peer_perspective=session.assistant_peer_id,
+ )
+ card = ctx.peer_card or []
+ return card if isinstance(card, list) else [str(card)]
+ except Exception as e:
+ logger.debug("Failed to fetch peer card from Honcho: %s", e)
+ return []
+
+ def search_context(self, session_key: str, query: str, max_tokens: int = 800) -> str:
+ """
+ Semantic search over Honcho session context.
+
+ Returns raw excerpts ranked by relevance to the query. No LLM
+ reasoning โ cheaper and faster than dialectic_query. Good for
+ factual lookups where the model will do its own synthesis.
+
+ Args:
+ session_key: Session to search against.
+ query: Search query for semantic matching.
+ max_tokens: Token budget for returned content.
+
+ Returns:
+ Relevant context excerpts as a string, or empty string if none.
+ """
+ session = self._cache.get(session_key)
+ if not session:
+ return ""
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+ if not honcho_session:
+ return ""
+
+ try:
+ ctx = honcho_session.context(
+ summary=False,
+ tokens=max_tokens,
+ peer_target=session.user_peer_id,
+ peer_perspective=session.assistant_peer_id,
+ search_query=query,
+ )
+ parts = []
+ if ctx.peer_representation:
+ parts.append(ctx.peer_representation)
+ card = ctx.peer_card or []
+ if card:
+ facts = card if isinstance(card, list) else [str(card)]
+ parts.append("\n".join(f"- {f}" for f in facts))
+ return "\n\n".join(parts)
+ except Exception as e:
+ logger.debug("Honcho search_context failed: %s", e)
+ return ""
+
+ def create_conclusion(self, session_key: str, content: str) -> bool:
+ """Write a conclusion about the user back to Honcho.
+
+ Conclusions are facts the AI peer observes about the user โ
+ preferences, corrections, clarifications, project context.
+ They feed into the user's peer card and representation.
+
+ Args:
+ session_key: Session to associate the conclusion with.
+ content: The conclusion text (e.g. "User prefers dark mode").
+
+ Returns:
+ True on success, False on failure.
+ """
+ if not content or not content.strip():
+ return False
+
+ session = self._cache.get(session_key)
+ if not session:
+ logger.warning("No session cached for '%s', skipping conclusion", session_key)
+ return False
+
+ assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
+ try:
+ conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id)
+ conclusions_scope.create([{
+ "content": content.strip(),
+ "session_id": session.honcho_session_id,
+ }])
+ logger.info("Created conclusion for %s: %s", session_key, content[:80])
+ return True
+ except Exception as e:
+ logger.error("Failed to create conclusion: %s", e)
+ return False
+
+ def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool:
+ """
+ Seed the AI peer's Honcho representation from text content.
+
+ Useful for priming AI identity from SOUL.md, exported chats, or
+ any structured description. The content is sent as an assistant
+ peer message so Honcho's reasoning model can incorporate it.
+
+ Args:
+ session_key: The session key to associate with.
+ content: The identity/persona content to seed.
+ source: Metadata tag for the source (e.g. "soul_md", "export").
+
+ Returns:
+ True on success, False on failure.
+ """
+ if not content or not content.strip():
+ return False
+
+ session = self._cache.get(session_key)
+ if not session:
+ logger.warning("No session cached for '%s', skipping AI seed", session_key)
+ return False
+
+ assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+ if not honcho_session:
+ logger.warning("No Honcho session cached for '%s', skipping AI seed", session_key)
+ return False
+
+ try:
+ wrapped = (
+ f"\n"
+ f"{source} \n"
+ f"\n"
+ f"{content.strip()}\n"
+ f" "
+ )
+ honcho_session.add_messages([assistant_peer.message(wrapped)])
+ logger.info("Seeded AI identity from '%s' into %s", source, session_key)
+ return True
+ except Exception as e:
+ logger.error("Failed to seed AI identity: %s", e)
+ return False
+
+ def get_ai_representation(self, session_key: str) -> dict[str, str]:
+ """
+ Fetch the AI peer's current Honcho representation.
+
+ Returns:
+ Dict with 'representation' and 'card' keys, empty strings if unavailable.
+ """
+ session = self._cache.get(session_key)
+ if not session:
+ return {"representation": "", "card": ""}
+
+ honcho_session = self._sessions_cache.get(session.honcho_session_id)
+ if not honcho_session:
+ return {"representation": "", "card": ""}
+
+ try:
+ ctx = honcho_session.context(
+ summary=False,
+ tokens=self._context_tokens,
+ peer_target=session.assistant_peer_id,
+ peer_perspective=session.user_peer_id,
+ )
+ ai_card = ctx.peer_card or []
+ return {
+ "representation": ctx.peer_representation or "",
+ "card": "\n".join(ai_card) if isinstance(ai_card, list) else str(ai_card),
+ }
+ except Exception as e:
+ logger.debug("Failed to fetch AI representation: %s", e)
+ return {"representation": "", "card": ""}
+
def list_sessions(self) -> list[dict[str, Any]]:
"""List all cached sessions."""
return [
diff --git a/landingpage/index.html b/landingpage/index.html
index 6f8dc3b3862..e24ed11c48a 100644
--- a/landingpage/index.html
+++ b/landingpage/index.html
@@ -1,505 +1,665 @@
-
+
-
-
-
+
+
+
Hermes Agent โ An Agent That Grows With You
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- โ
- Hermes Agent
-
-
+
-
-
-
-
- Open Source ยท MIT License
-
+
+
+
+ Open Source • MIT License
+
-
-
+
+
+โโโ โโโโโโโโโโโโโโโโโโ โโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
+โโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโ
+โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโ โโโโโโ โโโ โโโ
+โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโ โโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโ โโโ
+โโโ โโโโโโโโโโโโโโ โโโโโโ โโโ โโโโโโโโโโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโ
+โโโ โโโโโโโโโโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโ โโโ โโโ โโโโโโโ โโโโโโโโโโโ โโโโโ โโโ
+
+
+
+ An agent that
+ grows with you.
+
+
+
+ It's not a coding copilot tethered to an IDE or a chatbot wrapper
+ around a single API. It's an autonomous agent that
+ lives on your server, remembers what it learns, and gets more capable
+ the longer it runs.
+
+
+
-
-
-
-
- It's not a coding copilot tethered to an IDE or a chatbot wrapper around a single API.
- It's an autonomous agent that lives on your server, remembers what it learns,
- and gets more capable the longer it runs.
-
+