* feat(memory): add pluggable memory provider interface with profile isolation Introduces a pluggable MemoryProvider ABC so external memory backends can integrate with Hermes without modifying core files. Each backend becomes a plugin implementing a standard interface, orchestrated by MemoryManager. Key architecture: - agent/memory_provider.py — ABC with core + optional lifecycle hooks - agent/memory_manager.py — single integration point in the agent loop - agent/builtin_memory_provider.py — wraps existing MEMORY.md/USER.md Profile isolation fixes applied to all 6 shipped plugins: - Cognitive Memory: use get_hermes_home() instead of raw env var - Hindsight Memory: check $HERMES_HOME/hindsight/config.json first, fall back to legacy ~/.hindsight/ for backward compat - Hermes Memory Store: replace hardcoded ~/.hermes paths with get_hermes_home() for config loading and DB path defaults - Mem0 Memory: use get_hermes_home() instead of raw env var - RetainDB Memory: auto-derive profile-scoped project name from hermes_home path (hermes-<profile>), explicit env var overrides - OpenViking Memory: read-only, no local state, isolation via .env MemoryManager.initialize_all() now injects hermes_home into kwargs so every provider can resolve profile-scoped storage without importing get_hermes_home() themselves. Plugin system: adds register_memory_provider() to PluginContext and get_plugin_memory_providers() accessor. Based on PR #3825. 46 tests (37 unit + 5 E2E + 4 plugin registration). * refactor(memory): drop cognitive plugin, rewrite OpenViking as full provider Remove cognitive-memory plugin (#727) — core mechanics are broken: decay runs 24x too fast (hourly not daily), prefetch uses row ID as timestamp, search limited by importance not similarity. Rewrite openviking-memory plugin from a read-only search wrapper into a full bidirectional memory provider using the complete OpenViking session lifecycle API: - sync_turn: records user/assistant messages to OpenViking session (threaded, non-blocking) - on_session_end: commits session to trigger automatic memory extraction into 6 categories (profile, preferences, entities, events, cases, patterns) - prefetch: background semantic search via find() endpoint - on_memory_write: mirrors built-in memory writes to the session - is_available: checks env var only, no network calls (ABC compliance) Tools expanded from 3 to 5: - viking_search: semantic search with mode/scope/limit - viking_read: tiered content (abstract ~100tok / overview ~2k / full) - viking_browse: filesystem-style navigation (list/tree/stat) - viking_remember: explicit memory storage via session - viking_add_resource: ingest URLs/docs into knowledge base Uses direct HTTP via httpx (no openviking SDK dependency needed). Response truncation on viking_read to prevent context flooding. * fix(memory): harden Mem0 plugin — thread safety, non-blocking sync, circuit breaker - Remove redundant mem0_context tool (identical to mem0_search with rerank=true, top_k=5 — wastes a tool slot and confuses the model) - Thread sync_turn so it's non-blocking — Mem0's server-side LLM extraction can take 5-10s, was stalling the agent after every turn - Add threading.Lock around _get_client() for thread-safe lazy init (prefetch and sync threads could race on first client creation) - Add circuit breaker: after 5 consecutive API failures, pause calls for 120s instead of hammering a down server every turn. Auto-resets after cooldown. Logs a warning when tripped. - Track success/failure in prefetch, sync_turn, and all tool calls - Wait for previous sync to finish before starting a new one (prevents unbounded thread accumulation on rapid turns) - Clean up shutdown to join both prefetch and sync threads * fix(memory): enforce single external memory provider limit MemoryManager now rejects a second non-builtin provider with a warning. Built-in memory (MEMORY.md/USER.md) is always accepted. Only ONE external plugin provider is allowed at a time. This prevents tool schema bloat (some providers add 3-5 tools each) and conflicting memory backends. The warning message directs users to configure memory.provider in config.yaml to select which provider to activate. Updated all 47 tests to use builtin + one external pattern instead of multiple externals. Added test_second_external_rejected to verify the enforcement. * feat(memory): add ByteRover memory provider plugin Implements the ByteRover integration (from PR #3499 by hieuntg81) as a MemoryProvider plugin instead of direct run_agent.py modifications. ByteRover provides persistent memory via the brv CLI — a hierarchical knowledge tree with tiered retrieval (fuzzy text then LLM-driven search). Local-first with optional cloud sync. Plugin capabilities: - prefetch: background brv query for relevant context - sync_turn: curate conversation turns (threaded, non-blocking) - on_memory_write: mirror built-in memory writes to brv - on_pre_compress: extract insights before context compression Tools (3): - brv_query: search the knowledge tree - brv_curate: store facts/decisions/patterns - brv_status: check CLI version and context tree state Profile isolation: working directory at $HERMES_HOME/byterover/ (scoped per profile). Binary resolution cached with thread-safe double-checked locking. All write operations threaded to avoid blocking the agent (curate can take 120s with LLM processing). * fix(memory): thread remaining sync_turns, fix holographic, add config key Plugin fixes: - Hindsight: thread sync_turn (was blocking up to 30s via _run_in_thread) - RetainDB: thread sync_turn (was blocking on HTTP POST) - Both: shutdown now joins sync threads alongside prefetch threads Holographic retrieval fixes: - reason(): removed dead intersection_key computation (bundled but never used in scoring). Now reuses pre-computed entity_residuals directly, moved role_content encoding outside the inner loop. - contradict(): added _MAX_CONTRADICT_FACTS=500 scaling guard. Above 500 facts, only checks the most recently updated ones to avoid O(n^2) explosion (~125K comparisons at 500 is acceptable). Config: - Added memory.provider key to DEFAULT_CONFIG ("" = builtin only). No version bump needed (deep_merge handles new keys automatically). * feat(memory): extract Honcho as a MemoryProvider plugin Creates plugins/honcho-memory/ as a thin adapter over the existing honcho_integration/ package. All 4 Honcho tools (profile, search, context, conclude) move from the normal tool registry to the MemoryProvider interface. The plugin delegates all work to HonchoSessionManager — no Honcho logic is reimplemented. It uses the existing config chain: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars. Lifecycle hooks: - initialize: creates HonchoSessionManager via existing client factory - prefetch: background dialectic query - sync_turn: records messages + flushes to API (threaded) - on_memory_write: mirrors user profile writes as conclusions - on_session_end: flushes all pending messages This is a prerequisite for the MemoryManager wiring in run_agent.py. Once wired, Honcho goes through the same provider interface as all other memory plugins, and the scattered Honcho code in run_agent.py can be consolidated into the single MemoryManager integration point. * feat(memory): wire MemoryManager into run_agent.py Adds 8 integration points for the external memory provider plugin, all purely additive (zero existing code modified): 1. Init (~L1130): Create MemoryManager, find matching plugin provider from memory.provider config, initialize with session context 2. Tool injection (~L1160): Append provider tool schemas to self.tools and self.valid_tool_names after memory_manager init 3. System prompt (~L2705): Add external provider's system_prompt_block alongside existing MEMORY.md/USER.md blocks 4. Tool routing (~L5362): Route provider tool calls through memory_manager.handle_tool_call() before the catchall handler 5. Memory write bridge (~L5353): Notify external provider via on_memory_write() when the built-in memory tool writes 6. Pre-compress (~L5233): Call on_pre_compress() before context compression discards messages 7. Prefetch (~L6421): Inject provider prefetch results into the current-turn user message (same pattern as Honcho turn context) 8. Turn sync + session end (~L8161, ~L8172): sync_all() after each completed turn, queue_prefetch_all() for next turn, on_session_end() + shutdown_all() at conversation end All hooks are wrapped in try/except — a failing provider never breaks the agent. The existing memory system, Honcho integration, and all other code paths are completely untouched. Full suite: 7222 passed, 4 pre-existing failures. * refactor(memory): remove legacy Honcho integration from core Extracts all Honcho-specific code from run_agent.py, model_tools.py, toolsets.py, and gateway/run.py. Honcho is now exclusively available as a memory provider plugin (plugins/honcho-memory/). Removed from run_agent.py (-457 lines): - Honcho init block (session manager creation, activation, config) - 8 Honcho methods: _honcho_should_activate, _strip_honcho_tools, _activate_honcho, _register_honcho_exit_hook, _queue_honcho_prefetch, _honcho_prefetch, _honcho_save_user_observation, _honcho_sync - _inject_honcho_turn_context module-level function - Honcho system prompt block (tool descriptions, CLI commands) - Honcho context injection in api_messages building - Honcho params from __init__ (honcho_session_key, honcho_manager, honcho_config) - HONCHO_TOOL_NAMES constant - All honcho-specific tool dispatch forwarding Removed from other files: - model_tools.py: honcho_tools import, honcho params from handle_function_call - toolsets.py: honcho toolset definition, honcho tools from core tools list - gateway/run.py: honcho params from AIAgent constructor calls Removed tests (-339 lines): - 9 Honcho-specific test methods from test_run_agent.py - TestHonchoAtexitFlush class from test_exit_cleanup_interrupt.py Restored two regex constants (_SURROGATE_RE, _BUDGET_WARNING_RE) that were accidentally removed during the honcho function extraction. The honcho_integration/ package is kept intact — the plugin delegates to it. tools/honcho_tools.py registry entries are now dead code (import commented out in model_tools.py) but the file is preserved for reference. Full suite: 7207 passed, 4 pre-existing failures. Zero regressions. * refactor(memory): restructure plugins, add CLI, clean gateway, migration notice Plugin restructure: - Move all memory plugins from plugins/<name>-memory/ to plugins/memory/<name>/ (byterover, hindsight, holographic, honcho, mem0, openviking, retaindb) - New plugins/memory/__init__.py discovery module that scans the directory directly, loading providers by name without the general plugin system - run_agent.py uses load_memory_provider() instead of get_plugin_memory_providers() CLI wiring: - hermes memory setup — interactive curses picker + config wizard - hermes memory status — show active provider, config, availability - hermes memory off — disable external provider (built-in only) - hermes honcho — now shows migration notice pointing to hermes memory setup Gateway cleanup: - Remove _get_or_create_gateway_honcho (already removed in prev commit) - Remove _shutdown_gateway_honcho and _shutdown_all_gateway_honcho methods - Remove all calls to shutdown methods (4 call sites) - Remove _honcho_managers/_honcho_configs dict references Dead code removal: - Delete tools/honcho_tools.py (279 lines, import was already commented out) - Delete tests/gateway/test_honcho_lifecycle.py (131 lines, tested removed methods) - Remove if False placeholder from run_agent.py Migration: - Honcho migration notice on startup: detects existing honcho.json or ~/.honcho/config.json, prints guidance to run hermes memory setup. Only fires when memory.provider is not set and not in quiet mode. Full suite: 7203 passed, 4 pre-existing failures. Zero regressions. * feat(memory): standardize plugin config + add per-plugin documentation Config architecture: - Add save_config(values, hermes_home) to MemoryProvider ABC - Honcho: writes to $HERMES_HOME/honcho.json (SDK native) - Mem0: writes to $HERMES_HOME/mem0.json - Hindsight: writes to $HERMES_HOME/hindsight/config.json - Holographic: writes to config.yaml under plugins.hermes-memory-store - OpenViking/RetainDB/ByteRover: env-var only (default no-op) Setup wizard (hermes memory setup): - Now calls provider.save_config() for non-secret config - Secrets still go to .env via env vars - Only memory.provider activation key goes to config.yaml Documentation: - README.md for each of the 7 providers in plugins/memory/<name>/ - Requirements, setup (wizard + manual), config reference, tools table - Consistent format across all providers The contract for new memory plugins: - get_config_schema() declares all fields (REQUIRED) - save_config() writes native config (REQUIRED if not env-var-only) - Secrets use env_var field in schema, written to .env by wizard - README.md in the plugin directory * docs: add memory providers user guide + developer guide New pages: - user-guide/features/memory-providers.md — comprehensive guide covering all 7 shipped providers (Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover). Each with setup, config, tools, cost, and unique features. Includes comparison table and profile isolation notes. - developer-guide/memory-provider-plugin.md — how to build a new memory provider plugin. Covers ABC, required methods, config schema, save_config, threading contract, profile isolation, testing. Updated pages: - user-guide/features/memory.md — replaced Honcho section with link to new Memory Providers page - user-guide/features/honcho.md — replaced with migration redirect to the new Memory Providers page - sidebars.ts — added both new pages to navigation * fix(memory): auto-migrate Honcho users to memory provider plugin When honcho.json or ~/.honcho/config.json exists but memory.provider is not set, automatically set memory.provider: honcho in config.yaml and activate the plugin. The plugin reads the same config files, so all data and credentials are preserved. Zero user action needed. Persists the migration to config.yaml so it only fires once. Prints a one-line confirmation in non-quiet mode. * fix(memory): only auto-migrate Honcho when enabled + credentialed Check HonchoClientConfig.enabled AND (api_key OR base_url) before auto-migrating — not just file existence. Prevents false activation for users who disabled Honcho, stopped using it (config lingers), or have ~/.honcho/ from a different tool. * feat(memory): auto-install pip dependencies during hermes memory setup Reads pip_dependencies from plugin.yaml, checks which are missing, installs them via pip before config walkthrough. Also shows install guidance for external_dependencies (e.g. brv CLI for ByteRover). Updated all 7 plugin.yaml files with pip_dependencies: - honcho: honcho-ai - mem0: mem0ai - openviking: httpx - hindsight: hindsight-client - holographic: (none) - retaindb: requests - byterover: (external_dependencies for brv CLI) * fix: remove remaining Honcho crash risks from cli.py and gateway cli.py: removed Honcho session re-mapping block (would crash importing deleted tools/honcho_tools.py), Honcho flush on compress, Honcho session display on startup, Honcho shutdown on exit, honcho_session_key AIAgent param. gateway/run.py: removed honcho_session_key params from helper methods, sync_honcho param, _honcho.shutdown() block. tests: fixed test_cron_session_with_honcho_key_skipped (was passing removed honcho_key param to _flush_memories_for_session). * fix: include plugins/ in pyproject.toml package list Without this, plugins/memory/ wouldn't be included in non-editable installs. Hermes always runs from the repo checkout so this is belt- and-suspenders, but prevents breakage if the install method changes. * fix(memory): correct pip-to-import name mapping for dep checks The heuristic dep.replace('-', '_') fails for packages where the pip name differs from the import name: honcho-ai→honcho, mem0ai→mem0, hindsight-client→hindsight_client. Added explicit mapping table so hermes memory setup doesn't try to reinstall already-installed packages. * chore: remove dead code from old plugin memory registration path - hermes_cli/plugins.py: removed register_memory_provider(), _memory_providers list, get_plugin_memory_providers() — memory providers now use plugins/memory/ discovery, not the general plugin system - hermes_cli/main.py: stripped 74 lines of dead honcho argparse subparsers (setup, status, sessions, map, peer, mode, tokens, identity, migrate) — kept only the migration redirect - agent/memory_provider.py: updated docstring to reflect new registration path - tests: replaced TestPluginMemoryProviderRegistration with TestPluginMemoryDiscovery that tests the actual plugins/memory/ discovery system. Added 3 new tests (discover, load, nonexistent). * chore: delete dead honcho_integration/cli.py and its tests cli.py (794 lines) was the old 'hermes honcho' command handler — nobody calls it since cmd_honcho was replaced with a migration redirect. Deleted tests that imported from removed code: - tests/honcho_integration/test_cli.py (tested _resolve_api_key) - tests/honcho_integration/test_config_isolation.py (tested CLI config paths) - tests/tools/test_honcho_tools.py (tested the deleted tools/honcho_tools.py) Remaining honcho_integration/ files (actively used by the plugin): - client.py (445 lines) — config loading, SDK client creation - session.py (991 lines) — session management, queries, flush * refactor: move honcho_integration/ into the honcho plugin Moves client.py (445 lines) and session.py (991 lines) from the top-level honcho_integration/ package into plugins/memory/honcho/. No Honcho code remains in the main codebase. - plugins/memory/honcho/client.py — config loading, SDK client creation - plugins/memory/honcho/session.py — session management, queries, flush - Updated all imports: run_agent.py (auto-migration), hermes_cli/doctor.py, plugin __init__.py, session.py cross-import, all tests - Removed honcho_integration/ package and pyproject.toml entry - Renamed tests/honcho_integration/ → tests/honcho_plugin/ * docs: update architecture + gateway-internals for memory provider system - architecture.md: replaced honcho_integration/ with plugins/memory/ - gateway-internals.md: replaced Honcho-specific session routing and flush lifecycle docs with generic memory provider interface docs * fix: update stale mock path for resolve_active_host after honcho plugin migration * fix(memory): address review feedback — P0 lifecycle, ABC contract, honcho CLI restore Review feedback from Honcho devs (erosika): P0 — Provider lifecycle: - Remove on_session_end() + shutdown_all() from run_conversation() tail (was killing providers after every turn in multi-turn sessions) - Add shutdown_memory_provider() method on AIAgent for callers - Wire shutdown into CLI atexit, reset_conversation, gateway stop/expiry Bug fixes: - Remove sync_honcho=False kwarg from /btw callsites (TypeError crash) - Fix doctor.py references to dead 'hermes honcho setup' command - Cache prefetch_all() before tool loop (was re-calling every iteration) ABC contract hardening (all backwards-compatible): - Add session_id kwarg to prefetch/sync_turn/queue_prefetch - Make on_pre_compress() return str (provider insights in compression) - Add **kwargs to on_turn_start() for runtime context - Add on_delegation() hook for parent-side subagent observation - Document agent_context/agent_identity/agent_workspace kwargs on initialize() (prevents cron corruption, enables profile scoping) - Fix docstring: single external provider, not multiple Honcho CLI restoration: - Add plugins/memory/honcho/cli.py (from main's honcho_integration/cli.py with imports adapted to plugin path) - Restore full hermes honcho command with all subcommands (status, peer, mode, tokens, identity, enable/disable, sync, peers, --target-profile) - Restore auto-clone on profile creation + sync on hermes update - hermes honcho setup now redirects to hermes memory setup * fix(memory): wire on_delegation, skip_memory for cron/flush, fix ByteRover return type - Wire on_delegation() in delegate_tool.py — parent's memory provider is notified with task+result after each subagent completes - Add skip_memory=True to cron scheduler (prevents cron system prompts from corrupting user representations — closes #4052) - Add skip_memory=True to gateway flush agent (throwaway agent shouldn't activate memory provider) - Fix ByteRover on_pre_compress() return type: None -> str * fix(honcho): port profile isolation fixes from PR #4632 Ports 5 bug fixes found during profile testing (erosika's PR #4632): 1. 3-tier config resolution — resolve_config_path() now checks $HERMES_HOME/honcho.json → ~/.hermes/honcho.json → ~/.honcho/config.json (non-default profiles couldn't find shared host blocks) 2. Thread host=_host_key() through from_global_config() in cmd_setup, cmd_status, cmd_identity (--target-profile was being ignored) 3. Use bare profile name as aiPeer (not host key with dots) — Honcho's peer ID pattern is ^[a-zA-Z0-9_-]+$, dots are invalid 4. Wrap add_peers() in try/except — was fatal on new AI peers, killed all message uploads for the session 5. Gate Honcho clone behind --clone/--clone-all on profile create (bare create should be blank-slate) Also: sanitize assistant_peer_id via _sanitize_id() * fix(tests): add module cleanup fixture to test_cli_provider_resolution test_cli_provider_resolution._import_cli() wipes tools.*, cli, and run_agent from sys.modules to force fresh imports, but had no cleanup. This poisoned all subsequent tests on the same xdist worker — mocks targeting tools.file_tools, tools.send_message_tool, etc. patched the NEW module object while already-imported functions still referenced the OLD one. Caused ~25 cascade failures: send_message KeyError, process_registry FileNotFoundError, file_read_guards timeouts, read_loop_detection file-not-found, mcp_oauth None port, and provider_parity/codex_execution stale tool lists. Fix: autouse fixture saves all affected modules before each test and restores them after, matching the pattern in test_managed_browserbase_and_modal.py.
2260 lines
85 KiB
Python
2260 lines
85 KiB
Python
"""
|
||
Configuration management for Hermes Agent.
|
||
|
||
Config files are stored in ~/.hermes/ for easy access:
|
||
- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)
|
||
- ~/.hermes/.env - API keys and secrets
|
||
|
||
This module provides:
|
||
- hermes config - Show current configuration
|
||
- hermes config edit - Open config in editor
|
||
- hermes config set - Set a specific value
|
||
- hermes config wizard - Re-run setup wizard
|
||
"""
|
||
|
||
import os
|
||
import platform
|
||
import re
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
|
||
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
|
||
|
||
_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",
|
||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||
"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)
|
||
# =============================================================================
|
||
|
||
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||
_MANAGED_SYSTEM_NAMES = {
|
||
"brew": "Homebrew",
|
||
"homebrew": "Homebrew",
|
||
"nix": "NixOS",
|
||
"nixos": "NixOS",
|
||
}
|
||
|
||
|
||
def get_managed_system() -> Optional[str]:
|
||
"""Return the package manager owning this install, if any."""
|
||
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||
if raw:
|
||
normalized = raw.lower()
|
||
if normalized in _MANAGED_TRUE_VALUES:
|
||
return "NixOS"
|
||
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||
|
||
managed_marker = get_hermes_home() / ".managed"
|
||
if managed_marker.exists():
|
||
return "NixOS"
|
||
return None
|
||
|
||
|
||
def is_managed() -> bool:
|
||
"""Check if Hermes is running in package-manager-managed mode.
|
||
|
||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||
script, so interactive shells also see it).
|
||
"""
|
||
return get_managed_system() is not None
|
||
|
||
|
||
def get_managed_update_command() -> Optional[str]:
|
||
"""Return the preferred upgrade command for a managed install."""
|
||
managed_system = get_managed_system()
|
||
if managed_system == "Homebrew":
|
||
return "brew upgrade hermes-agent"
|
||
if managed_system == "NixOS":
|
||
return "sudo nixos-rebuild switch"
|
||
return None
|
||
|
||
|
||
def recommended_update_command() -> str:
|
||
"""Return the best update command for the current installation."""
|
||
return get_managed_update_command() or "hermes update"
|
||
|
||
|
||
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||
"""Build a user-facing error for managed installs."""
|
||
managed_system = get_managed_system() or "a package manager"
|
||
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
|
||
|
||
if managed_system == "NixOS":
|
||
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by NixOS "
|
||
f"(HERMES_MANAGED={env_hint}).\n"
|
||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||
" sudo nixos-rebuild switch"
|
||
)
|
||
|
||
if managed_system == "Homebrew":
|
||
env_hint = raw or "homebrew"
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by Homebrew "
|
||
f"(HERMES_MANAGED={env_hint}).\n"
|
||
"Use:\n"
|
||
" brew upgrade hermes-agent"
|
||
)
|
||
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
|
||
"Use your package manager to upgrade or reinstall Hermes."
|
||
)
|
||
|
||
def managed_error(action: str = "modify configuration"):
|
||
"""Print user-friendly error for managed mode."""
|
||
print(format_managed_message(action), file=sys.stderr)
|
||
|
||
|
||
# =============================================================================
|
||
# Config paths
|
||
# =============================================================================
|
||
|
||
# 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."""
|
||
return get_hermes_home() / "config.yaml"
|
||
|
||
def get_env_path() -> Path:
|
||
"""Get the .env file path (for API keys)."""
|
||
return get_hermes_home() / ".env"
|
||
|
||
def get_project_root() -> Path:
|
||
"""Get the project installation directory."""
|
||
return Path(__file__).parent.parent.resolve()
|
||
|
||
def _secure_dir(path):
|
||
"""Set directory to owner-only access (0700). No-op on Windows."""
|
||
try:
|
||
os.chmod(path, 0o700)
|
||
except (OSError, NotImplementedError):
|
||
pass
|
||
|
||
|
||
def _secure_file(path):
|
||
"""Set file to owner-only read/write (0600). No-op on Windows."""
|
||
try:
|
||
if os.path.exists(str(path)):
|
||
os.chmod(path, 0o600)
|
||
except (OSError, NotImplementedError):
|
||
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()
|
||
home.mkdir(parents=True, exist_ok=True)
|
||
_secure_dir(home)
|
||
for subdir in ("cron", "sessions", "logs", "memories"):
|
||
d = home / subdir
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
_secure_dir(d)
|
||
_ensure_default_soul_md(home)
|
||
|
||
|
||
# =============================================================================
|
||
# Config loading/saving
|
||
# =============================================================================
|
||
|
||
DEFAULT_CONFIG = {
|
||
"model": "",
|
||
"fallback_providers": [],
|
||
"credential_pool_strategies": {},
|
||
"toolsets": ["hermes-cli"],
|
||
"agent": {
|
||
"max_turns": 90,
|
||
# Tool-use enforcement: injects system prompt guidance that tells the
|
||
# model to actually call tools instead of describing intended actions.
|
||
# Values: "auto" (default — applies to gpt/codex models), true/false
|
||
# (force on/off for all models), or a list of model-name substrings
|
||
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
|
||
"tool_use_enforcement": "auto",
|
||
},
|
||
|
||
"terminal": {
|
||
"backend": "local",
|
||
"modal_mode": "auto",
|
||
"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",
|
||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
|
||
"container_cpu": 1,
|
||
"container_memory": 5120, # MB (default 5GB)
|
||
"container_disk": 51200, # MB (default 50GB)
|
||
"container_persistent": True, # Persist filesystem across sessions
|
||
# Docker volume mounts — share host directories with the container.
|
||
# 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
|
||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||
"camofox": {
|
||
# When true, Hermes sends a stable profile-scoped userId to Camofox
|
||
# so the server can map it to a persistent browser profile directory.
|
||
# Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR.
|
||
# When false (default), each session gets a random userId (ephemeral).
|
||
"managed_persistence": False,
|
||
},
|
||
},
|
||
|
||
# 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": True,
|
||
"max_snapshots": 50, # Max checkpoints to keep per directory
|
||
},
|
||
|
||
# Maximum characters returned by a single read_file call. Reads that
|
||
# exceed this are rejected with guidance to use offset+limit.
|
||
# 100K chars ≈ 25–35K tokens across typical tokenisers.
|
||
"file_read_max_chars": 100_000,
|
||
|
||
"compression": {
|
||
"enabled": True,
|
||
"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.
|
||
# Format: provider is the provider name, model is the model slug.
|
||
# "auto" for provider = auto-detect best available provider.
|
||
# Empty model = use provider's default auxiliary model.
|
||
# All tasks fall back to openrouter:google/gemini-3-flash-preview if
|
||
# the configured provider is unavailable.
|
||
"auxiliary": {
|
||
"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 — LLM API call timeout; increase for slow local vision models
|
||
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
|
||
},
|
||
"web_extract": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30, # seconds — increase for slow local models
|
||
},
|
||
"compression": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
|
||
},
|
||
"session_search": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
},
|
||
"skills_hub": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
},
|
||
"approval": {
|
||
"provider": "auto",
|
||
"model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku)
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
},
|
||
"mcp": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
},
|
||
"flush_memories": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
},
|
||
},
|
||
|
||
"display": {
|
||
"compact": False,
|
||
"personality": "kawaii",
|
||
"resume_display": "full",
|
||
"busy_input_mode": "interrupt",
|
||
"bell_on_complete": False,
|
||
"show_reasoning": False,
|
||
"streaming": False,
|
||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||
"skin": "default",
|
||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||
},
|
||
|
||
# Privacy settings
|
||
"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" | "neutts" (local)
|
||
"edge": {
|
||
"voice": "en-US-AriaNeural",
|
||
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
|
||
},
|
||
"elevenlabs": {
|
||
"voice_id": "pNInz6obpgDQGcFmaJgB", # Adam
|
||
"model_id": "eleven_multilingual_v2",
|
||
},
|
||
"openai": {
|
||
"model": "gpt-4o-mini-tts",
|
||
"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,
|
||
"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": {
|
||
"mode": "off",
|
||
"min_ms": 800,
|
||
"max_ms": 2500,
|
||
},
|
||
|
||
# Persistent memory -- bounded curated memory injected into system prompt
|
||
"memory": {
|
||
"memory_enabled": True,
|
||
"user_profile_enabled": True,
|
||
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
|
||
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
|
||
# External memory provider plugin (empty = built-in only).
|
||
# Set to a provider name to activate: "openviking", "mem0",
|
||
# "hindsight", "holographic", "retaindb", "byterover".
|
||
# Only ONE external provider is allowed at a time.
|
||
"provider": "",
|
||
},
|
||
|
||
# Subagent delegation — override the provider:model used by delegate_task
|
||
# so child agents can run on a different (cheaper/faster) provider and model.
|
||
# Uses the same runtime provider resolution as CLI/gateway startup, so all
|
||
# configured providers (OpenRouter, Nous, Z.ai, Kimi, etc.) are supported.
|
||
"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
|
||
# injected at the start of every API call for few-shot priming.
|
||
# Never saved to sessions, logs, or trajectories.
|
||
"prefill_messages_file": "",
|
||
|
||
# Skills — external skill directories for sharing skills across tools/agents.
|
||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||
# always goes to ~/.hermes/skills/.
|
||
"skills": {
|
||
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
|
||
},
|
||
|
||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||
# This section is only needed for hermes-specific overrides; everything else
|
||
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
|
||
"honcho": {},
|
||
|
||
# IANA timezone (e.g. "Asia/Kolkata", "America/New_York").
|
||
# Empty string means use server-local time.
|
||
"timezone": "",
|
||
|
||
# Discord platform settings (gateway mode)
|
||
"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)
|
||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||
},
|
||
|
||
# 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",
|
||
"timeout": 60,
|
||
},
|
||
|
||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||
"command_allowlist": [],
|
||
# User-defined quick commands that bypass the agent loop (type: exec only)
|
||
"quick_commands": {},
|
||
# Custom personalities — add your own entries here
|
||
# Supports string format: {"name": "system prompt"}
|
||
# 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": [],
|
||
},
|
||
},
|
||
|
||
"cron": {
|
||
# Wrap delivered cron responses with a header (task name) and footer
|
||
# ("The agent cannot see this message"). Set to false for clean output.
|
||
"wrap_response": True,
|
||
},
|
||
|
||
# Config schema version - bump this when adding new required fields
|
||
"_config_version": 11,
|
||
}
|
||
|
||
# =============================================================================
|
||
# Config Migration System
|
||
# =============================================================================
|
||
|
||
# Track which env vars were introduced in each config version.
|
||
# Migration only mentions vars new since the user's previous version.
|
||
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
|
||
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"],
|
||
11: ["TERMINAL_MODAL_MODE"],
|
||
}
|
||
|
||
# Required environment variables with metadata for migration prompts.
|
||
# LLM provider is required but handled in the setup wizard's provider
|
||
# selection step (Nous Portal / OpenRouter / Custom endpoint), so this
|
||
# dict is intentionally empty — no single env var is universally required.
|
||
REQUIRED_ENV_VARS = {}
|
||
|
||
# Optional environment variables that enhance functionality
|
||
OPTIONAL_ENV_VARS = {
|
||
# ── Provider (handled in provider selection, not shown in checklists) ──
|
||
"NOUS_BASE_URL": {
|
||
"description": "Nous Portal base URL override",
|
||
"prompt": "Nous Portal base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENROUTER_API_KEY": {
|
||
"description": "OpenRouter API key (for vision, web scraping helpers, and MoA)",
|
||
"prompt": "OpenRouter API key",
|
||
"url": "https://openrouter.ai/keys",
|
||
"password": True,
|
||
"tools": ["vision_analyze", "mixture_of_agents"],
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GLM_API_KEY": {
|
||
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
|
||
"prompt": "Z.AI / GLM API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"ZAI_API_KEY": {
|
||
"description": "Z.AI API key (alias for GLM_API_KEY)",
|
||
"prompt": "Z.AI API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"Z_AI_API_KEY": {
|
||
"description": "Z.AI API key (alias for GLM_API_KEY)",
|
||
"prompt": "Z.AI API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GLM_BASE_URL": {
|
||
"description": "Z.AI / GLM base URL override",
|
||
"prompt": "Z.AI / GLM base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"KIMI_API_KEY": {
|
||
"description": "Kimi / Moonshot API key",
|
||
"prompt": "Kimi API key",
|
||
"url": "https://platform.moonshot.cn/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"KIMI_BASE_URL": {
|
||
"description": "Kimi / Moonshot base URL override",
|
||
"prompt": "Kimi base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_API_KEY": {
|
||
"description": "MiniMax API key (international)",
|
||
"prompt": "MiniMax API key",
|
||
"url": "https://www.minimax.io/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_BASE_URL": {
|
||
"description": "MiniMax base URL override",
|
||
"prompt": "MiniMax base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_CN_API_KEY": {
|
||
"description": "MiniMax API key (China endpoint)",
|
||
"prompt": "MiniMax (China) API key",
|
||
"url": "https://www.minimaxi.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_CN_BASE_URL": {
|
||
"description": "MiniMax (China) base URL override",
|
||
"prompt": "MiniMax (China) base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"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",
|
||
},
|
||
"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 ──
|
||
"EXA_API_KEY": {
|
||
"description": "Exa API key for AI-native web search and contents",
|
||
"prompt": "Exa API key",
|
||
"url": "https://exa.ai/",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"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",
|
||
"url": "https://firecrawl.dev/",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"FIRECRAWL_API_URL": {
|
||
"description": "Firecrawl API URL for self-hosted instances (optional)",
|
||
"prompt": "Firecrawl API URL (leave empty for cloud)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"FIRECRAWL_GATEWAY_URL": {
|
||
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
|
||
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_DOMAIN": {
|
||
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
|
||
"prompt": "Tool-gateway domain suffix",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_SCHEME": {
|
||
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
|
||
"prompt": "Tool-gateway URL scheme",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_USER_TOKEN": {
|
||
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
|
||
"prompt": "Tool-gateway user token",
|
||
"url": None,
|
||
"password": True,
|
||
"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",
|
||
"url": "https://browserbase.com/",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"BROWSERBASE_PROJECT_ID": {
|
||
"description": "Browserbase project ID (optional — only needed for cloud browser)",
|
||
"prompt": "Browserbase project ID",
|
||
"url": "https://browserbase.com/",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"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",
|
||
},
|
||
"CAMOFOX_URL": {
|
||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||
"prompt": "Camofox server URL",
|
||
"url": "https://github.com/jo-inc/camofox-browser",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"FAL_KEY": {
|
||
"description": "FAL API key for image generation",
|
||
"prompt": "FAL API key",
|
||
"url": "https://fal.ai/",
|
||
"tools": ["image_generate"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"TINKER_API_KEY": {
|
||
"description": "Tinker API key for RL training",
|
||
"prompt": "Tinker API key",
|
||
"url": "https://tinker-console.thinkingmachines.ai/keys",
|
||
"tools": ["rl_start_training", "rl_check_status", "rl_stop_training"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"WANDB_API_KEY": {
|
||
"description": "Weights & Biases API key for experiment tracking",
|
||
"prompt": "WandB API key",
|
||
"url": "https://wandb.ai/authorize",
|
||
"tools": ["rl_get_results", "rl_check_status"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"VOICE_TOOLS_OPENAI_KEY": {
|
||
"description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS",
|
||
"prompt": "OpenAI API Key (for Whisper STT + TTS)",
|
||
"url": "https://platform.openai.com/api-keys",
|
||
"tools": ["voice_transcription", "openai_tts"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"ELEVENLABS_API_KEY": {
|
||
"description": "ElevenLabs API key for premium text-to-speech voices",
|
||
"prompt": "ElevenLabs API key",
|
||
"url": "https://elevenlabs.io/",
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"GITHUB_TOKEN": {
|
||
"description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
|
||
"prompt": "GitHub Token",
|
||
"url": "https://github.com/settings/tokens",
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
|
||
# ── Honcho ──
|
||
"HONCHO_API_KEY": {
|
||
"description": "Honcho API key for AI-native persistent memory",
|
||
"prompt": "Honcho API key",
|
||
"url": "https://app.honcho.dev",
|
||
"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": {
|
||
"description": "Telegram bot token from @BotFather",
|
||
"prompt": "Telegram bot token",
|
||
"url": "https://t.me/BotFather",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"TELEGRAM_ALLOWED_USERS": {
|
||
"description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)",
|
||
"prompt": "Allowed Telegram user IDs (comma-separated)",
|
||
"url": "https://t.me/userinfobot",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"DISCORD_BOT_TOKEN": {
|
||
"description": "Discord bot token from Developer Portal",
|
||
"prompt": "Discord bot token",
|
||
"url": "https://discord.com/developers/applications",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"DISCORD_ALLOWED_USERS": {
|
||
"description": "Comma-separated Discord user IDs allowed to use the bot",
|
||
"prompt": "Allowed Discord user IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"SLACK_BOT_TOKEN": {
|
||
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
|
||
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
||
"im:history, im:read, im:write, users:read, files:write",
|
||
"prompt": "Slack Bot Token (xoxb-...)",
|
||
"url": "https://api.slack.com/apps",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"SLACK_APP_TOKEN": {
|
||
"description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → "
|
||
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
|
||
"message.channels, message.groups, app_mention",
|
||
"prompt": "Slack App Token (xapp-...)",
|
||
"url": "https://api.slack.com/apps",
|
||
"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",
|
||
},
|
||
"MATTERMOST_REQUIRE_MENTION": {
|
||
"description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
|
||
"prompt": "Require @mention in channels",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_FREE_RESPONSE_CHANNELS": {
|
||
"description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
|
||
"prompt": "Free-response channel 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)",
|
||
"url": None,
|
||
"password": False,
|
||
"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": {
|
||
"description": "Working directory for terminal commands via messaging",
|
||
"prompt": "Messaging working directory (default: home)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"SUDO_PASSWORD": {
|
||
"description": "Sudo password for terminal commands requiring root access",
|
||
"prompt": "Sudo password",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_MAX_ITERATIONS": {
|
||
"description": "Maximum tool-calling iterations per conversation (default: 90)",
|
||
"prompt": "Max iterations",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
|
||
# now configured via display.tool_progress in config.yaml (off|new|all|verbose).
|
||
# Gateway falls back to these env vars for backward compatibility.
|
||
"HERMES_TOOL_PROGRESS": {
|
||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||
"prompt": "Tool progress (deprecated — use config.yaml)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_TOOL_PROGRESS_MODE": {
|
||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||
"prompt": "Progress mode (deprecated — use config.yaml)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_PREFILL_MESSAGES_FILE": {
|
||
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
|
||
"prompt": "Prefill messages file path",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_EPHEMERAL_SYSTEM_PROMPT": {
|
||
"description": "Ephemeral system prompt injected at API-call time (never persisted to sessions)",
|
||
"prompt": "Ephemeral system prompt",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
}
|
||
|
||
if not _managed_nous_tools_enabled():
|
||
for _hidden_var in (
|
||
"FIRECRAWL_GATEWAY_URL",
|
||
"TOOL_GATEWAY_DOMAIN",
|
||
"TOOL_GATEWAY_SCHEME",
|
||
"TOOL_GATEWAY_USER_TOKEN",
|
||
):
|
||
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
|
||
|
||
|
||
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||
"""
|
||
Check which environment variables are missing.
|
||
|
||
Returns list of dicts with var info for missing variables.
|
||
"""
|
||
missing = []
|
||
|
||
# Check required vars
|
||
for var_name, info in REQUIRED_ENV_VARS.items():
|
||
if not get_env_value(var_name):
|
||
missing.append({"name": var_name, **info, "is_required": True})
|
||
|
||
# Check optional vars (if not required_only)
|
||
if not required_only:
|
||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||
if not get_env_value(var_name):
|
||
missing.append({"name": var_name, **info, "is_required": False})
|
||
|
||
return missing
|
||
|
||
|
||
def _set_nested(config: dict, dotted_key: str, value):
|
||
"""Set a value at an arbitrarily nested dotted key path.
|
||
|
||
Creates intermediate dicts as needed, e.g. ``_set_nested(c, "a.b.c", 1)``
|
||
ensures ``c["a"]["b"]["c"] == 1``.
|
||
"""
|
||
parts = dotted_key.split(".")
|
||
current = config
|
||
for part in parts[:-1]:
|
||
if part not in current or not isinstance(current.get(part), dict):
|
||
current[part] = {}
|
||
current = current[part]
|
||
current[parts[-1]] = value
|
||
|
||
|
||
def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||
"""
|
||
Check which config fields are missing or outdated (recursive).
|
||
|
||
Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys
|
||
present in defaults but absent from the user's loaded config.
|
||
"""
|
||
config = load_config()
|
||
missing = []
|
||
|
||
def _check(defaults: dict, current: dict, prefix: str = ""):
|
||
for key, default_value in defaults.items():
|
||
if key.startswith('_'):
|
||
continue
|
||
full_key = key if not prefix else f"{prefix}.{key}"
|
||
if key not in current:
|
||
missing.append({
|
||
"key": full_key,
|
||
"default": default_value,
|
||
"description": f"New config option: {full_key}",
|
||
})
|
||
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
|
||
_check(default_value, current[key], full_key)
|
||
|
||
_check(DEFAULT_CONFIG, config)
|
||
return missing
|
||
|
||
|
||
def check_config_version() -> Tuple[int, int]:
|
||
"""
|
||
Check config version.
|
||
|
||
Returns (current_version, latest_version).
|
||
"""
|
||
config = load_config()
|
||
current = config.get("_config_version", 0)
|
||
latest = DEFAULT_CONFIG.get("_config_version", 1)
|
||
return current, latest
|
||
|
||
|
||
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
|
||
"""
|
||
Migrate config to latest version, prompting for new required fields.
|
||
|
||
Args:
|
||
interactive: If True, prompt user for missing values
|
||
quiet: If True, suppress output
|
||
|
||
Returns:
|
||
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()
|
||
|
||
# ── Version 3 → 4: migrate tool progress from .env to config.yaml ──
|
||
if current_ver < 4:
|
||
config = load_config()
|
||
display = config.get("display", {})
|
||
if not isinstance(display, dict):
|
||
display = {}
|
||
if "tool_progress" not in display:
|
||
old_enabled = get_env_value("HERMES_TOOL_PROGRESS")
|
||
old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE")
|
||
if old_enabled and old_enabled.lower() in ("false", "0", "no"):
|
||
display["tool_progress"] = "off"
|
||
results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
|
||
elif old_mode and old_mode.lower() in ("new", "all"):
|
||
display["tool_progress"] = old_mode.lower()
|
||
results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
|
||
else:
|
||
display["tool_progress"] = "all"
|
||
results["config_added"].append("display.tool_progress=all (default)")
|
||
config["display"] = display
|
||
save_config(config)
|
||
if not quiet:
|
||
print(f" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}")
|
||
|
||
# ── Version 4 → 5: add timezone field ──
|
||
if current_ver < 5:
|
||
config = load_config()
|
||
if "timezone" not in config:
|
||
old_tz = os.getenv("HERMES_TIMEZONE", "")
|
||
if old_tz and old_tz.strip():
|
||
config["timezone"] = old_tz.strip()
|
||
results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)")
|
||
else:
|
||
config["timezone"] = ""
|
||
results["config_added"].append("timezone= (empty, uses server-local)")
|
||
save_config(config)
|
||
if not quiet:
|
||
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}")
|
||
|
||
# Check for missing required env vars
|
||
missing_env = get_missing_env_vars(required_only=True)
|
||
|
||
if missing_env and not quiet:
|
||
print("\n⚠️ Missing required environment variables:")
|
||
for var in missing_env:
|
||
print(f" • {var['name']}: {var['description']}")
|
||
|
||
if interactive and missing_env:
|
||
print("\nLet's configure them now:\n")
|
||
for var in missing_env:
|
||
if var.get("url"):
|
||
print(f" Get your key at: {var['url']}")
|
||
|
||
if var.get("password"):
|
||
import getpass
|
||
value = getpass.getpass(f" {var['prompt']}: ")
|
||
else:
|
||
value = input(f" {var['prompt']}: ").strip()
|
||
|
||
if value:
|
||
save_env_value(var["name"], value)
|
||
results["env_added"].append(var["name"])
|
||
print(f" ✓ Saved {var['name']}")
|
||
else:
|
||
results["warnings"].append(f"Skipped {var['name']} - some features may not work")
|
||
print()
|
||
|
||
# Check for missing optional env vars and offer to configure interactively
|
||
# Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users
|
||
missing_optional = get_missing_env_vars(required_only=False)
|
||
required_names = {v["name"] for v in missing_env} if missing_env else set()
|
||
missing_optional = [
|
||
v for v in missing_optional
|
||
if v["name"] not in required_names and not v.get("advanced")
|
||
]
|
||
|
||
# Only offer to configure env vars that are NEW since the user's previous version
|
||
new_var_names = set()
|
||
for ver in range(current_ver + 1, latest_ver + 1):
|
||
new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))
|
||
|
||
if new_var_names and interactive and not quiet:
|
||
new_and_unset = [
|
||
(name, OPTIONAL_ENV_VARS[name])
|
||
for name in sorted(new_var_names)
|
||
if not get_env_value(name) and name in OPTIONAL_ENV_VARS
|
||
]
|
||
if new_and_unset:
|
||
print(f"\n {len(new_and_unset)} new optional key(s) in this update:")
|
||
for name, info in new_and_unset:
|
||
print(f" • {name} — {info.get('description', '')}")
|
||
print()
|
||
try:
|
||
answer = input(" Configure new keys? [y/N]: ").strip().lower()
|
||
except (EOFError, KeyboardInterrupt):
|
||
answer = "n"
|
||
|
||
if answer in ("y", "yes"):
|
||
print()
|
||
for name, info in new_and_unset:
|
||
if info.get("url"):
|
||
print(f" {info.get('description', name)}")
|
||
print(f" Get your key at: {info['url']}")
|
||
else:
|
||
print(f" {info.get('description', name)}")
|
||
if info.get("password"):
|
||
import getpass
|
||
value = getpass.getpass(f" {info.get('prompt', name)} (Enter to skip): ")
|
||
else:
|
||
value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip()
|
||
if value:
|
||
save_env_value(name, value)
|
||
results["env_added"].append(name)
|
||
print(f" ✓ Saved {name}")
|
||
print()
|
||
else:
|
||
print(" Set later with: hermes config set <key> <value>")
|
||
|
||
# Check for missing config fields
|
||
missing_config = get_missing_config_fields()
|
||
|
||
if missing_config:
|
||
config = load_config()
|
||
|
||
for field in missing_config:
|
||
key = field["key"]
|
||
default = field["default"]
|
||
|
||
_set_nested(config, key, default)
|
||
results["config_added"].append(key)
|
||
if not quiet:
|
||
print(f" ✓ Added {key} = {default}")
|
||
|
||
# Update version and save
|
||
config["_config_version"] = latest_ver
|
||
save_config(config)
|
||
elif current_ver < latest_ver:
|
||
# Just update version
|
||
config = load_config()
|
||
config["_config_version"] = latest_ver
|
||
save_config(config)
|
||
|
||
return results
|
||
|
||
|
||
def _deep_merge(base: dict, override: dict) -> dict:
|
||
"""Recursively merge *override* into *base*, preserving nested defaults.
|
||
|
||
Keys in *override* take precedence. If both values are dicts the merge
|
||
recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will
|
||
keep the default ``tts.elevenlabs.model_id`` intact.
|
||
"""
|
||
result = base.copy()
|
||
for key, value in override.items():
|
||
if (
|
||
key in result
|
||
and isinstance(result[key], dict)
|
||
and isinstance(value, dict)
|
||
):
|
||
result[key] = _deep_merge(result[key], value)
|
||
else:
|
||
result[key] = value
|
||
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_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Move stale root-level provider/base_url into model section.
|
||
|
||
Some users (or older code) placed ``provider:`` and ``base_url:`` at the
|
||
config root instead of inside ``model:``. These root-level keys are only
|
||
used as a fallback when the corresponding ``model.*`` key is empty — they
|
||
never override an existing ``model.provider`` or ``model.base_url``.
|
||
After migration the root-level keys are removed so they can't cause
|
||
confusion on subsequent loads.
|
||
"""
|
||
# Only act if there are root-level keys to migrate
|
||
has_root = any(config.get(k) for k in ("provider", "base_url"))
|
||
if not has_root:
|
||
return config
|
||
|
||
config = dict(config)
|
||
model = config.get("model")
|
||
if not isinstance(model, dict):
|
||
model = {"default": model} if model else {}
|
||
config["model"] = model
|
||
|
||
for key in ("provider", "base_url"):
|
||
root_val = config.get(key)
|
||
if root_val and not model.get(key):
|
||
model[key] = root_val
|
||
config.pop(key, None)
|
||
|
||
return config
|
||
|
||
|
||
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)
|
||
agent_config = dict(config.get("agent") or {})
|
||
|
||
if "max_turns" in config and "max_turns" not in agent_config:
|
||
agent_config["max_turns"] = config["max_turns"]
|
||
|
||
if "max_turns" not in agent_config:
|
||
agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"]
|
||
|
||
config["agent"] = agent_config
|
||
config.pop("max_turns", None)
|
||
return config
|
||
|
||
|
||
|
||
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)
|
||
|
||
if config_path.exists():
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
user_config = yaml.safe_load(f) or {}
|
||
|
||
if "max_turns" in user_config:
|
||
agent_user_config = dict(user_config.get("agent") or {})
|
||
if agent_user_config.get("max_turns") is None:
|
||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||
user_config["agent"] = agent_user_config
|
||
user_config.pop("max_turns", None)
|
||
|
||
config = _deep_merge(config, user_config)
|
||
except Exception as e:
|
||
print(f"Warning: Failed to load config: {e}")
|
||
|
||
return _expand_env_vars(_normalize_root_model_keys(_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 = """
|
||
# ── 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).
|
||
#
|
||
# security:
|
||
# redact_secrets: false
|
||
|
||
# ── 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
|
||
"""
|
||
|
||
|
||
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()
|
||
config_path = get_config_path()
|
||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||
|
||
# Build optional commented-out sections for features that are off by
|
||
# default or only relevant when explicitly configured.
|
||
parts = []
|
||
sec = normalized.get("security", {})
|
||
if not sec or sec.get("redact_secrets") is None:
|
||
parts.append(_SECURITY_COMMENT)
|
||
fb = normalized.get("fallback_model", {})
|
||
if not fb or not (fb.get("provider") and fb.get("model")):
|
||
parts.append(_FALLBACK_COMMENT)
|
||
|
||
atomic_yaml_write(
|
||
config_path,
|
||
normalized,
|
||
extra_content="".join(parts) if parts else None,
|
||
)
|
||
_secure_file(config_path)
|
||
|
||
|
||
def load_env() -> Dict[str, str]:
|
||
"""Load environment variables from ~/.hermes/.env."""
|
||
env_path = get_env_path()
|
||
env_vars = {}
|
||
|
||
if env_path.exists():
|
||
# On Windows, open() defaults to the system locale (cp1252) which can
|
||
# fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.
|
||
open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||
with open(env_path, **open_kw) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line and not line.startswith('#') and '=' in line:
|
||
key, _, value = line.partition('=')
|
||
env_vars[key.strip()] = value.strip().strip('"\'')
|
||
|
||
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()
|
||
|
||
# On Windows, open() defaults to the system locale (cp1252) which can
|
||
# cause OSError errno 22 on UTF-8 .env files.
|
||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||
|
||
lines = []
|
||
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
|
||
for i, line in enumerate(lines):
|
||
if line.strip().startswith(f"{key}="):
|
||
lines[i] = f"{key}={value}\n"
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
# Ensure there's a newline at the end of the file before appending
|
||
if lines and not lines[-1].endswith("\n"):
|
||
lines[-1] += "\n"
|
||
lines.append(f"{key}={value}\n")
|
||
|
||
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(lines)
|
||
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)
|
||
|
||
os.environ[key] = value
|
||
|
||
# Restrict .env permissions to owner-only (contains API keys)
|
||
if not _IS_WINDOWS:
|
||
try:
|
||
os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR)
|
||
except OSError:
|
||
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
|
||
if key in os.environ:
|
||
return os.environ[key]
|
||
|
||
# Then check .env file
|
||
env_vars = load_env()
|
||
return env_vars.get(key)
|
||
|
||
|
||
# =============================================================================
|
||
# Config display
|
||
# =============================================================================
|
||
|
||
def redact_key(key: str) -> str:
|
||
"""Redact an API key for display."""
|
||
if not key:
|
||
return color("(not set)", Colors.DIM)
|
||
if len(key) < 12:
|
||
return "***"
|
||
return key[:4] + "..." + key[-4:]
|
||
|
||
|
||
def show_config():
|
||
"""Display current configuration."""
|
||
config = load_config()
|
||
|
||
print()
|
||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
|
||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||
|
||
# Paths
|
||
print()
|
||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||
print(f" Config: {get_config_path()}")
|
||
print(f" Secrets: {get_env_path()}")
|
||
print(f" Install: {get_project_root()}")
|
||
|
||
# API Keys
|
||
print()
|
||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||
|
||
keys = [
|
||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
|
||
("EXA_API_KEY", "Exa"),
|
||
("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'])}")
|
||
|
||
# Display
|
||
print()
|
||
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
|
||
display = config.get('display', {})
|
||
print(f" Personality: {display.get('personality', 'kawaii')}")
|
||
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
|
||
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
|
||
|
||
# Terminal
|
||
print()
|
||
print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
|
||
terminal = config.get('terminal', {})
|
||
print(f" Backend: {terminal.get('backend', 'local')}")
|
||
print(f" Working dir: {terminal.get('cwd', '.')}")
|
||
print(f" Timeout: {terminal.get('timeout', 60)}s")
|
||
|
||
if terminal.get('backend') == 'docker':
|
||
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://nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
elif terminal.get('backend') == 'modal':
|
||
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':
|
||
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
daytona_key = get_env_value('DAYTONA_API_KEY')
|
||
print(f" API key: {'configured' if daytona_key else '(not set)'}")
|
||
elif terminal.get('backend') == 'ssh':
|
||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||
print(f" SSH host: {ssh_host or '(not set)'}")
|
||
print(f" SSH user: {ssh_user or '(not set)'}")
|
||
|
||
# Timezone
|
||
print()
|
||
print(color("◆ Timezone", Colors.CYAN, Colors.BOLD))
|
||
tz = config.get('timezone', '')
|
||
if tz:
|
||
print(f" Timezone: {tz}")
|
||
else:
|
||
print(f" Timezone: {color('(server-local)', Colors.DIM)}")
|
||
|
||
# Compression
|
||
print()
|
||
print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD))
|
||
compression = config.get('compression', {})
|
||
enabled = compression.get('enabled', True)
|
||
print(f" Enabled: {'yes' if enabled else 'no'}")
|
||
if enabled:
|
||
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
|
||
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}")
|
||
|
||
# Auxiliary models
|
||
auxiliary = config.get('auxiliary', {})
|
||
aux_tasks = {
|
||
"Vision": auxiliary.get('vision', {}),
|
||
"Web extract": auxiliary.get('web_extract', {}),
|
||
}
|
||
has_overrides = any(
|
||
t.get('provider', 'auto') != 'auto' or t.get('model', '')
|
||
for t in aux_tasks.values()
|
||
)
|
||
if has_overrides:
|
||
print()
|
||
print(color("◆ Auxiliary Models (overrides)", Colors.CYAN, Colors.BOLD))
|
||
for label, task_cfg in aux_tasks.items():
|
||
prov = task_cfg.get('provider', 'auto')
|
||
mdl = task_cfg.get('model', '')
|
||
if prov != 'auto' or mdl:
|
||
parts = [f"provider={prov}"]
|
||
if mdl:
|
||
parts.append(f"model={mdl}")
|
||
print(f" {label:12s} {', '.join(parts)}")
|
||
|
||
# Messaging
|
||
print()
|
||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||
|
||
telegram_token = get_env_value('TELEGRAM_BOT_TOKEN')
|
||
discord_token = get_env_value('DISCORD_BOT_TOKEN')
|
||
|
||
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
|
||
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
|
||
|
||
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 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
|
||
if not config_path.exists():
|
||
save_config(DEFAULT_CONFIG)
|
||
print(f"Created {config_path}")
|
||
|
||
# Find editor
|
||
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
|
||
|
||
if not editor:
|
||
# Try common editors
|
||
for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']:
|
||
import shutil
|
||
if shutil.which(cmd):
|
||
editor = cmd
|
||
break
|
||
|
||
if not editor:
|
||
print("No editor found. Config file is at:")
|
||
print(f" {config_path}")
|
||
return
|
||
|
||
print(f"Opening {config_path} in {editor}...")
|
||
subprocess.run([editor, str(config_path)])
|
||
|
||
|
||
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',
|
||
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
|
||
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
|
||
'TOOL_GATEWAY_USER_TOKEN', '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',
|
||
'GITHUB_TOKEN', 'HONCHO_API_KEY', 'WANDB_API_KEY',
|
||
'TINKER_API_KEY',
|
||
]
|
||
|
||
if key.upper() in api_keys or key.upper().endswith('_API_KEY') or key.upper().endswith('_TOKEN') or key.upper().startswith('TERMINAL_SSH'):
|
||
save_env_value(key.upper(), value)
|
||
print(f"✓ Set {key} in {get_env_path()}")
|
||
return
|
||
|
||
# Otherwise it goes to config.yaml
|
||
# Read the raw user config (not merged with defaults) to avoid
|
||
# dumping all default values back to the file
|
||
config_path = get_config_path()
|
||
user_config = {}
|
||
if config_path.exists():
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
user_config = yaml.safe_load(f) or {}
|
||
except Exception:
|
||
user_config = {}
|
||
|
||
# Handle nested keys (e.g., "tts.provider")
|
||
parts = key.split('.')
|
||
current = user_config
|
||
|
||
for part in parts[:-1]:
|
||
if part not in current or not isinstance(current.get(part), dict):
|
||
current[part] = {}
|
||
current = current[part]
|
||
|
||
# Convert value to appropriate type
|
||
if value.lower() in ('true', 'yes', 'on'):
|
||
value = True
|
||
elif value.lower() in ('false', 'no', 'off'):
|
||
value = False
|
||
elif value.isdigit():
|
||
value = int(value)
|
||
elif value.replace('.', '', 1).isdigit():
|
||
value = float(value)
|
||
|
||
current[parts[-1]] = value
|
||
|
||
# Write only user config back (not the full merged defaults)
|
||
ensure_hermes_home()
|
||
with open(config_path, 'w', encoding="utf-8") as f:
|
||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||
|
||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||
_config_to_env_sync = {
|
||
"terminal.backend": "TERMINAL_ENV",
|
||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||
"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))
|
||
|
||
print(f"✓ Set {key} = {value} in {config_path}")
|
||
|
||
|
||
# =============================================================================
|
||
# Command handler
|
||
# =============================================================================
|
||
|
||
def config_command(args):
|
||
"""Handle config subcommands."""
|
||
subcmd = getattr(args, 'config_command', None)
|
||
|
||
if subcmd is None or subcmd == "show":
|
||
show_config()
|
||
|
||
elif subcmd == "edit":
|
||
edit_config()
|
||
|
||
elif subcmd == "set":
|
||
key = getattr(args, 'key', None)
|
||
value = getattr(args, 'value', None)
|
||
if not key or value is None:
|
||
print("Usage: hermes config set <key> <value>")
|
||
print()
|
||
print("Examples:")
|
||
print(" hermes config set model anthropic/claude-sonnet-4")
|
||
print(" hermes config set terminal.backend docker")
|
||
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
|
||
sys.exit(1)
|
||
set_config_value(key, value)
|
||
|
||
elif subcmd == "path":
|
||
print(get_config_path())
|
||
|
||
elif subcmd == "env-path":
|
||
print(get_env_path())
|
||
|
||
elif subcmd == "migrate":
|
||
print()
|
||
print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
|
||
print()
|
||
|
||
# Check what's missing
|
||
missing_env = get_missing_env_vars(required_only=False)
|
||
missing_config = get_missing_config_fields()
|
||
current_ver, latest_ver = check_config_version()
|
||
|
||
if not missing_env and not missing_config and current_ver >= latest_ver:
|
||
print(color("✓ Configuration is up to date!", Colors.GREEN))
|
||
print()
|
||
return
|
||
|
||
# Show what needs to be updated
|
||
if current_ver < latest_ver:
|
||
print(f" Config version: {current_ver} → {latest_ver}")
|
||
|
||
if missing_config:
|
||
print(f"\n {len(missing_config)} new config option(s) will be added with defaults")
|
||
|
||
required_missing = [v for v in missing_env if v.get("is_required")]
|
||
optional_missing = [
|
||
v for v in missing_env
|
||
if not v.get("is_required") and not v.get("advanced")
|
||
]
|
||
|
||
if required_missing:
|
||
print(f"\n ⚠️ {len(required_missing)} required API key(s) missing:")
|
||
for var in required_missing:
|
||
print(f" • {var['name']}")
|
||
|
||
if optional_missing:
|
||
print(f"\n ℹ️ {len(optional_missing)} optional API key(s) not configured:")
|
||
for var in optional_missing:
|
||
tools = var.get("tools", [])
|
||
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
|
||
print(f" • {var['name']}{tools_str}")
|
||
|
||
print()
|
||
|
||
# Run migration
|
||
results = migrate_config(interactive=True, quiet=False)
|
||
|
||
print()
|
||
if results["env_added"] or results["config_added"]:
|
||
print(color("✓ Configuration updated!", Colors.GREEN))
|
||
|
||
if results["warnings"]:
|
||
print()
|
||
for warning in results["warnings"]:
|
||
print(color(f" ⚠️ {warning}", Colors.YELLOW))
|
||
|
||
print()
|
||
|
||
elif subcmd == "check":
|
||
# Non-interactive check for what's missing
|
||
print()
|
||
print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
|
||
print()
|
||
|
||
current_ver, latest_ver = check_config_version()
|
||
if current_ver >= latest_ver:
|
||
print(f" Config version: {current_ver} ✓")
|
||
else:
|
||
print(color(f" Config version: {current_ver} → {latest_ver} (update available)", Colors.YELLOW))
|
||
|
||
print()
|
||
print(color(" Required:", Colors.BOLD))
|
||
for var_name in REQUIRED_ENV_VARS:
|
||
if get_env_value(var_name):
|
||
print(f" ✓ {var_name}")
|
||
else:
|
||
print(color(f" ✗ {var_name} (missing)", Colors.RED))
|
||
|
||
print()
|
||
print(color(" Optional:", Colors.BOLD))
|
||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||
if get_env_value(var_name):
|
||
print(f" ✓ {var_name}")
|
||
else:
|
||
tools = info.get("tools", [])
|
||
tools_str = f" → {', '.join(tools[:2])}" if tools else ""
|
||
print(color(f" ○ {var_name}{tools_str}", Colors.DIM))
|
||
|
||
missing_config = get_missing_config_fields()
|
||
if missing_config:
|
||
print()
|
||
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
|
||
print(" Run 'hermes config migrate' to add them")
|
||
|
||
print()
|
||
|
||
else:
|
||
print(f"Unknown config command: {subcmd}")
|
||
print()
|
||
print("Available commands:")
|
||
print(" hermes config Show current configuration")
|
||
print(" hermes config edit Open config in editor")
|
||
print(" hermes config set <key> <value> 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")
|
||
print(" hermes config env-path Show .env file path")
|
||
sys.exit(1)
|