Merge branch 'main' into bb/gui
This commit is contained in:
+114
-35
@@ -1,4 +1,4 @@
|
||||
"""Shared helpers for attaching Hermes to a local Chrome CDP port."""
|
||||
"""Shared helpers for attaching Hermes to a local Chromium-family CDP port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,23 +21,53 @@ _DARWIN_APPS = (
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
)
|
||||
|
||||
_WINDOWS_INSTALL_PARTS = (
|
||||
("Google", "Chrome", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chrome.exe"),
|
||||
("Chromium", "Application", "chromium.exe"),
|
||||
("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
("Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
_WINDOWS_BROWSER_GROUPS = (
|
||||
(("chrome.exe", "chrome"), (("Google", "Chrome", "Application", "chrome.exe"),)),
|
||||
(
|
||||
("chromium.exe", "chromium"),
|
||||
(("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")),
|
||||
),
|
||||
(("brave.exe", "brave"), (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),)),
|
||||
(("msedge.exe", "msedge"), (("Microsoft", "Edge", "Application", "msedge.exe"),)),
|
||||
)
|
||||
|
||||
_LINUX_BIN_NAMES = (
|
||||
"google-chrome", "google-chrome-stable", "chromium-browser",
|
||||
"chromium", "brave-browser", "microsoft-edge",
|
||||
_WINDOWS_BIN_NAMES = tuple(name for names, _ in _WINDOWS_BROWSER_GROUPS for name in names)
|
||||
_WINDOWS_INSTALL_PARTS = tuple(parts for _, group in _WINDOWS_BROWSER_GROUPS for parts in group)
|
||||
|
||||
_LINUX_BROWSER_GROUPS = (
|
||||
(
|
||||
("google-chrome", "google-chrome-stable"),
|
||||
("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"),
|
||||
),
|
||||
(
|
||||
("chromium-browser", "chromium"),
|
||||
("/usr/bin/chromium-browser", "/usr/bin/chromium"),
|
||||
),
|
||||
(
|
||||
("brave-browser", "brave-browser-stable", "brave"),
|
||||
(
|
||||
"/usr/bin/brave-browser",
|
||||
"/usr/bin/brave-browser-stable",
|
||||
"/usr/bin/brave",
|
||||
"/snap/bin/brave",
|
||||
"/opt/brave.com/brave/brave-browser",
|
||||
"/opt/brave.com/brave/brave",
|
||||
"/opt/brave-bin/brave",
|
||||
),
|
||||
),
|
||||
(
|
||||
("microsoft-edge", "microsoft-edge-stable", "msedge"),
|
||||
(
|
||||
"/usr/bin/microsoft-edge",
|
||||
"/usr/bin/microsoft-edge-stable",
|
||||
"/opt/microsoft/msedge/microsoft-edge",
|
||||
"/opt/microsoft/msedge/msedge",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_WINDOWS_BIN_NAMES = (
|
||||
"chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
|
||||
"chrome", "msedge", "brave", "chromium",
|
||||
)
|
||||
_LINUX_BIN_NAMES = tuple(name for names, _ in _LINUX_BROWSER_GROUPS for name in names)
|
||||
_LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path in paths)
|
||||
|
||||
|
||||
def get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
@@ -53,10 +83,14 @@ def get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
candidates.append(path)
|
||||
seen.add(normalized)
|
||||
|
||||
def add_install_paths(bases: tuple[str | None, ...]) -> None:
|
||||
for base in filter(None, bases):
|
||||
for parts in _WINDOWS_INSTALL_PARTS:
|
||||
add(os.path.join(base, *parts))
|
||||
def add_windows_install_paths(
|
||||
bases: tuple[str | None, ...],
|
||||
install_groups: tuple[tuple[tuple[str, ...], tuple[tuple[str, ...], ...]], ...],
|
||||
) -> None:
|
||||
for _, group in install_groups:
|
||||
for base in filter(None, bases):
|
||||
for parts in group:
|
||||
add(os.path.join(base, *parts))
|
||||
|
||||
if system == "Darwin":
|
||||
for app in _DARWIN_APPS:
|
||||
@@ -64,18 +98,25 @@ def get_chrome_debug_candidates(system: str) -> list[str]:
|
||||
return candidates
|
||||
|
||||
if system == "Windows":
|
||||
for name in _WINDOWS_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths((
|
||||
install_bases = (
|
||||
os.environ.get("ProgramFiles"),
|
||||
os.environ.get("ProgramFiles(x86)"),
|
||||
os.environ.get("LOCALAPPDATA"),
|
||||
))
|
||||
)
|
||||
for names, install_parts in _WINDOWS_BROWSER_GROUPS:
|
||||
for name in names:
|
||||
add(shutil.which(name))
|
||||
for base in filter(None, install_bases):
|
||||
for parts in install_parts:
|
||||
add(os.path.join(base, *parts))
|
||||
return candidates
|
||||
|
||||
for name in _LINUX_BIN_NAMES:
|
||||
add(shutil.which(name))
|
||||
add_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"))
|
||||
for names, paths in _LINUX_BROWSER_GROUPS:
|
||||
for name in names:
|
||||
add(shutil.which(name))
|
||||
for path in paths:
|
||||
add(path)
|
||||
add_windows_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"), _WINDOWS_BROWSER_GROUPS)
|
||||
return candidates
|
||||
|
||||
|
||||
@@ -92,6 +133,42 @@ def _chrome_debug_args(port: int) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool:
|
||||
"""Return True when ``url`` exposes a reachable Chrome DevTools endpoint."""
|
||||
import socket
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url if "://" in url else f"http://{url}")
|
||||
try:
|
||||
port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme)
|
||||
if scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return False
|
||||
|
||||
root = f"{scheme}://{parsed.netloc}".rstrip("/")
|
||||
for probe in (f"{root}/json/version", f"{root}/json"):
|
||||
try:
|
||||
with urllib.request.urlopen(probe, timeout=timeout) as resp:
|
||||
if 200 <= getattr(resp, "status", 200) < 300:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
|
||||
system = system or platform.system()
|
||||
candidates = get_chrome_debug_candidates(system)
|
||||
@@ -126,13 +203,15 @@ def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str |
|
||||
return False
|
||||
|
||||
os.makedirs(chrome_debug_data_dir(), exist_ok=True)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[candidates[0], *_chrome_debug_args(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detach_kwargs(system),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
for candidate in candidates:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[candidate, *_chrome_debug_args(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**_detach_kwargs(system),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
@@ -187,7 +187,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||||
"Tools & Skills", aliases=("reload_skills",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
CommandDef("browser", "Connect browser tools to your live Chromium-family browser via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
CommandDef("plugins", "List installed plugins and their status",
|
||||
|
||||
@@ -1646,6 +1646,15 @@ DEFAULT_CONFIG = {
|
||||
# the sweep on every CLI invocation). Tracked via state_meta in
|
||||
# state.db itself, so it's shared across all processes.
|
||||
"min_interval_hours": 24,
|
||||
# Legacy per-session JSON snapshot writer. When true, the agent
|
||||
# rewrites ``~/.hermes/sessions/session_{sid}.json`` on every turn
|
||||
# boundary with the full message list. state.db is canonical and
|
||||
# has every field the snapshot stored (plus per-message timestamps
|
||||
# and token counts), so this is off by default — the snapshots had
|
||||
# no consumer outside their own overwrite guard and accumulated
|
||||
# GBs of disk on heavy users. Opt in only if you have an external
|
||||
# tool that consumes the JSON files directly.
|
||||
"write_json_snapshots": False,
|
||||
},
|
||||
|
||||
# Contextual first-touch onboarding hints (see agent/onboarding.py).
|
||||
|
||||
@@ -777,7 +777,33 @@ def run_doctor(args):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_section("xAI Model Retirement (May 15, 2026)")
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
|
||||
_xai_cfg = load_config()
|
||||
retired_refs = find_retired_xai_refs(_xai_cfg)
|
||||
if not retired_refs:
|
||||
check_ok("No retired xAI models in config")
|
||||
else:
|
||||
for ref in retired_refs:
|
||||
check_warn(format_issue(ref))
|
||||
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
manual_issues.append(
|
||||
f"Update {len(retired_refs)} retired xAI model reference(s) "
|
||||
f"in config.yaml — see {MIGRATION_GUIDE_URL}"
|
||||
)
|
||||
except Exception as _xai_check_err:
|
||||
check_warn("xAI retirement check skipped", f"({_xai_check_err})")
|
||||
|
||||
_section("Auth Providers")
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
|
||||
+220
-24
@@ -270,11 +270,20 @@ import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool:
|
||||
"""Import-safe Termux check for cold-start-sensitive CLI paths."""
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return bool(
|
||||
check.get("TERMUX_VERSION")
|
||||
or "com.termux/files/usr" in prefix
|
||||
or prefix.startswith("/data/data/com.termux/")
|
||||
)
|
||||
|
||||
|
||||
def _relative_time(ts) -> str:
|
||||
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
||||
if not ts:
|
||||
@@ -976,6 +985,72 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_TUI_BUILD_INPUT_DIRS = (
|
||||
"src",
|
||||
"packages/hermes-ink/src",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_FILES = (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
"babel.compiler.config.cjs",
|
||||
"scripts/build.mjs",
|
||||
"packages/hermes-ink/package.json",
|
||||
"packages/hermes-ink/package-lock.json",
|
||||
"packages/hermes-ink/index.js",
|
||||
"packages/hermes-ink/text-input.js",
|
||||
)
|
||||
|
||||
_TUI_BUILD_INPUT_SUFFIXES = frozenset(
|
||||
{".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"}
|
||||
)
|
||||
|
||||
|
||||
def _iter_tui_build_inputs(root: Path):
|
||||
"""Yield source/config files that affect ``ui-tui/dist/entry.js``."""
|
||||
for rel in _TUI_BUILD_INPUT_FILES:
|
||||
path = root / rel
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
for rel in _TUI_BUILD_INPUT_DIRS:
|
||||
base = root / rel
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in base.rglob("*"):
|
||||
if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def _tui_need_rebuild(root: Path) -> bool:
|
||||
"""True when ``dist/entry.js`` is missing or older than TUI inputs.
|
||||
|
||||
The TUI bundle is self-contained. Rebuilding it on every launch adds a
|
||||
visible cold-start tax on slow Termux CPUs, while a simple mtime freshness
|
||||
check still rebuilds immediately after source updates, dependency updates,
|
||||
or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour.
|
||||
"""
|
||||
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
|
||||
if force in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
|
||||
entry = root / "dist" / "entry.js"
|
||||
try:
|
||||
output_mtime = entry.stat().st_mtime
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
for path in _iter_tui_build_inputs(root):
|
||||
try:
|
||||
if path.stat().st_mtime > output_mtime:
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_tui_node() -> None:
|
||||
"""Make sure `node` + `npm` are on PATH for the TUI.
|
||||
|
||||
@@ -1090,6 +1165,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
@@ -1109,6 +1185,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
did_install = True
|
||||
|
||||
if tui_dev:
|
||||
# Keep the local @hermes/ink package exports in sync with source.
|
||||
@@ -1137,21 +1214,28 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [str(tsx), "src/entry.tsx"], tui_dir
|
||||
return [npm, "start"], tui_dir
|
||||
|
||||
# Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
# Desktop/dev launches retain the historical "always rebuild" behaviour.
|
||||
# Termux cold starts use the freshness check because esbuild startup is
|
||||
# expensive on old mobile CPUs.
|
||||
should_build = True
|
||||
if _is_termux_startup_environment():
|
||||
should_build = did_install or _tui_need_rebuild(tui_dir)
|
||||
|
||||
if should_build:
|
||||
npm = _node_bin("npm")
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("TUI build failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node_bin("node")
|
||||
return [node, str(tui_dir / "dist" / "entry.js")], tui_dir
|
||||
@@ -1413,6 +1497,29 @@ def cmd_chat(args):
|
||||
# If resolution fails, keep the original value — _init_agent will
|
||||
# report "Session not found" with the original input
|
||||
|
||||
# xAI retirement warning — one-shot, non-blocking, never fails startup
|
||||
try:
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
from hermes_cli.config import load_config as _load_config_for_xai_check
|
||||
|
||||
_retired_xai_refs = find_retired_xai_refs(_load_config_for_xai_check())
|
||||
if _retired_xai_refs:
|
||||
sys.stderr.write(
|
||||
f"\033[33m⚠ xAI retires {len(_retired_xai_refs)} model(s) "
|
||||
f"in your config on {RETIREMENT_DATE}:\033[0m\n"
|
||||
)
|
||||
for _ref in _retired_xai_refs:
|
||||
sys.stderr.write(f" \033[33m⚠\033[0m {format_issue(_ref)}\n")
|
||||
sys.stderr.write(f" \033[2mMigration guide: {MIGRATION_GUIDE_URL}\033[0m\n")
|
||||
sys.stderr.write(" \033[2mRun 'hermes doctor' for details.\033[0m\n\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First-run guard: check if any provider is configured before launching
|
||||
if not _has_any_provider_configured():
|
||||
print()
|
||||
@@ -2592,6 +2699,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
ProviderConfig,
|
||||
_prompt_model_selection,
|
||||
@@ -2652,6 +2760,7 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
|
||||
def _model_flow_ai_gateway(config, current_model=""):
|
||||
"""Vercel AI Gateway provider: ensure API key, then pick model with pricing."""
|
||||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
_prompt_model_selection,
|
||||
@@ -4245,8 +4354,11 @@ def _model_flow_named_custom(config, provider_info):
|
||||
print(f" Provider: {name} ({base_url})")
|
||||
|
||||
|
||||
# Curated model lists for direct API-key providers — single source in models.py
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
# Keep the historical eager model catalog import on desktop/CI. Termux defers
|
||||
# it to the model-selection handlers so plain `hermes --tui` does not pay for
|
||||
# requests/models.dev catalog imports before the Node TUI starts.
|
||||
if not _is_termux_startup_environment():
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
|
||||
def _current_reasoning_effort(config) -> str:
|
||||
@@ -4363,6 +4475,7 @@ def _model_flow_copilot(config, current_model=""):
|
||||
)
|
||||
from hermes_cli.config import save_env_value, load_config, save_config
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
fetch_github_model_catalog,
|
||||
github_model_reasoning_efforts,
|
||||
@@ -4555,6 +4668,7 @@ def _model_flow_copilot_acp(config, current_model=""):
|
||||
resolve_external_process_provider_credentials,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_github_model_catalog,
|
||||
normalize_copilot_model_id,
|
||||
)
|
||||
@@ -4758,6 +4872,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
provider_id = "kimi-coding"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
@@ -4868,7 +4983,7 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import fetch_api_models
|
||||
from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models
|
||||
|
||||
provider_id = "stepfun"
|
||||
pconfig = PROVIDER_REGISTRY[provider_id]
|
||||
@@ -5248,6 +5363,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
save_config,
|
||||
)
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_MODELS,
|
||||
fetch_api_models,
|
||||
opencode_model_api_mode,
|
||||
normalize_opencode_model_id,
|
||||
@@ -7669,9 +7785,7 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
|
||||
|
||||
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
|
||||
check = env or os.environ
|
||||
prefix = str(check.get("PREFIX", ""))
|
||||
return "com.termux" in prefix or prefix.startswith("/data/data/com.termux/")
|
||||
return _is_termux_startup_environment(env)
|
||||
|
||||
|
||||
def _is_android_python() -> bool:
|
||||
@@ -10348,7 +10462,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"computer-use",
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
@@ -10442,6 +10556,47 @@ def _plugin_cli_discovery_needed() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _try_termux_fast_tui_launch() -> bool:
|
||||
"""Launch obvious Termux TUI invocations before building every subparser.
|
||||
|
||||
`hermes --tui` is the hot path on phones. The full parser setup imports
|
||||
command modules for model, fallback, migrate, kanban, bundles, plugins,
|
||||
etc. even though the TUI immediately execs Node. On Termux only, parse the
|
||||
lightweight top-level/chat parser and hand off to ``cmd_chat`` when the
|
||||
invocation is unambiguously the built-in TUI/chat path.
|
||||
"""
|
||||
if not _is_termux_startup_environment():
|
||||
return False
|
||||
|
||||
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
||||
return False
|
||||
|
||||
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
|
||||
if not wants_tui:
|
||||
return False
|
||||
|
||||
first = _first_positional_argv()
|
||||
if first not in {None, "chat"}:
|
||||
return False
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:]))
|
||||
|
||||
# Preserve top-level behaviours whose semantics are not "launch chat/TUI".
|
||||
if getattr(args, "version", False) or getattr(args, "oneshot", None):
|
||||
return False
|
||||
if getattr(args, "command", None) not in {None, "chat"}:
|
||||
return False
|
||||
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
|
||||
return False
|
||||
|
||||
cmd_chat(args)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
@@ -10459,6 +10614,9 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _try_termux_fast_tui_launch():
|
||||
return
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
@@ -10555,6 +10713,44 @@ def main():
|
||||
)
|
||||
fallback_parser.set_defaults(func=cmd_fallback)
|
||||
|
||||
# =========================================================================
|
||||
# migrate command
|
||||
# =========================================================================
|
||||
from hermes_cli.migrate import cmd_migrate, cmd_migrate_xai
|
||||
|
||||
migrate_parser = subparsers.add_parser(
|
||||
"migrate",
|
||||
help="Migrate configuration for retired models or deprecated settings",
|
||||
description=(
|
||||
"Diagnose and (optionally) rewrite the active config.yaml to "
|
||||
"replace references to retired models or deprecated settings."
|
||||
),
|
||||
)
|
||||
migrate_subparsers = migrate_parser.add_subparsers(dest="migrate_type")
|
||||
|
||||
migrate_xai = migrate_subparsers.add_parser(
|
||||
"xai",
|
||||
help="Migrate xAI models scheduled for retirement on May 15, 2026",
|
||||
description=(
|
||||
"Scan config.yaml for references to xAI models retiring on "
|
||||
"May 15, 2026 and, with --apply, rewrite them in-place to the "
|
||||
"official replacements per the xAI migration guide. The original "
|
||||
"config.yaml is backed up before any rewrite."
|
||||
),
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Rewrite config.yaml in-place (default: dry-run, no writes)",
|
||||
)
|
||||
migrate_xai.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
help="Skip the timestamped backup of config.yaml when applying",
|
||||
)
|
||||
migrate_xai.set_defaults(func=cmd_migrate_xai)
|
||||
migrate_parser.set_defaults(func=cmd_migrate)
|
||||
|
||||
# =========================================================================
|
||||
# gateway command
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""CLI handlers for ``hermes migrate ...``.
|
||||
|
||||
Currently exposes only ``hermes migrate xai`` — diagnoses and (with --apply)
|
||||
rewrites references to xAI models retired on May 15, 2026.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
|
||||
def cmd_migrate(args: Any) -> int:
|
||||
"""Dispatcher for ``hermes migrate <subtype>``."""
|
||||
sub = getattr(args, "migrate_type", None)
|
||||
if sub == "xai":
|
||||
return cmd_migrate_xai(args)
|
||||
|
||||
print("usage: hermes migrate xai [--apply] [--no-backup]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def cmd_migrate_xai(args: Any) -> int:
|
||||
"""Run xAI May-15 model migration in dry-run or apply mode."""
|
||||
from hermes_cli.xai_retirement import (
|
||||
MIGRATION_GUIDE_URL,
|
||||
RETIREMENT_DATE,
|
||||
apply_migration,
|
||||
find_retired_xai_refs,
|
||||
format_issue,
|
||||
)
|
||||
|
||||
apply = bool(getattr(args, "apply", False))
|
||||
no_backup = bool(getattr(args, "no_backup", False))
|
||||
|
||||
config = load_config()
|
||||
issues = find_retired_xai_refs(config)
|
||||
|
||||
print()
|
||||
print(color(
|
||||
f"◆ xAI Model Retirement Migration ({RETIREMENT_DATE})",
|
||||
Colors.CYAN, Colors.BOLD,
|
||||
))
|
||||
print()
|
||||
|
||||
if not issues:
|
||||
print(f" {color('✓', Colors.GREEN)} No retired xAI models in config — nothing to migrate.")
|
||||
return 0
|
||||
|
||||
print(f" Found {len(issues)} retired xAI model reference(s):")
|
||||
print()
|
||||
for issue in issues:
|
||||
print(f" {color('⚠', Colors.YELLOW)} {format_issue(issue)}")
|
||||
print()
|
||||
print(f" {color('→', Colors.CYAN)} Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
print()
|
||||
|
||||
config_path = _resolve_config_path()
|
||||
|
||||
if not apply:
|
||||
print(color("Dry-run mode — no changes written.", Colors.DIM))
|
||||
print(color(
|
||||
"Re-run with `hermes migrate xai --apply` to rewrite "
|
||||
f"{config_path} in-place (backup created automatically).",
|
||||
Colors.DIM,
|
||||
))
|
||||
return 0
|
||||
|
||||
if not config_path or not config_path.exists():
|
||||
print(
|
||||
f" {color('✗', Colors.RED)} Could not locate config.yaml "
|
||||
f"(looked at: {config_path})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = apply_migration(
|
||||
config_path=config_path,
|
||||
issues=issues,
|
||||
backup=not no_backup,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f" {color('✗', Colors.RED)} Migration failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not result.config_changed:
|
||||
print(f" {color('⚠', Colors.YELLOW)} No changes written.")
|
||||
return 0
|
||||
|
||||
if result.backup_path is not None:
|
||||
print(f" {color('✓', Colors.GREEN)} Backup: {result.backup_path}")
|
||||
print(
|
||||
f" {color('✓', Colors.GREEN)} Updated {len(result.issues_resolved)} "
|
||||
f"slot(s) in {result.file_path}"
|
||||
)
|
||||
print()
|
||||
print(color(
|
||||
"Run `hermes doctor` to confirm no retired xAI models remain.",
|
||||
Colors.DIM,
|
||||
))
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_config_path() -> Path:
|
||||
"""Best-effort: locate the active config.yaml on disk."""
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "config.yaml"
|
||||
+2
-2
@@ -31,7 +31,7 @@ TIPS = [
|
||||
"/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.",
|
||||
"/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.",
|
||||
"/tools disable browser temporarily removes browser tools for the current session.",
|
||||
"/browser connect attaches browser tools to your running Chrome instance via CDP.",
|
||||
"/browser connect attaches browser tools to your running Chromium-family browser via CDP.",
|
||||
"/plugins lists installed plugins and their status.",
|
||||
"/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.",
|
||||
"/reload-mcp hot-reloads MCP server configuration without restarting.",
|
||||
@@ -300,7 +300,7 @@ TIPS = [
|
||||
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
|
||||
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
|
||||
"Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.",
|
||||
"BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.",
|
||||
"BROWSER_CDP_URL connects browser tools to any running Chromium-family browser — accepts WebSocket, HTTP, or host:port.",
|
||||
"BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).",
|
||||
"The CLI auto-switches to compact mode in terminals narrower than 80 columns.",
|
||||
"Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Detect xAI models retired on May 15, 2026.
|
||||
|
||||
Source: https://docs.x.ai/developers/migration/may-15-retirement
|
||||
|
||||
Pure logic: walks a Hermes config dict, returns issues for any reference
|
||||
to a retired xAI model. No I/O, no CLI dependencies — testable in isolation
|
||||
and reusable from both `hermes doctor` and a future `hermes migrate xai`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
MIGRATION_GUIDE_URL = "https://docs.x.ai/developers/migration/may-15-retirement"
|
||||
RETIREMENT_DATE = "May 15, 2026"
|
||||
|
||||
|
||||
# Official mapping per xAI migration guide.
|
||||
# Some entries set ``reasoning_effort`` because non-reasoning variants don't
|
||||
# have a one-to-one replacement: ``grok-4.3`` reasons by default, so emulating
|
||||
# ``*-non-reasoning`` behavior on it requires ``reasoning_effort="none"``.
|
||||
_RETIRED_MODELS: Dict[str, Dict[str, Optional[str]]] = {
|
||||
"grok-4-0709": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
||||
"grok-4-1-fast-reasoning": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-4-1-fast-non-reasoning": {"replacement": "grok-4.3", "reasoning_effort": "none", "note": None},
|
||||
"grok-code-fast-1": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-3": {"replacement": "grok-4.3", "reasoning_effort": None, "note": None},
|
||||
"grok-imagine-image-pro": {"replacement": "grok-imagine-image-quality", "reasoning_effort": None, "note": None},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetirementIssue:
|
||||
"""A reference to a retired xAI model found in a Hermes config."""
|
||||
|
||||
config_path: str # e.g. "principal.model" or "auxiliary.vision.model"
|
||||
current_model: str # exact value found in config (preserves casing/prefix)
|
||||
replacement: str # recommended xAI replacement
|
||||
reasoning_effort: Optional[str] = None # set if non-reasoning variant migration
|
||||
note: Optional[str] = None # disambiguation note when applicable
|
||||
|
||||
|
||||
def _normalize(model_id: str) -> str:
|
||||
"""Strip provider prefix (``x-ai/grok-4`` → ``grok-4``) and lowercase."""
|
||||
m = model_id.strip().lower()
|
||||
for prefix in ("x-ai/", "xai/"):
|
||||
if m.startswith(prefix):
|
||||
m = m[len(prefix):]
|
||||
break
|
||||
return m
|
||||
|
||||
|
||||
def _looks_like_xai(model_id: Optional[str]) -> bool:
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
return False
|
||||
return _normalize(model_id).startswith("grok-")
|
||||
|
||||
|
||||
def find_retired_xai_refs(config: Dict[str, Any]) -> List[RetirementIssue]:
|
||||
"""Walk all model slots in a Hermes config and return retirement issues.
|
||||
|
||||
Slots scanned:
|
||||
- ``principal.model``
|
||||
- ``auxiliary.<any>.model`` (introspective — covers future aux slots)
|
||||
- ``delegation.model``
|
||||
- ``tts.xai.model``
|
||||
- ``plugins.image_gen.xai.model``
|
||||
"""
|
||||
issues: List[RetirementIssue] = []
|
||||
|
||||
def _check(path: str, model: Any) -> None:
|
||||
if not _looks_like_xai(model):
|
||||
return
|
||||
norm = _normalize(model)
|
||||
entry = _RETIRED_MODELS.get(norm)
|
||||
if entry is None:
|
||||
return
|
||||
issues.append(RetirementIssue(
|
||||
config_path=path,
|
||||
current_model=model,
|
||||
replacement=entry["replacement"],
|
||||
reasoning_effort=entry.get("reasoning_effort"),
|
||||
note=entry.get("note"),
|
||||
))
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return issues
|
||||
|
||||
principal = config.get("principal")
|
||||
if isinstance(principal, dict):
|
||||
_check("principal.model", principal.get("model"))
|
||||
|
||||
aux = config.get("auxiliary")
|
||||
if isinstance(aux, dict):
|
||||
for slot_name, slot_cfg in aux.items():
|
||||
if isinstance(slot_cfg, dict):
|
||||
_check(f"auxiliary.{slot_name}.model", slot_cfg.get("model"))
|
||||
|
||||
delegation = config.get("delegation")
|
||||
if isinstance(delegation, dict):
|
||||
_check("delegation.model", delegation.get("model"))
|
||||
|
||||
tts = config.get("tts")
|
||||
if isinstance(tts, dict):
|
||||
tts_xai = tts.get("xai")
|
||||
if isinstance(tts_xai, dict):
|
||||
_check("tts.xai.model", tts_xai.get("model"))
|
||||
|
||||
plugins = config.get("plugins")
|
||||
if isinstance(plugins, dict):
|
||||
image_gen = plugins.get("image_gen")
|
||||
if isinstance(image_gen, dict):
|
||||
ig_xai = image_gen.get("xai")
|
||||
if isinstance(ig_xai, dict):
|
||||
_check("plugins.image_gen.xai.model", ig_xai.get("model"))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def format_issue(issue: RetirementIssue) -> str:
|
||||
"""One-line human-readable rendering of a retirement issue."""
|
||||
parts = [
|
||||
f"{issue.config_path}: {issue.current_model!r} → use {issue.replacement!r}"
|
||||
]
|
||||
if issue.reasoning_effort:
|
||||
parts.append(f'(set reasoning_effort: "{issue.reasoning_effort}")')
|
||||
if issue.note:
|
||||
parts.append(f"[note: {issue.note}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply migration to config.yaml (round-trip preserves comments/order/types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyResult:
|
||||
"""Outcome of an apply_migration call."""
|
||||
|
||||
file_path: Path
|
||||
backup_path: Optional[Path]
|
||||
issues_resolved: List[RetirementIssue]
|
||||
config_changed: bool
|
||||
|
||||
|
||||
def _walk_to_parent(yaml_doc: Any, dotted_path: str) -> "tuple[Any, str]":
|
||||
"""Resolve a dotted slot path to (parent_mapping, leaf_key).
|
||||
|
||||
Example: "auxiliary.vision.model" -> (yaml_doc["auxiliary"]["vision"], "model").
|
||||
Raises KeyError if any intermediate node is missing or not a mapping.
|
||||
"""
|
||||
parts = dotted_path.split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Path must have at least one parent: {dotted_path!r}")
|
||||
node = yaml_doc
|
||||
for segment in parts[:-1]:
|
||||
if not isinstance(node, dict) or segment not in node:
|
||||
raise KeyError(f"Path segment {segment!r} missing in {dotted_path!r}")
|
||||
node = node[segment]
|
||||
return node, parts[-1]
|
||||
|
||||
|
||||
def apply_migration(
|
||||
config_path: Path,
|
||||
issues: List[RetirementIssue],
|
||||
backup: bool = True,
|
||||
) -> ApplyResult:
|
||||
"""Rewrite ``config_path`` in-place so each issue is resolved.
|
||||
|
||||
For every issue, the model name is replaced by ``issue.replacement``. If the
|
||||
issue has ``reasoning_effort`` set (i.e. the migration is from a
|
||||
``*-non-reasoning`` variant), a sibling ``reasoning_effort`` key is added
|
||||
or updated alongside the model.
|
||||
|
||||
Uses ``ruamel.yaml`` round-trip mode so comments, key order, indentation,
|
||||
and type literals (booleans, ints) are preserved.
|
||||
|
||||
A backup copy is written to
|
||||
``<config_path>.bak-pre-migrate-xai-YYYYMMDD-HHMMSS`` before rewriting,
|
||||
unless ``backup=False``.
|
||||
"""
|
||||
from ruamel.yaml import YAML # local import — avoid hard dep at module load
|
||||
|
||||
config_path = Path(config_path)
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(config_path)
|
||||
|
||||
if not issues:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
yaml = YAML(typ="rt")
|
||||
yaml.preserve_quotes = True
|
||||
with config_path.open("r", encoding="utf-8") as fh:
|
||||
doc = yaml.load(fh)
|
||||
|
||||
if doc is None:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
resolved: List[RetirementIssue] = []
|
||||
for issue in issues:
|
||||
try:
|
||||
parent, leaf = _walk_to_parent(doc, issue.config_path)
|
||||
except KeyError:
|
||||
# Slot vanished between scan and apply — skip silently
|
||||
continue
|
||||
parent[leaf] = issue.replacement
|
||||
if issue.reasoning_effort:
|
||||
parent["reasoning_effort"] = issue.reasoning_effort
|
||||
resolved.append(issue)
|
||||
|
||||
if not resolved:
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=None,
|
||||
issues_resolved=[],
|
||||
config_changed=False,
|
||||
)
|
||||
|
||||
backup_path: Optional[Path] = None
|
||||
if backup:
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = config_path.with_name(
|
||||
f"{config_path.name}.bak-pre-migrate-xai-{ts}"
|
||||
)
|
||||
shutil.copy2(config_path, backup_path)
|
||||
|
||||
with config_path.open("w", encoding="utf-8") as fh:
|
||||
yaml.dump(doc, fh)
|
||||
|
||||
return ApplyResult(
|
||||
file_path=config_path,
|
||||
backup_path=backup_path,
|
||||
issues_resolved=resolved,
|
||||
config_changed=True,
|
||||
)
|
||||
Reference in New Issue
Block a user