{"repo": "burgebj/hermes", "files": {"hermes_cli/config.py": {"c1435dc5fa80e87d0a7df7ede5453f9dcb130043": {"t": 1785746883.998761, "n": 250566, "h": "c965ea6b6bbcbbf5", "c": "\"\"\"\nConfiguration management for Hermes Agent.\n\nConfig files are stored in ~/.hermes/ for easy access:\n- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.hermes/.env - API keys and secrets\n\nThis module provides:\n- hermes config - Show current configuration\n- hermes config edit - Open config in editor\n- hermes config set - Set a specific value\n- hermes config wizard - Re-run setup wizard\n\"\"\"\n\nimport copy\nimport logging\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nfrom hermes_cli.secret_prompt import masked_secret_prompt\n\nlogger = logging.getLogger(__name__)\n\n# Track which (config_path, mtime_ns, size) tuples we've already warned about\n# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr\n# every time. Cleared automatically when the file changes (different mtime).\n_CONFIG_PARSE_WARNED: set = set()\n\n\ndef _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n \"\"\"Surface a config.yaml parse failure to user, log, and stderr.\n\n A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()``\n to silently fall back to ``DEFAULT_CONFIG``, which means every user\n override (auxiliary providers, fallback chain, model overrides, etc.)\n is dropped. Before this helper that was a one-line ``print(...)`` that\n scrolled off-screen on the first invocation and was never seen again.\n\n Now: warn once per (path, mtime_ns, size) on stderr **and** in\n ``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``\n surfaces it. Re-warns automatically if the file changes (different\n mtime/size), so users editing the config see the next failure.\n \"\"\"\n try:\n st = config_path.stat()\n key = (str(config_path), st.st_mtime_ns, st.st_size)\n except OSError:\n key = (str(config_path), 0, 0)\n if key in _CONFIG_PARSE_WARNED:\n return\n _CONFIG_PARSE_WARNED.add(key)\n\n msg = (\n f\"Failed to parse {config_path}: {exc}. \"\n f\"Falling back to default config — every user override \"\n f\"(auxiliary providers, fallback chain, model settings) is being IGNORED. \"\n f\"Fix the YAML and restart.\"\n )\n logger.warning(msg)\n try:\n sys.stderr.write(f\"⚠️ hermes config: {msg}\\n\")\n sys.stderr.flush()\n except Exception:\n pass\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n\n# Env var names that influence how the next subprocess executes —\n# never writable through ``save_env_value``. Anything that controls\n# the loader, interpreter, shell, or replacement editor counts:\n#\n# * ``LD_PRELOAD`` / ``LD_LIBRARY_PATH`` / ``LD_AUDIT`` — Linux dynamic\n# loader. ``DYLD_*`` — macOS equivalent. Planting a path here means\n# the next ``subprocess.run([...])`` Hermes makes loads attacker code\n# before main().\n# * ``PYTHONPATH`` / ``PYTHONHOME`` / ``PYTHONSTARTUP`` /\n# ``PYTHONUSERBASE`` — Python interpreter init. Hermes itself starts\n# from one of these on every restart.\n# * ``NODE_OPTIONS`` / ``NODE_PATH`` — Node interpreter; affects npm,\n# ``hermes update``, the TUI build.\n# * ``PATH`` — too broad to allow. The dashboard never needs to rewrite\n# the operator's PATH; if a tool can't be found, the fix is to add an\n# absolute path in the integration config, not to mutate PATH globally.\n# * ``GIT_SSH_COMMAND`` / ``GIT_EXEC_PATH`` — git rewrites that fire\n# on every plugin install / ``hermes update``.\n# * ``BROWSER`` / ``EDITOR`` / ``VISUAL`` / ``PAGER`` — commands the\n# shell or CLI invokes implicitly. Wrong values here = RCE on next\n# ``$EDITOR``.\n# * ``SHELL`` — what subprocess uses with ``shell=True`` (we try to\n# avoid that, but defense in depth).\n# * ``HERMES_HOME`` / ``HERMES_PROFILE`` / ``HERMES_CONFIG`` /\n# ``HERMES_ENV`` — Hermes runtime location flags. Writing these into\n# ``.env`` would relocate state in ways the user did not request from\n# the dashboard. ``config.yaml`` is the supported surface for these.\n#\n# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate\n# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,\n# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The\n# denylist is name-by-name on purpose so the gate stays narrow and\n# doesn't accidentally break provider setup wizards.\n#\n# This is enforced on *write* only — values already in ``.env`` (set\n# by the operator out-of-band, or pre-existing) keep working. The\n# point is that the dashboard's writable surface cannot escalate by\n# planting them.\n_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({\n # Loader / linker\n \"LD_PRELOAD\", \"LD_LIBRARY_PATH\", \"LD_AUDIT\", \"LD_DEBUG\",\n \"DYLD_INSERT_LIBRARIES\", \"DYLD_LIBRARY_PATH\", \"DYLD_FRAMEWORK_PATH\",\n \"DYLD_FALLBACK_LIBRARY_PATH\", \"DYLD_FALLBACK_FRAMEWORK_PATH\",\n # Python\n \"PYTHONPATH\", \"PYTHONHOME\", \"PYTHONSTARTUP\", \"PYTHONUSERBASE\",\n \"PYTHONEXECUTABLE\", \"PYTHONNOUSERSITE\",\n # Node\n \"NODE_OPTIONS\", \"NODE_PATH\",\n # General\n \"PATH\", \"SHELL\", \"BROWSER\", \"EDITOR\", \"VISUAL\", \"PAGER\",\n # Git\n \"GIT_SSH_COMMAND\", \"GIT_EXEC_PATH\", \"GIT_SHELL\",\n # Hermes runtime location — never via dashboard env writer.\n # NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*,\n # HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed.\n \"HERMES_HOME\", \"HERMES_PROFILE\", \"HERMES_CONFIG\", \"HERMES_ENV\",\n})\n\n\ndef _reject_denylisted_env_var(key: str) -> None:\n \"\"\"Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`.\n\n Centralised so both the regular and \"secure\" env writers share the\n same gate, and so the message is consistent for callers.\n \"\"\"\n if key in _ENV_VAR_NAME_DENYLIST:\n raise ValueError(\n f\"Environment variable {key!r} is on the writer denylist. \"\n \"Names that influence subprocess execution (LD_PRELOAD, \"\n \"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location \"\n \"(HERMES_HOME, HERMES_PROFILE, ...) cannot be persisted via \"\n \"the env writer. If you really need this, edit \"\n \"~/.hermes/.env directly.\"\n )\n\n_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}\n# (path, mtime_ns, size) -> cached expanded config dict.\n# load_config() returns a deepcopy of the cached value when the file\n# hasn't changed since the last load, skipping yaml.safe_load +\n# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).\n# save_config() + migrate_config() write via atomic_yaml_write which\n# produces a fresh inode, so stat() sees a new mtime_ns and the next\n# load repopulates automatically — no explicit invalidation hook.\n_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as\n# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want\n# the user's on-disk values without defaults merged in.\n_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# Serializes all config read/write paths. libyaml's C extension is not\n# thread-safe for concurrent safe_load() on the same file, and multiple\n# tool threads (approval.py, browser_tool.py, setup flows) hit\n# load_config / read_raw_config / save_config from different threads\n# during long agent runs. RLock (not Lock) because save_config internally\n# calls read_raw_config. Also covers mutation of the module-level cache\n# dicts above.\n_CONFIG_LOCK = threading.RLock()\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset({\n \"OPENAI_API_KEY\", \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\", \"DISCORD_HOME_CHANNEL_NAME\",\n \"TELEGRAM_HOME_CHANNEL\", \"TELEGRAM_HOME_CHANNEL_NAME\",\n \"SLACK_HOME_CHANNEL\", \"SLACK_HOME_CHANNEL_NAME\",\n \"SIGNAL_ACCOUNT\", \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\", \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"SIGNAL_HOME_CHANNEL\", \"SIGNAL_HOME_CHANNEL_NAME\",\n \"SMS_HOME_CHANNEL\", \"SMS_HOME_CHANNEL_NAME\",\n \"DINGTALK_CLIENT_ID\", \"DINGTALK_CLIENT_SECRET\",\n \"DINGTALK_HOME_CHANNEL\", \"DINGTALK_HOME_CHANNEL_NAME\",\n \"FEISHU_APP_ID\", \"FEISHU_APP_SECRET\", \"FEISHU_ENCRYPT_KEY\", \"FEISHU_VERIFICATION_TOKEN\",\n \"FEISHU_HOME_CHANNEL\", \"FEISHU_HOME_CHANNEL_NAME\",\n \"YUANBAO_HOME_CHANNEL\", \"YUANBAO_HOME_CHANNEL_NAME\",\n \"WECOM_BOT_ID\", \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\", \"WECOM_CALLBACK_CORP_SECRET\", \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\", \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\", \"WECOM_CALLBACK_PORT\",\n \"WECOM_HOME_CHANNEL\", \"WECOM_HOME_CHANNEL_NAME\",\n \"WEIXIN_ACCOUNT_ID\", \"WEIXIN_TOKEN\", \"WEIXIN_BASE_URL\", \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\", \"WEIXIN_HOME_CHANNEL_NAME\", \"WEIXIN_DM_POLICY\", \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\", \"WEIXIN_GROUP_ALLOWED_USERS\", \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\", \"BLUEBUBBLES_PASSWORD\",\n \"BLUEBUBBLES_HOME_CHANNEL\", \"BLUEBUBBLES_HOME_CHANNEL_NAME\",\n \"QQ_APP_ID\", \"QQ_CLIENT_SECRET\", \"QQBOT_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL_NAME\",\n \"QQ_HOME_CHANNEL\", \"QQ_HOME_CHANNEL_NAME\", # legacy aliases (pre-rename, still read for back-compat)\n \"QQ_ALLOWED_USERS\", \"QQ_GROUP_ALLOWED_USERS\", \"QQ_ALLOW_ALL_USERS\", \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\", \"QQ_STT_BASE_URL\", \"QQ_STT_MODEL\",\n \"IRC_SERVER\", \"IRC_PORT\", \"IRC_NICKNAME\", \"IRC_CHANNEL\",\n \"IRC_USE_TLS\", \"IRC_SERVER_PASSWORD\", \"IRC_NICKSERV_PASSWORD\",\n \"TERMINAL_ENV\", \"TERMINAL_SSH_KEY\", \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\", \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\", \"MATTERMOST_HOME_CHANNEL_NAME\", \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\", \"MATRIX_ENCRYPTION\", \"MATRIX_DEVICE_ID\", \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\", \"MATRIX_FREE_RESPONSE_ROOMS\", \"MATRIX_AUTO_THREAD\", \"MATRIX_DM_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n # Langfuse observability plugin — optional tuning keys + standard SDK vars.\n # Activation is via plugins.enabled (opt-in through `hermes plugins enable\n # observability/langfuse`); credentials gate the plugin at runtime.\n \"HERMES_LANGFUSE_ENV\",\n \"HERMES_LANGFUSE_RELEASE\",\n \"HERMES_LANGFUSE_SAMPLE_RATE\",\n \"HERMES_LANGFUSE_MAX_CHARS\",\n \"HERMES_LANGFUSE_DEBUG\",\n \"LANGFUSE_PUBLIC_KEY\",\n \"LANGFUSE_SECRET_KEY\",\n \"LANGFUSE_BASE_URL\",\n})\nimport yaml\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"HERMES_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_hermes_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Hermes is running in package-manager-managed mode.\n\n Two signals: the HERMES_MANAGED env var (set by the systemd service),\n or a .managed marker file in HERMES_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\n_NIX_UPDATE_MSG = \"Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)\"\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade hermes-agent\"\n if managed_system == \"NixOS\":\n return _NIX_UPDATE_MSG\n return None\n\n\ndef detect_install_method(project_root: Optional[Path] = None) -> str:\n \"\"\"Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.\n\n Resolution order:\n 1. Stamped ``~/.hermes/.install_method`` file (written by installers)\n 2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)\n 3. Container detection (/.dockerenv, /run/.containerenv, cgroup)\n 4. .git directory presence -> 'git'\n 5. Fallback -> 'pip'\n \"\"\"\n stamp = get_hermes_home() / \".install_method\"\n try:\n method = stamp.read_text(encoding=\"utf-8\").strip().lower()\n if method:\n return method\n except OSError:\n pass\n managed = get_managed_system()\n if managed:\n return managed.lower().replace(\" \", \"-\")\n from hermes_constants import is_container\n if is_container():\n return \"docker\"\n if project_root is None:\n project_root = Path(__file__).parent.parent.resolve()\n if (project_root / \".git\").is_dir():\n return \"git\"\n return \"pip\"\n\n\ndef stamp_install_method(method: str) -> None:\n \"\"\"Write the install method to ~/.hermes/.install_method.\"\"\"\n stamp = get_hermes_home() / \".install_method\"\n try:\n stamp.parent.mkdir(parents=True, exist_ok=True)\n stamp.write_text(method + \"\\n\", encoding=\"utf-8\")\n except OSError:\n pass\n\n\ndef recommended_update_command_for_method(method: str) -> str:\n \"\"\"Return the update command or guidance for a given install method.\"\"\"\n if method == \"nixos\":\n return _NIX_UPDATE_MSG\n if method == \"homebrew\":\n return \"brew upgrade hermes-agent\"\n if method == \"docker\":\n return \"docker pull nousresearch/hermes-agent:latest\"\n if method == \"pip\":\n import shutil\n uv = shutil.which(\"uv\")\n if uv:\n return \"uv pip install --upgrade hermes-agent\"\n return \"pip install --upgrade hermes-agent\"\n return \"hermes update\"\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n managed_cmd = get_managed_update_command()\n if managed_cmd:\n return managed_cmd\n method = detect_install_method()\n return recommended_update_command_for_method(method)\n\n\ndef format_managed_message(action: str = \"modify this Hermes installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"HERMES_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Hermes installation is managed by NixOS \"\n f\"(HERMES_MANAGED={env_hint}).\\n\"\n \"Edit services.hermes-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Hermes installation is managed by Homebrew \"\n f\"(HERMES_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade hermes-agent\"\n )\n\n return (\n f\"Cannot {action}: this Hermes installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Hermes.\"\n )\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from HERMES_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, hermes_bin\n or None if container mode is not active, we're already inside the\n container, or HERMES_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"HERMES_DEV\") == \"1\":\n return None\n\n from hermes_constants import is_container\n if is_container():\n return None\n\n container_mode_file = get_hermes_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\", encoding=\"utf-8\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"hermes-agent\")\n exec_user = info.get(\"exec_user\", \"hermes\")\n hermes_bin = info.get(\"hermes_bin\", \"/data/current-package/bin/hermes\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"hermes_bin\": hermes_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from hermes_constants — canonical definition lives there.\nfrom hermes_constants import get_hermes_home # noqa: F811,E402\nfrom utils import atomic_replace\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_hermes_home() / \"config.yaml\"\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_hermes_home() / \".env\"\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the hermes group can\n share state with the gateway service.\n\n The mode can be overridden via the HERMES_HOME_MODE environment variable\n (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"HERMES_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _is_container() -> bool:\n \"\"\"Detect if we're running inside a Docker/Podman/LXC container.\n\n When Hermes runs in a container with volume-mounted config files, forcing\n 0o600 permissions breaks multi-process setups where the gateway and\n dashboard run as different UIDs or the volume mount requires broader\n permissions.\n \"\"\"\n # Explicit opt-out\n if os.environ.get(\"HERMES_CONTAINER\") or os.environ.get(\"HERMES_SKIP_CHMOD\"):\n return True\n # Docker / Podman marker file\n if os.path.exists(\"/.dockerenv\"):\n return True\n # LXC / cgroup-based detection\n try:\n with open(\"/proc/1/cgroup\", \"r\", encoding=\"utf-8\") as f:\n cgroup_content = f.read()\n if \"docker\" in cgroup_content or \"lxc\" in cgroup_content or \"kubepods\" in cgroup_content:\n return True\n except (OSError, IOError):\n pass\n return False\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n\n Skipped in containers — Docker/Podman volume mounts often need broader\n permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems.\n \"\"\"\n if is_managed() or _is_container():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_hermes_home():\n \"\"\"Ensure ~/.hermes directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_hermes_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_hermes_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\n \"cron\", \"sessions\", \"logs\", \"logs/curator\", \"memories\",\n \"pairing\", \"hooks\", \"image_cache\", \"audio_cache\", \"skills\",\n ):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_hermes_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"HERMES_HOME {home} does not exist. \"\n \"Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. \"\n \"Run 'sudo nixos-rebuild switch' first.\"\n )\n # Curator reports dir is a sub-path of logs/; create it if missing.\n # In managed mode the activation script may not know about this subdir,\n # so we mkdir it ourselves (it's inside an already-secured logs/ dir).\n (home / \"logs\" / \"curator\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"hermes-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n #\n # 180s is calibrated for realistic in-flight agent turns: a typical\n # coding conversation mid-reasoning runs 60–150s per call, so a 60s\n # budget routinely interrupted legitimate work on /restart. Raise\n # further in config.yaml if you run very-long-reasoning models.\n \"restart_drain_timeout\": 180,\n # Max app-level retry attempts for API errors (connection drops,\n # provider timeouts, 5xx, etc.) before the agent surfaces the\n # failure. The OpenAI SDK already does its own low-level retries\n # (max_retries=2 default) for transient network errors; this is\n # the Hermes-level retry loop that wraps the whole call. Lower\n # this to 1 if you use fallback providers and want fast failover\n # on flaky primaries; raise it if you prefer to tolerate longer\n # provider hiccups on a single provider.\n \"api_max_retries\": 3,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Maximum time (seconds) the gateway will block an agent waiting for\n # a clarify-tool response from the user. Hit this and the agent\n # unblocks with \"[user did not respond within Xm]\" so it can adapt\n # rather than pinning the running-agent guard forever. CLI clarify\n # blocks indefinitely (input() is synchronous) and ignores this.\n \"clarify_timeout\": 600,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n # Lower values mean faster feedback on slow tasks but more chat\n # noise; 180s is a compromise that catches spinning weak-model runs\n # (60+ tool iterations with tiny output) before users assume the\n # bot is dead and /restart.\n \"gateway_notify_interval\": 180,\n # Freshness window for the gateway auto-continue note (seconds).\n # After a gateway crash/restart/SIGTERM mid-run, the next user\n # message gets a \"[System note: your previous turn was\n # interrupted — process the unfinished tool result(s) first]\"\n # prepended so the model picks up where it left off. That's the\n # right behaviour while the interruption is fresh, but stale\n # markers (transcript last touched hours or days ago) can revive\n # an unrelated old task when the user's next message starts new\n # work. This window is the max age of the last persisted\n # transcript row for which we still inject the continue note.\n # Default 3600s comfortably covers a long turn (gateway_timeout\n # default is 1800s) plus runtime slack. Set to 0 to disable the\n # gate and restore pre-fix behaviour (always inject).\n \"gateway_auto_continue_freshness\": 3600,\n # How user-attached images are presented to the main model on each turn.\n # \"auto\" — attach natively when the active model reports\n # supports_vision=True AND the user hasn't explicitly\n # configured auxiliary.vision.provider. Otherwise fall\n # back to text (vision_analyze pre-analysis).\n # \"native\" — always attach natively; non-vision models will either\n # error at the provider or get a last-chance text fallback\n # (see run_agent._prepare_messages_for_api).\n # \"text\" — always pre-analyze with vision_analyze and prepend the\n # description as text; the main model never sees pixels.\n # Affects gateway platforms, the TUI, and CLI /attach. vision_analyze\n # remains available as a tool regardless of this setting — the routing\n # only controls how inbound user images are presented.\n \"image_input_mode\": \"auto\",\n \"disabled_toolsets\": [],\n },\n \n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n # Extra files to source in the login shell when building the\n # per-session environment snapshot. Use this when tools like nvm,\n # pyenv, asdf, or custom PATH entries are registered by files that\n # a bash login shell would skip — most commonly ``~/.bashrc``\n # (bash doesn't source bashrc in non-interactive login mode) or\n # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.\n # Paths support ``~`` / ``${VAR}``. Missing files are silently\n # skipped. When empty, Hermes auto-sources ``~/.profile``,\n # ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the\n # snapshot shell is bash (this is the ``auto_source_bashrc``\n # behaviour — disable with that key if you want strict login-only\n # semantics).\n \"shell_init_files\": [],\n # When true (default), Hermes sources the user's shell rc files\n # (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the\n # login shell used to build the environment snapshot. This\n # captures PATH additions, shell functions, and aliases — which a\n # plain ``bash -l -c`` would otherwise miss because bash skips\n # bashrc in non-interactive login mode, and because a default\n # Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive\n # sources. ``~/.profile`` and ``~/.bash_profile`` are tried first\n # because ``n`` / ``nvm`` / ``asdf`` installers typically write\n # their PATH exports there without an interactivity guard. Turn\n # this off if your rc files misbehave when sourced\n # non-interactively (e.g. one that hard-exits on TTY checks).\n \"auto_source_bashrc\": True,\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Hermes\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example:\n # [\"/home/user/projects:/workspace/projects\",\n # \"/home/user/.hermes/cache/documents:/output\"]\n # For gateway MEDIA delivery, write inside Docker to /output/... and emit\n # the host-visible path in MEDIA:, not the container path.\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n \"docker_extra_args\": [], # Extra flags passed verbatim to docker run\n # Explicit opt-in: run the Docker container as the host user's uid:gid\n # (via `--user`). When enabled, files written into bind-mounted dirs\n # (docker_volumes, the persistent workspace, or the auto-mounted cwd)\n # are owned by your host user instead of root, which avoids needing\n # `sudo chown` after container runs. Default off to preserve behavior\n # for images whose entrypoints expect to start as root (e.g. the\n # bundled Hermes image, which drops to the `hermes` user via\n # s6-setuidgid inside each supervised service).\n # When on, SETUID/SETGID caps are omitted from the container since\n # no privilege drop is needed.\n \"docker_run_as_host_user\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n\n \"web\": {\n \"backend\": \"\", # shared fallback — applies to both search and extract\n \"search_backend\": \"\", # per-capability override for web_search (e.g. \"searxng\")\n \"extract_backend\": \"\", # per-capability override for web_extract (e.g. \"native\")\n },\n\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n # Browser engine for local mode. Passed as ``--engine `` to\n # agent-browser v0.25.3+.\n # \"auto\" — use Chrome (default, don't pass --engine at all)\n # \"lightpanda\" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)\n # \"chrome\" — explicitly request Chrome\n # Also settable via AGENT_BROWSER_ENGINE env var.\n \"engine\": \"auto\",\n \"auto_local_for_private_urls\": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud\n \"cdp_url\": \"\", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome\n # CDP supervisor — dialog + frame detection via a persistent WebSocket.\n # Active only when a CDP-capable backend is attached (Browserbase or\n # local Chrome via /browser connect). See\n # website/docs/developer-guide/browser-supervisor.md.\n \"dialog_policy\": \"must_respond\", # must_respond | auto_dismiss | auto_accept\n \"dialog_timeout_s\": 300, # Safety auto-dismiss after N seconds under must_respond\n \"camofox\": {\n # When true, Hermes sends a stable profile-scoped userId to Camofox\n # so the server maps it to a persistent Firefox profile automatically.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n # Optional externally managed Camofox identity. Useful when another\n # app owns the visible browser and Hermes should operate in it.\n \"user_id\": \"\",\n \"session_key\": \"\",\n # Rehydrate tab_id from Camofox before creating a new tab.\n \"adopt_existing_tab\": False,\n },\n },\n\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once\n # per conversation turn (on first write_file/patch call). Use /rollback\n # to restore.\n #\n # Defaults changed in v2 (single shared shadow store, real pruning):\n # - enabled: True -> False (opt-in; most users never use /rollback)\n # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)\n # - auto_prune: False -> True (orphans/stale pruned automatically)\n # Opt in via ``hermes chat --checkpoints`` or set enabled=True here.\n \"checkpoints\": {\n \"enabled\": False,\n # Max checkpoints to keep per working directory. Pre-v2 this only\n # limited the `/rollback` listing; v2 actually rewrites the ref and\n # garbage-collects older commits.\n \"max_snapshots\": 20,\n # Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When\n # exceeded, the oldest checkpoint per project is dropped in a\n # round-robin pass until total size falls under the cap.\n # 0 disables the size cap.\n \"max_total_size_mb\": 500,\n # Skip any single file larger than this when staging a checkpoint.\n # Prevents accidental snapshotting of datasets, model weights, and\n # other large generated assets. 0 disables the filter.\n \"max_file_size_mb\": 10,\n # Auto-maintenance: hermes sweeps the checkpoint base at startup\n # (at most once per ``min_interval_hours``) and:\n # * deletes project entries whose workdir no longer exists (orphan)\n # * deletes project entries whose last_touch is older than\n # ``retention_days``\n # * GCs the single shared store to reclaim unreachable objects\n # * enforces ``max_total_size_mb`` across remaining projects\n # * deletes ``legacy-*`` archives older than ``retention_days``\n \"auto_prune\": True,\n \"retention_days\": 7,\n \"delete_orphans\": True,\n \"min_interval_hours\": 24,\n },\n\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n\n # Tool-output truncation thresholds. When terminal output or a\n # single read_file page exceeds these limits, Hermes truncates the\n # payload sent to the model (keeping head + tail for terminal,\n # enforcing pagination for read_file). Tuning these trades context\n # footprint against how much raw output the model can see in one\n # shot. Ported from anomalyco/opencode PR #23770.\n #\n # - max_bytes: terminal_tool output cap, in chars\n # (default 50_000 ≈ 12-15K tokens).\n # - max_lines: read_file pagination cap — the maximum `limit`\n # a single read_file call can request before\n # being clamped (default 2000).\n # - max_line_length: per-line cap applied when read_file emits a\n # line-numbered view (default 2000 chars).\n \"tool_output\": {\n \"max_bytes\": 50_000,\n \"max_lines\": 2000,\n \"max_line_length\": 2000,\n },\n\n # Tool loop guardrails nudge models when they repeat failed or\n # non-progressing tool calls. Soft warnings are always-on by default;\n # hard stops are opt-in so interactive CLI/TUI sessions keep flowing.\n \"tool_loop_guardrails\": {\n \"warnings_enabled\": True,\n \"hard_stop_enabled\": False,\n \"warn_after\": {\n \"exact_failure\": 2,\n \"same_tool_failure\": 3,\n \"idempotent_no_progress\": 2,\n },\n \"hard_stop_after\": {\n \"exact_failure\": 5,\n \"same_tool_failure\": 8,\n \"idempotent_no_progress\": 5,\n },\n },\n\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n \"hygiene_hard_message_limit\": 400, # gateway session-hygiene force-compress threshold by message count\n \"protect_first_n\": 3, # non-system head messages always preserved\n # verbatim, in ADDITION to the system prompt\n # (which is always implicitly protected). Set to\n # 0 for long-running rolling-compaction sessions\n # where you want nothing pinned except the\n # system prompt + rolling summary + recent tail.\n \"abort_on_summary_failure\": False, # When True, auto-compression that fails\n # to generate a summary (aux LLM errored / returned\n # non-JSON / timed out) aborts entirely instead of\n # dropping the middle window with a static\n # \"summary unavailable\" placeholder. Messages are\n # preserved unchanged and the session \"freezes\" at\n # its current size until the user runs /compress\n # (which bypasses the failure cooldown) or /new.\n # Default False matches historical behavior; set to\n # True if you'd rather pause than silently lose\n # context turns when your aux model is flaky.\n },\n\n # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).\n # cache_ttl must be \"5m\" or \"1h\" (Anthropic-supported tiers); other values are ignored.\n \"prompt_caching\": {\n \"cache_ttl\": \"5m\",\n },\n\n # OpenRouter-specific settings.\n # response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).\n # When enabled, identical requests return cached responses for free (zero billing).\n # This is separate from Anthropic prompt caching and works alongside it.\n # See: https://openrouter.ai/docs/guides/features/response-caching\n # response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).\n # Default 300 (5 minutes). Only used when response_cache is enabled.\n # min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).\n # Only applied when model.model is \"openrouter/pareto-code\". Higher\n # values route to stronger (more expensive) coders; lower values open\n # up cheaper, faster options. Default 0.65 lands on the mid-tier\n # coder on the current Pareto frontier. Empty string = let OpenRouter\n # pick the strongest available coder (router's documented default\n # when the plugins block is omitted).\n # See: https://openrouter.ai/docs/guides/routing/routers/pareto-router\n \"openrouter\": {\n \"response_cache\": True,\n \"response_cache_ttl\": 300,\n \"min_coding_score\": 0.65,\n },\n\n # AWS Bedrock provider configuration.\n # Only used when model.provider is \"bedrock\".\n \"bedrock\": {\n \"region\": \"\", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)\n \"discovery\": {\n \"enabled\": True, # Auto-discover models via ListFoundationModels\n \"provider_filter\": [], # Only show models from these providers (e.g. [\"anthropic\", \"amazon\"])\n \"refresh_interval\": 3600, # Cache discovery results for this many seconds\n },\n \"guardrail\": {\n # Amazon Bedrock Guardrails — content filtering and safety policies.\n # Create a guardrail in the Bedrock console, then set the ID and version here.\n # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html\n \"guardrail_identifier\": \"\", # e.g. \"abc123def456\"\n \"guardrail_version\": \"\", # e.g. \"1\" or \"DRAFT\"\n \"stream_processing_mode\": \"async\", # \"sync\" or \"async\"\n \"trace\": \"disabled\", # \"enabled\", \"disabled\", or \"enabled_full\"\n },\n },\n\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n #\n # extra_body: forwarded verbatim as request body fields on every aux call\n # for that task. Use this to set provider-specific knobs (independent of\n # main-agent settings). On OpenRouter you can set provider routing prefs\n # and the Pareto Code coding-score floor here. Example:\n #\n # auxiliary:\n # compression:\n # provider: openrouter\n # model: openrouter/pareto-code\n # extra_body:\n # provider: # OpenRouter provider routing\n # order: [anthropic, google]\n # sort: throughput # or price | latency\n # plugins: # OpenRouter Pareto Code router\n # - id: pareto-router\n # min_coding_score: 0.5\n #\n # Each aux task is independent — main-agent provider_routing and\n # openrouter.min_coding_score do NOT propagate to aux calls by design.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | nous | codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"extra_body\": {}, # OpenAI-compatible provider-specific request fields\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n \"extra_body\": {},\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n \"extra_body\": {},\n },\n # Note: session_search no longer uses an auxiliary LLM (PR #27590 —\n # single-shape tool returns DB content directly). The old\n # ``auxiliary.session_search.*`` block was removed here. Existing\n # values in user config.yaml files are harmless leftovers and ignored.\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"title_generation\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n # Triage specifier — flesh out a rough one-liner in the Kanban\n # Triage column into a concrete spec, then promote it to ``todo``.\n # Invoked by ``hermes kanban specify`` (single id or --all). Set a\n # cheap, capable model here (gemini-flash works well); the main\n # model is overkill for short spec expansion.\n \"triage_specifier\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120,\n \"extra_body\": {},\n },\n # Kanban decomposer — decomposes a triage task into a graph of\n # child tasks routed to specialist profiles by description.\n # Invoked by ``hermes kanban decompose`` and the kanban\n # auto-decompose dispatcher tick. Returns a JSON task graph;\n # uses more tokens than the specifier so allow more headroom.\n \"kanban_decomposer\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 180,\n \"extra_body\": {},\n },\n # Profile describer — auto-generates a 1-2 sentence description\n # of what a profile is good at. Invoked by\n # ``hermes profile describe --auto`` and the dashboard's\n # auto-generate button. Short, cheap call.\n \"profile_describer\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 60,\n \"extra_body\": {},\n },\n # Curator — skill-usage review fork. Timeout is generous because the\n # review pass can take several minutes on reasoning models (umbrella\n # building over hundreds of candidate skills). \"auto\" = use main chat\n # model; override via `hermes model` → auxiliary → Curator to route\n # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).\n \"curator\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600,\n \"extra_body\": {},\n },\n },\n \n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n # Recap tuning for /resume and startup resume. The defaults match the\n # historical hardcoded values; expose them as config so power users can\n # widen or tighten the snapshot to taste.\n \"resume_exchanges\": 10, # max user+assistant pairs to show\n \"resume_max_user_chars\": 300, # truncate user message text\n \"resume_max_assistant_chars\": 200, # truncate non-last assistant text\n \"resume_max_assistant_lines\": 3, # truncate non-last assistant lines\n # When True (default), assistant entries that are *only* tool calls\n # (no visible text) are skipped in the recap. This prevents the recap\n # from being dominated by `[2 tool calls: terminal, read_file]` lines\n # when an exchange was tool-heavy. Set False to restore the legacy\n # behavior of showing tool-call summaries inline.\n \"resume_skip_tool_only\": True,\n \"busy_input_mode\": \"interrupt\", # interrupt | queue | steer\n # When true, `hermes --tui` auto-resumes the most recent human-\n # facing session on launch instead of forging a fresh one.\n # Mirrors `hermes -c` muscle memory. Default off so existing\n # users aren't surprised. HERMES_TUI_RESUME= always wins.\n \"tui_auto_resume_recent\": False,\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": False,\n \"timestamps\": False, # Show [HH:MM] on user and assistant labels\n \"final_response_markdown\": \"strip\", # render | strip | raw\n # Preserve recent classic CLI output across Ctrl+L, /redraw, and\n # terminal resize full-screen clears. Disable if a terminal emulator\n # behaves badly with replayed scrollback.\n \"persistent_output\": True,\n \"persistent_output_max_lines\": 200,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n # File-mutation verifier footer. When true (default), the agent\n # appends a one-line advisory to its final response whenever a\n # write_file / patch call failed during the turn and was never\n # superseded by a successful write to the same path. This catches\n # the \"batch of parallel patches, half fail, model claims success\"\n # class of over-claim that otherwise forces users to run\n # `git status` to verify edits landed. Set false to suppress.\n \"file_mutation_verifier\": True,\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n # UI language for static user-facing messages (approval prompts, a\n # handful of gateway slash-command replies). Does NOT affect agent\n # responses, log lines, tool outputs, or slash-command descriptions.\n # Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.\n \"language\": \"en\",\n # TUI busy indicator style: kaomoji (default), emoji, unicode (braille\n # spinner), or ascii. Live-swappable via `/indicator