2026-02-20 23:23:32 -08:00
|
|
|
"""Shared constants for Hermes Agent.
|
|
|
|
|
|
|
|
|
|
Import-safe module with no dependencies — can be imported from anywhere
|
|
|
|
|
without risk of circular imports.
|
|
|
|
|
"""
|
|
|
|
|
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_hermes_home() -> Path:
|
|
|
|
|
"""Return the Hermes home directory (default: ~/.hermes).
|
|
|
|
|
|
|
|
|
|
Reads HERMES_HOME env var, falls back to ~/.hermes.
|
|
|
|
|
This is the single source of truth — all other copies should import this.
|
|
|
|
|
"""
|
2026-04-13 14:49:10 -05:00
|
|
|
val = os.environ.get("HERMES_HOME", "").strip()
|
|
|
|
|
return Path(val) if val else Path.home() / ".hermes"
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
|
|
|
|
|
|
2026-04-10 05:53:10 -07:00
|
|
|
def get_default_hermes_root() -> Path:
|
|
|
|
|
"""Return the root Hermes directory for profile-level operations.
|
|
|
|
|
|
|
|
|
|
In standard deployments this is ``~/.hermes``.
|
|
|
|
|
|
|
|
|
|
In Docker or custom deployments where ``HERMES_HOME`` points outside
|
|
|
|
|
``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly
|
|
|
|
|
— that IS the root.
|
|
|
|
|
|
|
|
|
|
In profile mode where ``HERMES_HOME`` is ``<root>/profiles/<name>``,
|
|
|
|
|
returns ``<root>`` so that ``profile list`` can see all profiles.
|
|
|
|
|
Works both for standard (``~/.hermes/profiles/coder``) and Docker
|
|
|
|
|
(``/opt/data/profiles/coder``) layouts.
|
|
|
|
|
|
|
|
|
|
Import-safe — no dependencies beyond stdlib.
|
|
|
|
|
"""
|
|
|
|
|
native_home = Path.home() / ".hermes"
|
|
|
|
|
env_home = os.environ.get("HERMES_HOME", "")
|
|
|
|
|
if not env_home:
|
|
|
|
|
return native_home
|
|
|
|
|
env_path = Path(env_home)
|
|
|
|
|
try:
|
|
|
|
|
env_path.resolve().relative_to(native_home.resolve())
|
|
|
|
|
# HERMES_HOME is under ~/.hermes (normal or profile mode)
|
|
|
|
|
return native_home
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Docker / custom deployment.
|
|
|
|
|
# Check if this is a profile path: <root>/profiles/<name>
|
|
|
|
|
# If the immediate parent dir is named "profiles", the root is
|
|
|
|
|
# the grandparent — this covers Docker profiles correctly.
|
|
|
|
|
if env_path.parent.name == "profiles":
|
|
|
|
|
return env_path.parent.parent
|
|
|
|
|
|
|
|
|
|
# Not a profile path — HERMES_HOME itself is the root
|
|
|
|
|
return env_path
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
|
|
|
|
"""Return the optional-skills directory, honoring package-manager wrappers.
|
|
|
|
|
|
|
|
|
|
Packaged installs may ship ``optional-skills`` outside the Python package
|
|
|
|
|
tree and expose it via ``HERMES_OPTIONAL_SKILLS``.
|
|
|
|
|
"""
|
|
|
|
|
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
|
|
|
|
|
if override:
|
|
|
|
|
return Path(override)
|
|
|
|
|
if default is not None:
|
|
|
|
|
return default
|
|
|
|
|
return get_hermes_home() / "optional-skills"
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 15:22:19 -07:00
|
|
|
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
|
|
|
|
"""Resolve a Hermes subdirectory with backward compatibility.
|
|
|
|
|
|
|
|
|
|
New installs get the consolidated layout (e.g. ``cache/images``).
|
|
|
|
|
Existing installs that already have the old path (e.g. ``image_cache``)
|
|
|
|
|
keep using it — no migration required.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``).
|
|
|
|
|
old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``).
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Absolute ``Path`` — old location if it exists on disk, otherwise the new one.
|
|
|
|
|
"""
|
|
|
|
|
home = get_hermes_home()
|
|
|
|
|
old_path = home / old_name
|
|
|
|
|
if old_path.exists():
|
|
|
|
|
return old_path
|
|
|
|
|
return home / new_subpath
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 23:47:21 -07:00
|
|
|
def display_hermes_home() -> str:
|
|
|
|
|
"""Return a user-friendly display string for the current HERMES_HOME.
|
|
|
|
|
|
|
|
|
|
Uses ``~/`` shorthand for readability::
|
|
|
|
|
|
|
|
|
|
default: ``~/.hermes``
|
|
|
|
|
profile: ``~/.hermes/profiles/coder``
|
|
|
|
|
custom: ``/opt/hermes-custom``
|
|
|
|
|
|
|
|
|
|
Use this in **user-facing** print/log messages instead of hardcoding
|
|
|
|
|
``~/.hermes``. For code that needs a real ``Path``, use
|
|
|
|
|
:func:`get_hermes_home` instead.
|
|
|
|
|
"""
|
|
|
|
|
home = get_hermes_home()
|
|
|
|
|
try:
|
|
|
|
|
return "~/" + str(home.relative_to(Path.home()))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return str(home)
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 13:37:45 -07:00
|
|
|
def get_subprocess_home() -> str | None:
|
|
|
|
|
"""Return a per-profile HOME directory for subprocesses, or None.
|
|
|
|
|
|
|
|
|
|
When ``{HERMES_HOME}/home/`` exists on disk, subprocesses should use it
|
|
|
|
|
as ``HOME`` so system tools (git, ssh, gh, npm …) write their configs
|
|
|
|
|
inside the Hermes data directory instead of the OS-level ``/root`` or
|
|
|
|
|
``~/``. This provides:
|
|
|
|
|
|
|
|
|
|
* **Docker persistence** — tool configs land inside the persistent volume.
|
|
|
|
|
* **Profile isolation** — each profile gets its own git identity, SSH
|
|
|
|
|
keys, gh tokens, etc.
|
|
|
|
|
|
|
|
|
|
The Python process's own ``os.environ["HOME"]`` and ``Path.home()`` are
|
|
|
|
|
**never** modified — only subprocess environments should inject this value.
|
|
|
|
|
Activation is directory-based: if the ``home/`` subdirectory doesn't
|
|
|
|
|
exist, returns ``None`` and behavior is unchanged.
|
|
|
|
|
"""
|
|
|
|
|
hermes_home = os.getenv("HERMES_HOME")
|
|
|
|
|
if not hermes_home:
|
|
|
|
|
return None
|
|
|
|
|
profile_home = os.path.join(hermes_home, "home")
|
|
|
|
|
if os.path.isdir(profile_home):
|
|
|
|
|
return profile_home
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 13:27:02 -07:00
|
|
|
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_reasoning_effort(effort: str) -> dict | None:
|
|
|
|
|
"""Parse a reasoning effort level into a config dict.
|
|
|
|
|
|
2026-04-09 11:06:39 -05:00
|
|
|
Valid levels: "none", "minimal", "low", "medium", "high", "xhigh".
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
Returns None when the input is empty or unrecognized (caller uses default).
|
|
|
|
|
Returns {"enabled": False} for "none".
|
|
|
|
|
Returns {"enabled": True, "effort": <level>} for valid effort levels.
|
|
|
|
|
"""
|
|
|
|
|
if not effort or not effort.strip():
|
|
|
|
|
return None
|
|
|
|
|
effort = effort.strip().lower()
|
|
|
|
|
if effort == "none":
|
|
|
|
|
return {"enabled": False}
|
|
|
|
|
if effort in VALID_REASONING_EFFORTS:
|
|
|
|
|
return {"enabled": True, "effort": effort}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 14:53:02 -07:00
|
|
|
def is_termux() -> bool:
|
|
|
|
|
"""Return True when running inside a Termux (Android) environment.
|
|
|
|
|
|
|
|
|
|
Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific
|
|
|
|
|
``PREFIX`` path. Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
prefix = os.getenv("PREFIX", "")
|
|
|
|
|
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 21:15:47 -07:00
|
|
|
_wsl_detected: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_wsl() -> bool:
|
|
|
|
|
"""Return True when running inside WSL (Windows Subsystem for Linux).
|
|
|
|
|
|
|
|
|
|
Checks ``/proc/version`` for the ``microsoft`` marker that both WSL1
|
|
|
|
|
and WSL2 inject. Result is cached for the process lifetime.
|
|
|
|
|
Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
global _wsl_detected
|
|
|
|
|
if _wsl_detected is not None:
|
|
|
|
|
return _wsl_detected
|
|
|
|
|
try:
|
|
|
|
|
with open("/proc/version", "r") as f:
|
|
|
|
|
_wsl_detected = "microsoft" in f.read().lower()
|
|
|
|
|
except Exception:
|
|
|
|
|
_wsl_detected = False
|
|
|
|
|
return _wsl_detected
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 14:42:46 -07:00
|
|
|
_container_detected: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_container() -> bool:
|
|
|
|
|
"""Return True when running inside a Docker/Podman container.
|
|
|
|
|
|
|
|
|
|
Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
|
|
|
|
|
and ``/proc/1/cgroup`` for container runtime markers. Result is
|
|
|
|
|
cached for the process lifetime. Import-safe — no heavy deps.
|
|
|
|
|
"""
|
|
|
|
|
global _container_detected
|
|
|
|
|
if _container_detected is not None:
|
|
|
|
|
return _container_detected
|
|
|
|
|
if os.path.exists("/.dockerenv"):
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
if os.path.exists("/run/.containerenv"):
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
try:
|
|
|
|
|
with open("/proc/1/cgroup", "r") as f:
|
|
|
|
|
cgroup = f.read()
|
|
|
|
|
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
|
|
|
|
|
_container_detected = True
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
_container_detected = False
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
# ─── Well-Known Paths ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_config_path() -> Path:
|
|
|
|
|
"""Return the path to ``config.yaml`` under HERMES_HOME.
|
|
|
|
|
|
|
|
|
|
Replaces the ``get_hermes_home() / "config.yaml"`` pattern repeated
|
|
|
|
|
in 7+ files (skill_utils.py, hermes_logging.py, hermes_time.py, etc.).
|
|
|
|
|
"""
|
|
|
|
|
return get_hermes_home() / "config.yaml"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_skills_dir() -> Path:
|
|
|
|
|
"""Return the path to the skills directory under HERMES_HOME."""
|
|
|
|
|
return get_hermes_home() / "skills"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_env_path() -> Path:
|
|
|
|
|
"""Return the path to the ``.env`` file under HERMES_HOME."""
|
|
|
|
|
return get_hermes_home() / ".env"
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 23:12:11 -07:00
|
|
|
# ─── Network Preferences ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_ipv4_preference(force: bool = False) -> None:
|
|
|
|
|
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections.
|
|
|
|
|
|
|
|
|
|
On servers with broken or unreachable IPv6, Python tries AAAA records
|
|
|
|
|
first and hangs for the full TCP timeout before falling back to IPv4.
|
|
|
|
|
This affects httpx, requests, urllib, the OpenAI SDK — everything that
|
|
|
|
|
uses ``socket.getaddrinfo``.
|
|
|
|
|
|
|
|
|
|
When *force* is True, patches ``getaddrinfo`` so that calls with
|
|
|
|
|
``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead,
|
|
|
|
|
skipping IPv6 entirely. If no A record exists, falls back to the
|
|
|
|
|
original unfiltered resolution so pure-IPv6 hosts still work.
|
|
|
|
|
|
|
|
|
|
Safe to call multiple times — only patches once.
|
|
|
|
|
Set ``network.force_ipv4: true`` in ``config.yaml`` to enable.
|
|
|
|
|
"""
|
|
|
|
|
if not force:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
import socket
|
|
|
|
|
|
|
|
|
|
# Guard against double-patching
|
|
|
|
|
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
_original_getaddrinfo = socket.getaddrinfo
|
|
|
|
|
|
|
|
|
|
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
|
|
|
|
if family == 0: # AF_UNSPEC — caller didn't request a specific family
|
|
|
|
|
try:
|
|
|
|
|
return _original_getaddrinfo(
|
|
|
|
|
host, port, socket.AF_INET, type, proto, flags
|
|
|
|
|
)
|
|
|
|
|
except socket.gaierror:
|
|
|
|
|
# No A record — fall back to full resolution (pure-IPv6 hosts)
|
|
|
|
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
|
|
|
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
|
|
|
|
|
|
|
|
|
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
|
|
|
|
|
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 23:23:32 -08:00
|
|
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
|
|
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
2026-03-08 18:40:50 +10:00
|
|
|
|
2026-03-17 00:12:16 -07:00
|
|
|
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|