Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67cd15800d | |||
| 1bca6f3930 | |||
| a994cf5e5a | |||
| ff78ad4c81 | |||
| 491e79bca9 | |||
| 89d8127772 | |||
| f890a94c12 | |||
| 4d7e3c7157 | |||
| 1bd206ea5d | |||
| f8e1ee10aa | |||
| c1ef9b2250 | |||
| 3a68ec3172 | |||
| d30ea65c9b | |||
| fb4b87f4af | |||
| 5b0243e6ad | |||
| 54b876a5c9 | |||
| 83e5249be6 | |||
| fb2af3bd1d | |||
| cc63b2d1cd | |||
| 45396aaa92 | |||
| 04367e2fac | |||
| cdb64a869a | |||
| 1e59d4813c | |||
| f776191650 | |||
| 44d02f35d2 | |||
| b2e1a095f8 | |||
| ffd5d37f9b | |||
| 720507efac | |||
| 8a794d029d | |||
| e64b047663 | |||
| 11aa44d34d | |||
| 07746dca0c | |||
| 7e0c2c3ce3 | |||
| 3c8f910973 | |||
| 13f3e67165 | |||
| 4a7c17fca5 | |||
| f007284d05 | |||
| 3d47af01c3 | |||
| 275fcc6673 | |||
| ab62614a89 | |||
| de368cac54 | |||
| 0d1003559d | |||
| eba8d52d54 | |||
| 72104eb06f | |||
| 4b35836ba4 | |||
| bd376fe976 | |||
| f93637b3a1 | |||
| 7b4fe0528f | |||
| 950f69475f | |||
| 7dac75f2ae | |||
| ed9af6e589 | |||
| 158f49f19a | |||
| 86250a3e45 | |||
| ea342f2382 | |||
| 60ecde8ac7 | |||
| f3069c649c | |||
| 0976bf6cd0 | |||
| da3e22bcfa | |||
| 9fd78c7a8e | |||
| 5ceed021dc |
@@ -231,6 +231,21 @@ VOICE_TOOLS_OPENAI_KEY=
|
||||
# Slack allowed users (comma-separated Slack user IDs)
|
||||
# SLACK_ALLOWED_USERS=
|
||||
|
||||
# =============================================================================
|
||||
# TELEGRAM INTEGRATION
|
||||
# =============================================================================
|
||||
# Telegram Bot Token - From @BotFather (https://t.me/BotFather)
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_ALLOWED_USERS= # Comma-separated user IDs
|
||||
# TELEGRAM_HOME_CHANNEL= # Default chat for cron delivery
|
||||
# TELEGRAM_HOME_CHANNEL_NAME= # Display name for home channel
|
||||
|
||||
# Webhook mode (optional — for cloud deployments like Fly.io/Railway)
|
||||
# Default is long polling. Setting TELEGRAM_WEBHOOK_URL switches to webhook mode.
|
||||
# TELEGRAM_WEBHOOK_URL=https://my-app.fly.dev/telegram
|
||||
# TELEGRAM_WEBHOOK_PORT=8443
|
||||
# TELEGRAM_WEBHOOK_SECRET= # Recommended for production
|
||||
|
||||
# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair)
|
||||
# WHATSAPP_ENABLED=false
|
||||
# WHATSAPP_ALLOWED_USERS=15551234567
|
||||
|
||||
@@ -19,6 +19,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
|
||||
@@ -12,6 +12,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -162,6 +162,36 @@ def _is_oauth_token(key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for non-Anthropic endpoints using the Anthropic Messages API.
|
||||
|
||||
Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate
|
||||
with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth
|
||||
detection should be skipped for these endpoints.
|
||||
"""
|
||||
if not base_url:
|
||||
return False # No base_url = direct Anthropic API
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
if "anthropic.com" in normalized:
|
||||
return False # Direct Anthropic API — OAuth applies
|
||||
return True # Any other endpoint is a third-party proxy
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints follow this pattern.
|
||||
"""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith(
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
)
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
@@ -180,7 +210,25 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
|
||||
if _is_oauth_token(api_key):
|
||||
if _requires_bearer_auth(base_url):
|
||||
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
|
||||
# Authorization: Bearer even for regular API keys. Route those endpoints
|
||||
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
|
||||
# Check this before OAuth token shape detection because MiniMax secrets do
|
||||
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
|
||||
# Anthropic OAuth/setup tokens.
|
||||
kwargs["auth_token"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
elif _is_third_party_anthropic_endpoint(base_url):
|
||||
# Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their
|
||||
# own API keys with x-api-key auth. Skip OAuth detection — their keys
|
||||
# don't follow Anthropic's sk-ant-* prefix convention and would be
|
||||
# misclassified as OAuth tokens.
|
||||
kwargs["api_key"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
elif _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
@@ -313,7 +361,14 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
|
||||
if new_access:
|
||||
new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||
_write_claude_code_credentials(new_access, new_refresh, new_expires_ms)
|
||||
# Parse scopes from refresh response — Claude Code >=2.1.81
|
||||
# requires a "scopes" field in the credential store and checks
|
||||
# for "user:inference" before accepting the token as valid.
|
||||
scope_str = result.get("scope", "")
|
||||
scopes = scope_str.split() if scope_str else None
|
||||
_write_claude_code_credentials(
|
||||
new_access, new_refresh, new_expires_ms, scopes=scopes,
|
||||
)
|
||||
logger.debug("Refreshed Claude Code OAuth token via %s", endpoint)
|
||||
return new_access
|
||||
except Exception as e:
|
||||
@@ -322,8 +377,20 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _write_claude_code_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
|
||||
"""Write refreshed credentials back to ~/.claude/.credentials.json."""
|
||||
def _write_claude_code_credentials(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
expires_at_ms: int,
|
||||
*,
|
||||
scopes: Optional[list] = None,
|
||||
) -> None:
|
||||
"""Write refreshed credentials back to ~/.claude/.credentials.json.
|
||||
|
||||
The optional *scopes* list (e.g. ``["user:inference", "user:profile", ...]``)
|
||||
is persisted so that Claude Code's own auth check recognises the credential
|
||||
as valid. Claude Code >=2.1.81 gates on the presence of ``"user:inference"``
|
||||
in the stored scopes before it will use the token.
|
||||
"""
|
||||
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||
try:
|
||||
# Read existing file to preserve other fields
|
||||
@@ -331,11 +398,19 @@ def _write_claude_code_credentials(access_token: str, refresh_token: str, expire
|
||||
if cred_path.exists():
|
||||
existing = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||
|
||||
existing["claudeAiOauth"] = {
|
||||
oauth_data: Dict[str, Any] = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
if scopes is not None:
|
||||
oauth_data["scopes"] = scopes
|
||||
elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
|
||||
# Preserve previously-stored scopes when the refresh response
|
||||
# does not include a scope field.
|
||||
oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
|
||||
|
||||
existing["claudeAiOauth"] = oauth_data
|
||||
|
||||
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cred_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
||||
|
||||
@@ -7,7 +7,7 @@ the best available backend without duplicating fallback logic.
|
||||
Resolution order for text tasks (auto mode):
|
||||
1. OpenRouter (OPENROUTER_API_KEY)
|
||||
2. Nous Portal (~/.hermes/auth.json active provider)
|
||||
3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY)
|
||||
3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
|
||||
4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
|
||||
wrapped to look like a chat.completions client)
|
||||
5. Native Anthropic
|
||||
@@ -584,15 +584,11 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config/env.
|
||||
"""Read the user's configured main model from config.yaml.
|
||||
|
||||
Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default
|
||||
so the auxiliary client can use the same model as the main agent when no
|
||||
dedicated auxiliary model is available.
|
||||
config.yaml model.default is the single source of truth for the active
|
||||
model. Environment variables are no longer consulted.
|
||||
"""
|
||||
from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
|
||||
if from_env:
|
||||
return from_env.strip()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
|
||||
@@ -176,6 +176,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
"models.github.ai": "copilot",
|
||||
"api.fireworks.ai": "fireworks",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"opencode-zen": "opencode",
|
||||
"opencode-go": "opencode-go",
|
||||
"kilocode": "kilo",
|
||||
"fireworks": "fireworks-ai",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1124,9 +1124,9 @@ class HermesCLI:
|
||||
self.acp_args: list[str] = []
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.getenv("OPENAI_BASE_URL")
|
||||
or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"])
|
||||
)
|
||||
or CLI_CONFIG["model"].get("base_url", "")
|
||||
or os.getenv("OPENROUTER_BASE_URL", "")
|
||||
) or None
|
||||
# Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY,
|
||||
# custom endpoint → prefer OPENAI_API_KEY (issue #560).
|
||||
# Note: _ensure_runtime_credentials() re-resolves this before first use.
|
||||
@@ -2837,6 +2837,28 @@ class HermesCLI:
|
||||
print(" Example: python cli.py --toolsets web,terminal")
|
||||
print()
|
||||
|
||||
def _handle_profile_command(self):
|
||||
"""Display active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
print()
|
||||
if profile_name:
|
||||
print(f" Profile: {profile_name}")
|
||||
else:
|
||||
print(" Profile: default")
|
||||
print(f" Home: {display}")
|
||||
print()
|
||||
|
||||
def show_config(self):
|
||||
"""Display current configuration with kawaii ASCII art."""
|
||||
# Get terminal config from environment (which was set from cli-config.yaml)
|
||||
@@ -3217,7 +3239,7 @@ class HermesCLI:
|
||||
print(f" {mid}{current_marker}")
|
||||
elif p["id"] == "custom":
|
||||
from hermes_cli.models import _get_custom_base_url
|
||||
custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
|
||||
custom_url = _get_custom_base_url()
|
||||
if custom_url:
|
||||
print(f" endpoint: {custom_url}")
|
||||
if is_active:
|
||||
@@ -3679,6 +3701,8 @@ class HermesCLI:
|
||||
return False
|
||||
elif canonical == "help":
|
||||
self.show_help()
|
||||
elif canonical == "profile":
|
||||
self._handle_profile_command()
|
||||
elif canonical == "tools":
|
||||
self._handle_tools_command(cmd_original)
|
||||
elif canonical == "toolsets":
|
||||
@@ -3836,6 +3860,8 @@ class HermesCLI:
|
||||
self.console.print(f" Status bar {state}")
|
||||
elif canonical == "verbose":
|
||||
self._toggle_verbose()
|
||||
elif canonical == "yolo":
|
||||
self._toggle_yolo()
|
||||
elif canonical == "reasoning":
|
||||
self._handle_reasoning_command(cmd_original)
|
||||
elif canonical == "compress":
|
||||
@@ -3878,6 +3904,8 @@ class HermesCLI:
|
||||
self._handle_stop_command()
|
||||
elif canonical == "background":
|
||||
self._handle_background_command(cmd_original)
|
||||
elif canonical == "btw":
|
||||
self._handle_btw_command(cmd_original)
|
||||
elif canonical == "queue":
|
||||
# Extract prompt after "/queue " or "/q "
|
||||
parts = cmd_original.split(None, 1)
|
||||
@@ -4164,6 +4192,121 @@ class HermesCLI:
|
||||
self._background_tasks[task_id] = thread
|
||||
thread.start()
|
||||
|
||||
def _handle_btw_command(self, cmd: str):
|
||||
"""Handle /btw <question> — ephemeral side question using session context.
|
||||
|
||||
Snapshots the current conversation history, spawns a no-tools agent in
|
||||
a background thread, and prints the answer without persisting anything
|
||||
to the main session.
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
_cprint(" Usage: /btw <question>")
|
||||
_cprint(" Example: /btw what module owns session title sanitization?")
|
||||
_cprint(" Answers using session context. No tools, not persisted.")
|
||||
return
|
||||
|
||||
question = parts[1].strip()
|
||||
task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
_cprint(" (>_<) Cannot start /btw: no valid credentials.")
|
||||
return
|
||||
|
||||
turn_route = self._resolve_turn_agent_config(question)
|
||||
history_snapshot = list(self.conversation_history)
|
||||
|
||||
preview = question[:60] + ("..." if len(question) > 60 else "")
|
||||
_cprint(f' 💬 /btw: "{preview}"')
|
||||
|
||||
def run_btw():
|
||||
try:
|
||||
btw_agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
api_key=turn_route["runtime"].get("api_key"),
|
||||
base_url=turn_route["runtime"].get("base_url"),
|
||||
provider=turn_route["runtime"].get("provider"),
|
||||
api_mode=turn_route["runtime"].get("api_mode"),
|
||||
acp_command=turn_route["runtime"].get("command"),
|
||||
acp_args=turn_route["runtime"].get("args"),
|
||||
max_iterations=8,
|
||||
enabled_toolsets=[],
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
session_id=task_id,
|
||||
platform="cli",
|
||||
reasoning_config=self.reasoning_config,
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
fallback_model=self._fallback_model,
|
||||
session_db=None,
|
||||
skip_memory=True,
|
||||
skip_context_files=True,
|
||||
persist_session=False,
|
||||
)
|
||||
|
||||
btw_prompt = (
|
||||
"[Ephemeral /btw side question. Answer using the conversation "
|
||||
"context. No tools available. Be direct and concise.]\n\n"
|
||||
+ question
|
||||
)
|
||||
result = btw_agent.run_conversation(
|
||||
user_message=btw_prompt,
|
||||
conversation_history=history_snapshot,
|
||||
task_id=task_id,
|
||||
sync_honcho=False,
|
||||
)
|
||||
|
||||
response = (result.get("final_response") or "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
|
||||
# TUI refresh before printing
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
time.sleep(0.05)
|
||||
print()
|
||||
|
||||
if response:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
_resp_color = _skin.get_color("response_border", "#4F6D4A")
|
||||
except Exception:
|
||||
_resp_color = "#4F6D4A"
|
||||
|
||||
ChatConsole().print(Panel(
|
||||
_rich_text_from_ansi(response),
|
||||
title=f"[{_resp_color} bold]⚕ /btw[/]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
))
|
||||
else:
|
||||
_cprint(" 💬 /btw: (no response)")
|
||||
|
||||
if self.bell_on_complete:
|
||||
sys.stdout.write("\a")
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
time.sleep(0.05)
|
||||
print()
|
||||
_cprint(f" ❌ /btw failed: {e}")
|
||||
finally:
|
||||
if self._app:
|
||||
self._invalidate(min_interval=0)
|
||||
|
||||
thread = threading.Thread(target=run_btw, daemon=True, name=f"btw-{task_id}")
|
||||
thread.start()
|
||||
|
||||
@staticmethod
|
||||
def _try_launch_chrome_debug(port: int, system: str) -> bool:
|
||||
"""Try to launch Chrome/Chromium with remote debugging enabled.
|
||||
@@ -4434,6 +4577,17 @@ class HermesCLI:
|
||||
}
|
||||
_cprint(labels.get(self.tool_progress_mode, ""))
|
||||
|
||||
def _toggle_yolo(self):
|
||||
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
|
||||
import os
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.")
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.")
|
||||
|
||||
def _handle_reasoning_command(self, cmd: str):
|
||||
"""Handle /reasoning — manage effort level and display toggle.
|
||||
|
||||
@@ -5560,6 +5714,8 @@ class HermesCLI:
|
||||
self.agent = None
|
||||
|
||||
# Initialize agent if needed
|
||||
if self.agent is None:
|
||||
_cprint(f"{_DIM}Initializing agent...{_RST}")
|
||||
if not self._init_agent(
|
||||
model_override=turn_route["model"],
|
||||
runtime_override=turn_route["runtime"],
|
||||
@@ -7408,6 +7564,20 @@ class HermesCLI:
|
||||
# Register atexit cleanup so resources are freed even on unexpected exit
|
||||
atexit.register(_run_cleanup)
|
||||
|
||||
# Register signal handlers for graceful shutdown on SSH disconnect / SIGTERM
|
||||
def _signal_handler(signum, frame):
|
||||
"""Handle SIGHUP/SIGTERM by triggering graceful cleanup."""
|
||||
logger.debug("Received signal %s, triggering graceful shutdown", signum)
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
try:
|
||||
import signal as _signal
|
||||
_signal.signal(_signal.SIGTERM, _signal_handler)
|
||||
if hasattr(_signal, 'SIGHUP'):
|
||||
_signal.signal(_signal.SIGHUP, _signal_handler)
|
||||
except Exception:
|
||||
pass # Signal handlers may fail in restricted environments
|
||||
|
||||
# Install a custom asyncio exception handler that suppresses the
|
||||
# "Event loop is closed" RuntimeError from httpx transport cleanup.
|
||||
# This is defense-in-depth — the primary fix is neuter_async_httpx_del
|
||||
@@ -7431,7 +7601,7 @@ class HermesCLI:
|
||||
except Exception:
|
||||
pass
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
finally:
|
||||
self._should_exit = True
|
||||
@@ -7470,6 +7640,23 @@ class HermesCLI:
|
||||
self._session_db.end_session(self.agent.session_id, "cli_close")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not close session in DB: %s", e)
|
||||
# Plugin hook: on_session_end — safety net for interrupted exits.
|
||||
# run_conversation() already fires this per-turn on normal completion,
|
||||
# so only fire here if the agent was mid-turn (_agent_running) when
|
||||
# the exit occurred, meaning run_conversation's hook didn't fire.
|
||||
if self.agent and getattr(self, '_agent_running', False):
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=self.agent.session_id,
|
||||
completed=False,
|
||||
interrupted=True,
|
||||
model=getattr(self.agent, 'model', None),
|
||||
platform=getattr(self.agent, 'platform', None) or "cli",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_run_cleanup()
|
||||
self._print_exit_summary()
|
||||
|
||||
|
||||
+11
-2
@@ -27,9 +27,16 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
@@ -543,6 +550,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||
|
||||
# Telegram settings → env vars (env vars take precedence)
|
||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||
|
||||
@@ -380,6 +380,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
stream_delta_callback=None,
|
||||
tool_progress_callback=None,
|
||||
) -> Any:
|
||||
"""
|
||||
Create an AIAgent instance using the gateway's runtime config.
|
||||
@@ -412,6 +413,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
session_id=session_id,
|
||||
platform="api_server",
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
)
|
||||
return agent
|
||||
|
||||
@@ -514,6 +516,15 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if delta is not None:
|
||||
_stream_q.put(delta)
|
||||
|
||||
def _on_tool_progress(name, preview, args):
|
||||
"""Inject tool progress into the SSE stream for Open WebUI."""
|
||||
if name.startswith("_"):
|
||||
return # Skip internal events (_thinking)
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(name)
|
||||
label = preview or name
|
||||
_stream_q.put(f"\n`{emoji} {label}`\n")
|
||||
|
||||
# Start agent in background. agent_ref is a mutable container
|
||||
# so the SSE writer can interrupt the agent on client disconnect.
|
||||
agent_ref = [None]
|
||||
@@ -523,6 +534,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt=system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=_on_delta,
|
||||
tool_progress_callback=_on_tool_progress,
|
||||
agent_ref=agent_ref,
|
||||
))
|
||||
|
||||
@@ -1194,6 +1206,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
stream_delta_callback=None,
|
||||
tool_progress_callback=None,
|
||||
agent_ref: Optional[list] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
@@ -1214,6 +1227,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
|
||||
@@ -683,14 +683,22 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e)
|
||||
return False
|
||||
|
||||
def _reactions_enabled(self) -> bool:
|
||||
"""Check if message reactions are enabled via config/env."""
|
||||
return os.getenv("DISCORD_REACTIONS", "true").lower() not in ("false", "0", "no")
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Add an in-progress reaction for normal Discord message events."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._add_reaction(message, "👀")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._remove_reaction(message, "👀")
|
||||
|
||||
+164
-4
@@ -49,6 +49,14 @@ _STORE_DIR = _get_hermes_dir("platforms/matrix/store", "matrix/store")
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
# E2EE key export file for persistence across restarts.
|
||||
_KEY_EXPORT_FILE = _STORE_DIR / "exported_keys.txt"
|
||||
_KEY_EXPORT_PASSPHRASE = "hermes-matrix-e2ee-keys"
|
||||
|
||||
# Pending undecrypted events: cap and TTL for retry buffer.
|
||||
_MAX_PENDING_EVENTS = 100
|
||||
_PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min
|
||||
|
||||
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used."""
|
||||
@@ -111,6 +119,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._processed_events: deque = deque(maxlen=1000)
|
||||
self._processed_events_set: set = set()
|
||||
|
||||
# Buffer for undecrypted events pending key receipt.
|
||||
# Each entry: (room, event, timestamp)
|
||||
self._pending_megolm: list = []
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -232,6 +244,16 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.info("Matrix: E2EE crypto initialized")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: crypto init issue: %s", exc)
|
||||
|
||||
# Import previously exported Megolm keys (survives restarts).
|
||||
if _KEY_EXPORT_FILE.exists():
|
||||
try:
|
||||
await client.import_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: imported Megolm keys from backup")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not import keys: %s", exc)
|
||||
elif self._encryption:
|
||||
logger.warning(
|
||||
"Matrix: E2EE requested but crypto store is not loaded; "
|
||||
@@ -286,6 +308,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# Export Megolm keys before closing so the next restart can decrypt
|
||||
# events that used sessions from this run.
|
||||
if self._client and self._encryption and getattr(self._client, "olm", None):
|
||||
try:
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
await self._client.export_keys(
|
||||
str(_KEY_EXPORT_FILE), _KEY_EXPORT_PASSPHRASE,
|
||||
)
|
||||
logger.info("Matrix: exported Megolm keys for next restart")
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: could not export keys on disconnect: %s", exc)
|
||||
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
@@ -665,17 +699,22 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
Hermes uses a custom sync loop instead of matrix-nio's sync_forever(),
|
||||
so we need to explicitly drive the key management work that sync_forever()
|
||||
normally handles for encrypted rooms.
|
||||
|
||||
Also auto-trusts all devices (so senders share session keys with us)
|
||||
and retries decryption for any buffered MegolmEvents.
|
||||
"""
|
||||
client = self._client
|
||||
if not client or not self._encryption or not getattr(client, "olm", None):
|
||||
return
|
||||
|
||||
did_query_keys = client.should_query_keys
|
||||
|
||||
tasks = [asyncio.create_task(client.send_to_device_messages())]
|
||||
|
||||
if client.should_upload_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_upload()))
|
||||
|
||||
if client.should_query_keys:
|
||||
if did_query_keys:
|
||||
tasks.append(asyncio.create_task(client.keys_query()))
|
||||
|
||||
if client.should_claim_keys:
|
||||
@@ -691,6 +730,111 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: E2EE maintenance task failed: %s", exc)
|
||||
|
||||
# After key queries, auto-trust all devices so senders share keys with
|
||||
# us. For a bot this is the right default — we want to decrypt
|
||||
# everything, not enforce manual verification.
|
||||
if did_query_keys:
|
||||
self._auto_trust_devices()
|
||||
|
||||
# Retry any buffered undecrypted events now that new keys may have
|
||||
# arrived (from key requests, key queries, or to-device forwarding).
|
||||
if self._pending_megolm:
|
||||
await self._retry_pending_decryptions()
|
||||
|
||||
def _auto_trust_devices(self) -> None:
|
||||
"""Trust/verify all unverified devices we know about.
|
||||
|
||||
When other clients see our device as verified, they proactively share
|
||||
Megolm session keys with us. Without this, many clients will refuse
|
||||
to include an unverified device in key distributions.
|
||||
"""
|
||||
client = self._client
|
||||
if not client:
|
||||
return
|
||||
|
||||
device_store = getattr(client, "device_store", None)
|
||||
if not device_store:
|
||||
return
|
||||
|
||||
own_device = getattr(client, "device_id", None)
|
||||
trusted_count = 0
|
||||
|
||||
try:
|
||||
# DeviceStore.__iter__ yields OlmDevice objects directly.
|
||||
for device in device_store:
|
||||
if getattr(device, "device_id", None) == own_device:
|
||||
continue
|
||||
if not getattr(device, "verified", False):
|
||||
client.verify_device(device)
|
||||
trusted_count += 1
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: auto-trust error: %s", exc)
|
||||
|
||||
if trusted_count:
|
||||
logger.info("Matrix: auto-trusted %d new device(s)", trusted_count)
|
||||
|
||||
async def _retry_pending_decryptions(self) -> None:
|
||||
"""Retry decrypting buffered MegolmEvents after new keys arrive."""
|
||||
import nio
|
||||
|
||||
client = self._client
|
||||
if not client or not self._pending_megolm:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
still_pending: list = []
|
||||
|
||||
for room, event, ts in self._pending_megolm:
|
||||
# Drop events that have aged past the TTL.
|
||||
if now - ts > _PENDING_EVENT_TTL:
|
||||
logger.debug(
|
||||
"Matrix: dropping expired pending event %s (age %.0fs)",
|
||||
getattr(event, "event_id", "?"), now - ts,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted = client.decrypt_event(event)
|
||||
except Exception:
|
||||
# Still missing the key — keep in buffer.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
if isinstance(decrypted, nio.MegolmEvent):
|
||||
# decrypt_event returned the same undecryptable event.
|
||||
still_pending.append((room, event, ts))
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Matrix: decrypted buffered event %s (%s)",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
|
||||
# Route to the appropriate handler based on decrypted type.
|
||||
try:
|
||||
if isinstance(decrypted, nio.RoomMessageText):
|
||||
await self._on_room_message(room, decrypted)
|
||||
elif isinstance(
|
||||
decrypted,
|
||||
(nio.RoomMessageImage, nio.RoomMessageAudio,
|
||||
nio.RoomMessageVideo, nio.RoomMessageFile),
|
||||
):
|
||||
await self._on_room_message_media(room, decrypted)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matrix: decrypted event %s has unhandled type %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
type(decrypted).__name__,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: error processing decrypted event %s: %s",
|
||||
getattr(event, "event_id", "?"), exc,
|
||||
)
|
||||
|
||||
self._pending_megolm = still_pending
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
@@ -712,13 +856,29 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:
|
||||
return
|
||||
|
||||
# Handle decrypted MegolmEvents — extract the inner event.
|
||||
# Handle undecryptable MegolmEvents: request the missing session key
|
||||
# and buffer the event for retry once the key arrives.
|
||||
if isinstance(event, nio.MegolmEvent):
|
||||
# Failed to decrypt.
|
||||
logger.warning(
|
||||
"Matrix: could not decrypt event %s in %s",
|
||||
"Matrix: could not decrypt event %s in %s — requesting key",
|
||||
event.event_id, room.room_id,
|
||||
)
|
||||
|
||||
# Ask other devices in the room to forward the session key.
|
||||
try:
|
||||
resp = await self._client.request_room_key(event)
|
||||
if hasattr(resp, "event_id") or not isinstance(resp, Exception):
|
||||
logger.debug(
|
||||
"Matrix: room key request sent for session %s",
|
||||
getattr(event, "session_id", "?"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: room key request failed: %s", exc)
|
||||
|
||||
# Buffer for retry on next maintenance cycle.
|
||||
self._pending_megolm.append((room, event, time.time()))
|
||||
if len(self._pending_megolm) > _MAX_PENDING_EVENTS:
|
||||
self._pending_megolm = self._pending_megolm[-_MAX_PENDING_EVENTS:]
|
||||
return
|
||||
|
||||
# Skip edits (m.replace relation).
|
||||
|
||||
@@ -622,10 +622,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# gateway command there automatically adds it to the Telegram menu.
|
||||
try:
|
||||
from telegram import BotCommand
|
||||
from hermes_cli.commands import telegram_bot_commands
|
||||
from hermes_cli.commands import telegram_menu_commands
|
||||
# Telegram allows up to 100 commands but has an undocumented
|
||||
# payload size limit. Skill descriptions are truncated to 40
|
||||
# chars in telegram_menu_commands() to fit 100 commands safely.
|
||||
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
|
||||
await self._bot.set_my_commands([
|
||||
BotCommand(name, desc) for name, desc in telegram_bot_commands()
|
||||
BotCommand(name, desc) for name, desc in menu_commands
|
||||
])
|
||||
if hidden_count:
|
||||
logger.info(
|
||||
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
|
||||
self.name, len(menu_commands), hidden_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Could not register Telegram command menu: %s",
|
||||
|
||||
@@ -135,6 +135,9 @@ def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:
|
||||
if addr.version != 4:
|
||||
logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw)
|
||||
continue
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified:
|
||||
logger.warning("Ignoring private/internal Telegram fallback IP: %s", raw)
|
||||
continue
|
||||
normalized.append(str(addr))
|
||||
return normalized
|
||||
|
||||
|
||||
+390
-80
@@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
"""Check if a command matches a known-but-inactive skill.
|
||||
|
||||
Returns a helpful message if the skill exists but is disabled or only
|
||||
available as an optional install. Returns None if no match found.
|
||||
"""
|
||||
# Normalize: command uses hyphens, skill names may use hyphens or underscores
|
||||
normalized = command_name.lower().replace("_", "-")
|
||||
try:
|
||||
from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names
|
||||
disabled = _get_disabled_skill_names()
|
||||
|
||||
# Check disabled built-in skills
|
||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
continue
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized and name in disabled:
|
||||
return (
|
||||
f"The **{command_name}** skill is installed but disabled.\n"
|
||||
f"Enable it with: `hermes skills config`"
|
||||
)
|
||||
|
||||
# Check optional skills (shipped with repo but not installed)
|
||||
from hermes_constants import get_hermes_home, get_optional_skills_dir
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
|
||||
if optional_dir.exists():
|
||||
for skill_md in optional_dir.rglob("SKILL.md"):
|
||||
name = skill_md.parent.name.lower().replace("_", "-")
|
||||
if name == normalized:
|
||||
# Build install path: official/<category>/<name>
|
||||
rel = skill_md.parent.relative_to(optional_dir)
|
||||
parts = list(rel.parts)
|
||||
install_path = f"official/{'/'.join(parts)}"
|
||||
return (
|
||||
f"The **{command_name}** skill is available but not installed.\n"
|
||||
f"Install it with: `hermes skills install {install_path}`"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _platform_config_key(platform: "Platform") -> str:
|
||||
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
|
||||
return "cli" if platform == Platform.LOCAL else platform.value
|
||||
@@ -320,20 +364,19 @@ def _load_gateway_config() -> dict:
|
||||
|
||||
|
||||
def _resolve_gateway_model(config: dict | None = None) -> str:
|
||||
"""Read model from env/config — mirrors the resolution in _run_agent_sync.
|
||||
"""Read model from config.yaml — single source of truth.
|
||||
|
||||
Without this, temporary AIAgent instances (memory flush, /compress) fall
|
||||
back to the hardcoded default which fails when the active provider is
|
||||
openai-codex.
|
||||
"""
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or ""
|
||||
cfg = config if config is not None else _load_gateway_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
model = model_cfg
|
||||
return model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
model = model_cfg.get("default") or model_cfg.get("model") or model
|
||||
return model
|
||||
return model_cfg.get("default") or model_cfg.get("model") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_hermes_bin() -> Optional[list[str]]:
|
||||
@@ -432,6 +475,8 @@ class GatewayRunner:
|
||||
self._honcho_managers: Dict[str, Any] = {}
|
||||
self._honcho_configs: Dict[str, Any] = {}
|
||||
|
||||
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
@@ -1651,6 +1696,11 @@ class GatewayRunner:
|
||||
# In DMs: offer pairing code. In groups: silently ignore.
|
||||
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
||||
platform_name = source.platform.value if source.platform else "unknown"
|
||||
# Rate-limit ALL pairing responses (code or rejection) to
|
||||
# prevent spamming the user with repeated messages when
|
||||
# multiple DMs arrive in quick succession.
|
||||
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
|
||||
return None
|
||||
code = self.pairing_store.generate_code(
|
||||
platform_name, source.user_id, source.user_name or ""
|
||||
)
|
||||
@@ -1672,6 +1722,8 @@ class GatewayRunner:
|
||||
"Too many pairing requests right now~ "
|
||||
"Please try again later!"
|
||||
)
|
||||
# Record rate limit so subsequent messages are silently ignored
|
||||
self.pairing_store._record_rate_limit(platform_name, source.user_id)
|
||||
return None
|
||||
|
||||
# PRIORITY handling when an agent is already running for this session.
|
||||
@@ -1817,7 +1869,13 @@ class GatewayRunner:
|
||||
|
||||
if canonical == "help":
|
||||
return await self._handle_help_command(event)
|
||||
|
||||
if canonical == "commands":
|
||||
return await self._handle_commands_command(event)
|
||||
|
||||
if canonical == "profile":
|
||||
return await self._handle_profile_command(event)
|
||||
|
||||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
@@ -1830,6 +1888,9 @@ class GatewayRunner:
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
if canonical == "yolo":
|
||||
return await self._handle_yolo_command(event)
|
||||
|
||||
if canonical == "provider":
|
||||
return await self._handle_provider_command(event)
|
||||
|
||||
@@ -1900,6 +1961,9 @@ class GatewayRunner:
|
||||
if canonical == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
if canonical == "btw":
|
||||
return await self._handle_btw_command(event)
|
||||
|
||||
if canonical == "voice":
|
||||
return await self._handle_voice_command(event)
|
||||
|
||||
@@ -1974,6 +2038,12 @@ class GatewayRunner:
|
||||
if msg:
|
||||
event.text = msg
|
||||
# Fall through to normal message processing with skill content
|
||||
else:
|
||||
# Not an active skill — check if it's a known-but-disabled or
|
||||
# uninstalled skill and give actionable guidance.
|
||||
_unavail_msg = _check_unavailable_skill(command)
|
||||
if _unavail_msg:
|
||||
return _unavail_msg
|
||||
except Exception as e:
|
||||
logger.debug("Skill command check failed (non-fatal): %s", e)
|
||||
|
||||
@@ -2211,6 +2281,29 @@ class GatewayRunner:
|
||||
_hyg_api_key = _hyg_runtime.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check custom_providers per-model context_length
|
||||
# (same fallback as run_agent.py lines 1171-1189).
|
||||
# Must run after runtime resolution so _hyg_base_url is set.
|
||||
if _hyg_config_context_length is None and _hyg_base_url:
|
||||
try:
|
||||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||||
if isinstance(_hyg_custom_providers, list):
|
||||
for _cp in _hyg_custom_providers:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_url = (_cp.get("base_url") or "").rstrip("/")
|
||||
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
|
||||
_cp_models = _cp.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
_cp_model_cfg = _cp_models.get(_hyg_model, {})
|
||||
if isinstance(_cp_model_cfg, dict):
|
||||
_cp_ctx = _cp_model_cfg.get("context_length")
|
||||
if _cp_ctx is not None:
|
||||
_hyg_config_context_length = int(_cp_ctx)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2258,18 +2351,7 @@ class GatewayRunner:
|
||||
f"{_compress_token_threshold:,}",
|
||||
)
|
||||
|
||||
_hyg_adapter = self.adapters.get(source.platform)
|
||||
_hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
f"🗜️ Session is large ({_msg_count} messages, "
|
||||
f"~{_approx_tokens:,} tokens). Auto-compressing...",
|
||||
metadata=_hyg_meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
@@ -2330,62 +2412,17 @@ class GatewayRunner:
|
||||
f"{_approx_tokens:,}", f"{_new_tokens:,}",
|
||||
)
|
||||
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
f"🗜️ Compressed: {_msg_count} → "
|
||||
f"{_new_count} messages, "
|
||||
f"~{_approx_tokens:,} → "
|
||||
f"~{_new_tokens:,} tokens",
|
||||
metadata=_hyg_meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Still too large after compression — warn user
|
||||
if _new_tokens >= _warn_token_threshold:
|
||||
logger.warning(
|
||||
"Session hygiene: still ~%s tokens after "
|
||||
"compression — suggesting /reset",
|
||||
"compression",
|
||||
f"{_new_tokens:,}",
|
||||
)
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
"⚠️ Session is still very large "
|
||||
"after compression "
|
||||
f"(~{_new_tokens:,} tokens). "
|
||||
"Consider using /reset to start "
|
||||
"fresh if you experience issues.",
|
||||
metadata=_hyg_meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Session hygiene auto-compress failed: %s", e
|
||||
)
|
||||
# Compression failed and session is dangerously large
|
||||
if _approx_tokens >= _warn_token_threshold:
|
||||
_hyg_adapter = self.adapters.get(source.platform)
|
||||
_hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
if _hyg_adapter:
|
||||
try:
|
||||
await _hyg_adapter.send(
|
||||
source.chat_id,
|
||||
f"⚠️ Session is very large "
|
||||
f"({_msg_count} messages, "
|
||||
f"~{_approx_tokens:,} tokens) and "
|
||||
"auto-compression failed. Consider "
|
||||
"using /compress or /reset to avoid "
|
||||
"issues.",
|
||||
metadata=_hyg_meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First-message onboarding -- only on the very first interaction ever
|
||||
if not history and not self.session_store.has_any_sessions():
|
||||
@@ -2724,7 +2761,7 @@ class GatewayRunner:
|
||||
{
|
||||
"role": "session_meta",
|
||||
"tools": tool_defs or [],
|
||||
"model": os.getenv("HERMES_MODEL", ""),
|
||||
"model": _resolve_gateway_model(),
|
||||
"platform": source.platform.value if source.platform else "",
|
||||
"timestamp": ts,
|
||||
}
|
||||
@@ -2999,6 +3036,36 @@ class GatewayRunner:
|
||||
return f"{header}\n\n{session_info}"
|
||||
return header
|
||||
|
||||
async def _handle_profile_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /profile — show active profile name and home directory."""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
from pathlib import Path
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
# Detect profile name from HERMES_HOME path
|
||||
# Profile paths look like: ~/.hermes/profiles/<name>
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
if profile_name:
|
||||
lines = [
|
||||
f"👤 **Profile:** `{profile_name}`",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
"👤 **Profile:** default",
|
||||
f"📂 **Home:** `{display}`",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
source = event.source
|
||||
@@ -3065,12 +3132,69 @@ class GatewayRunner:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):")
|
||||
for cmd in sorted(skill_cmds):
|
||||
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):")
|
||||
# Show first 10, then point to /commands for the rest
|
||||
sorted_cmds = sorted(skill_cmds)
|
||||
for cmd in sorted_cmds[:10]:
|
||||
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
|
||||
if len(sorted_cmds) > 10:
|
||||
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_commands_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /commands [page] - paginated list of all commands and skills."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
raw_args = event.get_command_args().strip()
|
||||
if raw_args:
|
||||
try:
|
||||
requested_page = int(raw_args)
|
||||
except ValueError:
|
||||
return "Usage: `/commands [page]`"
|
||||
else:
|
||||
requested_page = 1
|
||||
|
||||
# Build combined entry list: built-in commands + skill commands
|
||||
entries = list(gateway_help_lines())
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
skill_cmds = get_skill_commands()
|
||||
if skill_cmds:
|
||||
entries.append("")
|
||||
entries.append("⚡ **Skill Commands**:")
|
||||
for cmd in sorted(skill_cmds):
|
||||
desc = skill_cmds[cmd].get("description", "").strip() or "Skill command"
|
||||
entries.append(f"`{cmd}` — {desc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not entries:
|
||||
return "No commands available."
|
||||
|
||||
from gateway.config import Platform
|
||||
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
|
||||
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
|
||||
page = max(1, min(requested_page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
page_entries = entries[start:start + page_size]
|
||||
|
||||
lines = [
|
||||
f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})",
|
||||
"",
|
||||
*page_entries,
|
||||
]
|
||||
if total_pages > 1:
|
||||
nav_parts = []
|
||||
if page > 1:
|
||||
nav_parts.append(f"`/commands {page - 1}` ← prev")
|
||||
if page < total_pages:
|
||||
nav_parts.append(f"next → `/commands {page + 1}`")
|
||||
lines.extend(["", " | ".join(nav_parts)])
|
||||
if page != requested_page:
|
||||
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_provider_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /provider command - show available providers."""
|
||||
@@ -3102,9 +3226,11 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
current_provider = "openrouter"
|
||||
|
||||
# Detect custom endpoint
|
||||
if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
|
||||
current_provider = "custom"
|
||||
# Detect custom endpoint from config base_url
|
||||
if current_provider == "openrouter":
|
||||
_cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
|
||||
if _cfg_base and "openrouter.ai" not in _cfg_base:
|
||||
current_provider = "custom"
|
||||
|
||||
current_label = _PROVIDER_LABELS.get(current_provider, current_provider)
|
||||
|
||||
@@ -3916,6 +4042,167 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _handle_btw_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /btw <question> — ephemeral side question in the same chat."""
|
||||
question = event.get_command_args().strip()
|
||||
if not question:
|
||||
return (
|
||||
"Usage: /btw <question>\n"
|
||||
"Example: /btw what module owns session title sanitization?\n\n"
|
||||
"Answers using session context. No tools, not persisted."
|
||||
)
|
||||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
|
||||
# Guard: one /btw at a time per session
|
||||
existing = getattr(self, "_active_btw_tasks", {}).get(session_key)
|
||||
if existing and not existing.done():
|
||||
return "A /btw is already running for this chat. Wait for it to finish."
|
||||
|
||||
if not hasattr(self, "_active_btw_tasks"):
|
||||
self._active_btw_tasks: dict = {}
|
||||
|
||||
import uuid as _uuid
|
||||
task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}"
|
||||
_task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id))
|
||||
self._background_tasks.add(_task)
|
||||
self._active_btw_tasks[session_key] = _task
|
||||
|
||||
def _cleanup(task):
|
||||
self._background_tasks.discard(task)
|
||||
if self._active_btw_tasks.get(session_key) is task:
|
||||
self._active_btw_tasks.pop(session_key, None)
|
||||
|
||||
_task.add_done_callback(_cleanup)
|
||||
|
||||
preview = question[:60] + ("..." if len(question) > 60 else "")
|
||||
return f'💬 /btw: "{preview}"\nReply will appear here shortly.'
|
||||
|
||||
async def _run_btw_task(
|
||||
self, question: str, source, session_key: str, task_id: str,
|
||||
) -> None:
|
||||
"""Execute an ephemeral /btw side question and deliver the answer."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter:
|
||||
logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id)
|
||||
return
|
||||
|
||||
_thread_meta = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
|
||||
try:
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
if not runtime_kwargs.get("api_key"):
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
"❌ /btw failed: no provider credentials configured.",
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
return
|
||||
|
||||
user_config = _load_gateway_config()
|
||||
model = _resolve_gateway_model(user_config)
|
||||
platform_key = _platform_config_key(source.platform)
|
||||
reasoning_config = self._load_reasoning_config()
|
||||
turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs)
|
||||
pr = self._provider_routing
|
||||
|
||||
# Snapshot history from running agent or stored transcript
|
||||
running_agent = self._running_agents.get(session_key)
|
||||
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
history_snapshot = list(getattr(running_agent, "_session_messages", []) or [])
|
||||
else:
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
history_snapshot = self.session_store.load_transcript(session_entry.session_id)
|
||||
|
||||
btw_prompt = (
|
||||
"[Ephemeral /btw side question. Answer using the conversation "
|
||||
"context. No tools available. Be direct and concise.]\n\n"
|
||||
+ question
|
||||
)
|
||||
|
||||
def run_sync():
|
||||
agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
**turn_route["runtime"],
|
||||
max_iterations=8,
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
enabled_toolsets=[],
|
||||
reasoning_config=reasoning_config,
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
provider_sort=pr.get("sort"),
|
||||
provider_require_parameters=pr.get("require_parameters", False),
|
||||
provider_data_collection=pr.get("data_collection"),
|
||||
session_id=task_id,
|
||||
platform=platform_key,
|
||||
session_db=None,
|
||||
fallback_model=self._fallback_model,
|
||||
skip_memory=True,
|
||||
skip_context_files=True,
|
||||
persist_session=False,
|
||||
)
|
||||
return agent.run_conversation(
|
||||
user_message=btw_prompt,
|
||||
conversation_history=history_snapshot,
|
||||
task_id=task_id,
|
||||
sync_honcho=False,
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, run_sync)
|
||||
|
||||
response = (result.get("final_response") or "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
if not response:
|
||||
response = "(No response generated)"
|
||||
|
||||
media_files, response = adapter.extract_media(response)
|
||||
images, text_content = adapter.extract_images(response)
|
||||
preview = question[:60] + ("..." if len(question) > 60 else "")
|
||||
header = f'💬 /btw: "{preview}"\n\n'
|
||||
|
||||
if text_content:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=header + text_content,
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
elif not images and not media_files:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=header + "(No response generated)",
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
|
||||
for image_url, alt_text in (images or []):
|
||||
try:
|
||||
await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for media_path in (media_files or []):
|
||||
try:
|
||||
await adapter.send_file(chat_id=source.chat_id, file_path=media_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("/btw task %s failed", task_id)
|
||||
try:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=f"❌ /btw failed: {e}",
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _handle_reasoning_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /reasoning command — manage reasoning effort and display toggle.
|
||||
|
||||
@@ -3999,6 +4286,16 @@ class GatewayRunner:
|
||||
else:
|
||||
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass."""
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
return "⚠️ YOLO mode **OFF** — dangerous commands will require approval."
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution."
|
||||
|
||||
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /verbose command — cycle tool progress display mode.
|
||||
|
||||
@@ -4485,8 +4782,8 @@ class GatewayRunner:
|
||||
async def _handle_update_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /update command — update Hermes Agent to the latest version.
|
||||
|
||||
Spawns ``hermes update`` in a separate systemd scope so it survives the
|
||||
gateway restart that ``hermes update`` may trigger at the end. Marker
|
||||
Spawns ``hermes update`` in a detached session (via ``setsid``) so it
|
||||
survives the gateway restart that ``hermes update`` may trigger. Marker
|
||||
files are written so either the current gateway process or the next one
|
||||
can notify the user when the update finishes.
|
||||
"""
|
||||
@@ -4494,6 +4791,10 @@ class GatewayRunner:
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from hermes_cli.config import is_managed, format_managed_message
|
||||
|
||||
if is_managed():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
git_dir = project_root / '.git'
|
||||
@@ -4522,28 +4823,28 @@ class GatewayRunner:
|
||||
pending_path.write_text(json.dumps(pending))
|
||||
exit_code_path.unlink(missing_ok=True)
|
||||
|
||||
# Spawn `hermes update` in a separate cgroup so it survives gateway
|
||||
# restart. systemd-run --user --scope creates a transient scope unit.
|
||||
# Spawn `hermes update` detached so it survives gateway restart.
|
||||
# Use setsid for portable session detach (works under system services
|
||||
# where systemd-run --user fails due to missing D-Bus session).
|
||||
hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
|
||||
update_cmd = (
|
||||
f"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; "
|
||||
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
|
||||
)
|
||||
try:
|
||||
systemd_run = shutil.which("systemd-run")
|
||||
if systemd_run:
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
# Preferred: setsid creates a new session, fully detached
|
||||
subprocess.Popen(
|
||||
[systemd_run, "--user", "--scope",
|
||||
"--unit=hermes-update", "--",
|
||||
"bash", "-c", update_cmd],
|
||||
[setsid_bin, "bash", "-c", update_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
# Fallback: best-effort detach with start_new_session
|
||||
# Fallback: start_new_session=True calls os.setsid() in child
|
||||
subprocess.Popen(
|
||||
["bash", "-c", f"nohup {update_cmd} &"],
|
||||
["bash", "-c", update_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
@@ -5534,7 +5835,9 @@ class GatewayRunner:
|
||||
# If so, update the session store entry so the NEXT message loads
|
||||
# the compressed transcript, not the stale pre-compression one.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent.session_id,
|
||||
@@ -5546,6 +5849,13 @@ class GatewayRunner:
|
||||
|
||||
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
|
||||
# When compression created a new session, the messages list was
|
||||
# shortened. Using the original history offset would produce an
|
||||
# empty new_messages slice, causing the gateway to write only a
|
||||
# user/assistant pair — losing the compressed summary and tail.
|
||||
# Reset to 0 so the gateway writes ALL compressed messages.
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
# Auto-generate session title after first exchange (non-blocking)
|
||||
if final_response and self._session_db:
|
||||
try:
|
||||
@@ -5567,7 +5877,7 @@ class GatewayRunner:
|
||||
"messages": result_holder[0].get("messages", []) if result_holder[0] else [],
|
||||
"api_calls": result_holder[0].get("api_calls", 0) if result_holder[0] else 0,
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": len(agent_history),
|
||||
"history_offset": _effective_history_offset,
|
||||
"last_prompt_tokens": _last_prompt_toks,
|
||||
"input_tokens": _input_toks,
|
||||
"output_tokens": _output_toks,
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
id="alibaba",
|
||||
name="Alibaba Cloud (DashScope)",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
inference_base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
api_key_env_vars=("DASHSCOPE_API_KEY",),
|
||||
base_url_env_var="DASHSCOPE_BASE_URL",
|
||||
),
|
||||
|
||||
@@ -432,10 +432,11 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
try:
|
||||
behind = get_update_result(timeout=0.5)
|
||||
if behind and behind > 0:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]hermes update[/bold] to update[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
+252
-4
@@ -4,14 +4,19 @@ Usage:
|
||||
hermes claw migrate # Interactive migration from ~/.openclaw
|
||||
hermes claw migrate --dry-run # Preview what would be migrated
|
||||
hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts
|
||||
hermes claw cleanup # Archive leftover OpenClaw directories
|
||||
hermes claw cleanup --dry-run # Preview what would be archived
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
from hermes_cli.setup import (
|
||||
Colors,
|
||||
color,
|
||||
@@ -19,6 +24,7 @@ from hermes_cli.setup import (
|
||||
print_info,
|
||||
print_success,
|
||||
print_error,
|
||||
print_warning,
|
||||
prompt_yes_no,
|
||||
)
|
||||
|
||||
@@ -27,8 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
PROJECT_ROOT
|
||||
/ "optional-skills"
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
/ "migration"
|
||||
/ "openclaw-migration"
|
||||
/ "scripts"
|
||||
@@ -45,6 +50,18 @@ _OPENCLAW_SCRIPT_INSTALLED = (
|
||||
/ "openclaw_to_hermes.py"
|
||||
)
|
||||
|
||||
# Known OpenClaw directory names (current + legacy)
|
||||
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot")
|
||||
|
||||
# State files commonly found in OpenClaw workspace directories that cause
|
||||
# confusion after migration (the agent discovers them and writes to them)
|
||||
_WORKSPACE_STATE_GLOBS = (
|
||||
"*/todo.json",
|
||||
"*/sessions/*",
|
||||
"*/memory/*.json",
|
||||
"*/logs/*",
|
||||
)
|
||||
|
||||
|
||||
def _find_migration_script() -> Path | None:
|
||||
"""Find the openclaw_to_hermes.py script in known locations."""
|
||||
@@ -71,19 +88,88 @@ def _load_migration_module(script_path: Path):
|
||||
return mod
|
||||
|
||||
|
||||
def _find_openclaw_dirs() -> list[Path]:
|
||||
"""Find all OpenClaw directories on disk."""
|
||||
found = []
|
||||
for name in _OPENCLAW_DIR_NAMES:
|
||||
candidate = Path.home() / name
|
||||
if candidate.is_dir():
|
||||
found.append(candidate)
|
||||
return found
|
||||
|
||||
|
||||
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""Scan an OpenClaw directory for workspace state files that cause confusion.
|
||||
|
||||
Returns a list of (path, description) tuples.
|
||||
"""
|
||||
findings: list[tuple[Path, str]] = []
|
||||
|
||||
# Direct state files in the root
|
||||
for name in ("todo.json", "sessions", "logs"):
|
||||
candidate = source_dir / name
|
||||
if candidate.exists():
|
||||
kind = "directory" if candidate.is_dir() else "file"
|
||||
findings.append((candidate, f"Root {kind}: {name}"))
|
||||
|
||||
# State files inside workspace directories
|
||||
for child in sorted(source_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
# Check for workspace-like subdirectories
|
||||
for state_name in ("todo.json", "sessions", "logs", "memory"):
|
||||
state_path = child / state_name
|
||||
if state_path.exists():
|
||||
kind = "directory" if state_path.is_dir() else "file"
|
||||
rel = state_path.relative_to(source_dir)
|
||||
findings.append((state_path, f"Workspace {kind}: {rel}"))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path:
|
||||
"""Rename an OpenClaw directory to .pre-migration.
|
||||
|
||||
Returns the archive path.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d")
|
||||
archive_name = f"{source_dir.name}.pre-migration"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
|
||||
# If archive already exists, add timestamp
|
||||
if archive_path.exists():
|
||||
archive_name = f"{source_dir.name}.pre-migration-{timestamp}"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
|
||||
# If still exists (multiple runs same day), add counter
|
||||
counter = 2
|
||||
while archive_path.exists():
|
||||
archive_name = f"{source_dir.name}.pre-migration-{timestamp}-{counter}"
|
||||
archive_path = source_dir.parent / archive_name
|
||||
counter += 1
|
||||
|
||||
if not dry_run:
|
||||
source_dir.rename(archive_path)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def claw_command(args):
|
||||
"""Route hermes claw subcommands."""
|
||||
action = getattr(args, "claw_action", None)
|
||||
|
||||
if action == "migrate":
|
||||
_cmd_migrate(args)
|
||||
elif action in ("cleanup", "clean"):
|
||||
_cmd_cleanup(args)
|
||||
else:
|
||||
print("Usage: hermes claw migrate [options]")
|
||||
print("Usage: hermes claw <command> [options]")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" migrate Migrate settings from OpenClaw to Hermes")
|
||||
print(" cleanup Archive leftover OpenClaw directories after migration")
|
||||
print()
|
||||
print("Run 'hermes claw migrate --help' for migration options.")
|
||||
print("Run 'hermes claw <command> --help' for options.")
|
||||
|
||||
|
||||
def _cmd_migrate(args):
|
||||
@@ -210,6 +296,168 @@ def _cmd_migrate(args):
|
||||
# Print results
|
||||
_print_migration_report(report, dry_run)
|
||||
|
||||
# After successful non-dry-run migration, offer to archive the source directory
|
||||
if not dry_run and report.get("summary", {}).get("migrated", 0) > 0:
|
||||
_offer_source_archival(source_dir, getattr(args, "yes", False))
|
||||
|
||||
|
||||
def _offer_source_archival(source_dir: Path, auto_yes: bool = False):
|
||||
"""After migration, offer to rename the source directory to prevent state fragmentation.
|
||||
|
||||
OpenClaw workspace directories contain state files (todo.json, sessions, etc.)
|
||||
that the agent may discover and write to, causing confusion. Renaming the
|
||||
directory prevents this.
|
||||
"""
|
||||
if not source_dir.is_dir():
|
||||
return
|
||||
|
||||
# Scan for state files that could cause problems
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
print()
|
||||
print_header("Post-Migration Cleanup")
|
||||
print_info("The OpenClaw directory still exists and contains workspace state files")
|
||||
print_info("that can confuse the agent (todo lists, sessions, logs).")
|
||||
if state_files:
|
||||
print()
|
||||
print(color(" Found state files:", Colors.YELLOW))
|
||||
# Show up to 10 most relevant findings
|
||||
for path, desc in state_files[:10]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 10:
|
||||
print(f" ... and {len(state_files) - 10} more")
|
||||
print()
|
||||
print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/")
|
||||
print_info("This prevents the agent from discovering old workspace directories.")
|
||||
print_info("You can always rename it back if needed.")
|
||||
print()
|
||||
|
||||
if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
print_info("The original directory has been renamed, not deleted.")
|
||||
print_info(f"To undo: mv {archive_path} {source_dir}")
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped. You can archive later with: hermes claw cleanup")
|
||||
|
||||
|
||||
def _cmd_cleanup(args):
|
||||
"""Archive leftover OpenClaw directories after migration.
|
||||
|
||||
Scans for OpenClaw directories that still exist after migration and offers
|
||||
to rename them to .pre-migration to prevent state fragmentation.
|
||||
"""
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
auto_yes = getattr(args, "yes", False)
|
||||
explicit_source = getattr(args, "source", None)
|
||||
|
||||
print()
|
||||
print(
|
||||
color(
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print(
|
||||
color(
|
||||
"│ ⚕ Hermes — OpenClaw Cleanup │",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print(
|
||||
color(
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
|
||||
# Find OpenClaw directories
|
||||
if explicit_source:
|
||||
dirs_to_check = [Path(explicit_source)]
|
||||
else:
|
||||
dirs_to_check = _find_openclaw_dirs()
|
||||
|
||||
if not dirs_to_check:
|
||||
print()
|
||||
print_success("No OpenClaw directories found. Nothing to clean up.")
|
||||
return
|
||||
|
||||
total_archived = 0
|
||||
|
||||
for source_dir in dirs_to_check:
|
||||
print()
|
||||
print_header(f"Found: {source_dir}")
|
||||
|
||||
# Scan for state files
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
# Show directory stats
|
||||
try:
|
||||
workspace_dirs = [
|
||||
d for d in source_dir.iterdir()
|
||||
if d.is_dir() and not d.name.startswith(".")
|
||||
and any((d / name).exists() for name in ("todo.json", "SOUL.md", "MEMORY.md", "USER.md"))
|
||||
]
|
||||
except OSError:
|
||||
workspace_dirs = []
|
||||
|
||||
if workspace_dirs:
|
||||
print_info(f"Workspace directories: {len(workspace_dirs)}")
|
||||
for ws in workspace_dirs[:5]:
|
||||
items = []
|
||||
if (ws / "todo.json").exists():
|
||||
items.append("todo.json")
|
||||
if (ws / "sessions").is_dir():
|
||||
items.append("sessions/")
|
||||
if (ws / "SOUL.md").exists():
|
||||
items.append("SOUL.md")
|
||||
if (ws / "MEMORY.md").exists():
|
||||
items.append("MEMORY.md")
|
||||
detail = ", ".join(items) if items else "empty"
|
||||
print(f" {ws.name}/ ({detail})")
|
||||
if len(workspace_dirs) > 5:
|
||||
print(f" ... and {len(workspace_dirs) - 5} more")
|
||||
|
||||
if state_files:
|
||||
print()
|
||||
print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW))
|
||||
for path, desc in state_files[:8]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 8:
|
||||
print(f" ... and {len(state_files) - 8} more")
|
||||
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
archive_path = _archive_directory(source_dir, dry_run=True)
|
||||
print_info(f"Would archive: {source_dir} → {archive_path}")
|
||||
else:
|
||||
if auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
total_archived += 1
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped.")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
if dry_run:
|
||||
print_info(f"Dry run complete. {len(dirs_to_check)} directory(ies) would be archived.")
|
||||
print_info("Run without --dry-run to archive them.")
|
||||
elif total_archived:
|
||||
print_success(f"Cleaned up {total_archived} OpenClaw directory(ies).")
|
||||
print_info("Directories were renamed, not deleted. You can undo by renaming them back.")
|
||||
else:
|
||||
print_info("No directories were archived.")
|
||||
|
||||
|
||||
def _print_migration_report(report: dict, dry_run: bool):
|
||||
"""Print a formatted migration report."""
|
||||
|
||||
+18
-2
@@ -1,8 +1,24 @@
|
||||
"""Shared ANSI color utilities for Hermes CLI modules."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def should_use_color() -> bool:
|
||||
"""Return True when colored output is appropriate.
|
||||
|
||||
Respects the NO_COLOR environment variable (https://no-color.org/)
|
||||
and TERM=dumb, in addition to the existing TTY check.
|
||||
"""
|
||||
if os.environ.get("NO_COLOR") is not None:
|
||||
return False
|
||||
if os.environ.get("TERM") == "dumb":
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
@@ -16,7 +32,7 @@ class Colors:
|
||||
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
"""Apply color codes to text (only when output is a TTY)."""
|
||||
if not sys.stdout.isatty():
|
||||
"""Apply color codes to text (only when color output is appropriate)."""
|
||||
if not should_use_color():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
@@ -67,10 +67,13 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
gateway_only=True),
|
||||
CommandDef("background", "Run a prompt in the background", "Session",
|
||||
aliases=("bg",), args_hint="<prompt>"),
|
||||
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
|
||||
args_hint="<question>"),
|
||||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("status", "Show session info", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
gateway_only=True, aliases=("set-home",)),
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
@@ -90,6 +93,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
|
||||
"Configuration", cli_only=True,
|
||||
gateway_config_gate="display.tool_progress_command"),
|
||||
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
|
||||
"Configuration"),
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
@@ -118,6 +123,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
"Tools & Skills", cli_only=True),
|
||||
|
||||
# Info
|
||||
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
|
||||
gateway_only=True, args_hint="[page]"),
|
||||
CommandDef("help", "Show available commands", "Info"),
|
||||
CommandDef("usage", "Show token usage for the current session", "Info"),
|
||||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||||
@@ -361,6 +368,69 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
return result
|
||||
|
||||
|
||||
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
||||
"""Return Telegram menu commands capped to the Bot API limit.
|
||||
|
||||
Priority order (higher priority = never bumped by overflow):
|
||||
1. Core CommandDef commands (always included)
|
||||
2. Plugin slash commands (take precedence over skills)
|
||||
3. Built-in skill commands (fill remaining slots, alphabetical)
|
||||
|
||||
Skills are the only tier that gets trimmed when the cap is hit.
|
||||
User-installed hub skills are excluded — accessible via /skills.
|
||||
|
||||
Returns:
|
||||
(menu_commands, hidden_count) where hidden_count is the number of
|
||||
skill commands omitted due to the cap.
|
||||
"""
|
||||
all_commands = list(telegram_bot_commands())
|
||||
|
||||
# Plugin slash commands get priority over skills
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
tg_name = cmd_name.replace("-", "_")
|
||||
desc = "Plugin command"
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
all_commands.append((tg_name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remaining slots go to built-in skill commands (not hub-installed).
|
||||
skill_entries: list[tuple[str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path.startswith(_skills_dir):
|
||||
continue
|
||||
if skill_path.startswith(_hub_dir):
|
||||
continue
|
||||
name = cmd_key.lstrip("/").replace("-", "_")
|
||||
desc = info.get("description", "")
|
||||
# Keep descriptions short — setMyCommands has an undocumented
|
||||
# total payload limit. 40 chars fits 100 commands safely.
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
skill_entries.append((name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Skills fill remaining slots — they're the only tier that gets trimmed
|
||||
remaining_slots = max(0, max_commands - len(all_commands))
|
||||
hidden_count = max(0, len(skill_entries) - remaining_slots)
|
||||
all_commands.extend(skill_entries[:remaining_slots])
|
||||
return all_commands[:max_commands], hidden_count
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
+80
-11
@@ -52,26 +52,86 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
# Managed mode (NixOS declarative config)
|
||||
# =============================================================================
|
||||
|
||||
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||||
_MANAGED_SYSTEM_NAMES = {
|
||||
"brew": "Homebrew",
|
||||
"homebrew": "Homebrew",
|
||||
"nix": "NixOS",
|
||||
"nixos": "NixOS",
|
||||
}
|
||||
|
||||
|
||||
def get_managed_system() -> Optional[str]:
|
||||
"""Return the package manager owning this install, if any."""
|
||||
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||||
if raw:
|
||||
normalized = raw.lower()
|
||||
if normalized in _MANAGED_TRUE_VALUES:
|
||||
return "NixOS"
|
||||
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||||
|
||||
managed_marker = get_hermes_home() / ".managed"
|
||||
if managed_marker.exists():
|
||||
return "NixOS"
|
||||
return None
|
||||
|
||||
|
||||
def is_managed() -> bool:
|
||||
"""Check if hermes is running in Nix-managed mode.
|
||||
"""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).
|
||||
"""
|
||||
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
managed_marker = get_hermes_home() / ".managed"
|
||||
return managed_marker.exists()
|
||||
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(
|
||||
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
|
||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||
" sudo nixos-rebuild switch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(format_managed_message(action), file=sys.stderr)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -392,6 +452,7 @@ DEFAULT_CONFIG = {
|
||||
"require_mention": True, # Require @mention to respond in server channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||||
},
|
||||
|
||||
# WhatsApp platform settings (gateway mode)
|
||||
@@ -706,6 +767,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"CAMOFOX_URL": {
|
||||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||||
"prompt": "Camofox server URL",
|
||||
"url": "https://github.com/jo-inc/camofox-browser",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"FAL_KEY": {
|
||||
"description": "FAL API key for image generation",
|
||||
"prompt": "FAL API key",
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ def cron_list(show_all: bool = False):
|
||||
print()
|
||||
|
||||
for job in jobs:
|
||||
job_id = job.get("id", "?")[:8]
|
||||
job_id = job.get("id", "?")
|
||||
name = job.get("name", "(unnamed)")
|
||||
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
|
||||
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
|
||||
|
||||
+15
-8
@@ -406,8 +406,11 @@ def run_doctor(args):
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
# Check if docker daemon is running
|
||||
result = subprocess.run(["docker", "info"], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
check_ok("docker", "(daemon running)")
|
||||
else:
|
||||
check_fail("docker daemon not running")
|
||||
@@ -426,12 +429,16 @@ def run_doctor(args):
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if ssh_host:
|
||||
# Try to connect
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
result = None
|
||||
if result is not None and result.returncode == 0:
|
||||
check_ok(f"SSH connection to {ssh_host}")
|
||||
else:
|
||||
check_fail(f"SSH connection to {ssh_host}")
|
||||
|
||||
+104
-46
@@ -829,6 +829,17 @@ def cmd_setup(args):
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
select_provider_and_model()
|
||||
|
||||
|
||||
def select_provider_and_model():
|
||||
"""Core provider selection + model picking logic.
|
||||
|
||||
Shared by ``cmd_model`` (``hermes model``) and the setup wizard
|
||||
(``setup_model_provider`` in setup.py). Handles the full flow:
|
||||
provider picker, credential prompting, model selection, and config
|
||||
persistence.
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
resolve_provider, AuthError, format_auth_error,
|
||||
)
|
||||
@@ -858,7 +869,10 @@ def cmd_model(args):
|
||||
except AuthError as exc:
|
||||
warning = format_auth_error(exc)
|
||||
print(f"Warning: {warning} Falling back to auto provider detection.")
|
||||
active = resolve_provider("auto")
|
||||
try:
|
||||
active = resolve_provider("auto")
|
||||
except AuthError:
|
||||
active = "openrouter" # no provider yet; show full picker
|
||||
|
||||
# Detect custom endpoint
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
@@ -1050,10 +1064,6 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
|
||||
selected = _prompt_model_selection(openrouter_models, current_model=current_model)
|
||||
if selected:
|
||||
# Clear any custom endpoint and set provider to openrouter
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Update config provider and deactivate any OAuth provider
|
||||
@@ -1143,10 +1153,6 @@ def _model_flow_nous(config, current_model=""):
|
||||
# Reactivate Nous as the provider and update config
|
||||
inference_url = creds.get("base_url", "")
|
||||
_update_config_for_provider("nous", inference_url)
|
||||
# Clear any custom endpoint that might conflict
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
print(f"Default model set to: {selected} (via Nous Portal)")
|
||||
else:
|
||||
print("No change.")
|
||||
@@ -1191,10 +1197,6 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
# Clear custom endpoint env vars that would otherwise override Codex.
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
print(f"Default model set to: {selected} (via OpenAI Codex)")
|
||||
else:
|
||||
print("No change.")
|
||||
@@ -1275,11 +1277,6 @@ def _model_flow_custom(config):
|
||||
if probe.get("suggested_base_url"):
|
||||
print(f" If this server expects /v1, try base URL: {probe['suggested_base_url']}")
|
||||
|
||||
if base_url:
|
||||
save_env_value("OPENAI_BASE_URL", effective_url)
|
||||
if api_key:
|
||||
save_env_value("OPENAI_API_KEY", api_key)
|
||||
|
||||
if model_name:
|
||||
_save_model_choice(model_name)
|
||||
|
||||
@@ -1291,14 +1288,33 @@ def _model_flow_custom(config):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = effective_url
|
||||
if effective_key:
|
||||
model["api_key"] = effective_key
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
# Sync the caller's config dict so the setup wizard's final
|
||||
# save_config(config) preserves our model settings. Without
|
||||
# this, the wizard overwrites model.provider/base_url with
|
||||
# the stale values from its own config dict (#4172).
|
||||
config["model"] = dict(model)
|
||||
|
||||
print(f"Default model set to: {model_name} (via {effective_url})")
|
||||
else:
|
||||
if base_url or api_key:
|
||||
deactivate_provider()
|
||||
# Even without a model name, persist the custom endpoint on the
|
||||
# caller's config dict so the setup wizard doesn't lose it.
|
||||
_caller_model = config.get("model")
|
||||
if not isinstance(_caller_model, dict):
|
||||
_caller_model = {"default": _caller_model} if _caller_model else {}
|
||||
_caller_model["provider"] = "custom"
|
||||
_caller_model["base_url"] = effective_url
|
||||
if effective_key:
|
||||
_caller_model["api_key"] = effective_key
|
||||
_caller_model.pop("api_mode", None)
|
||||
config["model"] = _caller_model
|
||||
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
|
||||
|
||||
# Auto-save to custom_providers so it appears in the menu next time
|
||||
@@ -1439,9 +1455,6 @@ def _model_flow_named_custom(config, provider_info):
|
||||
|
||||
# If a model is saved, just activate immediately — no probing needed
|
||||
if saved_model:
|
||||
save_env_value("OPENAI_BASE_URL", base_url)
|
||||
if api_key:
|
||||
save_env_value("OPENAI_API_KEY", api_key)
|
||||
_save_model_choice(saved_model)
|
||||
|
||||
cfg = load_config()
|
||||
@@ -1451,6 +1464,8 @@ def _model_flow_named_custom(config, provider_info):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = base_url
|
||||
if api_key:
|
||||
model["api_key"] = api_key
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -1513,9 +1528,6 @@ def _model_flow_named_custom(config, provider_info):
|
||||
return
|
||||
|
||||
# Activate and save the model to the custom_providers entry
|
||||
save_env_value("OPENAI_BASE_URL", base_url)
|
||||
if api_key:
|
||||
save_env_value("OPENAI_API_KEY", api_key)
|
||||
_save_model_choice(model_name)
|
||||
|
||||
cfg = load_config()
|
||||
@@ -1525,6 +1537,8 @@ def _model_flow_named_custom(config, provider_info):
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = base_url
|
||||
if api_key:
|
||||
model["api_key"] = api_key
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -1577,11 +1591,15 @@ _PROVIDER_MODELS = {
|
||||
"kimi-k2-0905-preview",
|
||||
],
|
||||
"minimax": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
@@ -1829,11 +1847,6 @@ def _model_flow_copilot(config, current_model=""):
|
||||
catalog=catalog,
|
||||
api_key=api_key,
|
||||
) or selected
|
||||
# Clear stale custom-endpoint overrides so the Copilot provider wins cleanly.
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
initial_cfg = load_config()
|
||||
current_effort = _current_reasoning_effort(initial_cfg)
|
||||
reasoning_efforts = github_model_reasoning_efforts(
|
||||
@@ -2058,11 +2071,6 @@ def _model_flow_kimi(config, current_model=""):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
# Clear custom endpoint if set (avoid confusion)
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Update config with provider and base URL
|
||||
@@ -2165,11 +2173,6 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
# Clear custom endpoint if set (avoid confusion)
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Update config with provider and base URL
|
||||
@@ -2381,11 +2384,6 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
# Clear custom endpoint if set
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Update config with provider — clear base_url since
|
||||
@@ -2467,10 +2465,14 @@ def cmd_version(args):
|
||||
# Show update status (synchronous — acceptable since user asked for version info)
|
||||
try:
|
||||
from hermes_cli.banner import check_for_updates
|
||||
from hermes_cli.config import recommended_update_command
|
||||
behind = check_for_updates()
|
||||
if behind and behind > 0:
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
print(f"Update available: {behind} {commits_word} behind — run 'hermes update'")
|
||||
print(
|
||||
f"Update available: {behind} {commits_word} behind — "
|
||||
f"run '{recommended_update_command()}'"
|
||||
)
|
||||
elif behind == 0:
|
||||
print("Up to date")
|
||||
except Exception:
|
||||
@@ -2821,6 +2823,11 @@ def _invalidate_update_cache():
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
import shutil
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
@@ -3156,6 +3163,7 @@ def cmd_update(args):
|
||||
_gw_service_name = get_service_name()
|
||||
existing_pid = get_running_pid()
|
||||
has_systemd_service = False
|
||||
has_system_service = False
|
||||
has_launchd_service = False
|
||||
|
||||
try:
|
||||
@@ -3168,6 +3176,19 @@ def cmd_update(args):
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Also check for a system-level service (hermes gateway install --system).
|
||||
# This covers gateways running under system systemd where --user
|
||||
# fails due to missing D-Bus session.
|
||||
if not has_systemd_service and is_linux():
|
||||
try:
|
||||
check = subprocess.run(
|
||||
["systemctl", "is-active", _gw_service_name],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
has_system_service = check.stdout.strip() == "active"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Check for macOS launchd service
|
||||
if is_macos():
|
||||
try:
|
||||
@@ -3182,7 +3203,7 @@ def cmd_update(args):
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
if existing_pid or has_systemd_service or has_launchd_service:
|
||||
if existing_pid or has_systemd_service or has_system_service or has_launchd_service:
|
||||
print()
|
||||
|
||||
# When a service manager is handling the gateway, let it
|
||||
@@ -3223,6 +3244,21 @@ def cmd_update(args):
|
||||
print(" hermes gateway restart")
|
||||
else:
|
||||
print(" Try manually: hermes gateway restart")
|
||||
elif has_system_service:
|
||||
# System-level service (hermes gateway install --system).
|
||||
# No D-Bus session needed — systemctl without --user talks
|
||||
# directly to the system manager over /run/systemd/private.
|
||||
print("→ Restarting system gateway service...")
|
||||
restart = subprocess.run(
|
||||
["systemctl", "restart", _gw_service_name],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if restart.returncode == 0:
|
||||
print("✓ Gateway restarted (system service).")
|
||||
else:
|
||||
print(f"⚠ Gateway restart failed: {restart.stderr.strip()}")
|
||||
print(" System services may require root. Try:")
|
||||
print(f" sudo systemctl restart {_gw_service_name}")
|
||||
elif has_launchd_service:
|
||||
# Refresh the plist first (picks up --replace and other
|
||||
# changes from the update we just pulled).
|
||||
@@ -4703,6 +4739,28 @@ For more help on a command:
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
# claw cleanup
|
||||
claw_cleanup = claw_subparsers.add_parser(
|
||||
"cleanup",
|
||||
aliases=["clean"],
|
||||
help="Archive leftover OpenClaw directories after migration",
|
||||
description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--source",
|
||||
help="Path to a specific OpenClaw directory to clean up"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview what would be archived without making changes"
|
||||
)
|
||||
claw_cleanup.add_argument(
|
||||
"--yes", "-y",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
def cmd_claw(args):
|
||||
from hermes_cli.claw import claw_command
|
||||
claw_command(args)
|
||||
|
||||
@@ -27,6 +27,7 @@ GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.5", ""),
|
||||
("anthropic/claude-haiku-4.5", ""),
|
||||
("openai/gpt-5.4", ""),
|
||||
@@ -56,6 +57,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5.4",
|
||||
@@ -189,7 +191,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"opencode-go": [
|
||||
"glm-5",
|
||||
"kimi-k2.5",
|
||||
"minimax-m2.5",
|
||||
"minimax-m2.7",
|
||||
],
|
||||
"ai-gateway": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
@@ -347,7 +349,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status, has_usable_secret
|
||||
if pid == "custom":
|
||||
custom_base_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
|
||||
custom_base_url = _get_custom_base_url() or ""
|
||||
has_creds = bool(custom_base_url.strip())
|
||||
elif pid == "openrouter":
|
||||
has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
|
||||
|
||||
@@ -265,10 +265,11 @@ def cmd_install(identifier: str, force: bool = False) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
||||
f"Run [bold]hermes update[/bold] to get a newer installer."
|
||||
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ def _read_config_model(profile_dir: Path) -> tuple:
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg, None
|
||||
if isinstance(model_cfg, dict):
|
||||
return model_cfg.get("model"), model_cfg.get("provider")
|
||||
return model_cfg.get("default") or model_cfg.get("model"), model_cfg.get("provider")
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
@@ -229,28 +229,22 @@ def _resolve_openrouter_runtime(
|
||||
requested_norm = (requested_provider or "").strip().lower()
|
||||
cfg_provider = cfg_provider.strip().lower()
|
||||
|
||||
env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip()
|
||||
env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip()
|
||||
|
||||
# Use config base_url when available and the provider context matches.
|
||||
# OPENAI_BASE_URL env var is no longer consulted — config.yaml is
|
||||
# the single source of truth for endpoint URLs.
|
||||
use_config_base_url = False
|
||||
if cfg_base_url.strip() and not explicit_base_url:
|
||||
if requested_norm == "auto":
|
||||
if (not cfg_provider or cfg_provider == "auto") and not env_openai_base_url:
|
||||
if not cfg_provider or cfg_provider == "auto":
|
||||
use_config_base_url = True
|
||||
elif requested_norm == "custom" and cfg_provider == "custom":
|
||||
# provider: custom — use base_url from config (Fixes #1760).
|
||||
use_config_base_url = True
|
||||
|
||||
# When the user explicitly requested the openrouter provider, skip
|
||||
# OPENAI_BASE_URL — it typically points to a custom / non-OpenRouter
|
||||
# endpoint and would prevent switching back to OpenRouter (#874).
|
||||
skip_openai_base = requested_norm == "openrouter"
|
||||
|
||||
# For custom, prefer config base_url over env so config.yaml is honored (#1760).
|
||||
base_url = (
|
||||
(explicit_base_url or "").strip()
|
||||
or (cfg_base_url.strip() if use_config_base_url else "")
|
||||
or ("" if skip_openai_base else env_openai_base_url)
|
||||
or env_openrouter_base_url
|
||||
or OPENROUTER_BASE_URL
|
||||
).rstrip("/")
|
||||
|
||||
+45
-912
File diff suppressed because it is too large
Load Diff
+20
-12
@@ -285,23 +285,31 @@ def show_status(args):
|
||||
_gw_svc = get_service_name()
|
||||
except Exception:
|
||||
_gw_svc = "hermes-gateway"
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", _gw_svc],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
except subprocess.TimeoutExpired:
|
||||
is_active = False
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
is_loaded = False
|
||||
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
|
||||
print(" Manager: launchd")
|
||||
else:
|
||||
|
||||
@@ -273,6 +273,16 @@ TOOL_CATEGORIES = {
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"tag": "Local anti-detection browser (Firefox/Camoufox)",
|
||||
"env_vars": [
|
||||
{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377",
|
||||
"url": "https://github.com/jo-inc/camofox-browser"},
|
||||
],
|
||||
"browser_provider": "camofox",
|
||||
"post_setup": "camofox",
|
||||
},
|
||||
],
|
||||
},
|
||||
"homeassistant": {
|
||||
@@ -337,6 +347,28 @@ def _run_post_setup(post_setup_key: str):
|
||||
elif not node_modules.exists():
|
||||
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
|
||||
|
||||
elif post_setup_key == "camofox":
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser"
|
||||
if not camofox_dir.exists() and shutil.which("npm"):
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["npm", "install", "--silent"],
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Camofox installed")
|
||||
else:
|
||||
_print_warning(" npm install failed - run manually: npm install")
|
||||
if camofox_dir.exists():
|
||||
_print_info(" Start the Camofox server:")
|
||||
_print_info(" npx @askjo/camoufox-browser")
|
||||
_print_info(" First run downloads the Camoufox engine (~300MB)")
|
||||
_print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
elif not shutil.which("npm"):
|
||||
_print_warning(" Node.js not found. Install Camofox via Docker:")
|
||||
_print_info(" docker run -p 9377:9377 jo-inc/camofox-browser")
|
||||
|
||||
elif post_setup_key == "rl_training":
|
||||
try:
|
||||
__import__("tinker_atropos")
|
||||
@@ -565,7 +597,9 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
||||
if cat:
|
||||
for provider in cat.get("providers", []):
|
||||
env_vars = provider.get("env_vars", [])
|
||||
if env_vars and all(get_env_value(e["key"]) for e in env_vars):
|
||||
if not env_vars:
|
||||
return True # No-key provider (e.g. Local Browser, Edge TTS)
|
||||
if all(get_env_value(e["key"]) for e in env_vars):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -949,8 +983,13 @@ def _configure_simple_requirements(ts_key: str):
|
||||
key_label = " OPENAI_API_KEY" if "api.openai.com" in base_url.lower() else " API key"
|
||||
api_key = _prompt(key_label, password=True)
|
||||
if api_key and api_key.strip():
|
||||
save_env_value("OPENAI_BASE_URL", base_url)
|
||||
save_env_value("OPENAI_API_KEY", api_key.strip())
|
||||
# Save vision base URL to config (not .env — only secrets go there)
|
||||
from hermes_cli.config import load_config, save_config
|
||||
_cfg = load_config()
|
||||
_aux = _cfg.setdefault("auxiliary", {}).setdefault("vision", {})
|
||||
_aux["base_url"] = base_url
|
||||
save_config(_cfg)
|
||||
if "api.openai.com" in base_url.lower():
|
||||
save_env_value("AUXILIARY_VISION_MODEL", "gpt-4o-mini")
|
||||
_print_success(" Saved")
|
||||
|
||||
@@ -17,6 +17,20 @@ def get_hermes_home() -> Path:
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -10,16 +10,27 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
|
||||
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
"""Return the active Honcho config path (instance-local or global)."""
|
||||
"""Return the active Honcho config path for reading (instance-local or global)."""
|
||||
return resolve_config_path()
|
||||
|
||||
|
||||
def _local_config_path() -> Path:
|
||||
"""Return the instance-local Honcho config path for writing.
|
||||
|
||||
Always returns $HERMES_HOME/honcho.json so each profile/instance gets
|
||||
its own config file. The global ~/.honcho/config.json is only used as
|
||||
a read fallback (via resolve_config_path) for cross-app interop.
|
||||
"""
|
||||
return get_hermes_home() / "honcho.json"
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
path = _config_path()
|
||||
if path.exists():
|
||||
@@ -31,7 +42,7 @@ def _read_config() -> dict:
|
||||
|
||||
|
||||
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||
path = path or _config_path()
|
||||
path = path or _local_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||
@@ -95,13 +106,13 @@ def cmd_setup(args) -> None:
|
||||
"""Interactive Honcho setup wizard."""
|
||||
cfg = _read_config()
|
||||
|
||||
active_path = _config_path()
|
||||
write_path = _local_config_path()
|
||||
read_path = _config_path()
|
||||
print("\nHoncho memory setup\n" + "─" * 40)
|
||||
print(" Honcho gives Hermes persistent cross-session memory.")
|
||||
if active_path != GLOBAL_CONFIG_PATH:
|
||||
print(f" Instance config: {active_path}")
|
||||
else:
|
||||
print(" Config is shared with other hosts at ~/.honcho/config.json")
|
||||
print(f" Config: {write_path}")
|
||||
if read_path != write_path and read_path.exists():
|
||||
print(f" (seeding from existing config at {read_path})")
|
||||
print()
|
||||
|
||||
if not _ensure_sdk_installed():
|
||||
@@ -189,7 +200,7 @@ def cmd_setup(args) -> None:
|
||||
hermes_host.setdefault("saveMessages", True)
|
||||
|
||||
_write_config(cfg)
|
||||
print(f"\n Config written to {active_path}")
|
||||
print(f"\n Config written to {write_path}")
|
||||
|
||||
# Test connection
|
||||
print(" Testing connection... ", end="", flush=True)
|
||||
@@ -237,6 +248,7 @@ def cmd_status(args) -> None:
|
||||
cfg = _read_config()
|
||||
|
||||
active_path = _config_path()
|
||||
write_path = _local_config_path()
|
||||
|
||||
if not cfg:
|
||||
print(f" No Honcho config found at {active_path}")
|
||||
@@ -259,6 +271,8 @@ def cmd_status(args) -> None:
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Host: {hcfg.host}")
|
||||
print(f" Config path: {active_path}")
|
||||
if write_path != active_path:
|
||||
print(f" Write path: {write_path} (instance-local)")
|
||||
print(f" AI peer: {hcfg.ai_peer}")
|
||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ def get_tool_definitions(
|
||||
# Determine which tool names the caller wants
|
||||
tools_to_include: set = set()
|
||||
|
||||
if enabled_toolsets:
|
||||
if enabled_toolsets is not None:
|
||||
for toolset_name in enabled_toolsets:
|
||||
if validate_toolset(toolset_name):
|
||||
resolved = resolve_toolset(toolset_name)
|
||||
|
||||
@@ -2455,9 +2455,24 @@ class Migrator:
|
||||
notes.append("")
|
||||
|
||||
notes.extend([
|
||||
"## IMPORTANT: Archive the OpenClaw Directory",
|
||||
"",
|
||||
"After migration, your OpenClaw directory still exists on disk with workspace",
|
||||
"state files (todo.json, sessions, logs). If the Hermes agent discovers these",
|
||||
"directories, it may read/write to them instead of the Hermes state, causing",
|
||||
"confusion (e.g., cron jobs reading a different todo list than interactive sessions).",
|
||||
"",
|
||||
"**Strongly recommended:** Run `hermes claw cleanup` to rename the OpenClaw",
|
||||
"directory to `.openclaw.pre-migration`. This prevents the agent from finding it.",
|
||||
"The directory is renamed, not deleted — you can undo this at any time.",
|
||||
"",
|
||||
"If you skip this step and notice the agent getting confused about workspaces",
|
||||
"or todo lists, run `hermes claw cleanup` to fix it.",
|
||||
"",
|
||||
"## Hermes-Specific Setup",
|
||||
"",
|
||||
"After migration, you may want to:",
|
||||
"- Run `hermes claw cleanup` to archive the OpenClaw directory (prevents state confusion)",
|
||||
"- Run `hermes setup` to configure any remaining settings",
|
||||
"- Run `hermes mcp list` to verify MCP servers were imported correctly",
|
||||
"- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user