mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
- Add shared is_wsl() to hermes_constants (like is_termux) - Update supports_systemd_services() to verify systemd is actually running on WSL before returning True - Add WSL-specific guidance in gateway install/start/setup/status for both cases: WSL+systemd and WSL without systemd - Improve help strings: 'run' now says recommended for WSL/Docker, 'start'/'install' now mention systemd/launchd explicitly - Add WSL gateway FAQ section with tmux/nohup/Task Scheduler tips - Update CLI commands docs with WSL tip - Deduplicate _is_wsl() from clipboard.py to shared hermes_constants - Fix clipboard tests to reset hermes_constants cache - 20 new WSL-specific tests covering detection, systemd check, supports_systemd_services integration, and command output Motivated by user feedback: took 1 hour to figure out run vs start on WSL, Telegram bot kept disconnecting due to flaky WSL systemd.
197 lines
6.6 KiB
Python
197 lines
6.6 KiB
Python
"""Shared constants for Hermes Agent.
|
|
|
|
Import-safe module with no dependencies — can be imported from anywhere
|
|
without risk of circular imports.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
def get_hermes_home() -> Path:
|
|
"""Return the Hermes home directory (default: ~/.hermes).
|
|
|
|
Reads HERMES_HOME env var, falls back to ~/.hermes.
|
|
This is the single source of truth — all other copies should import this.
|
|
"""
|
|
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
|
|
|
|
|
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
|
|
|
|
|
|
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"
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
|
|
|
|
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
|
|
|
|
|
|
def parse_reasoning_effort(effort: str) -> dict | None:
|
|
"""Parse a reasoning effort level into a config dict.
|
|
|
|
Valid levels: "none", "minimal", "low", "medium", "high", "xhigh".
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
_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
|
|
|
|
|
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
|
|
|
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
|
|
|
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
|