Merge remote-tracking branch 'origin/main' into bb/tui-long-session-perf

This commit is contained in:
Brooklyn Nicholson 2026-04-26 21:07:15 -05:00
commit 7da2f07641
115 changed files with 17650 additions and 406 deletions

View file

@ -4452,8 +4452,14 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
from hermes_cli.models import fetch_ollama_cloud_models
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
# During setup, force a live refresh so the picker reflects newly
# released models (e.g. deepseek v4 flash, kimi k2.6) the moment
# the user enters their key — not an hour later when the disk
# cache TTL expires.
model_list = fetch_ollama_cloud_models(
api_key=api_key_for_probe, base_url=effective_base
api_key=api_key_for_probe,
base_url=effective_base,
force_refresh=True,
)
if model_list:
print(f" Found {len(model_list)} model(s) from Ollama Cloud")
@ -5024,6 +5030,83 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
return default
def _web_ui_build_needed(web_dir: Path) -> bool:
"""Return True if the web UI dist is missing or stale.
Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite
manifest as the sentinel because it is written last and therefore has the
newest mtime of any build output.
"""
dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
sentinel = dist_dir / ".vite" / "manifest.json"
if not sentinel.exists():
sentinel = dist_dir / "index.html"
if not sentinel.exists():
return True
dist_mtime = sentinel.stat().st_mtime
skip = frozenset({"node_modules", "dist"})
for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
dirnames[:] = [d for d in dirnames if d not in skip]
for fn in filenames:
if fn.endswith((".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".vue")):
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:
return True
for meta in (
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"vite.config.ts",
"vite.config.js",
):
mp = web_dir / meta
if mp.exists() and mp.stat().st_mtime > dist_mtime:
return True
return False
def _run_npm_install_deterministic(
npm: str,
cwd: Path,
*,
extra_args: tuple[str, ...] = (),
capture_output: bool = True,
) -> subprocess.CompletedProcess:
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present;
falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of
sync on a WIP checkout). Without this, ``npm install`` on npm 10 silently
rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves
the working tree dirty and causes the next ``hermes update`` to stash the
lockfile repeatedly.
"""
lockfile = cwd / "package-lock.json"
if lockfile.exists():
ci_cmd = [npm, "ci", *extra_args]
ci_result = subprocess.run(
ci_cmd,
cwd=cwd,
capture_output=capture_output,
text=True,
check=False,
)
if ci_result.returncode == 0:
return ci_result
# Fall through to `npm install` — lockfile may be out of sync on a
# WIP fork/branch, or `npm ci` may not be available on very old npm.
install_cmd = [npm, "install", *extra_args]
return subprocess.run(
install_cmd,
cwd=cwd,
capture_output=capture_output,
text=True,
check=False,
)
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available.
@ -5037,6 +5120,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if not (web_dir / "package.json").exists():
return True
if not _web_ui_build_needed(web_dir):
return True
npm = shutil.which("npm")
if not npm:
if fatal:
@ -5044,7 +5130,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
print("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
print("→ Building web UI...")
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
if r1.returncode != 0:
print(
f" {'' if fatal else ''} Web UI npm install failed"
@ -5755,12 +5841,10 @@ def _update_node_dependencies() -> None:
if not (path / "package.json").exists():
continue
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=path,
capture_output=True,
text=True,
check=False,
result = _run_npm_install_deterministic(
npm,
path,
extra_args=("--silent", "--no-fund", "--no-audit", "--progress=false"),
)
if result.returncode == 0:
print(f"{label}")
@ -5996,6 +6080,88 @@ def _cmd_update_check():
print(f" Run '{recommended_update_command()}' to install.")
def _ensure_fhs_path_guard() -> None:
"""Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells.
Mirrors the post-symlink probe added to ``scripts/install.sh`` so that
existing FHS-layout root installs on RHEL/CentOS/Rocky/Alma 8+ get
repaired on ``hermes update`` without requiring a reinstall. The
installer's assumption that ``/usr/local/bin`` is on PATH for every
standard shell breaks on those distros in non-login interactive shells
(su, sudo -s, tmux panes, some web terminals): /etc/bashrc doesn't
add /usr/local/bin and /root/.bash_profile doesn't either. Symptom:
``hermes`` prints ``command not found`` even though the symlink lives
at /usr/local/bin/hermes.
Silent no-op on: non-Linux, non-root, non-FHS installs, and any system
where ``bash -i -c 'command -v hermes'`` already resolves. Idempotent.
"""
if sys.platform != "linux":
return
try:
if os.geteuid() != 0:
return
except AttributeError:
return
# Only act when this is actually an FHS-layout install (command link at
# /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent).
fhs_link = Path("/usr/local/bin/hermes")
if not fhs_link.is_symlink() and not fhs_link.exists():
return
# Probe a fresh non-login interactive bash the way the user will use it.
# ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
# which is the exact scenario where RHEL root loses /usr/local/bin.
home = os.environ.get("HOME") or "/root"
try:
probe = subprocess.run(
["env", "-i",
f"HOME={home}",
f"TERM={os.environ.get('TERM', 'dumb')}",
"bash", "-i", "-c", "command -v hermes"],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return # no bash or probe hung — don't block update on this
if probe.returncode == 0:
return # already on PATH, nothing to do
path_line = 'export PATH="/usr/local/bin:$PATH"'
path_comment = (
"# Hermes Agent — ensure /usr/local/bin is on PATH "
"(RHEL non-login shells)"
)
wrote_any = False
for candidate in (".bashrc", ".bash_profile"):
cfg = Path(home) / candidate
if not cfg.is_file():
continue
try:
existing = cfg.read_text(errors="replace")
except OSError:
continue
# Idempotency: skip if any uncommented PATH= line already references
# /usr/local/bin. Mirrors the grep pattern used by install.sh.
already_guarded = any(
"/usr/local/bin" in line
and "PATH" in line
and not line.lstrip().startswith("#")
for line in existing.splitlines()
)
if already_guarded:
continue
try:
with cfg.open("a", encoding="utf-8") as f:
f.write("\n" + path_comment + "\n" + path_line + "\n")
except OSError as e:
print(f" ⚠ Could not update {cfg}: {e}")
continue
print(f" ✓ Added /usr/local/bin to PATH in {cfg}")
wrote_any = True
if wrote_any:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
def cmd_update(args):
"""Update Hermes Agent to the latest version.
@ -6439,6 +6605,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("✓ Update complete!")
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
# for non-login interactive shells. No-op on every other platform.
try:
_ensure_fhs_path_guard()
except Exception as e:
logger.debug("FHS PATH guard check failed: %s", e)
# Write exit code *before* the gateway restart attempt.
# When running as ``hermes update --gateway`` (spawned by the gateway's
# /update command), this process lives inside the gateway's systemd
@ -9084,7 +9257,7 @@ Examples:
"--source", help="Filter by source (cli, telegram, discord, etc.)"
)
sessions_browse.add_argument(
"--limit", type=int, default=50, help="Max sessions to load (default: 50)"
"--limit", type=int, default=500, help="Max sessions to load (default: 500)"
)
def _confirm_prompt(prompt: str) -> bool:
@ -9181,7 +9354,8 @@ Examples:
):
print("Cancelled.")
return
if db.delete_session(resolved_session_id):
sessions_dir = get_hermes_home() / "sessions"
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
print(f"Deleted session '{resolved_session_id}'.")
else:
print(f"Session '{args.session_id}' not found.")
@ -9195,7 +9369,9 @@ Examples:
):
print("Cancelled.")
return
count = db.prune_sessions(older_than_days=days, source=args.source)
sessions_dir = get_hermes_home() / "sessions"
count = db.prune_sessions(older_than_days=days, source=args.source,
sessions_dir=sessions_dir)
print(f"Pruned {count} session(s).")
elif action == "rename":
@ -9213,7 +9389,7 @@ Examples:
print(f"Error: {e}")
elif action == "browse":
limit = getattr(args, "limit", 50) or 50
limit = getattr(args, "limit", 500) or 500
source = getattr(args, "source", None)
_browse_exclude = None if source else ["tool"]
sessions = db.list_sessions_rich(