Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-20 16:01:41 -04:00
72 changed files with 2726 additions and 742 deletions
+114 -35
View File
@@ -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
+1 -1
View File
@@ -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",
+9
View File
@@ -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).
+26
View File
@@ -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
View File
@@ -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
# =========================================================================
+115
View File
@@ -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
View File
@@ -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).",
+253
View File
@@ -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,
)