| """ |
| 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 copy |
| import logging |
| import os |
| import platform |
| import re |
| import stat |
| import subprocess |
| import sys |
| import tempfile |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Dict, Any, Optional, List, Tuple |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _IS_WINDOWS = platform.system() == "Windows" |
| _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
| _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} |
| |
| |
| _EXTRA_ENV_KEYS = frozenset({ |
| "OPENAI_API_KEY", "OPENAI_BASE_URL", |
| "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", |
| "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", |
| "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", |
| "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", |
| "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", |
| "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", |
| "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", |
| "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", |
| "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", |
| "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME", |
| "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", |
| "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", |
| "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", |
| "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", |
| "WHATSAPP_MODE", "WHATSAPP_ENABLED", |
| "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", |
| "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", |
| "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", |
| "MATRIX_RECOVERY_KEY", |
| }) |
| import yaml |
|
|
| from hermes_cli.colors import Colors, color |
| from hermes_cli.default_soul import DEFAULT_SOUL_MD |
|
|
|
|
| |
| |
| |
|
|
| _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) |
|
|
|
|
| |
| |
| |
|
|
| def get_container_exec_info() -> Optional[dict]: |
| """Read container mode metadata from HERMES_HOME/.container-mode. |
| |
| Returns a dict with keys: backend, container_name, exec_user, hermes_bin |
| or None if container mode is not active, we're already inside the |
| container, or HERMES_DEV=1 is set. |
| |
| The .container-mode file is written by the NixOS activation script when |
| container.enable = true. It tells the host CLI to exec into the container |
| instead of running locally. |
| """ |
| if os.environ.get("HERMES_DEV") == "1": |
| return None |
|
|
| from hermes_constants import is_container |
| if is_container(): |
| return None |
|
|
| container_mode_file = get_hermes_home() / ".container-mode" |
|
|
| try: |
| info = {} |
| with open(container_mode_file, "r") as f: |
| for line in f: |
| line = line.strip() |
| if "=" in line and not line.startswith("#"): |
| key, _, value = line.partition("=") |
| info[key.strip()] = value.strip() |
| except FileNotFoundError: |
| return None |
| |
|
|
| backend = info.get("backend", "docker") |
| container_name = info.get("container_name", "hermes-agent") |
| exec_user = info.get("exec_user", "hermes") |
| hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") |
|
|
| return { |
| "backend": backend, |
| "container_name": container_name, |
| "exec_user": exec_user, |
| "hermes_bin": hermes_bin, |
| } |
|
|
|
|
| |
| |
| |
|
|
| |
| from hermes_constants import get_hermes_home |
|
|
| 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 by default). No-op on Windows. |
| |
| Skipped in managed mode β the NixOS module sets group-readable |
| permissions (0750) so interactive users in the hermes group can |
| share state with the gateway service. |
| |
| The mode can be overridden via the HERMES_HOME_MODE environment variable |
| (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx, |
| caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory. |
| The execute-only bit on a directory permits cd-through without exposing |
| directory listings. |
| """ |
| if is_managed(): |
| return |
| try: |
| mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() |
| mode = int(mode_str, 8) if mode_str else 0o700 |
| except ValueError: |
| mode = 0o700 |
| try: |
| os.chmod(path, mode) |
| except (OSError, NotImplementedError): |
| pass |
|
|
|
|
| def _is_container() -> bool: |
| """Detect if we're running inside a Docker/Podman/LXC container. |
| |
| When Hermes runs in a container with volume-mounted config files, forcing |
| 0o600 permissions breaks multi-process setups where the gateway and |
| dashboard run as different UIDs or the volume mount requires broader |
| permissions. |
| """ |
| |
| if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"): |
| return True |
| |
| if os.path.exists("/.dockerenv"): |
| return True |
| |
| try: |
| with open("/proc/1/cgroup", "r") as f: |
| cgroup_content = f.read() |
| if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content: |
| return True |
| except (OSError, IOError): |
| pass |
| return False |
|
|
|
|
| def _secure_file(path): |
| """Set file to owner-only read/write (0600). No-op on Windows. |
| |
| Skipped in managed mode β the NixOS activation script sets |
| group-readable permissions (0640) on config files. |
| |
| Skipped in containers β Docker/Podman volume mounts often need broader |
| permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems. |
| """ |
| if is_managed() or _is_container(): |
| return |
| 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. |
| |
| In managed mode (NixOS), dirs are created by the activation script with |
| setgid + group-writable (2770). We skip mkdir and set umask(0o007) so |
| any files created (e.g. SOUL.md) are group-writable (0660). |
| """ |
| home = get_hermes_home() |
| if is_managed(): |
| old_umask = os.umask(0o007) |
| try: |
| _ensure_hermes_home_managed(home) |
| finally: |
| os.umask(old_umask) |
| else: |
| 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) |
|
|
|
|
| def _ensure_hermes_home_managed(home: Path): |
| """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" |
| if not home.is_dir(): |
| raise RuntimeError( |
| f"HERMES_HOME {home} does not exist. " |
| "Run 'sudo nixos-rebuild switch' first." |
| ) |
| for subdir in ("cron", "sessions", "logs", "memories"): |
| d = home / subdir |
| if not d.is_dir(): |
| raise RuntimeError( |
| f"{d} does not exist. " |
| "Run 'sudo nixos-rebuild switch' first." |
| ) |
| |
| _ensure_default_soul_md(home) |
|
|
|
|
| |
| |
| |
|
|
| DEFAULT_CONFIG = { |
| "model": "", |
| "providers": {}, |
| "fallback_providers": [], |
| "credential_pool_strategies": {}, |
| "toolsets": ["hermes-cli"], |
| "agent": { |
| "max_turns": 90, |
| |
| |
| |
| |
| "gateway_timeout": 1800, |
| |
| |
| |
| |
| "restart_drain_timeout": 60, |
| |
| |
| |
| |
| |
| |
| |
| |
| "api_max_retries": 3, |
| "service_tier": "", |
| |
| |
| |
| |
| |
| "tool_use_enforcement": "auto", |
| |
| |
| |
| "gateway_timeout_warning": 900, |
| |
| |
| |
| |
| |
| |
| |
| "gateway_notify_interval": 180, |
| }, |
| |
| "terminal": { |
| "backend": "local", |
| "modal_mode": "auto", |
| "cwd": ".", |
| "timeout": 180, |
| |
| |
| |
| "env_passthrough": [], |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "shell_init_files": [], |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "auto_source_bashrc": True, |
| "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", |
| "docker_forward_env": [], |
| |
| |
| |
| |
| |
| "docker_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_cpu": 1, |
| "container_memory": 5120, |
| "container_disk": 51200, |
| "container_persistent": True, |
| |
| |
| |
| |
| |
| |
| |
| "docker_volumes": [], |
| |
| |
| "docker_mount_cwd_to_workspace": False, |
| |
| |
| |
| |
| "persistent_shell": True, |
| }, |
| |
| "browser": { |
| "inactivity_timeout": 120, |
| "command_timeout": 30, |
| "record_sessions": False, |
| "allow_private_urls": False, |
| "cdp_url": "", |
| |
| |
| |
| |
| "dialog_policy": "must_respond", |
| "dialog_timeout_s": 300, |
| "camofox": { |
| |
| |
| |
| "managed_persistence": False, |
| }, |
| }, |
|
|
| |
| |
| |
| "checkpoints": { |
| "enabled": True, |
| "max_snapshots": 50, |
| }, |
|
|
| |
| |
| |
| "file_read_max_chars": 100_000, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "tool_output": { |
| "max_bytes": 50_000, |
| "max_lines": 2000, |
| "max_line_length": 2000, |
| }, |
|
|
| "compression": { |
| "enabled": True, |
| "threshold": 0.50, |
| "target_ratio": 0.20, |
| "protect_last_n": 20, |
|
|
| }, |
|
|
| |
| |
| "bedrock": { |
| "region": "", |
| "discovery": { |
| "enabled": True, |
| "provider_filter": [], |
| "refresh_interval": 3600, |
| }, |
| "guardrail": { |
| |
| |
| |
| "guardrail_identifier": "", |
| "guardrail_version": "", |
| "stream_processing_mode": "async", |
| "trace": "disabled", |
| }, |
| }, |
|
|
| |
| |
| |
| |
| |
| |
| "auxiliary": { |
| "vision": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 120, |
| "extra_body": {}, |
| "download_timeout": 30, |
| }, |
| "web_extract": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 360, |
| "extra_body": {}, |
| }, |
| "compression": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 120, |
| "extra_body": {}, |
| }, |
| "session_search": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| "max_concurrency": 3, |
| }, |
| "skills_hub": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| }, |
| "approval": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| }, |
| "mcp": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| }, |
| "flush_memories": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| }, |
| "title_generation": { |
| "provider": "auto", |
| "model": "", |
| "base_url": "", |
| "api_key": "", |
| "timeout": 30, |
| "extra_body": {}, |
| }, |
| }, |
| |
| "display": { |
| "compact": False, |
| "personality": "kawaii", |
| "resume_display": "full", |
| "busy_input_mode": "interrupt", |
| "bell_on_complete": False, |
| "show_reasoning": False, |
| "streaming": False, |
| "final_response_markdown": "strip", |
| "inline_diffs": True, |
| "show_cost": False, |
| "skin": "default", |
| "user_message_preview": { |
| "first_lines": 2, |
| "last_lines": 2, |
| }, |
| "interim_assistant_messages": True, |
| "tool_progress_command": False, |
| "tool_progress_overrides": {}, |
| "tool_preview_length": 0, |
| "platforms": {}, |
| }, |
|
|
| |
| "dashboard": { |
| "theme": "default", |
| }, |
|
|
| |
| "privacy": { |
| "redact_pii": False, |
| }, |
| |
| |
| |
| |
| |
| |
| "tts": { |
| "provider": "edge", |
| "edge": { |
| "voice": "en-US-AriaNeural", |
| |
| }, |
| "elevenlabs": { |
| "voice_id": "pNInz6obpgDQGcFmaJgB", |
| "model_id": "eleven_multilingual_v2", |
| }, |
| "openai": { |
| "model": "gpt-4o-mini-tts", |
| "voice": "alloy", |
| |
| }, |
| "xai": { |
| "voice_id": "eve", |
| "language": "en", |
| "sample_rate": 24000, |
| "bit_rate": 128000, |
| }, |
| "mistral": { |
| "model": "voxtral-mini-tts-2603", |
| "voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", |
| }, |
| "neutts": { |
| "ref_audio": "", |
| "ref_text": "", |
| "model": "neuphonic/neutts-air-q4-gguf", |
| "device": "cpu", |
| }, |
| }, |
| |
| "stt": { |
| "enabled": True, |
| "provider": "local", |
| "local": { |
| "model": "base", |
| "language": "", |
| }, |
| "openai": { |
| "model": "whisper-1", |
| }, |
| "mistral": { |
| "model": "voxtral-mini-latest", |
| }, |
| }, |
|
|
| "voice": { |
| "record_key": "ctrl+b", |
| "max_recording_seconds": 120, |
| "auto_tts": False, |
| "beep_enabled": True, |
| "silence_threshold": 200, |
| "silence_duration": 3.0, |
| }, |
| |
| "human_delay": { |
| "mode": "off", |
| "min_ms": 800, |
| "max_ms": 2500, |
| }, |
| |
| |
| |
| |
| |
| |
| |
| "context": { |
| "engine": "compressor", |
| }, |
|
|
| |
| "memory": { |
| "memory_enabled": True, |
| "user_profile_enabled": True, |
| "memory_char_limit": 2200, |
| "user_char_limit": 1375, |
| |
| |
| |
| |
| "provider": "", |
| }, |
|
|
| |
| |
| |
| |
| "delegation": { |
| "model": "", |
| "provider": "", |
| "base_url": "", |
| "api_key": "", |
| |
| |
| |
| |
| |
| "inherit_mcp_toolsets": True, |
| "max_iterations": 50, |
| |
| "child_timeout_seconds": 600, |
| |
| |
| |
| "reasoning_effort": "", |
| |
| "max_concurrent_children": 3, |
| |
| |
| |
| "max_spawn_depth": 1, |
| "orchestrator_enabled": True, |
| }, |
|
|
| |
| |
| |
| "prefill_messages_file": "", |
| |
| |
| |
| |
| "skills": { |
| "external_dirs": [], |
| |
| |
| |
| |
| "template_vars": True, |
| |
| |
| |
| |
| |
| |
| "inline_shell": False, |
| |
| "inline_shell_timeout": 10, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "guard_agent_created": False, |
| }, |
|
|
| |
| |
| |
| "honcho": {}, |
|
|
| |
| |
| "timezone": "", |
|
|
| |
| "discord": { |
| "require_mention": True, |
| "free_response_channels": "", |
| "allowed_channels": "", |
| "auto_thread": True, |
| "reactions": True, |
| "channel_prompts": {}, |
| |
| |
| |
| |
| |
| |
| |
| "server_actions": "", |
| }, |
|
|
| |
| "whatsapp": { |
| |
| |
| |
| |
| }, |
|
|
| |
| "telegram": { |
| "channel_prompts": {}, |
| }, |
|
|
| |
| "slack": { |
| "channel_prompts": {}, |
| }, |
|
|
| |
| "mattermost": { |
| "channel_prompts": {}, |
| }, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| "approvals": { |
| "mode": "manual", |
| "timeout": 60, |
| "cron_mode": "deny", |
| }, |
|
|
| |
| "command_allowlist": [], |
| |
| "quick_commands": {}, |
|
|
| |
| |
| |
| |
| |
| |
| |
| "hooks": {}, |
|
|
| |
| |
| |
| |
| "hooks_auto_accept": False, |
| |
| |
| |
| "personalities": {}, |
|
|
| |
| "security": { |
| "allow_private_urls": False, |
| "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_response": True, |
| |
| |
| |
| |
| "max_parallel_jobs": None, |
| }, |
|
|
| |
| "code_execution": { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "mode": "project", |
| }, |
|
|
| |
| |
| "logging": { |
| "level": "INFO", |
| "max_size_mb": 5, |
| "backup_count": 3, |
| }, |
|
|
| |
| "network": { |
| |
| |
| |
| "force_ipv4": False, |
| }, |
|
|
| |
| |
| |
| |
| |
| "sessions": { |
| |
| |
| |
| |
| |
| "auto_prune": False, |
| |
| |
| "retention_days": 90, |
| |
| |
| |
| |
| |
| |
| "vacuum_after_prune": True, |
| |
| |
| |
| "min_interval_hours": 24, |
| }, |
|
|
| |
| "_config_version": 22, |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| 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_ENV_VARS = {} |
|
|
| |
| OPTIONAL_ENV_VARS = { |
| |
| "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, |
| }, |
| "GOOGLE_API_KEY": { |
| "description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)", |
| "prompt": "Google AI Studio API key", |
| "url": "https://aistudio.google.com/app/apikey", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "GEMINI_API_KEY": { |
| "description": "Google AI Studio API key (alias for GOOGLE_API_KEY)", |
| "prompt": "Gemini API key", |
| "url": "https://aistudio.google.com/app/apikey", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "GEMINI_BASE_URL": { |
| "description": "Google AI Studio base URL override", |
| "prompt": "Gemini base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "XAI_API_KEY": { |
| "description": "xAI API key", |
| "prompt": "xAI API key", |
| "url": "https://console.x.ai/", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "XAI_BASE_URL": { |
| "description": "xAI base URL override", |
| "prompt": "xAI base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "NVIDIA_API_KEY": { |
| "description": "NVIDIA NIM API key (build.nvidia.com or local NIM endpoint)", |
| "prompt": "NVIDIA NIM API key", |
| "url": "https://build.nvidia.com/", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "NVIDIA_BASE_URL": { |
| "description": "NVIDIA NIM base URL override (e.g. http://localhost:8000/v1 for local NIM)", |
| "prompt": "NVIDIA NIM base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "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, |
| }, |
| "KIMI_CN_API_KEY": { |
| "description": "Kimi / Moonshot China API key", |
| "prompt": "Kimi (China) API key", |
| "url": "https://platform.moonshot.cn/", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "STEPFUN_API_KEY": { |
| "description": "StepFun Step Plan API key", |
| "prompt": "StepFun Step Plan API key", |
| "url": "https://platform.stepfun.com/", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "STEPFUN_BASE_URL": { |
| "description": "StepFun Step Plan base URL override", |
| "prompt": "StepFun Step Plan base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "ARCEEAI_API_KEY": { |
| "description": "Arcee AI API key", |
| "prompt": "Arcee AI API key", |
| "url": "https://chat.arcee.ai/", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "ARCEE_BASE_URL": { |
| "description": "Arcee AI base URL override", |
| "prompt": "Arcee 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, |
| }, |
| "HERMES_QWEN_BASE_URL": { |
| "description": "Qwen Portal base URL override (default: https://portal.qwen.ai/v1)", |
| "prompt": "Qwen Portal base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "HERMES_GEMINI_CLIENT_ID": { |
| "description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)", |
| "prompt": "Google OAuth client ID (optional β leave empty to use the public default)", |
| "url": "https://console.cloud.google.com/apis/credentials", |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "HERMES_GEMINI_CLIENT_SECRET": { |
| "description": "Google OAuth client secret for google-gemini-cli (optional)", |
| "prompt": "Google OAuth client secret (optional)", |
| "url": "https://console.cloud.google.com/apis/credentials", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "HERMES_GEMINI_PROJECT_ID": { |
| "description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)", |
| "prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)", |
| "url": None, |
| "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, |
| }, |
| "OLLAMA_API_KEY": { |
| "description": "Ollama Cloud API key (ollama.com β cloud-hosted open models)", |
| "prompt": "Ollama Cloud API key", |
| "url": "https://ollama.com/settings", |
| "password": True, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "OLLAMA_BASE_URL": { |
| "description": "Ollama Cloud base URL override (default: https://ollama.com/v1)", |
| "prompt": "Ollama base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "XIAOMI_API_KEY": { |
| "description": "Xiaomi MiMo API key for MiMo models (mimo-v2.5-pro, mimo-v2.5, mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)", |
| "prompt": "Xiaomi MiMo API Key", |
| "url": "https://platform.xiaomimimo.com", |
| "password": True, |
| "category": "provider", |
| }, |
| "XIAOMI_BASE_URL": { |
| "description": "Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)", |
| "prompt": "Xiaomi base URL (leave empty for default)", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "AWS_REGION": { |
| "description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)", |
| "prompt": "AWS Region", |
| "url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html", |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
| "AWS_PROFILE": { |
| "description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)", |
| "prompt": "AWS Profile", |
| "url": None, |
| "password": False, |
| "category": "provider", |
| "advanced": True, |
| }, |
|
|
| |
| "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", |
| }, |
| "FIRECRAWL_BROWSER_TTL": { |
| "description": "Firecrawl browser session TTL in seconds (optional, default 300)", |
| "prompt": "Browser session TTL (seconds)", |
| "tools": ["browser_navigate", "browser_click"], |
| "password": False, |
| "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", |
| }, |
| "MISTRAL_API_KEY": { |
| "description": "Mistral API key for Voxtral TTS and transcription (STT)", |
| "prompt": "Mistral API key", |
| "url": "https://console.mistral.ai/", |
| "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_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", |
| }, |
|
|
| |
| "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", |
| }, |
| "TELEGRAM_PROXY": { |
| "description": "Proxy URL for Telegram connections (overrides HTTPS_PROXY). Supports http://, https://, socks5://", |
| "prompt": "Telegram proxy URL (optional)", |
| "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", |
| }, |
| "DISCORD_REPLY_TO_MODE": { |
| "description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)", |
| "prompt": "Discord reply mode (off/first/all)", |
| "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: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", |
| }, |
| "MATRIX_REQUIRE_MENTION": { |
| "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.", |
| "prompt": "Require @mention in rooms (true/false)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "MATRIX_FREE_RESPONSE_ROOMS": { |
| "description": "Comma-separated Matrix room IDs where bot responds without @mention", |
| "prompt": "Free-response room IDs (comma-separated)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "MATRIX_AUTO_THREAD": { |
| "description": "Auto-create threads for messages in Matrix rooms (default: true)", |
| "prompt": "Auto-create threads in rooms (true/false)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "MATRIX_DEVICE_ID": { |
| "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)", |
| "prompt": "Matrix device ID (stable across restarts)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "MATRIX_RECOVERY_KEY": { |
| "description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings β Security β Recovery Key)", |
| "prompt": "Matrix recovery key", |
| "url": None, |
| "password": True, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "BLUEBUBBLES_SERVER_URL": { |
| "description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)", |
| "prompt": "BlueBubbles server URL", |
| "url": "https://bluebubbles.app/", |
| "password": False, |
| "category": "messaging", |
| }, |
| "BLUEBUBBLES_PASSWORD": { |
| "description": "BlueBubbles server password (from BlueBubbles Server β Settings β API)", |
| "prompt": "BlueBubbles server password", |
| "url": None, |
| "password": True, |
| "category": "messaging", |
| }, |
| "BLUEBUBBLES_ALLOWED_USERS": { |
| "description": "Comma-separated iMessage addresses (email or phone) allowed to use the bot", |
| "prompt": "Allowed iMessage addresses (comma-separated)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| }, |
| "BLUEBUBBLES_ALLOW_ALL_USERS": { |
| "description": "Allow all BlueBubbles users without allowlist", |
| "prompt": "Allow All BlueBubbles Users", |
| "category": "messaging", |
| }, |
| "QQ_APP_ID": { |
| "description": "QQ Bot App ID from QQ Open Platform (q.qq.com)", |
| "prompt": "QQ App ID", |
| "url": "https://q.qq.com", |
| "category": "messaging", |
| }, |
| "QQ_CLIENT_SECRET": { |
| "description": "QQ Bot Client Secret from QQ Open Platform", |
| "prompt": "QQ Client Secret", |
| "password": True, |
| "category": "messaging", |
| }, |
| "QQ_ALLOWED_USERS": { |
| "description": "Comma-separated QQ user IDs allowed to use the bot", |
| "prompt": "QQ Allowed Users", |
| "category": "messaging", |
| }, |
| "QQ_GROUP_ALLOWED_USERS": { |
| "description": "Comma-separated QQ group IDs allowed to interact with the bot", |
| "prompt": "QQ Group Allowed Users", |
| "category": "messaging", |
| }, |
| "QQ_ALLOW_ALL_USERS": { |
| "description": "Allow all QQ users without an allowlist (true/false)", |
| "prompt": "Allow All QQ Users", |
| "category": "messaging", |
| }, |
| "QQBOT_HOME_CHANNEL": { |
| "description": "Default QQ channel/group for cron delivery and notifications", |
| "prompt": "QQ Home Channel", |
| "category": "messaging", |
| }, |
| "QQBOT_HOME_CHANNEL_NAME": { |
| "description": "Display name for the QQ home channel", |
| "prompt": "QQ Home Channel Name", |
| "category": "messaging", |
| }, |
| "QQ_SANDBOX": { |
| "description": "Enable QQ sandbox mode for development testing (true/false)", |
| "prompt": "QQ Sandbox Mode", |
| "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. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.", |
| "prompt": "API server auth key (required for network access)", |
| "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 β server refuses to start without API_SERVER_KEY.", |
| "prompt": "API server host", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "API_SERVER_MODEL_NAME": { |
| "description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.", |
| "prompt": "API server model name", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "GATEWAY_PROXY_URL": { |
| "description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only β all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.", |
| "prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)", |
| "url": None, |
| "password": False, |
| "category": "messaging", |
| "advanced": True, |
| }, |
| "GATEWAY_PROXY_KEY": { |
| "description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.", |
| "prompt": "Remote API server auth key", |
| "url": None, |
| "password": True, |
| "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", |
| }, |
|
|
| |
| |
| |
| "SUDO_PASSWORD": { |
| "description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting", |
| "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": { |
| "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", |
| }, |
| } |
|
|
| |
| |
|
|
|
|
| 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 = [] |
| |
| |
| 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}) |
| |
| |
| 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 get_missing_skill_config_vars() -> List[Dict[str, Any]]: |
| """Return skill-declared config vars that are missing or empty in config.yaml. |
| |
| Scans all enabled skills for ``metadata.hermes.config`` entries, then checks |
| which ones are absent or empty under ``skills.config.<key>`` in the user's |
| config.yaml. Returns a list of dicts suitable for prompting. |
| """ |
| try: |
| from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX |
| except Exception: |
| return [] |
|
|
| all_vars = discover_all_skill_config_vars() |
| if not all_vars: |
| return [] |
|
|
| config = load_config() |
| missing: List[Dict[str, Any]] = [] |
| for var in all_vars: |
| |
| storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" |
| parts = storage_key.split(".") |
| current = config |
| value = None |
| for part in parts: |
| if isinstance(current, dict) and part in current: |
| current = current[part] |
| value = current |
| else: |
| value = None |
| break |
| |
| if value is None or (isinstance(value, str) and not value.strip()): |
| missing.append(var) |
| return missing |
|
|
|
|
| def _normalize_custom_provider_entry( |
| entry: Any, |
| *, |
| provider_key: str = "", |
| ) -> Optional[Dict[str, Any]]: |
| """Return a runtime-compatible custom provider entry or ``None``.""" |
| if not isinstance(entry, dict): |
| return None |
|
|
| |
| _CAMEL_ALIASES: Dict[str, str] = { |
| "apiKey": "api_key", |
| "baseUrl": "base_url", |
| "apiMode": "api_mode", |
| "keyEnv": "key_env", |
| "defaultModel": "default_model", |
| "contextLength": "context_length", |
| "rateLimitDelay": "rate_limit_delay", |
| } |
| _KNOWN_KEYS = { |
| "name", "api", "url", "base_url", "api_key", "key_env", |
| "api_mode", "transport", "model", "default_model", "models", |
| "context_length", "rate_limit_delay", |
| } |
| for camel, snake in _CAMEL_ALIASES.items(): |
| if camel in entry and snake not in entry: |
| logger.warning( |
| "providers.%s: camelCase key '%s' auto-mapped to '%s' " |
| "(use snake_case to avoid this warning)", |
| provider_key or "?", camel, snake, |
| ) |
| entry[snake] = entry[camel] |
| unknown = set(entry.keys()) - _KNOWN_KEYS - set(_CAMEL_ALIASES.keys()) |
| if unknown: |
| logger.warning( |
| "providers.%s: unknown config keys ignored: %s", |
| provider_key or "?", ", ".join(sorted(unknown)), |
| ) |
|
|
| from urllib.parse import urlparse |
|
|
| base_url = "" |
| for url_key in ("base_url", "url", "api"): |
| raw_url = entry.get(url_key) |
| if isinstance(raw_url, str) and raw_url.strip(): |
| candidate = raw_url.strip() |
| parsed = urlparse(candidate) |
| if parsed.scheme and parsed.netloc: |
| base_url = candidate |
| break |
| else: |
| logger.warning( |
| "providers.%s: '%s' value '%s' is not a valid URL " |
| "(no scheme or host) β skipped", |
| provider_key or "?", url_key, candidate, |
| ) |
| if not base_url: |
| return None |
|
|
| name = "" |
| raw_name = entry.get("name") |
| if isinstance(raw_name, str) and raw_name.strip(): |
| name = raw_name.strip() |
| elif provider_key.strip(): |
| name = provider_key.strip() |
| if not name: |
| return None |
|
|
| normalized: Dict[str, Any] = { |
| "name": name, |
| "base_url": base_url, |
| } |
|
|
| provider_key = provider_key.strip() |
| if provider_key: |
| normalized["provider_key"] = provider_key |
|
|
| api_key = entry.get("api_key") |
| if isinstance(api_key, str) and api_key.strip(): |
| normalized["api_key"] = api_key.strip() |
|
|
| key_env = entry.get("key_env") |
| if isinstance(key_env, str) and key_env.strip(): |
| normalized["key_env"] = key_env.strip() |
|
|
| api_mode = entry.get("api_mode") or entry.get("transport") |
| if isinstance(api_mode, str) and api_mode.strip(): |
| normalized["api_mode"] = api_mode.strip() |
|
|
| model_name = entry.get("model") or entry.get("default_model") |
| if isinstance(model_name, str) and model_name.strip(): |
| normalized["model"] = model_name.strip() |
|
|
| models = entry.get("models") |
| if isinstance(models, dict) and models: |
| normalized["models"] = models |
| elif isinstance(models, list) and models: |
| |
| |
| |
| |
| normalized["models"] = { |
| str(m): {} for m in models if isinstance(m, str) and m.strip() |
| } |
|
|
| context_length = entry.get("context_length") |
| if isinstance(context_length, int) and context_length > 0: |
| normalized["context_length"] = context_length |
|
|
| rate_limit_delay = entry.get("rate_limit_delay") |
| if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0: |
| normalized["rate_limit_delay"] = rate_limit_delay |
|
|
| return normalized |
|
|
|
|
| def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]: |
| """Normalize ``providers`` config entries into the legacy custom-provider shape.""" |
| if not isinstance(providers_dict, dict): |
| return [] |
|
|
| custom_providers: List[Dict[str, Any]] = [] |
| for key, entry in providers_dict.items(): |
| normalized = _normalize_custom_provider_entry(entry, provider_key=str(key)) |
| if normalized is not None: |
| custom_providers.append(normalized) |
|
|
| return custom_providers |
|
|
|
|
| def get_compatible_custom_providers( |
| config: Optional[Dict[str, Any]] = None, |
| ) -> List[Dict[str, Any]]: |
| """Return a deduplicated custom-provider view across legacy and v12+ config. |
| |
| ``custom_providers`` remains the on-disk legacy format, while ``providers`` |
| is the newer keyed schema. Runtime and picker flows still need a single |
| list-shaped view, but we should not materialise that compatibility layer |
| back into config.yaml because it duplicates entries in UIs. |
| """ |
| if config is None: |
| config = load_config() |
|
|
| compatible: List[Dict[str, Any]] = [] |
| seen_provider_keys: set = set() |
| seen_name_url_pairs: set = set() |
|
|
| def _append_if_new(entry: Optional[Dict[str, Any]]) -> None: |
| if entry is None: |
| return |
| provider_key = str(entry.get("provider_key", "") or "").strip().lower() |
| name = str(entry.get("name", "") or "").strip().lower() |
| base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower() |
| model = str(entry.get("model", "") or "").strip().lower() |
| pair = (name, base_url, model) |
|
|
| if provider_key and provider_key in seen_provider_keys: |
| return |
| if name and base_url and pair in seen_name_url_pairs: |
| return |
|
|
| compatible.append(entry) |
| if provider_key: |
| seen_provider_keys.add(provider_key) |
| if name and base_url: |
| seen_name_url_pairs.add(pair) |
|
|
| custom_providers = config.get("custom_providers") |
| if custom_providers is not None: |
| if not isinstance(custom_providers, list): |
| return [] |
| for entry in custom_providers: |
| _append_if_new(_normalize_custom_provider_entry(entry)) |
|
|
| for entry in providers_dict_to_custom_providers(config.get("providers")): |
| _append_if_new(entry) |
|
|
| return compatible |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| |
| _KNOWN_ROOT_KEYS = { |
| "_config_version", "model", "providers", "fallback_model", |
| "fallback_providers", "credential_pool_strategies", "toolsets", |
| "agent", "terminal", "display", "compression", "delegation", |
| "auxiliary", "custom_providers", "context", "memory", "gateway", |
| "sessions", |
| } |
|
|
| |
| _VALID_CUSTOM_PROVIDER_FIELDS = { |
| "name", "base_url", "api_key", "api_mode", "model", "models", |
| "context_length", "rate_limit_delay", |
| } |
|
|
| |
| _CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"} |
|
|
|
|
| @dataclass |
| class ConfigIssue: |
| """A detected config structure problem.""" |
|
|
| severity: str |
| message: str |
| hint: str |
|
|
|
|
| def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]: |
| """Validate config.yaml structure and return a list of detected issues. |
| |
| Catches common YAML formatting mistakes that produce confusing runtime |
| errors (like "Unknown provider") instead of clear diagnostics. |
| |
| Can be called with a pre-loaded config dict, or will load from disk. |
| """ |
| if config is None: |
| try: |
| config = load_config() |
| except Exception: |
| return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")] |
|
|
| issues: List[ConfigIssue] = [] |
|
|
| |
| cp = config.get("custom_providers") |
| if cp is not None: |
| if isinstance(cp, dict): |
| issues.append(ConfigIssue( |
| "error", |
| "custom_providers is a dict β it must be a YAML list (items prefixed with '-')", |
| "Change to:\n" |
| " custom_providers:\n" |
| " - name: my-provider\n" |
| " base_url: https://...\n" |
| " api_key: ...", |
| )) |
| |
| cp_keys = set(cp.keys()) if isinstance(cp, dict) else set() |
| suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS |
| if suspicious: |
| issues.append(ConfigIssue( |
| "warning", |
| f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields", |
| "These should be indented under a '- name: ...' list entry, not at root level", |
| )) |
| elif isinstance(cp, list): |
| |
| for i, entry in enumerate(cp): |
| if not isinstance(entry, dict): |
| issues.append(ConfigIssue( |
| "warning", |
| f"custom_providers[{i}] is not a dict (got {type(entry).__name__})", |
| "Each entry should have at minimum: name, base_url", |
| )) |
| continue |
| if not entry.get("name"): |
| issues.append(ConfigIssue( |
| "warning", |
| f"custom_providers[{i}] is missing 'name' field", |
| "Add a name, e.g.: name: my-provider", |
| )) |
| if not entry.get("base_url"): |
| issues.append(ConfigIssue( |
| "warning", |
| f"custom_providers[{i}] is missing 'base_url' field", |
| "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1", |
| )) |
|
|
| |
| fb = config.get("fallback_model") |
| if fb is not None: |
| if not isinstance(fb, dict): |
| issues.append(ConfigIssue( |
| "error", |
| f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}", |
| "Change to:\n" |
| " fallback_model:\n" |
| " provider: openrouter\n" |
| " model: anthropic/claude-sonnet-4", |
| )) |
| elif fb: |
| if not fb.get("provider"): |
| issues.append(ConfigIssue( |
| "warning", |
| "fallback_model is missing 'provider' field β fallback will be disabled", |
| "Add: provider: openrouter (or another provider)", |
| )) |
| if not fb.get("model"): |
| issues.append(ConfigIssue( |
| "warning", |
| "fallback_model is missing 'model' field β fallback will be disabled", |
| "Add: model: anthropic/claude-sonnet-4 (or another model)", |
| )) |
|
|
| |
| if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}): |
| issues.append(ConfigIssue( |
| "error", |
| "fallback_model appears inside custom_providers instead of at root level", |
| "Move fallback_model to the top level of config.yaml (no indentation)", |
| )) |
|
|
| |
| model_cfg = config.get("model") |
| if cp and not model_cfg: |
| issues.append(ConfigIssue( |
| "warning", |
| "custom_providers defined but no 'model' section β Hermes won't know which provider to use", |
| "Add a model section:\n" |
| " model:\n" |
| " provider: custom\n" |
| " default: your-model-name\n" |
| " base_url: https://...", |
| )) |
|
|
| |
| for key in config: |
| if key.startswith("_"): |
| continue |
| if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS: |
| issues.append(ConfigIssue( |
| "warning", |
| f"Root-level key '{key}' looks misplaced β should it be under 'model:' or inside a 'custom_providers' entry?", |
| f"Move '{key}' under the appropriate section", |
| )) |
|
|
| return issues |
|
|
|
|
| def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None: |
| """Print config structure warnings to stderr at startup. |
| |
| Called early in CLI and gateway init so users see problems before |
| they hit cryptic "Unknown provider" errors. Prints nothing if |
| config is healthy. |
| """ |
| try: |
| issues = validate_config_structure(config) |
| except Exception: |
| return |
| if not issues: |
| return |
|
|
| lines = ["\033[33mβ Config issues detected in config.yaml:\033[0m"] |
| for ci in issues: |
| marker = "\033[31mβ\033[0m" if ci.severity == "error" else "\033[33mβ \033[0m" |
| lines.append(f" {marker} {ci.message}") |
| lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m") |
| sys.stderr.write("\n".join(lines) + "\n\n") |
|
|
|
|
| def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> None: |
| """Warn if MESSAGING_CWD or TERMINAL_CWD is set in .env instead of config.yaml. |
| |
| These env vars are deprecated β the canonical setting is terminal.cwd |
| in config.yaml. Prints a migration hint to stderr. |
| """ |
| messaging_cwd = os.environ.get("MESSAGING_CWD") |
| terminal_cwd_env = os.environ.get("TERMINAL_CWD") |
|
|
| if config is None: |
| try: |
| config = load_config() |
| except Exception: |
| return |
|
|
| terminal_cfg = config.get("terminal", {}) |
| config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "." |
| |
| config_has_explicit_cwd = config_cwd not in (".", "auto", "cwd", "") |
|
|
| lines: list[str] = [] |
| if messaging_cwd: |
| lines.append( |
| f" \033[33mβ \033[0m MESSAGING_CWD={messaging_cwd} found in .env β " |
| f"this is deprecated." |
| ) |
| if terminal_cwd_env and not config_has_explicit_cwd: |
| |
| lines.append( |
| f" \033[33mβ \033[0m TERMINAL_CWD={terminal_cwd_env} found in .env β " |
| f"this is deprecated." |
| ) |
| if lines: |
| hint_path = os.environ.get("HERMES_HOME", "~/.hermes") |
| lines.insert(0, "\033[33mβ Deprecated .env settings detected:\033[0m") |
| lines.append( |
| f" \033[2mMove to config.yaml instead: " |
| f"terminal:\\n cwd: /your/project/path\033[0m" |
| ) |
| lines.append( |
| f" \033[2mThen remove the old entries from {hint_path}/.env\033[0m" |
| ) |
| sys.stderr.write("\n".join(lines) + "\n\n") |
|
|
|
|
| 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": []} |
|
|
| |
| try: |
| fixes = sanitize_env_file() |
| if fixes and not quiet: |
| print(f" β Repaired .env file ({fixes} corrupted entries fixed)") |
| except Exception: |
| pass |
|
|
| |
| current_ver, latest_ver = check_config_version() |
| |
| |
| 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']}") |
| |
| |
| 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}") |
|
|
| |
| |
| 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 < 12: |
| config = load_config() |
| custom_list = config.get("custom_providers") |
| if isinstance(custom_list, list) and custom_list: |
| providers_dict = config.get("providers", {}) |
| if not isinstance(providers_dict, dict): |
| providers_dict = {} |
| migrated_count = 0 |
| for entry in custom_list: |
| if not isinstance(entry, dict): |
| continue |
| old_name = entry.get("name", "") |
| old_url = entry.get("base_url", "") or entry.get("url", "") or "" |
| old_key = entry.get("api_key", "") |
| if not old_url: |
| continue |
|
|
| |
| key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "") |
| |
| while "--" in key: |
| key = key.replace("--", "-") |
| key = key.strip("-") |
| if not key: |
| |
| try: |
| from urllib.parse import urlparse |
| parsed = urlparse(old_url) |
| key = (parsed.hostname or "endpoint").replace(".", "-") |
| except Exception: |
| key = f"endpoint-{migrated_count}" |
|
|
| |
| if key in providers_dict: |
| key = f"{key}-{migrated_count}" |
|
|
| new_entry = {"api": old_url} |
| if old_name: |
| new_entry["name"] = old_name |
| if old_key and old_key not in ("no-key", "no-key-required", ""): |
| new_entry["api_key"] = old_key |
|
|
| |
| if entry.get("model"): |
| new_entry["default_model"] = entry["model"] |
| if entry.get("api_mode"): |
| new_entry["transport"] = entry["api_mode"] |
|
|
| providers_dict[key] = new_entry |
| migrated_count += 1 |
|
|
| if migrated_count > 0: |
| config["providers"] = providers_dict |
| |
| config.pop("custom_providers", None) |
| save_config(config) |
| if not quiet: |
| print(f" β Migrated {migrated_count} custom provider(s) to providers: section") |
| for key in list(providers_dict.keys())[-migrated_count:]: |
| ep = providers_dict[key] |
| print(f" β {key}: {ep.get('api', '')}") |
|
|
| |
| |
| |
| |
| if current_ver < 13: |
| for dead_var in ("LLM_MODEL", "OPENAI_MODEL"): |
| try: |
| old_val = get_env_value(dead_var) |
| if old_val: |
| save_env_value(dead_var, "") |
| if not quiet: |
| print(f" β Cleared {dead_var} from .env (no longer used β config.yaml is source of truth)") |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| |
| |
| if current_ver < 14: |
| |
| |
| raw = read_raw_config() |
| raw_stt = raw.get("stt", {}) |
| if isinstance(raw_stt, dict) and "model" in raw_stt: |
| legacy_model = raw_stt["model"] |
| provider = raw_stt.get("provider", "local") |
| config = load_config() |
| stt = config.get("stt", {}) |
| |
| stt.pop("model", None) |
| |
| |
| if provider in ("local", "local_command"): |
| |
| _local_models = { |
| "tiny.en", "tiny", "base.en", "base", "small.en", "small", |
| "medium.en", "medium", "large-v1", "large-v2", "large-v3", |
| "large", "distil-large-v2", "distil-medium.en", |
| "distil-small.en", "distil-large-v3", "distil-large-v3.5", |
| "large-v3-turbo", "turbo", |
| } |
| if legacy_model in _local_models: |
| |
| |
| raw_local = raw_stt.get("local", {}) |
| if not isinstance(raw_local, dict) or "model" not in raw_local: |
| local_cfg = stt.setdefault("local", {}) |
| local_cfg["model"] = legacy_model |
| |
| |
| else: |
| |
| |
| raw_provider = raw_stt.get(provider, {}) |
| if not isinstance(raw_provider, dict) or "model" not in raw_provider: |
| provider_cfg = stt.setdefault(provider, {}) |
| provider_cfg["model"] = legacy_model |
| config["stt"] = stt |
| save_config(config) |
| if not quiet: |
| print(f" β Migrated legacy stt.model to provider-specific config") |
|
|
| |
| if current_ver < 15: |
| config = read_raw_config() |
| display = config.get("display", {}) |
| if not isinstance(display, dict): |
| display = {} |
| if "interim_assistant_messages" not in display: |
| display["interim_assistant_messages"] = True |
| config["display"] = display |
| results["config_added"].append("display.interim_assistant_messages=true (default)") |
| save_config(config) |
| if not quiet: |
| print(" β Added display.interim_assistant_messages=true") |
|
|
| |
| if current_ver < 16: |
| config = read_raw_config() |
| display = config.get("display", {}) |
| if not isinstance(display, dict): |
| display = {} |
| old_overrides = display.get("tool_progress_overrides") |
| if isinstance(old_overrides, dict) and old_overrides: |
| platforms = display.get("platforms", {}) |
| if not isinstance(platforms, dict): |
| platforms = {} |
| for plat, mode in old_overrides.items(): |
| if plat not in platforms: |
| platforms[plat] = {} |
| if "tool_progress" not in platforms[plat]: |
| platforms[plat]["tool_progress"] = mode |
| display["platforms"] = platforms |
| config["display"] = display |
| save_config(config) |
| if not quiet: |
| migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items()) |
| print(f" β Migrated tool_progress_overrides β display.platforms: {migrated}") |
| results["config_added"].append("display.platforms (migrated from tool_progress_overrides)") |
|
|
| |
| if current_ver < 17: |
| config = read_raw_config() |
| comp = config.get("compression", {}) |
| if isinstance(comp, dict): |
| s_model = comp.pop("summary_model", None) |
| s_provider = comp.pop("summary_provider", None) |
| s_base_url = comp.pop("summary_base_url", None) |
| migrated_keys = [] |
| |
| if s_model and str(s_model).strip(): |
| aux = config.setdefault("auxiliary", {}) |
| aux_comp = aux.setdefault("compression", {}) |
| if not aux_comp.get("model"): |
| aux_comp["model"] = str(s_model).strip() |
| migrated_keys.append(f"model={s_model}") |
| if s_provider and str(s_provider).strip() not in ("", "auto"): |
| aux = config.setdefault("auxiliary", {}) |
| aux_comp = aux.setdefault("compression", {}) |
| if not aux_comp.get("provider") or aux_comp.get("provider") == "auto": |
| aux_comp["provider"] = str(s_provider).strip() |
| migrated_keys.append(f"provider={s_provider}") |
| if s_base_url and str(s_base_url).strip(): |
| aux = config.setdefault("auxiliary", {}) |
| aux_comp = aux.setdefault("compression", {}) |
| if not aux_comp.get("base_url"): |
| aux_comp["base_url"] = str(s_base_url).strip() |
| migrated_keys.append(f"base_url={s_base_url}") |
| if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None: |
| config["compression"] = comp |
| save_config(config) |
| if not quiet: |
| if migrated_keys: |
| print(f" β Migrated compression.summary_* β auxiliary.compression: {', '.join(migrated_keys)}") |
| else: |
| print(" β Removed unused compression.summary_* keys") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if current_ver < 21: |
| config = read_raw_config() |
| plugins_cfg = config.get("plugins") |
| if not isinstance(plugins_cfg, dict): |
| plugins_cfg = {} |
| |
| if "enabled" not in plugins_cfg: |
| disabled = plugins_cfg.get("disabled", []) or [] |
| if not isinstance(disabled, list): |
| disabled = [] |
| disabled_set = set(disabled) |
|
|
| |
| grandfathered: List[str] = [] |
| try: |
| user_plugins_dir = get_hermes_home() / "plugins" |
| if user_plugins_dir.is_dir(): |
| for child in sorted(user_plugins_dir.iterdir()): |
| if not child.is_dir(): |
| continue |
| manifest_file = child / "plugin.yaml" |
| if not manifest_file.exists(): |
| manifest_file = child / "plugin.yml" |
| if not manifest_file.exists(): |
| continue |
| try: |
| with open(manifest_file) as _mf: |
| manifest = yaml.safe_load(_mf) or {} |
| except Exception: |
| manifest = {} |
| name = manifest.get("name") or child.name |
| if name in disabled_set: |
| continue |
| grandfathered.append(name) |
| except Exception: |
| grandfathered = [] |
|
|
| plugins_cfg["enabled"] = grandfathered |
| config["plugins"] = plugins_cfg |
| save_config(config) |
| results["config_added"].append( |
| f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)" |
| ) |
| if not quiet: |
| if grandfathered: |
| print( |
| f" β Plugins now opt-in: grandfathered " |
| f"{len(grandfathered)} existing plugin(s) into plugins.enabled" |
| ) |
| else: |
| print( |
| " β Plugins now opt-in: no existing plugins to grandfather. " |
| "Use `hermes plugins enable <name>` to activate." |
| ) |
|
|
| if current_ver < latest_ver and not quiet: |
| print(f"Config version: {current_ver} β {latest_ver}") |
| |
| |
| 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() |
| |
| |
| |
| 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") |
| ] |
| |
| |
| 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>") |
| |
| |
| 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}") |
| |
| |
| config["_config_version"] = latest_ver |
| save_config(config) |
| elif current_ver < latest_ver: |
| |
| config = load_config() |
| config["_config_version"] = latest_ver |
| save_config(config) |
|
|
| |
| |
| |
| |
| missing_skill_config = get_missing_skill_config_vars() |
| if missing_skill_config and interactive and not quiet: |
| print(f"\n {len(missing_skill_config)} skill setting(s) not configured:") |
| for var in missing_skill_config: |
| skill_name = var.get("skill", "unknown") |
| print(f" β’ {var['key']} β {var['description']} (from skill: {skill_name})") |
| print() |
| try: |
| answer = input(" Configure skill settings? [y/N]: ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| answer = "n" |
|
|
| if answer in ("y", "yes"): |
| print() |
| config = load_config() |
| try: |
| from agent.skill_utils import SKILL_CONFIG_PREFIX |
| except Exception: |
| SKILL_CONFIG_PREFIX = "skills.config" |
| for var in missing_skill_config: |
| default = var.get("default", "") |
| default_hint = f" (default: {default})" if default else "" |
| value = input(f" {var['prompt']}{default_hint}: ").strip() |
| if not value and default: |
| value = str(default) |
| if value: |
| storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" |
| _set_nested(config, storage_key, value) |
| results["config_added"].append(var["key"]) |
| print(f" β Saved {var['key']} = {value}") |
| else: |
| results["warnings"].append( |
| f"Skipped {var['key']} β skill '{var.get('skill', '?')}' may ask for it later" |
| ) |
| print() |
| save_config(config) |
| else: |
| print(" Set later with: hermes config set <key> <value>") |
|
|
| 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 _items_by_unique_name(items): |
| """Return a name-indexed dict only when all items have unique string names.""" |
| if not isinstance(items, list): |
| return None |
| indexed = {} |
| for item in items: |
| if not isinstance(item, dict) or not isinstance(item.get("name"), str): |
| return None |
| name = item["name"] |
| if name in indexed: |
| return None |
| indexed[name] = item |
| return indexed |
|
|
|
|
| def _preserve_env_ref_templates(current, raw, loaded_expanded=None): |
| """Restore raw ``${VAR}`` templates when a value is otherwise unchanged. |
| |
| ``load_config()`` expands env refs for runtime use. When a caller later |
| persists that config after modifying some unrelated setting, keep the |
| original on-disk template instead of writing the expanded plaintext |
| secret back to ``config.yaml``. |
| |
| Prefer preserving the raw template when ``current`` still matches either |
| the value previously returned by ``load_config()`` for this config path or |
| the current environment expansion of ``raw``. This handles env-var |
| rotation between load and save while still treating mixed literal/template |
| string edits as caller-owned once their rendered value diverges. |
| """ |
| if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw): |
| if current == raw: |
| return raw |
| if isinstance(loaded_expanded, str) and current == loaded_expanded: |
| return raw |
| if _expand_env_vars(raw) == current: |
| return raw |
| return current |
|
|
| if isinstance(current, dict) and isinstance(raw, dict): |
| return { |
| key: _preserve_env_ref_templates( |
| value, |
| raw.get(key), |
| loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None, |
| ) |
| for key, value in current.items() |
| } |
|
|
| if isinstance(current, list) and isinstance(raw, list): |
| |
| |
| |
| |
| current_by_name = _items_by_unique_name(current) |
| raw_by_name = _items_by_unique_name(raw) |
| loaded_by_name = _items_by_unique_name(loaded_expanded) |
| if current_by_name is not None and raw_by_name is not None: |
| return [ |
| _preserve_env_ref_templates( |
| item, |
| raw_by_name.get(item.get("name")), |
| loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None, |
| ) |
| for item in current |
| ] |
| return [ |
| _preserve_env_ref_templates( |
| item, |
| raw[index] if index < len(raw) else None, |
| loaded_expanded[index] |
| if isinstance(loaded_expanded, list) and index < len(loaded_expanded) |
| else None, |
| ) |
| for index, item in enumerate(current) |
| ] |
|
|
| return current |
|
|
|
|
| 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. |
| """ |
| |
| 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 read_raw_config() -> Dict[str, Any]: |
| """Read ~/.hermes/config.yaml as-is, without merging defaults or migrating. |
| |
| Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't |
| be parsed. Use this for lightweight config reads where you just need a |
| single value and don't want the overhead of ``load_config()``'s deep-merge |
| + migration pipeline. |
| """ |
| try: |
| config_path = get_config_path() |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| return yaml.safe_load(f) or {} |
| except Exception: |
| pass |
| return {} |
|
|
|
|
| def load_config() -> Dict[str, Any]: |
| """Load configuration from ~/.hermes/config.yaml.""" |
| 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}") |
|
|
| normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) |
| expanded = _expand_env_vars(normalized) |
| _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(expanded) |
| return expanded |
|
|
|
|
| _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 auth) β OpenAI Codex |
| # nous (OAuth β hermes auth) β Nous Portal |
| # zai (ZAI_API_KEY) β Z.AI / GLM |
| # kimi-coding (KIMI_API_KEY) β Kimi / Moonshot |
| # kimi-coding-cn (KIMI_CN_API_KEY) β Kimi / Moonshot (China) |
| # minimax (MINIMAX_API_KEY) β MiniMax |
| # minimax-cn (MINIMAX_CN_API_KEY) β MiniMax (China) |
| # |
| # For custom OpenAI-compatible endpoints, add base_url and key_env. |
| # |
| # fallback_model: |
| # provider: openrouter |
| # model: anthropic/claude-sonnet-4 |
| """ |
|
|
|
|
| _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 auth) β OpenAI Codex |
| # nous (OAuth β hermes auth) β Nous Portal |
| # zai (ZAI_API_KEY) β Z.AI / GLM |
| # kimi-coding (KIMI_API_KEY) β Kimi / Moonshot |
| # kimi-coding-cn (KIMI_CN_API_KEY) β Kimi / Moonshot (China) |
| # minimax (MINIMAX_API_KEY) β MiniMax |
| # minimax-cn (MINIMAX_CN_API_KEY) β MiniMax (China) |
| # |
| # For custom OpenAI-compatible endpoints, add base_url and key_env. |
| # |
| # fallback_model: |
| # provider: openrouter |
| # model: anthropic/claude-sonnet-4 |
| """ |
|
|
|
|
| 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() |
| current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) |
| normalized = current_normalized |
| raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config())) |
| if raw_existing: |
| normalized = _preserve_env_ref_templates( |
| normalized, |
| raw_existing, |
| _LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)), |
| ) |
|
|
| |
| |
| 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 isinstance(fb, dict) 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) |
| _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) |
|
|
|
|
| def load_env() -> Dict[str, str]: |
| """Load environment variables from ~/.hermes/.env. |
| |
| Sanitizes lines before parsing so that corrupted files (e.g. |
| concatenated KEY=VALUE pairs on a single line) are handled |
| gracefully instead of producing mangled values such as duplicated |
| bot tokens. See #8908. |
| """ |
| env_path = get_env_path() |
| env_vars = {} |
| |
| if env_path.exists(): |
| |
| |
| open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} |
| with open(env_path, **open_kw) as f: |
| raw_lines = f.readlines() |
| |
| |
| lines = _sanitize_env_lines(raw_lines) |
| for line in lines: |
| 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 reading or 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 ``=``. |
| """ |
| |
| |
| 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() |
|
|
| |
| if not stripped or stripped.startswith("#"): |
| sanitized.append(raw + "\n") |
| continue |
|
|
| |
| |
| 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() |
| |
| 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 |
|
|
| |
| fixes = abs(len(sanitized) - len(original_lines)) |
| if fixes == 0: |
| |
| 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 _check_non_ascii_credential(key: str, value: str) -> str: |
| """Warn and strip non-ASCII characters from credential values. |
| |
| API keys and tokens must be pure ASCII β they are sent as HTTP header |
| values which httpx/httpcore encode as ASCII. Non-ASCII characters |
| (commonly introduced by copy-pasting from rich-text editors or PDFs |
| that substitute lookalike Unicode glyphs for ASCII letters) cause |
| ``UnicodeEncodeError: 'ascii' codec can't encode character`` at |
| request time. |
| |
| Returns the sanitized (ASCII-only) value. Prints a warning if any |
| non-ASCII characters were found and removed. |
| """ |
| try: |
| value.encode("ascii") |
| return value |
| except UnicodeEncodeError: |
| pass |
|
|
| |
| bad_chars: list[str] = [] |
| for i, ch in enumerate(value): |
| if ord(ch) > 127: |
| bad_chars.append(f" position {i}: {ch!r} (U+{ord(ch):04X})") |
| sanitized = value.encode("ascii", errors="ignore").decode("ascii") |
|
|
| print( |
| f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n" |
| f" This usually happens when copy-pasting from a PDF, rich-text editor,\n" |
| f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n" |
| f"\n" |
| + "\n".join(f" {line}" for line in bad_chars[:5]) |
| + ("\n ... and more" if len(bad_chars) > 5 else "") |
| + f"\n\n The non-ASCII characters have been stripped automatically.\n" |
| f" If authentication fails, re-copy the key from the provider's dashboard.\n", |
| file=sys.stderr, |
| ) |
| return sanitized |
|
|
|
|
| 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", "") |
| |
| value = _check_non_ascii_credential(key, value) |
| ensure_hermes_home() |
| env_path = get_env_path() |
| |
| |
| |
| 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() |
| |
| lines = _sanitize_env_lines(lines) |
| |
| |
| 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: |
| |
| 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_') |
| |
| original_mode = None |
| if env_path.exists(): |
| try: |
| original_mode = stat.S_IMODE(env_path.stat().st_mode) |
| except OSError: |
| pass |
| 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) |
| |
| if original_mode is not None: |
| try: |
| os.chmod(env_path, original_mode) |
| except OSError: |
| pass |
| except BaseException: |
| try: |
| os.unlink(tmp_path) |
| except OSError: |
| pass |
| raise |
| _secure_file(env_path) |
|
|
| os.environ[key] = value |
|
|
|
|
| def remove_env_value(key: str) -> bool: |
| """Remove a key from ~/.hermes/.env and os.environ. |
| |
| Returns True if the key was found and removed, False otherwise. |
| """ |
| if is_managed(): |
| managed_error(f"remove {key}") |
| return False |
| if not _ENV_VAR_NAME_RE.match(key): |
| raise ValueError(f"Invalid environment variable name: {key!r}") |
| env_path = get_env_path() |
| if not env_path.exists(): |
| os.environ.pop(key, None) |
| return False |
|
|
| 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: |
| lines = f.readlines() |
| lines = _sanitize_env_lines(lines) |
|
|
| new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")] |
| found = len(new_lines) < len(lines) |
|
|
| if found: |
| fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') |
| |
| original_mode = None |
| try: |
| original_mode = stat.S_IMODE(env_path.stat().st_mode) |
| except OSError: |
| pass |
| try: |
| with os.fdopen(fd, 'w', **write_kw) as f: |
| f.writelines(new_lines) |
| f.flush() |
| os.fsync(f.fileno()) |
| os.replace(tmp_path, env_path) |
| if original_mode is not None: |
| try: |
| os.chmod(env_path, original_mode) |
| except OSError: |
| pass |
| except BaseException: |
| try: |
| os.unlink(tmp_path) |
| except OSError: |
| pass |
| raise |
| _secure_file(env_path) |
|
|
| os.environ.pop(key, None) |
| return found |
|
|
|
|
| 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 reload_env() -> int: |
| """Re-read ~/.hermes/.env into os.environ. Returns count of vars updated. |
| |
| Adds/updates vars that changed and removes vars that were deleted from |
| the .env file (but only vars known to Hermes β OPTIONAL_ENV_VARS and |
| _EXTRA_ENV_KEYS β to avoid clobbering unrelated environment). |
| """ |
| env_vars = load_env() |
| known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS |
| count = 0 |
| for key, value in env_vars.items(): |
| if os.environ.get(key) != value: |
| os.environ[key] = value |
| count += 1 |
| |
| for key in known_keys: |
| if key not in env_vars and key in os.environ: |
| del os.environ[key] |
| count += 1 |
| return count |
|
|
|
|
| def get_env_value(key: str) -> Optional[str]: |
| """Get a value from ~/.hermes/.env or environment.""" |
| |
| if key in os.environ: |
| return os.environ[key] |
| |
| |
| env_vars = load_env() |
| return env_vars.get(key) |
|
|
|
|
| |
| |
| |
|
|
| 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)) |
| |
| |
| 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()}") |
| |
| |
| 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)}") |
| from hermes_cli.auth import get_anthropic_key |
| anthropic_value = get_anthropic_key() |
| print(f" {'Anthropic':<14} {redact_key(anthropic_value)}") |
| |
| |
| print() |
| print(color("β Model", Colors.CYAN, Colors.BOLD)) |
| print(f" Model: {config.get('model', 'not set')}") |
| print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}") |
| |
| |
| print() |
| 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'}") |
| ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {} |
| ump_first = ump.get('first_lines', 2) |
| ump_last = ump.get('last_lines', 2) |
| print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)") |
|
|
| |
| 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)'}") |
| |
| |
| 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)}") |
|
|
| |
| 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") |
| _aux_comp = config.get('auxiliary', {}).get('compression', {}) |
| _sm = _aux_comp.get('model', '') or '(auto)' |
| print(f" Model: {_sm}") |
| comp_provider = _aux_comp.get('provider', 'auto') |
| if comp_provider and comp_provider != 'auto': |
| print(f" Provider: {comp_provider}") |
| |
| |
| 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)}") |
| |
| |
| 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)}") |
| |
| |
| try: |
| from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values |
| skill_vars = discover_all_skill_config_vars() |
| if skill_vars: |
| resolved = resolve_skill_config_values(skill_vars) |
| print() |
| print(color("β Skill Settings", Colors.CYAN, Colors.BOLD)) |
| for var in skill_vars: |
| key = var["key"] |
| value = resolved.get(key, "") |
| skill_name = var.get("skill", "") |
| display_val = str(value) if value else color("(not set)", Colors.DIM) |
| print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}") |
| except Exception: |
| pass |
|
|
| 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() |
| |
| |
| if not config_path.exists(): |
| save_config(DEFAULT_CONFIG) |
| print(f"Created {config_path}") |
| |
| |
| editor = os.getenv('EDITOR') or os.getenv('VISUAL') |
| |
| if not editor: |
| |
| 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 |
| |
| 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', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'): |
| save_env_value(key.upper(), value) |
| print(f"β Set {key} in {get_env_path()}") |
| return |
| |
| |
| |
| |
| 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 = {} |
| |
| |
| 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] |
| |
| |
| 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 |
| |
| |
| ensure_hermes_home() |
| from utils import atomic_yaml_write |
| atomic_yaml_write(config_path, user_config, sort_keys=False) |
| |
| |
| |
| _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", |
| "terminal.container_cpu": "TERMINAL_CONTAINER_CPU", |
| "terminal.container_memory": "TERMINAL_CONTAINER_MEMORY", |
| "terminal.container_disk": "TERMINAL_CONTAINER_DISK", |
| "terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT", |
| } |
| 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}") |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| |
| 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 |
| |
| |
| 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() |
| |
| |
| 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": |
| |
| 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) |
|
|