Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .hadolint.yaml +36 -0
- Dockerfile +470 -0
- LICENSE +21 -0
- README.es.md +220 -0
- README.ur-pk.md +261 -0
- batch_runner.py +1008 -0
- cli-config.yaml.example +0 -0
- constraints-termux.txt +15 -0
- docker-compose.windows.yml +38 -0
- flake.lock +162 -0
- hermes_bootstrap.py +119 -0
- hermes_cli/_scan_venv_blockers.py +321 -0
- hermes_cli/anon_auth.py +869 -0
- hermes_cli/auth_xai.py +582 -0
- hermes_cli/cli_output.py +70 -0
- hermes_cli/colors.py +30 -0
- hermes_cli/curator.py +710 -0
- hermes_cli/desktop_update_verify.py +95 -0
- hermes_cli/focus_view.py +104 -0
- hermes_cli/goal_command.py +194 -0
- hermes_cli/inventory.py +749 -0
- hermes_cli/kanban_db_dispatch.py +0 -0
- hermes_cli/kanban_pr_acceptance_store.py +45 -0
- hermes_cli/main_install_repair.py +1245 -0
- hermes_cli/main_provider_setup.py +946 -0
- hermes_cli/main_tui_launch.py +873 -0
- hermes_cli/mcp_picker.py +242 -0
- hermes_cli/memory_setup.py +466 -0
- hermes_cli/middleware.py +239 -0
- hermes_cli/models_pricing.py +591 -0
- hermes_cli/provider_catalog.py +117 -0
- hermes_cli/session_export_md.py +219 -0
- hermes_cli/shared_session_attach.py +91 -0
- hermes_cli/sizefmt.py +20 -0
- hermes_cli/skin_engine.py +556 -0
- hermes_cli/slack_cli.py +155 -0
- hermes_cli/status.py +397 -0
- hermes_cli/update_cmd_git.py +490 -0
- hermes_cli/update_cmd_stash.py +375 -0
- hermes_cli/web_read_coalescing.py +66 -0
- hermes_cli/web_server_dashboard.py +836 -0
- hermes_cli/web_server_sessions.py +298 -0
- hermes_constants.py +1293 -0
- hermes_state_registry.py +462 -0
- hermes_state_repair.py +1152 -0
- hermes_state_sessions.py +1627 -0
- hermes_state_telegram.py +359 -0
- hermes_state_titles.py +179 -0
- hermes_state_wal.py +622 -0
- optional-skills/DESCRIPTION.md +24 -0
.hadolint.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# hadolint configuration for the Hermes Agent Dockerfile.
|
| 2 |
+
# See https://github.com/hadolint/hadolint#configure for rules.
|
| 3 |
+
#
|
| 4 |
+
# We want hadolint to surface NEW Dockerfile lint regressions, but we
|
| 5 |
+
# don't want to rewrite the existing image to silence rules that are
|
| 6 |
+
# either intentional or pragmatic tradeoffs for this project. Each
|
| 7 |
+
# ignore below has a one-line justification.
|
| 8 |
+
failure-threshold: warning
|
| 9 |
+
|
| 10 |
+
ignored:
|
| 11 |
+
# Pin versions in apt get install. We intentionally don't pin common
|
| 12 |
+
# tools (curl, git, openssh-client, etc.) — security updates flow in
|
| 13 |
+
# via the periodic base-image rebuild, and pinning would lock us to
|
| 14 |
+
# superseded patch releases. Same rationale as nearly every distro-
|
| 15 |
+
# base official image (python, node, debian).
|
| 16 |
+
- DL3008
|
| 17 |
+
# Use WORKDIR to switch to a directory. The image uses `(cd web && …)`
|
| 18 |
+
# / `(cd ../ui-tui && …)` inline subshells for one-off build steps
|
| 19 |
+
# because they don't affect later RUN commands; promoting them to
|
| 20 |
+
# full WORKDIR switches with restores would obscure intent.
|
| 21 |
+
- DL3003
|
| 22 |
+
# Multiple consecutive RUN instructions. The `touch README.md` + `uv
|
| 23 |
+
# sync` split is intentional — `touch` is cheap, `uv sync` is the
|
| 24 |
+
# expensive layer-cached step we want isolated, and merging them
|
| 25 |
+
# would invalidate the cache for trivial changes.
|
| 26 |
+
- DL3059
|
| 27 |
+
# Last USER should not be root. /init (s6-overlay) runs as root so the
|
| 28 |
+
# stage2 hook can usermod/groupmod and chown the data volume per
|
| 29 |
+
# HERMES_UID at runtime; each supervised service then drops to the
|
| 30 |
+
# hermes user via `s6-setuidgid`.
|
| 31 |
+
- DL3002
|
| 32 |
+
|
| 33 |
+
# Require explicit base-image pins (SHA256) — we already do this.
|
| 34 |
+
trustedRegistries:
|
| 35 |
+
- docker.io
|
| 36 |
+
- ghcr.io
|
Dockerfile
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
|
| 2 |
+
# corruption bug. Build a pinned shared library for the runtime image instead
|
| 3 |
+
# of relying on a distro backport that trixie does not currently provide.
|
| 4 |
+
# See #70480 and https://sqlite.org/wal.html#walresetbug.
|
| 5 |
+
FROM debian:13.4 AS sqlite_build
|
| 6 |
+
ARG SQLITE_AUTOCONF_VERSION=3530400
|
| 7 |
+
ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
|
| 8 |
+
RUN apt-get -o Acquire::Retries=3 update && \
|
| 9 |
+
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
| 10 |
+
build-essential ca-certificates curl && \
|
| 11 |
+
rm -rf /var/lib/apt/lists/* && \
|
| 12 |
+
(curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
|
| 13 |
+
-o /tmp/sqlite.tar.gz \
|
| 14 |
+
"https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
|
| 15 |
+
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
|
| 16 |
+
-o /tmp/sqlite.tar.gz \
|
| 17 |
+
"https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
|
| 18 |
+
printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
|
| 19 |
+
sha256sum -c /tmp/sqlite.sha256 && \
|
| 20 |
+
tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
|
| 21 |
+
cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
|
| 22 |
+
CFLAGS="-O2 \
|
| 23 |
+
-DSQLITE_ENABLE_FTS3 \
|
| 24 |
+
-DSQLITE_ENABLE_FTS3_PARENTHESIS \
|
| 25 |
+
-DSQLITE_ENABLE_FTS4 \
|
| 26 |
+
-DSQLITE_ENABLE_FTS5 \
|
| 27 |
+
-DSQLITE_ENABLE_RTREE \
|
| 28 |
+
-DSQLITE_ENABLE_GEOPOLY \
|
| 29 |
+
-DSQLITE_ENABLE_COLUMN_METADATA \
|
| 30 |
+
-DSQLITE_ENABLE_UNLOCK_NOTIFY \
|
| 31 |
+
-DSQLITE_ENABLE_DBSTAT_VTAB \
|
| 32 |
+
-DSQLITE_ENABLE_DBPAGE_VTAB \
|
| 33 |
+
-DSQLITE_ENABLE_MATH_FUNCTIONS \
|
| 34 |
+
-DSQLITE_ENABLE_PREUPDATE_HOOK \
|
| 35 |
+
-DSQLITE_ENABLE_SESSION \
|
| 36 |
+
-DSQLITE_SECURE_DELETE \
|
| 37 |
+
-DSQLITE_THREADSAFE=1 \
|
| 38 |
+
-DSQLITE_MAX_VARIABLE_NUMBER=250000" \
|
| 39 |
+
./configure --prefix=/opt/sqlite-fixed --disable-static && \
|
| 40 |
+
make -j"$(nproc)" && \
|
| 41 |
+
make install
|
| 42 |
+
|
| 43 |
+
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
| 44 |
+
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
| 45 |
+
# which reached EOL in April 2026 — we copy node + npm from the upstream
|
| 46 |
+
# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere).
|
| 47 |
+
# Bookworm-based slim image used so the produced binary links
|
| 48 |
+
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc
|
| 49 |
+
# 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see
|
| 50 |
+
# #4977.
|
| 51 |
+
FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source
|
| 52 |
+
FROM debian:13.4
|
| 53 |
+
|
| 54 |
+
# Disable Python stdout buffering to ensure logs are printed immediately.
|
| 55 |
+
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
|
| 56 |
+
# published container and writable state belongs under /opt/data.
|
| 57 |
+
ENV PYTHONUNBUFFERED=1
|
| 58 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 59 |
+
|
| 60 |
+
# Store Playwright browsers outside the volume mount so the build-time
|
| 61 |
+
# install survives the /opt/data volume overlay at runtime.
|
| 62 |
+
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
| 63 |
+
|
| 64 |
+
# Install system dependencies in one layer, clear APT cache.
|
| 65 |
+
# tini was previously PID 1 to reap orphaned zombie processes (MCP stdio
|
| 66 |
+
# subprocesses, git, bun, etc.) that would otherwise accumulate when hermes
|
| 67 |
+
# ran as PID 1. See #15012. Phase 2 of the s6-overlay supervision plan
|
| 68 |
+
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
|
| 69 |
+
# zombies non-blockingly on SIGCHLD and additionally supervises the main
|
| 70 |
+
# hermes process, the dashboard, and per-profile gateways.
|
| 71 |
+
RUN apt-get -o Acquire::Retries=3 update && \
|
| 72 |
+
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
| 73 |
+
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils && \
|
| 74 |
+
rm -rf /var/lib/apt/lists/*
|
| 75 |
+
|
| 76 |
+
# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
|
| 77 |
+
# public library name stable so both the system interpreter and the uv-created
|
| 78 |
+
# venv resolve the replacement without changing Python import paths.
|
| 79 |
+
COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
|
| 80 |
+
RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
|
| 81 |
+
ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
|
| 82 |
+
printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
|
| 83 |
+
ldconfig && \
|
| 84 |
+
python3 -c "import sqlite3, sys; \
|
| 85 |
+
v = sqlite3.sqlite_version_info; \
|
| 86 |
+
sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
|
| 87 |
+
db = sqlite3.connect(':memory:'); \
|
| 88 |
+
db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
|
| 89 |
+
db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
|
| 90 |
+
sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
|
| 91 |
+
db.close()"
|
| 92 |
+
|
| 93 |
+
# ---------- s6-overlay install ----------
|
| 94 |
+
# s6-overlay provides supervision for the main hermes process, the dashboard,
|
| 95 |
+
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.
|
| 96 |
+
#
|
| 97 |
+
# Multi-arch: BuildKit auto-populates TARGETARCH (amd64 / arm64). s6-overlay
|
| 98 |
+
# uses tarball names keyed on the kernel arch string (x86_64 / aarch64), so
|
| 99 |
+
# we map between them inline. The noarch + symlinks tarballs are
|
| 100 |
+
# architecture-independent and reused as-is.
|
| 101 |
+
#
|
| 102 |
+
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
|
| 103 |
+
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
|
| 104 |
+
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
|
| 105 |
+
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
|
| 106 |
+
# and every tarball is still checksum-verified below before extraction.
|
| 107 |
+
ARG TARGETARCH
|
| 108 |
+
ARG S6_OVERLAY_VERSION=3.2.3.0
|
| 109 |
+
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
|
| 110 |
+
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
|
| 111 |
+
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
|
| 112 |
+
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
|
| 113 |
+
RUN set -eu; \
|
| 114 |
+
case "${TARGETARCH:-amd64}" in \
|
| 115 |
+
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
|
| 116 |
+
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
|
| 117 |
+
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
|
| 118 |
+
esac; \
|
| 119 |
+
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
|
| 120 |
+
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
|
| 121 |
+
"${base}/s6-overlay-noarch.tar.xz"; \
|
| 122 |
+
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
|
| 123 |
+
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
|
| 124 |
+
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
|
| 125 |
+
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
|
| 126 |
+
{ \
|
| 127 |
+
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
|
| 128 |
+
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
|
| 129 |
+
printf '%s %s\n' "${S6_OVERLAY_SYMLINKS_SHA256}" /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
| 130 |
+
} > /tmp/s6-overlay.sha256; \
|
| 131 |
+
sha256sum -c /tmp/s6-overlay.sha256; \
|
| 132 |
+
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
|
| 133 |
+
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
|
| 134 |
+
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
| 135 |
+
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
|
| 136 |
+
|
| 137 |
+
# #34192 / #66679: backward-compat shim for orchestration templates that
|
| 138 |
+
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
|
| 139 |
+
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
|
| 140 |
+
# entrypoint on image update, etc.). A plain symlink to /init made the
|
| 141 |
+
# path exist, but forwarded tini flags like `-g` into s6-overlay's
|
| 142 |
+
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
|
| 143 |
+
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
|
| 144 |
+
# tini CLI surface, then exec's /init + main-wrapper — see
|
| 145 |
+
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
|
| 146 |
+
# updated.
|
| 147 |
+
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
|
| 148 |
+
|
| 149 |
+
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
| 150 |
+
RUN useradd -u 10000 -m -d /opt/data hermes
|
| 151 |
+
|
| 152 |
+
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
| 153 |
+
|
| 154 |
+
# Node 26: copy the node binary plus the bundled npm JS install from the
|
| 155 |
+
# upstream image. npm and npx are recreated as symlinks because they're
|
| 156 |
+
# symlinks in the source image (and need to live on PATH).
|
| 157 |
+
#
|
| 158 |
+
# No corepack: Node unbundled it upstream, so node:26 ships only npm in
|
| 159 |
+
# /usr/local/lib/node_modules. Nothing here needs it — no package.json
|
| 160 |
+
# declares a `packageManager`, and no build step shells out to yarn or pnpm.
|
| 161 |
+
#
|
| 162 |
+
# See node_source stage at the top of the file for the version-bump
|
| 163 |
+
# rationale (#4977).
|
| 164 |
+
COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
|
| 165 |
+
COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
| 166 |
+
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
| 167 |
+
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
| 168 |
+
|
| 169 |
+
WORKDIR /opt/hermes
|
| 170 |
+
|
| 171 |
+
# ---------- Layer-cached dependency install ----------
|
| 172 |
+
# Copy only package manifests first so npm install + Playwright are cached
|
| 173 |
+
# unless the lockfiles themselves change.
|
| 174 |
+
#
|
| 175 |
+
# ui-tui/packages/hermes-ink/ is copied IN FULL (not just its manifests)
|
| 176 |
+
# because it is referenced as a `file:` workspace dependency from
|
| 177 |
+
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
| 178 |
+
# workspace to real content instead of stopping at a bare package.json.
|
| 179 |
+
COPY package.json package-lock.json ./
|
| 180 |
+
COPY web/package.json web/
|
| 181 |
+
COPY ui-tui/package.json ui-tui/
|
| 182 |
+
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
| 183 |
+
# apps/shared/ is copied IN FULL because web/package.json references it as a
|
| 184 |
+
# `file:` workspace dependency (same pattern as hermes-ink above).
|
| 185 |
+
COPY apps/shared/ apps/shared/
|
| 186 |
+
|
| 187 |
+
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
| 188 |
+
# symlinks instead of copies. This is the default since npm 10+, which is
|
| 189 |
+
# what the image ships now (via the node:22 source stage). We set it
|
| 190 |
+
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
| 191 |
+
# 9.x defaulted to install-as-copy, which produced a hidden
|
| 192 |
+
# node_modules/.package-lock.json that permanently disagreed with the root
|
| 193 |
+
# lock on the @hermes/ink entry, tripped the TUI launcher's
|
| 194 |
+
# `_tui_need_npm_install()` check on every startup, and triggered a
|
| 195 |
+
# runtime `npm install` that then failed with EACCES. Keeping the env
|
| 196 |
+
# guards against a future regression if the source npm version changes.
|
| 197 |
+
ENV npm_config_install_links=false
|
| 198 |
+
|
| 199 |
+
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
|
| 200 |
+
for i in 1 2 3; do \
|
| 201 |
+
npx playwright install --with-deps chromium --only-shell && break || \
|
| 202 |
+
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
|
| 203 |
+
done && \
|
| 204 |
+
npm cache clean --force
|
| 205 |
+
|
| 206 |
+
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
|
| 207 |
+
# The photon plugin's Node sidecar needs its own node_modules
|
| 208 |
+
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
|
| 209 |
+
# `npm ci` on first connect would hit EROFS — bake the deps here instead
|
| 210 |
+
# (deterministic installs, NS-559). The patch script is copied alongside
|
| 211 |
+
# the manifests because package.json's postinstall runs it, which also
|
| 212 |
+
# means the spectrum-ts patch is applied at build time. Layer-cached:
|
| 213 |
+
# only re-runs when the sidecar manifests/patch change.
|
| 214 |
+
COPY plugins/platforms/photon/sidecar/package.json \
|
| 215 |
+
plugins/platforms/photon/sidecar/package-lock.json \
|
| 216 |
+
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
|
| 217 |
+
plugins/platforms/photon/sidecar/
|
| 218 |
+
RUN cd plugins/platforms/photon/sidecar && \
|
| 219 |
+
npm ci --no-audit --fetch-retries=5 && \
|
| 220 |
+
npm cache clean --force
|
| 221 |
+
|
| 222 |
+
# ---------- Layer-cached Python dependency install ----------
|
| 223 |
+
# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
|
| 224 |
+
# download + native-extension compile layer is cached unless those inputs
|
| 225 |
+
# change. Before this split the Python install sat after `COPY . .`, so
|
| 226 |
+
# every source-only commit re-did ~4-5 min of dep work on cold builds.
|
| 227 |
+
#
|
| 228 |
+
# README.md is referenced by pyproject.toml's `readme =` field, but it's
|
| 229 |
+
# excluded from the build context by .dockerignore's `*.md`. uv's build
|
| 230 |
+
# frontend stats the readme path during dep resolution, so we `touch` an
|
| 231 |
+
# empty placeholder — the real README is restored by `COPY . .` below.
|
| 232 |
+
#
|
| 233 |
+
# `uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp`
|
| 234 |
+
# installs the deps reachable through the composite `[all]` extra
|
| 235 |
+
# (handpicked set intended for the production image — excludes `[dev]`),
|
| 236 |
+
# plus gateway messaging adapters that should work in the published image
|
| 237 |
+
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
| 238 |
+
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
| 239 |
+
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
| 240 |
+
# redundancy), none of which belong in the published container.
|
| 241 |
+
#
|
| 242 |
+
# Provider packages (anthropic, bedrock, azure-identity) are included
|
| 243 |
+
# so Docker users can use these providers without requiring runtime
|
| 244 |
+
# lazy-install access to PyPI (often blocked in containerized envs).
|
| 245 |
+
#
|
| 246 |
+
# The [otlp] extra contains the SDK/exporter imported by Hermes when Gateway
|
| 247 |
+
# Health export is enabled. Collector and observability-backend dependencies
|
| 248 |
+
# remain external and are not part of the Hermes production image.
|
| 249 |
+
#
|
| 250 |
+
# The hindsight memory provider's client (hindsight-client) is baked in
|
| 251 |
+
# for the same reason: it lazy-installs into /opt/hermes/.venv at first
|
| 252 |
+
# use, which lives inside the (immutable) image layer rather than the
|
| 253 |
+
# mounted /opt/data volume, so it is lost on every container recreate /
|
| 254 |
+
# image update and recall/retain then fails with
|
| 255 |
+
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
| 256 |
+
#
|
| 257 |
+
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
| 258 |
+
# python-olm (transitive via mautrix[encryption]) builds from source on
|
| 259 |
+
# Python/image combinations without usable wheels. The Docker image is
|
| 260 |
+
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
| 261 |
+
# avoids the cross-platform failures that kept [matrix] out of [all]
|
| 262 |
+
# while still making Matrix work in the published container. Fixes #30399.
|
| 263 |
+
#
|
| 264 |
+
# Google Chat's [google-chat] extra (google-cloud-pubsub + Chat API clients)
|
| 265 |
+
# is baked so hosted/immutable images can enable the adapter without writing
|
| 266 |
+
# the sealed venv. Runtime --install-deps still routes through lazy_deps into
|
| 267 |
+
# HERMES_LAZY_INSTALL_TARGET when the extra is not present.
|
| 268 |
+
#
|
| 269 |
+
# The editable link is created after the source copy below.
|
| 270 |
+
COPY pyproject.toml uv.lock ./
|
| 271 |
+
RUN touch ./README.md
|
| 272 |
+
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra otlp --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix --extra google-chat
|
| 273 |
+
|
| 274 |
+
# ---------- Frontend build (cached independently from Python source) ----------
|
| 275 |
+
# Copy only the frontend source trees first so that Python-only changes don't
|
| 276 |
+
# invalidate the (relatively slow) web + ui-tui build layer.
|
| 277 |
+
COPY web/ web/
|
| 278 |
+
COPY ui-tui/ ui-tui/
|
| 279 |
+
COPY apps/shared/ apps/shared/
|
| 280 |
+
RUN cd web && npm run build && \
|
| 281 |
+
cd ../ui-tui && npm run build
|
| 282 |
+
|
| 283 |
+
# ---------- Source code ----------
|
| 284 |
+
# .dockerignore excludes node_modules, so the installs above survive.
|
| 285 |
+
# --link decouples this layer from parents for cache purposes; --chmod bakes
|
| 286 |
+
# the final read-only permissions at copy time so we skip the separate
|
| 287 |
+
# `chmod -R` pass that previously walked ~30k files across the venv +
|
| 288 |
+
# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
|
| 289 |
+
# gives the non-root hermes user read + traverse but no write; root retains
|
| 290 |
+
# write so the build steps below don't need chmod u+w dances.
|
| 291 |
+
COPY --link --chmod=a+rX,go-w . .
|
| 292 |
+
|
| 293 |
+
# ---------- Permissions ----------
|
| 294 |
+
# Link hermes-agent itself (editable). Deps are already installed in the
|
| 295 |
+
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
| 296 |
+
# resolution or downloads.
|
| 297 |
+
RUN uv pip install --no-cache-dir --no-deps -e "."
|
| 298 |
+
|
| 299 |
+
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
|
| 300 |
+
# already root-owned (COPY, uv sync, npm install all run as root) and
|
| 301 |
+
# read-only for the hermes user (go-w from the --chmod above).
|
| 302 |
+
|
| 303 |
+
USER root
|
| 304 |
+
RUN mkdir -p /opt/hermes/bin && \
|
| 305 |
+
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
| 306 |
+
chmod 0755 /opt/hermes /opt/hermes/bin/hermes && \
|
| 307 |
+
printf 'docker\n' > /opt/hermes/.install_method
|
| 308 |
+
# The ``.install_method`` stamp is baked next to the running code (the install
|
| 309 |
+
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
|
| 310 |
+
# volume that is commonly bind-mounted from the host and even shared with a
|
| 311 |
+
# host-side Desktop/CLI install; stamping it at boot used to clobber that
|
| 312 |
+
# host install's marker and wrongly block its ``hermes update``. A code-scoped
|
| 313 |
+
# stamp is read first by detect_install_method() and is immune to the share.
|
| 314 |
+
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
| 315 |
+
# the data volume. Each supervised service then drops to the hermes user via
|
| 316 |
+
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
| 317 |
+
# run as the default hermes user (UID 10000).
|
| 318 |
+
|
| 319 |
+
# ---------- Bake image provenance + build-time git revision ----------
|
| 320 |
+
# The versioned, non-secret provenance marker is the authoritative runtime
|
| 321 |
+
# signal that this filesystem came from an immutable image. It deliberately
|
| 322 |
+
# lives outside both /opt/hermes (which operators sometimes bind-mount as a
|
| 323 |
+
# checkout) and /opt/data (the mutable HERMES_HOME volume).
|
| 324 |
+
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
| 325 |
+
# container always returns nothing — meaning `hermes dump` reports
|
| 326 |
+
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
| 327 |
+
# That makes support triage from container bug reports impossible:
|
| 328 |
+
# we can't tell which commit the user is actually running.
|
| 329 |
+
#
|
| 330 |
+
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
| 331 |
+
# /opt/hermes/.hermes_build_sha at build time, and have
|
| 332 |
+
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
| 333 |
+
# banner.get_git_banner_state() try the baked SHA first, then fall back
|
| 334 |
+
# to live `git rev-parse` for source installs (unchanged behaviour).
|
| 335 |
+
#
|
| 336 |
+
# The arg is optional — local `docker build` without --build-arg omits the
|
| 337 |
+
# SHA file (and records a null provenance revision), so build-info falls back
|
| 338 |
+
# to live-git lookup. CI
|
| 339 |
+
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
|
| 340 |
+
# every published image has it.
|
| 341 |
+
ARG HERMES_GIT_SHA=
|
| 342 |
+
RUN set -eu; \
|
| 343 |
+
if [ -n "${HERMES_GIT_SHA}" ]; then \
|
| 344 |
+
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
|
| 345 |
+
fi; \
|
| 346 |
+
mkdir -p /etc/hermes; \
|
| 347 |
+
HERMES_GIT_SHA="${HERMES_GIT_SHA}" python3 -c 'import json, os, pathlib, tomllib; project = tomllib.loads(pathlib.Path("/opt/hermes/pyproject.toml").read_text(encoding="utf-8"))["project"]; marker = pathlib.Path("/etc/hermes/image-provenance.json"); marker.write_text(json.dumps({"schema": 1, "deployment_kind": "image", "manager": "docker", "image": "nousresearch/hermes-agent", "version": project["version"], "revision": os.environ.get("HERMES_GIT_SHA") or None}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8"); marker.chmod(0o444)'
|
| 348 |
+
|
| 349 |
+
# ---------- s6-overlay service wiring ----------
|
| 350 |
+
# Static services declared at build time: main-hermes + dashboard.
|
| 351 |
+
# Per-profile gateway services are registered dynamically at runtime by
|
| 352 |
+
# the profile create/delete hooks (Phase 4); they live under
|
| 353 |
+
# /run/service/ (tmpfs) and are reconciled on container restart by
|
| 354 |
+
# /etc/cont-init.d/02-reconcile-profiles (Phase 4 Task 4.0).
|
| 355 |
+
COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
|
| 356 |
+
|
| 357 |
+
# stage2-hook handles UID/GID remap, volume chown, config seeding,
|
| 358 |
+
# skills sync — all the work the old entrypoint.sh did before
|
| 359 |
+
# `exec hermes`. Wired in as cont-init.d/01- so it
|
| 360 |
+
# runs before user services start.
|
| 361 |
+
#
|
| 362 |
+
# 02-reconcile-profiles re-creates per-profile gateway s6 service
|
| 363 |
+
# slots from $HERMES_HOME/profiles/<name>/ after a container restart
|
| 364 |
+
# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
|
| 365 |
+
RUN mkdir -p /etc/cont-init.d && \
|
| 366 |
+
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
| 367 |
+
> /etc/cont-init.d/01-hermes-setup && \
|
| 368 |
+
chmod +x /etc/cont-init.d/01-hermes-setup
|
| 369 |
+
COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
|
| 370 |
+
COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-reconcile-profiles
|
| 371 |
+
|
| 372 |
+
# ---------- Runtime ----------
|
| 373 |
+
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
| 374 |
+
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
| 375 |
+
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
| 376 |
+
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
| 377 |
+
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
| 378 |
+
# nix/packaged-release path the launcher was designed for.
|
| 379 |
+
#
|
| 380 |
+
# Why this is required (not just an optimization): the root package-lock.json
|
| 381 |
+
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
| 382 |
+
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
| 383 |
+
# never `npm install`ed here). So the actualized node_modules permanently
|
| 384 |
+
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
| 385 |
+
# every launch, and the runtime `npm install` it triggers (a) can never
|
| 386 |
+
# converge against the partial monorepo and (b) races itself across concurrent
|
| 387 |
+
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
| 388 |
+
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
| 389 |
+
# check. (A separate launcher hardening is tracked independently.)
|
| 390 |
+
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
| 391 |
+
ENV HERMES_HOME=/opt/data
|
| 392 |
+
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
| 393 |
+
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
| 394 |
+
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
|
| 395 |
+
# lazy install can't mutate the agent's own venv and brick it. But opt-in
|
| 396 |
+
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
|
| 397 |
+
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
|
| 398 |
+
# policy 2026-05-12: one quarantined release must not break every install).
|
| 399 |
+
# Redirect those lazy installs to a writable dir on the durable data volume.
|
| 400 |
+
# lazy_deps appends this dir to the END of sys.path, so a package installed
|
| 401 |
+
# here can only ADD modules — it can never shadow or downgrade a core module,
|
| 402 |
+
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
|
| 403 |
+
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
|
| 404 |
+
# on the /opt/data volume, so it persists across container recreates / image
|
| 405 |
+
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
|
| 406 |
+
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
| 407 |
+
|
| 408 |
+
# `docker exec` privilege-drop shim. When operators run
|
| 409 |
+
# `docker exec <c> hermes ...` they default to root, and any file the
|
| 410 |
+
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
|
| 411 |
+
# up root-owned and unreadable to the supervised gateway (UID 10000).
|
| 412 |
+
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
|
| 413 |
+
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
|
| 414 |
+
# when invoked as root. Non-root callers (supervised processes,
|
| 415 |
+
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
|
| 416 |
+
# Recursion is impossible because the shim exec's the venv binary by
|
| 417 |
+
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
| 418 |
+
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
| 419 |
+
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
| 420 |
+
COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh
|
| 421 |
+
|
| 422 |
+
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
| 423 |
+
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
| 424 |
+
# same for the container's main process, but `docker exec` and our
|
| 425 |
+
# cont-init.d scripts don't pass through the wrapper. Expose the venv
|
| 426 |
+
# bin globally so `docker exec <container> hermes ...` and any
|
| 427 |
+
# subprocess that doesn't activate the venv first still find hermes.
|
| 428 |
+
#
|
| 429 |
+
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
|
| 430 |
+
# shim wins PATH resolution. The shim's last act is to exec the venv
|
| 431 |
+
# binary by absolute path, so this PATH ordering is transparent to
|
| 432 |
+
# every other consumer.
|
| 433 |
+
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
| 434 |
+
RUN mkdir -p /opt/data
|
| 435 |
+
VOLUME [ "/opt/data" ]
|
| 436 |
+
|
| 437 |
+
# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly.
|
| 438 |
+
# When the image really owns PID 1 (normal Docker / Podman), the dispatcher
|
| 439 |
+
# execs `/init` and preserves the full s6 supervision tree. When a platform
|
| 440 |
+
# wraps the image entrypoint under its own PID-1 init (Fly Machines,
|
| 441 |
+
# `docker run --init`, some schedulers), `/init` would abort with
|
| 442 |
+
# `can only run as pid 1`; in that case the dispatcher falls back to
|
| 443 |
+
# `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still
|
| 444 |
+
# work. See #38349.
|
| 445 |
+
#
|
| 446 |
+
# On the PID-1 path, s6-overlay's /init sets up the supervision tree, runs
|
| 447 |
+
# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services declared in
|
| 448 |
+
# /etc/s6-overlay/s6-rc.d/, then exec's its remaining argv as the container's
|
| 449 |
+
# "main program" with stdin/stdout/stderr inherited (this is what makes
|
| 450 |
+
# interactive --tui work). When the main program exits, /init begins stage 3
|
| 451 |
+
# shutdown and the container exits with the program's exit code. Replaces
|
| 452 |
+
# tini — see Phase 2 of docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md.
|
| 453 |
+
#
|
| 454 |
+
# We use the ENTRYPOINT+CMD split rather than CMD alone so the
|
| 455 |
+
# wrapper is prepended to user-supplied args automatically:
|
| 456 |
+
#
|
| 457 |
+
# docker run <image> → entrypoint-dispatch.sh (CMD default)
|
| 458 |
+
# docker run <image> chat -q "hi" → entrypoint-dispatch.sh chat -q hi
|
| 459 |
+
# docker run <image> sleep infinity → entrypoint-dispatch.sh sleep infinity
|
| 460 |
+
# docker run <image> --tui → entrypoint-dispatch.sh --tui
|
| 461 |
+
#
|
| 462 |
+
# main-wrapper.sh handles arg routing (bare-exec vs. hermes
|
| 463 |
+
# subcommand vs. no-args), drops to the hermes user via s6-setuidgid,
|
| 464 |
+
# and exec's the final program so its exit code becomes the container
|
| 465 |
+
# exit code. The dispatcher preserves that contract across both the
|
| 466 |
+
# supervised PID-1 path and the non-PID-1 fallback path. Without the
|
| 467 |
+
# wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be
|
| 468 |
+
# intercepted by /init's POSIX shell.
|
| 469 |
+
ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ]
|
| 470 |
+
CMD [ ]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Nous Research
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.es.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
|
| 3 |
+
</p>
|
| 4 |
+
|
| 5 |
+
# Hermes Agent ☤
|
| 6 |
+
<p align="center">
|
| 7 |
+
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
|
| 8 |
+
</p>
|
| 9 |
+
<p align="center">
|
| 10 |
+
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentación"></a>
|
| 11 |
+
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
| 12 |
+
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/Licencia-MIT-green?style=for-the-badge" alt="Licencia: MIT"></a>
|
| 13 |
+
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Creado%20por-Nous%20Research-blueviolet?style=for-the-badge" alt="Creado por Nous Research"></a>
|
| 14 |
+
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-blue?style=for-the-badge" alt="English"></a>
|
| 15 |
+
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
| 16 |
+
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
|
| 17 |
+
</p>
|
| 18 |
+
|
| 19 |
+
**El agente de IA con mejora continua creado por [Nous Research](https://nousresearch.com).** Es el único agente con un bucle de aprendizaje integrado: crea habilidades a partir de la experiencia, las mejora durante el uso, se impulsa a sí mismo a persistir el conocimiento, busca en sus propias conversaciones pasadas y construye un modelo cada vez más profundo de quién eres a lo largo de las sesiones. Ejecútalo en un VPS de $5, un clúster de GPUs o infraestructura sin servidor que cuesta casi nada cuando está inactivo. No está atado a tu laptop — habla con él desde Telegram mientras trabaja en una VM en la nube.
|
| 20 |
+
|
| 21 |
+
Usa cualquier modelo que quieras — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (más de 200 modelos), [NovitaAI](https://novita.ai), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, o tu propio endpoint. Cambia con `hermes model` — sin cambios de código, sin dependencias.
|
| 22 |
+
|
| 23 |
+
<table>
|
| 24 |
+
<tr><td><b>Una interfaz de terminal real</b></td><td>TUI completa con edición multilínea, autocompletado de comandos, historial de conversaciones, interrupción y redirección, y salida de herramientas en streaming.</td></tr>
|
| 25 |
+
<tr><td><b>Vive donde tú vives</b></td><td>Telegram, Discord, Slack, WhatsApp, Signal y CLI — todo desde un único proceso gateway. Transcripción de notas de voz, continuidad de conversación entre plataformas.</td></tr>
|
| 26 |
+
<tr><td><b>Un bucle de aprendizaje cerrado</b></td><td>Memoria curada por el agente con recordatorios periódicos. Creación autónoma de habilidades tras tareas complejas. Las habilidades mejoran solas durante el uso. Búsqueda FTS5 de sesiones con resumención por LLM para recuperación entre sesiones. Modelado de usuario dialéctico <a href="https://github.com/plastic-labs/honcho">Honcho</a>. Compatible con el estándar abierto de <a href="https://agentskills.io">agentskills.io</a>.</td></tr>
|
| 27 |
+
<tr><td><b>Automatizaciones programadas</b></td><td>Planificador cron integrado con entrega a cualquier plataforma. Informes diarios, copias de seguridad nocturnas, auditorías semanales — todo en lenguaje natural, ejecutándose de forma autónoma.</td></tr>
|
| 28 |
+
<tr><td><b>Delega y paraleliza</b></td><td>Lanza subagentes aislados para flujos de trabajo paralelos. Escribe scripts de Python que llaman a herramientas vía RPC, convirtiendo pipelines de múltiples pasos en turnos de coste cero de contexto.</td></tr>
|
| 29 |
+
<tr><td><b>Funciona en cualquier lugar, no solo en tu laptop</b></td><td>Seis backends de terminal — local, Docker, SSH, Singularity, Modal y Daytona. Daytona y Modal ofrecen persistencia sin servidor — el entorno de tu agente hiberna cuando está inactivo y se activa bajo demanda, costando casi nada entre sesiones. Ejecútalo en un VPS de $5 o un clúster de GPUs.</td></tr>
|
| 30 |
+
<tr><td><b>Listo para investigación</b></td><td>Generación de trayectorias en lote, compresión de trayectorias para entrenar la próxima generación de modelos de llamadas a herramientas.</td></tr>
|
| 31 |
+
</table>
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## Instalación rápida
|
| 36 |
+
|
| 37 |
+
### Linux, macOS, WSL2, Termux
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### Windows (nativo, PowerShell)
|
| 44 |
+
|
| 45 |
+
> **Nota:** En Windows nativo, Hermes funciona sin WSL — la CLI, el gateway, la TUI y las herramientas funcionan de forma nativa. Si prefieres usar WSL2, el comando de Linux/macOS de arriba también funciona allí. ¿Encontraste un error? Por favor [crea un issue](https://github.com/NousResearch/hermes-agent/issues).
|
| 46 |
+
|
| 47 |
+
Ejecuta esto en PowerShell:
|
| 48 |
+
|
| 49 |
+
```powershell
|
| 50 |
+
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y un Git Bash portátil** (MinGit, descomprimido en `%LOCALAPPDATA%\hermes\git` — no requiere administrador, completamente aislado de cualquier instalación de Git del sistema). Hermes usa este Git Bash incluido para ejecutar comandos de shell.
|
| 54 |
+
|
| 55 |
+
Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema.
|
| 56 |
+
|
| 57 |
+
> **Android / Termux:** La ruta manual probada está documentada en la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux). En Termux, Hermes instala el extra `.[termux]` curado porque el extra completo `.[all]` actualmente incluye dependencias de voz incompatibles con Android.
|
| 58 |
+
>
|
| 59 |
+
> **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux.
|
| 60 |
+
|
| 61 |
+
Después de la instalación:
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
source ~/.bashrc # recargar shell (o: source ~/.zshrc)
|
| 65 |
+
hermes # ¡empieza a chatear!
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Primeros pasos
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
hermes # CLI interactiva — inicia una conversación
|
| 74 |
+
hermes model # Elige tu proveedor y modelo LLM
|
| 75 |
+
hermes tools # Configura qué herramientas están habilitadas
|
| 76 |
+
hermes config set # Establece valores de configuración individuales
|
| 77 |
+
hermes gateway # Inicia el gateway de mensajería (Telegram, Discord, etc.)
|
| 78 |
+
hermes setup # Ejecuta el asistente de configuración completo
|
| 79 |
+
hermes claw migrate # Migra desde OpenClaw (si vienes de OpenClaw)
|
| 80 |
+
hermes update # Actualiza a la última versión
|
| 81 |
+
hermes doctor # Diagnostica cualquier problema
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
📖 **[Documentación completa →](https://hermes-agent.nousresearch.com/docs/)**
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Evita la colección de claves API — Nous Portal
|
| 89 |
+
|
| 90 |
+
Hermes funciona con cualquier proveedor que quieras — eso no cambiará. Pero si prefieres no recopilar cinco claves API separadas para el modelo, búsqueda web, generación de imágenes, TTS y un navegador en la nube, **[Nous Portal](https://portal.nousresearch.com)** las cubre todas bajo una sola suscripción:
|
| 91 |
+
|
| 92 |
+
- **Más de 300 modelos** — elige cualquiera con `/model <nombre>`
|
| 93 |
+
- **Tool Gateway** — búsqueda web (Firecrawl), generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales.
|
| 94 |
+
|
| 95 |
+
Un comando desde una instalación nueva:
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
hermes setup --portal
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
Esto te autentica vía OAuth, establece Nous como tu proveedor y activa el Tool Gateway. Comprueba qué está conectado en cualquier momento con `hermes portal info`. Detalles completos en la [página de documentación del Tool Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
|
| 102 |
+
|
| 103 |
+
Puedes seguir usando tus propias claves por herramienta cuando quieras — el gateway es por backend, no todo o nada.
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Referencia rápida: CLI vs Mensajería
|
| 108 |
+
|
| 109 |
+
Hermes tiene dos puntos de entrada: inicia la interfaz de terminal con `hermes`, o ejecuta el gateway y habla con él desde Telegram, Discord, Slack, WhatsApp, Signal o Email. Una vez en una conversación, muchos comandos de barra son compartidos entre ambas interfaces.
|
| 110 |
+
|
| 111 |
+
| Acción | CLI | Plataformas de mensajería |
|
| 112 |
+
| ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
|
| 113 |
+
| Empezar a chatear | `hermes` | Ejecuta `hermes gateway setup` + `hermes gateway start`, luego envía un mensaje al bot |
|
| 114 |
+
| Nueva conversación | `/new` o `/reset` | `/new` o `/reset` |
|
| 115 |
+
| Cambiar modelo | `/model [proveedor:modelo]` | `/model [proveedor:modelo]` |
|
| 116 |
+
| Establecer personalidad | `/personality [nombre]` | `/personality [nombre]` |
|
| 117 |
+
| Reintentar o deshacer último turno | `/retry`, `/undo` | `/retry`, `/undo` |
|
| 118 |
+
| Comprimir contexto / ver uso | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
| 119 |
+
| Explorar habilidades | `/skills` o `/<nombre-habilidad>` | `/<nombre-habilidad>` |
|
| 120 |
+
| Interrumpir trabajo actual | `Ctrl+C` o enviar un nuevo mensaje | `/stop` o enviar un nuevo mensaje |
|
| 121 |
+
| Estado específico de plataforma | `/platforms` | `/status`, `/sethome` |
|
| 122 |
+
|
| 123 |
+
Para las listas de comandos completas, consulta la [guía de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) y la [guía del Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## Documentación
|
| 128 |
+
|
| 129 |
+
Toda la documentación está en **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
|
| 130 |
+
|
| 131 |
+
| Sección | Contenido |
|
| 132 |
+
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
| 133 |
+
| [Inicio rápido](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Instalar → configurar → primera conversación en 2 minutos |
|
| 134 |
+
| [Uso de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Comandos, atajos de teclado, personalidades, sesiones |
|
| 135 |
+
| [Configuración](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Archivo de configuración, proveedores, modelos, todas las opciones |
|
| 136 |
+
| [Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
| 137 |
+
| [Seguridad](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Aprobación de comandos, emparejamiento por DM, aislamiento en contenedor |
|
| 138 |
+
| [Herramientas y Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | Más de 40 herramientas, sistema de toolsets, backends de terminal |
|
| 139 |
+
| [Sistema de Habilidades](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Memoria procedimental, Skills Hub, creación de habilidades |
|
| 140 |
+
| [Memoria](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Memoria persistente, perfiles de usuario, mejores prácticas |
|
| 141 |
+
| [Integración MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Conecta cualquier servidor MCP para capacidades extendidas |
|
| 142 |
+
| [Programación Cron](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Tareas programadas con entrega a plataforma |
|
| 143 |
+
| [Archivos de Contexto](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Contexto de proyecto que da forma a cada conversación |
|
| 144 |
+
| [Arquitectura](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Estructura del proyecto, bucle del agente, clases principales |
|
| 145 |
+
| [Contribuir](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Configuración de desarrollo, proceso de PR, estilo de código |
|
| 146 |
+
| [Referencia de CLI](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | Todos los comandos y flags |
|
| 147 |
+
| [Variables de Entorno](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Referencia completa de variables de entorno |
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## Migración desde OpenClaw
|
| 152 |
+
|
| 153 |
+
Si vienes de OpenClaw, Hermes puede importar automáticamente tu configuración, memorias, habilidades y claves API.
|
| 154 |
+
|
| 155 |
+
**Durante la configuración inicial:** El asistente de configuración (`hermes setup`) detecta automáticamente `~/.openclaw` y ofrece migrar antes de que comience la configuración.
|
| 156 |
+
|
| 157 |
+
**En cualquier momento después de instalar:**
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
hermes claw migrate # Migración interactiva (preset completo)
|
| 161 |
+
hermes claw migrate --dry-run # Vista previa de qué se migraría
|
| 162 |
+
hermes claw migrate --preset user-data # Migrar sin secretos
|
| 163 |
+
hermes claw migrate --overwrite # Sobreescribir conflictos existentes
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
Qué se importa:
|
| 167 |
+
|
| 168 |
+
- **SOUL.md** — archivo de personalidad
|
| 169 |
+
- **Memorias** — entradas de MEMORY.md y USER.md
|
| 170 |
+
- **Habilidades** — habilidades creadas por el usuario → `~/.hermes/skills/openclaw-imports/`
|
| 171 |
+
- **Lista de comandos permitidos** — patrones de aprobación
|
| 172 |
+
- **Configuración de mensajería** — configuración de plataformas, usuarios permitidos, directorio de trabajo
|
| 173 |
+
- **Claves API** — secretos en lista de permitidos (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs)
|
| 174 |
+
- **Assets de TTS** — archivos de audio del espacio de trabajo
|
| 175 |
+
- **Instrucciones del espacio de trabajo** — AGENTS.md (con `--workspace-target`)
|
| 176 |
+
|
| 177 |
+
Consulta `hermes claw migrate --help` para todas las opciones, o usa la habilidad `openclaw-migration` para una migración guiada interactiva por el agente con vistas previas de dry-run.
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## Contribuir
|
| 182 |
+
|
| 183 |
+
¡Las contribuciones son bienvenidas! Consulta la [Guía de Contribución](CONTRIBUTING.es.md) para la configuración del desarrollo, el estilo de código y el proceso de PR.
|
| 184 |
+
|
| 185 |
+
Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`:
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
git clone https://github.com/NousResearch/hermes-agent.git
|
| 189 |
+
cd hermes-agent
|
| 190 |
+
./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes
|
| 191 |
+
./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
Ruta manual (equivalente a lo anterior):
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 198 |
+
uv venv .venv --python 3.11
|
| 199 |
+
source .venv/bin/activate
|
| 200 |
+
uv pip install -e ".[all,dev]"
|
| 201 |
+
scripts/run_tests.sh
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Comunidad
|
| 207 |
+
|
| 208 |
+
- 💬 [Discord](https://discord.gg/NousResearch)
|
| 209 |
+
- 📚 [Skills Hub](https://agentskills.io)
|
| 210 |
+
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
| 211 |
+
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Servidor MCP de control de escritorio Linux para Hermes y otros hosts MCP, con árboles de accesibilidad AT-SPI, entrada Wayland/X11, capturas de pantalla y targeting de ventanas del compositor.
|
| 212 |
+
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Puente WeChat comunitario: Ejecuta Hermes Agent y OpenClaw en la misma cuenta de WeChat.
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## Licencia
|
| 217 |
+
|
| 218 |
+
MIT — ver [LICENSE](LICENSE).
|
| 219 |
+
|
| 220 |
+
Creado por [Nous Research](https://nousresearch.com).
|
README.ur-pk.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div dir="rtl">
|
| 2 |
+
|
| 3 |
+
<p align="center">
|
| 4 |
+
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
|
| 5 |
+
</p>
|
| 6 |
+
|
| 7 |
+
# ہرمیس ایجنٹ ☤ (Hermes Agent)
|
| 8 |
+
|
| 9 |
+
<p align="center">
|
| 10 |
+
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
| 11 |
+
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
| 12 |
+
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
| 13 |
+
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
| 14 |
+
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
|
| 15 |
+
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔
|
| 19 |
+
|
| 20 |
+
آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
|
| 21 |
+
|
| 22 |
+
<table>
|
| 23 |
+
<tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
|
| 24 |
+
<tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
|
| 25 |
+
<tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
|
| 26 |
+
<tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
|
| 27 |
+
<tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
|
| 28 |
+
<tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
|
| 29 |
+
<tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
|
| 30 |
+
</table>
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## فوری انسٹالیشن (Quick Install)
|
| 35 |
+
|
| 36 |
+
### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
|
| 37 |
+
|
| 38 |
+
<div dir="ltr">
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
### ونڈوز (نیٹو، پاور شیل)
|
| 47 |
+
|
| 48 |
+
> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
|
| 49 |
+
|
| 50 |
+
اسے پاور شیل (PowerShell) میں چلائیں:
|
| 51 |
+
|
| 52 |
+
<div dir="ltr">
|
| 53 |
+
|
| 54 |
+
```powershell
|
| 55 |
+
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔
|
| 61 |
+
|
| 62 |
+
اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
|
| 63 |
+
|
| 64 |
+
> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
|
| 65 |
+
>
|
| 66 |
+
> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
|
| 67 |
+
|
| 68 |
+
انسٹالیشن کے بعد:
|
| 69 |
+
|
| 70 |
+
<div dir="ltr">
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
|
| 74 |
+
hermes # بات چیت شروع کریں!
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## آغاز کریں (Getting Started)
|
| 82 |
+
|
| 83 |
+
<div dir="ltr">
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
hermes # انٹرایکٹو CLI — بات چیت شروع کریں
|
| 87 |
+
hermes model # اپنا LLM پرووا��یڈر اور ماڈل منتخب کریں
|
| 88 |
+
hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں
|
| 89 |
+
hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں
|
| 90 |
+
hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ)
|
| 91 |
+
hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا)
|
| 92 |
+
hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں)
|
| 93 |
+
hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں
|
| 94 |
+
hermes doctor # کسی بھی مسئلے کی تشخیص کریں
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
|
| 104 |
+
|
| 105 |
+
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
|
| 106 |
+
|
| 107 |
+
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
|
| 108 |
+
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
|
| 109 |
+
|
| 110 |
+
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
|
| 111 |
+
|
| 112 |
+
<div dir="ltr">
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
hermes setup --portal
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
|
| 121 |
+
|
| 122 |
+
آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## CLI بمقابلہ میسجنگ فوری حوالہ
|
| 127 |
+
|
| 128 |
+
ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
|
| 129 |
+
|
| 130 |
+
<div dir="ltr">
|
| 131 |
+
|
| 132 |
+
| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) |
|
| 133 |
+
| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
| 134 |
+
| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں |
|
| 135 |
+
| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` |
|
| 136 |
+
| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` |
|
| 137 |
+
| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` |
|
| 138 |
+
| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` |
|
| 139 |
+
| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` |
|
| 140 |
+
| مہارتیں (Skills) براؤز کریں | `/skills` یا `/<skill-name>` | `/<skill-name>` |
|
| 141 |
+
| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
|
| 142 |
+
| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
|
| 143 |
+
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## دستاویزات (Documentation)
|
| 151 |
+
|
| 152 |
+
تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں:
|
| 153 |
+
|
| 154 |
+
<div dir="ltr">
|
| 155 |
+
|
| 156 |
+
| سیکشن (Section) | تفصیل (What's Covered) |
|
| 157 |
+
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
| 158 |
+
| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں |
|
| 159 |
+
| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز |
|
| 160 |
+
| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز |
|
| 161 |
+
| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ |
|
| 162 |
+
| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن |
|
| 163 |
+
| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز |
|
| 164 |
+
| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا |
|
| 165 |
+
| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار |
|
| 166 |
+
| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں |
|
| 167 |
+
| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام |
|
| 168 |
+
| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
|
| 169 |
+
| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز |
|
| 170 |
+
| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز |
|
| 171 |
+
| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) |
|
| 172 |
+
| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
|
| 173 |
+
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## OpenClaw سے منتقلی
|
| 179 |
+
|
| 180 |
+
اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
|
| 181 |
+
|
| 182 |
+
**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
|
| 183 |
+
|
| 184 |
+
**انسٹالیشن کے بعد کسی بھی وقت:**
|
| 185 |
+
|
| 186 |
+
<div dir="ltr">
|
| 187 |
+
|
| 188 |
+
```bash
|
| 189 |
+
hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
|
| 190 |
+
hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
|
| 191 |
+
hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
|
| 192 |
+
hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
جو چیزیں امپورٹ ہوتی ہیں:
|
| 198 |
+
|
| 199 |
+
- **SOUL.md** — پرسونا (persona) فائل
|
| 200 |
+
- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات
|
| 201 |
+
- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/`
|
| 202 |
+
- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns)
|
| 203 |
+
- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری
|
| 204 |
+
- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs)
|
| 205 |
+
- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں
|
| 206 |
+
- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ)
|
| 207 |
+
|
| 208 |
+
تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## تعاون کریں (Contributing)
|
| 213 |
+
|
| 214 |
+
ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔
|
| 215 |
+
|
| 216 |
+
معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں:
|
| 217 |
+
|
| 218 |
+
<div dir="ltr">
|
| 219 |
+
|
| 220 |
+
```bash
|
| 221 |
+
git clone https://github.com/NousResearch/hermes-agent.git
|
| 222 |
+
cd hermes-agent
|
| 223 |
+
./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے
|
| 224 |
+
./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
مینوئل طریقہ (اوپر والے طریقے کے مساوی):
|
| 230 |
+
|
| 231 |
+
<div dir="ltr">
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 235 |
+
uv venv .venv --python 3.11
|
| 236 |
+
source .venv/bin/activate
|
| 237 |
+
uv pip install -e ".[all,dev]"
|
| 238 |
+
scripts/run_tests.sh
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## کمیونٹی (Community)
|
| 246 |
+
|
| 247 |
+
- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch)
|
| 248 |
+
- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io)
|
| 249 |
+
- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues)
|
| 250 |
+
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔
|
| 251 |
+
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
## لائسنس (License)
|
| 256 |
+
|
| 257 |
+
MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔
|
| 258 |
+
|
| 259 |
+
[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔
|
| 260 |
+
|
| 261 |
+
</div>
|
batch_runner.py
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Batch Agent Runner — run the agent over a JSONL prompt dataset in parallel.
|
| 3 |
+
|
| 4 |
+
Batches are processed by a multiprocessing pool with per-batch ``batch_N.jsonl`` output,
|
| 5 |
+
checkpointing for ``--resume``, trajectories in from/value format, and tool-usage
|
| 6 |
+
statistics aggregated across all batches. See ``main`` (fire CLI) for usage.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# hermes_bootstrap must be the very first import — UTF-8 stdio on Windows, no-op on POSIX.
|
| 10 |
+
try:
|
| 11 |
+
import hermes_bootstrap # noqa: F401
|
| 12 |
+
except ModuleNotFoundError:
|
| 13 |
+
# Partial ``hermes update`` (git reset landed, ``uv pip install -e .`` did not):
|
| 14 |
+
# only Windows UTF-8 stdio setup is skipped.
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
import traceback
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from multiprocessing import Lock, Pool
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 26 |
+
|
| 27 |
+
import fire
|
| 28 |
+
from rich.console import Console
|
| 29 |
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
|
| 30 |
+
|
| 31 |
+
from model_tools import TOOL_TO_TOOLSET_MAP
|
| 32 |
+
from run_agent import AIAgent
|
| 33 |
+
from toolset_distributions import (
|
| 34 |
+
list_distributions,
|
| 35 |
+
sample_toolsets_from_distribution,
|
| 36 |
+
validate_distribution,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
# Auto-derived from model_tools so it stays in sync as tools are added. Gives every
|
| 42 |
+
# trajectory a consistent tool_stats schema (Arrow/Parquet for HF datasets) and filters
|
| 43 |
+
# corrupted entries (hallucinated tool names) when combining trajectories.
|
| 44 |
+
ALL_POSSIBLE_TOOLS = set(TOOL_TO_TOOLSET_MAP.keys())
|
| 45 |
+
|
| 46 |
+
DEFAULT_TOOL_STATS = {'count': 0, 'success': 0, 'failure': 0}
|
| 47 |
+
_REASONING_KEYS = ("total_assistant_turns", "turns_with_reasoning", "turns_without_reasoning")
|
| 48 |
+
|
| 49 |
+
# BatchRunner.__init__ parameters stored as same-named attributes.
|
| 50 |
+
_RUNNER_FIELDS = (
|
| 51 |
+
"batch_size", "run_name", "distribution", "max_iterations", "base_url", "api_key", "model",
|
| 52 |
+
"num_workers", "verbose", "ephemeral_system_prompt", "log_prefix_chars", "providers_allowed",
|
| 53 |
+
"providers_ignored", "providers_order", "provider_sort", "openrouter_min_coding_score",
|
| 54 |
+
"reasoning_config", "prefill_messages", "max_samples",
|
| 55 |
+
)
|
| 56 |
+
# BatchRunner attributes forwarded verbatim to every AIAgent in the worker config.
|
| 57 |
+
_AGENT_PASSTHROUGH = (
|
| 58 |
+
"base_url", "api_key", "ephemeral_system_prompt", "providers_allowed", "providers_ignored",
|
| 59 |
+
"providers_order", "provider_sort", "openrouter_min_coding_score",
|
| 60 |
+
"reasoning_config", "prefill_messages",
|
| 61 |
+
# Without this, every batch task run is attributed to the "unknown" execution
|
| 62 |
+
# surface in shared metrics even though "batch" is a first-class surface.
|
| 63 |
+
"platform",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _normalize_tool_stats(tool_stats: Dict[str, Dict[str, int]]) -> Dict[str, Dict[str, int]]:
|
| 68 |
+
"""All possible tools with zero defaults (consistent HF schema), plus any unexpected tools."""
|
| 69 |
+
normalized = {
|
| 70 |
+
tool: tool_stats[tool].copy() if tool in tool_stats else DEFAULT_TOOL_STATS.copy()
|
| 71 |
+
for tool in ALL_POSSIBLE_TOOLS
|
| 72 |
+
}
|
| 73 |
+
for tool, stats in tool_stats.items():
|
| 74 |
+
if tool not in normalized:
|
| 75 |
+
normalized[tool] = stats.copy()
|
| 76 |
+
return normalized
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _normalize_tool_error_counts(tool_error_counts: Dict[str, int]) -> Dict[str, int]:
|
| 80 |
+
"""All possible tools with zero defaults, plus any unexpected tools."""
|
| 81 |
+
normalized = {tool: tool_error_counts.get(tool, 0) for tool in ALL_POSSIBLE_TOOLS}
|
| 82 |
+
for tool, count in tool_error_counts.items():
|
| 83 |
+
if tool not in normalized:
|
| 84 |
+
normalized[tool] = count
|
| 85 |
+
return normalized
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _merge_tool_stats(total: Dict[str, Dict[str, int]], tool_stats: Dict[str, Dict[str, int]]) -> None:
|
| 89 |
+
"""Add per-tool count/success/failure from *tool_stats* into *total* in place."""
|
| 90 |
+
for tool_name, stats in tool_stats.items():
|
| 91 |
+
agg = total.setdefault(tool_name, DEFAULT_TOOL_STATS.copy())
|
| 92 |
+
agg["count"] += stats["count"]
|
| 93 |
+
agg["success"] += stats["success"]
|
| 94 |
+
agg["failure"] += stats["failure"]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _merge_reasoning_stats(total: Dict[str, int], reasoning_stats: Dict[str, Any]) -> None:
|
| 98 |
+
"""Add the turn counters from *reasoning_stats* into *total* in place."""
|
| 99 |
+
for key in total:
|
| 100 |
+
total[key] += reasoning_stats.get(key, 0)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _tool_call_succeeded(content) -> bool:
|
| 104 |
+
"""Judge a tool response: JSON with a non-null ``error`` / ``success: false`` fails;
|
| 105 |
+
non-JSON fails only when empty or starting with ``Error:`` (no substring matching, to
|
| 106 |
+
avoid false positives). Non-zero exit codes are NOT failures — the model self-corrects."""
|
| 107 |
+
try:
|
| 108 |
+
content_json = json.loads(content) if isinstance(content, str) else content
|
| 109 |
+
except (json.JSONDecodeError, ValueError, TypeError):
|
| 110 |
+
return bool(content) and not content.strip().lower().startswith("error:")
|
| 111 |
+
if not isinstance(content_json, dict):
|
| 112 |
+
return True
|
| 113 |
+
if content_json.get("error") is not None:
|
| 114 |
+
return False
|
| 115 |
+
# Terminal wraps its response in a "content" field.
|
| 116 |
+
inner_content = content_json.get("content")
|
| 117 |
+
if isinstance(inner_content, dict) and inner_content.get("error") is not None:
|
| 118 |
+
return False
|
| 119 |
+
return content_json.get("success") is not False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _extract_tool_stats(messages: List[Dict[str, Any]]) -> Dict[str, Dict[str, int]]:
|
| 123 |
+
"""Per-tool call counts and success/failure tallies from a message history."""
|
| 124 |
+
tool_stats = {}
|
| 125 |
+
tool_calls_map = {} # tool_call_id -> tool name
|
| 126 |
+
|
| 127 |
+
for msg in messages:
|
| 128 |
+
if msg["role"] == "assistant" and "tool_calls" in msg and msg["tool_calls"]:
|
| 129 |
+
for tool_call in msg["tool_calls"]:
|
| 130 |
+
if not tool_call or not isinstance(tool_call, dict): continue
|
| 131 |
+
tool_name = tool_call["function"]["name"]
|
| 132 |
+
tool_stats.setdefault(tool_name, DEFAULT_TOOL_STATS.copy())["count"] += 1
|
| 133 |
+
tool_calls_map[tool_call["id"]] = tool_name
|
| 134 |
+
elif msg["role"] == "tool":
|
| 135 |
+
tool_call_id = msg.get("tool_call_id", "")
|
| 136 |
+
is_success = _tool_call_succeeded(msg.get("content", ""))
|
| 137 |
+
if tool_call_id in tool_calls_map:
|
| 138 |
+
tool_stats[tool_calls_map[tool_call_id]]["success" if is_success else "failure"] += 1
|
| 139 |
+
|
| 140 |
+
return tool_stats
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _turn_has_reasoning(msg: Dict[str, Any]) -> bool:
|
| 144 |
+
"""``<REASONING_SCRATCHPAD>`` in content, or a non-empty native ``reasoning`` field."""
|
| 145 |
+
if "<REASONING_SCRATCHPAD>" in (msg.get("content", "") or ""):
|
| 146 |
+
return True
|
| 147 |
+
return bool(msg.get("reasoning", "").strip()) if msg.get("reasoning") else False
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _extract_reasoning_stats(messages: List[Dict[str, Any]]) -> Dict[str, int]:
|
| 151 |
+
"""Count assistant turns with reasoning vs without."""
|
| 152 |
+
assistant_turns = [msg for msg in messages if msg.get("role") == "assistant"]
|
| 153 |
+
total = len(assistant_turns)
|
| 154 |
+
with_reasoning = sum(1 for msg in assistant_turns if _turn_has_reasoning(msg))
|
| 155 |
+
return {
|
| 156 |
+
"total_assistant_turns": total,
|
| 157 |
+
"turns_with_reasoning": with_reasoning,
|
| 158 |
+
"turns_without_reasoning": total - with_reasoning,
|
| 159 |
+
"has_any_reasoning": with_reasoning > 0,
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _failure_result(prompt_index: int, batch_num: int, error: str) -> Dict[str, Any]:
|
| 164 |
+
"""Result dict for a prompt that produced no trajectory."""
|
| 165 |
+
return {
|
| 166 |
+
"success": False,
|
| 167 |
+
"prompt_index": prompt_index,
|
| 168 |
+
"error": error,
|
| 169 |
+
"trajectory": None,
|
| 170 |
+
"tool_stats": {},
|
| 171 |
+
"toolsets_used": [],
|
| 172 |
+
"metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat()},
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _prepare_container_image(
|
| 177 |
+
prompt_index: int, prompt_data: Dict[str, Any], batch_num: int, task_id: str, config: Dict[str, Any]
|
| 178 |
+
) -> Optional[Dict[str, Any]]:
|
| 179 |
+
"""Register the dataset row's per-prompt container image (``image``/``docker_image``)
|
| 180 |
+
for this task's sandbox (Docker, Modal, Singularity, Daytona).
|
| 181 |
+
|
| 182 |
+
For Docker the image is verified (local cache, then pull) before spending tokens on the
|
| 183 |
+
agent loop; Modal pulls server-side so no local check. Returns a failure result when the
|
| 184 |
+
pull fails, else ``None``.
|
| 185 |
+
"""
|
| 186 |
+
container_image = prompt_data.get("image") or prompt_data.get("docker_image")
|
| 187 |
+
if not container_image:
|
| 188 |
+
return None
|
| 189 |
+
env_type = os.getenv("TERMINAL_ENV", "local")
|
| 190 |
+
if env_type == "docker":
|
| 191 |
+
import subprocess as _sp
|
| 192 |
+
try:
|
| 193 |
+
probe = _sp.run(
|
| 194 |
+
["docker", "image", "inspect", container_image],
|
| 195 |
+
capture_output=True, timeout=10,
|
| 196 |
+
)
|
| 197 |
+
if probe.returncode != 0:
|
| 198 |
+
if config.get("verbose"):
|
| 199 |
+
print(f" Prompt {prompt_index}: Pulling docker image {container_image}...", flush=True)
|
| 200 |
+
pull = _sp.run(
|
| 201 |
+
["docker", "pull", container_image],
|
| 202 |
+
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600,
|
| 203 |
+
)
|
| 204 |
+
if pull.returncode != 0:
|
| 205 |
+
return _failure_result(
|
| 206 |
+
prompt_index, batch_num,
|
| 207 |
+
f"Docker image not available: {container_image}\n{pull.stderr[:500]}",
|
| 208 |
+
)
|
| 209 |
+
except FileNotFoundError:
|
| 210 |
+
pass # Docker CLI not installed — skip check (e.g., Modal backend)
|
| 211 |
+
except Exception as img_err:
|
| 212 |
+
if config.get("verbose"):
|
| 213 |
+
print(f" Prompt {prompt_index}: Docker image check failed: {img_err}", flush=True)
|
| 214 |
+
from tools.terminal_tool import register_task_env_overrides
|
| 215 |
+
overrides = {
|
| 216 |
+
"docker_image": container_image,
|
| 217 |
+
"modal_image": container_image,
|
| 218 |
+
"singularity_image": f"docker://{container_image}",
|
| 219 |
+
"daytona_image": container_image,
|
| 220 |
+
}
|
| 221 |
+
if prompt_data.get("cwd"):
|
| 222 |
+
overrides["cwd"] = prompt_data["cwd"]
|
| 223 |
+
register_task_env_overrides(task_id, overrides)
|
| 224 |
+
if config.get("verbose"):
|
| 225 |
+
print(f" Prompt {prompt_index}: Using container image {container_image}")
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _process_single_prompt(
|
| 230 |
+
prompt_index: int,
|
| 231 |
+
prompt_data: Dict[str, Any],
|
| 232 |
+
batch_num: int,
|
| 233 |
+
config: Dict[str, Any]
|
| 234 |
+
) -> Dict[str, Any]:
|
| 235 |
+
"""Run the agent on one prompt; returns trajectory, stats and metadata (or a failure result)."""
|
| 236 |
+
prompt = prompt_data["prompt"]
|
| 237 |
+
task_id = f"task_{prompt_index}"
|
| 238 |
+
failure = _prepare_container_image(prompt_index, prompt_data, batch_num, task_id, config)
|
| 239 |
+
if failure is not None:
|
| 240 |
+
return failure
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
selected_toolsets = sample_toolsets_from_distribution(config["distribution"])
|
| 244 |
+
|
| 245 |
+
if config.get("verbose"):
|
| 246 |
+
print(f" Prompt {prompt_index}: Using toolsets {selected_toolsets}")
|
| 247 |
+
agent = AIAgent(
|
| 248 |
+
model=config["model"],
|
| 249 |
+
max_iterations=config["max_iterations"],
|
| 250 |
+
enabled_toolsets=selected_toolsets,
|
| 251 |
+
save_trajectories=False, # We handle saving ourselves
|
| 252 |
+
verbose_logging=config.get("verbose", False),
|
| 253 |
+
log_prefix_chars=config.get("log_prefix_chars", 100),
|
| 254 |
+
log_prefix=f"[B{batch_num}:P{prompt_index}]",
|
| 255 |
+
skip_context_files=True, # Don't pollute trajectories with SOUL.md/AGENTS.md
|
| 256 |
+
skip_memory=True, # Don't use persistent memory in batch runs
|
| 257 |
+
**{key: config.get(key) for key in _AGENT_PASSTHROUGH if key != "platform"},
|
| 258 |
+
# Batch is a first-class execution surface. Defaulting here (rather than
|
| 259 |
+
# relying on the caller's config dict) keeps task-run telemetry attributable
|
| 260 |
+
# even for callers that build a config without it.
|
| 261 |
+
platform=config.get("platform") or "batch",
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# task_id ensures each task gets its own isolated VM
|
| 265 |
+
result = agent.run_conversation(prompt, task_id=task_id)
|
| 266 |
+
|
| 267 |
+
# Stats before conversion — keep the original evaluation order.
|
| 268 |
+
tool_stats = _extract_tool_stats(result["messages"])
|
| 269 |
+
reasoning_stats = _extract_reasoning_stats(result["messages"])
|
| 270 |
+
trajectory = agent._convert_to_trajectory_format(result["messages"], prompt, result["completed"])
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
"success": True,
|
| 274 |
+
"prompt_index": prompt_index,
|
| 275 |
+
"trajectory": trajectory,
|
| 276 |
+
"tool_stats": tool_stats,
|
| 277 |
+
"reasoning_stats": reasoning_stats,
|
| 278 |
+
"completed": result["completed"],
|
| 279 |
+
# Sibling of the non-empty-response return below (#64686): the classifier's failure_reason must
|
| 280 |
+
# survive the empty-response normalization path too, or downstream consumers (TUI billing
|
| 281 |
+
# surface, transient-failure persistence) lose the structured reason exactly when the run
|
| 282 |
+
# produced no text.
|
| 283 |
+
"partial": result.get("partial", False),
|
| 284 |
+
"api_calls": result["api_calls"],
|
| 285 |
+
"toolsets_used": selected_toolsets,
|
| 286 |
+
"metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat(), "model": config["model"]},
|
| 287 |
+
}
|
| 288 |
+
except Exception as e:
|
| 289 |
+
print(f"❌ Error processing prompt {prompt_index}: {e}")
|
| 290 |
+
if config.get("verbose"):
|
| 291 |
+
traceback.print_exc()
|
| 292 |
+
return _failure_result(prompt_index, batch_num, str(e))
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _append_jsonl(path: Path, row: Dict[str, Any]) -> None:
|
| 296 |
+
"""Append one JSON row and fsync so a crash never loses an acknowledged prompt."""
|
| 297 |
+
with open(path, 'a', encoding='utf-8') as f:
|
| 298 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 299 |
+
f.flush()
|
| 300 |
+
os.fsync(f.fileno())
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _process_batch_worker(args: Tuple) -> Dict[str, Any]:
|
| 304 |
+
"""Pool worker: process one batch of ``(index, prompt_data)`` sequentially.
|
| 305 |
+
|
| 306 |
+
``args`` is ``(batch_num, batch_data, output_dir, completed_prompts, config)``.
|
| 307 |
+
"""
|
| 308 |
+
batch_num, batch_data, output_dir, completed_prompts_set, config = args
|
| 309 |
+
output_dir = Path(output_dir)
|
| 310 |
+
print(f"\n🔄 Batch {batch_num}: Starting ({len(batch_data)} prompts)")
|
| 311 |
+
batch_output_file = output_dir / f"batch_{batch_num}.jsonl"
|
| 312 |
+
prompts_to_process = [(idx, data) for idx, data in batch_data if idx not in completed_prompts_set]
|
| 313 |
+
|
| 314 |
+
if not prompts_to_process:
|
| 315 |
+
print(f"✅ Batch {batch_num}: Already completed (skipping)")
|
| 316 |
+
return {"batch_num": batch_num, "processed": 0, "skipped": len(batch_data), "tool_stats": {}, "completed_prompts": []}
|
| 317 |
+
print(f" Processing {len(prompts_to_process)} prompts (skipping {len(batch_data) - len(prompts_to_process)} already completed)")
|
| 318 |
+
batch_tool_stats = {}
|
| 319 |
+
batch_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0)
|
| 320 |
+
completed_in_batch = []
|
| 321 |
+
discarded_no_reasoning = 0
|
| 322 |
+
|
| 323 |
+
for prompt_index, prompt_data in prompts_to_process:
|
| 324 |
+
result = _process_single_prompt(prompt_index, prompt_data, batch_num, config)
|
| 325 |
+
|
| 326 |
+
if result["success"] and result["trajectory"]:
|
| 327 |
+
reasoning = result.get("reasoning_stats", {})
|
| 328 |
+
if not reasoning.get("has_any_reasoning", True):
|
| 329 |
+
print(f" 🚫 Prompt {prompt_index} discarded (no reasoning in any turn)")
|
| 330 |
+
discarded_no_reasoning += 1
|
| 331 |
+
completed_in_batch.append(prompt_index)
|
| 332 |
+
# Tombstone row (#93527): resume filters by scanning batch_*.jsonl
|
| 333 |
+
# rows for prompt content, so a discarded sample without a row would
|
| 334 |
+
# be re-run at full cost on every restart. The merge step excludes
|
| 335 |
+
# tombstones from trajectories.jsonl.
|
| 336 |
+
_append_jsonl(batch_output_file, {
|
| 337 |
+
"prompt_index": prompt_index,
|
| 338 |
+
"discarded": "no_reasoning",
|
| 339 |
+
"prompt": _entry_prompt_text(prompt_data),
|
| 340 |
+
})
|
| 341 |
+
continue
|
| 342 |
+
|
| 343 |
+
# Normalize for a consistent schema across all entries.
|
| 344 |
+
raw_tool_stats = result.get("tool_stats", {})
|
| 345 |
+
raw_error_counts = {
|
| 346 |
+
tool_name: stats.get("failure", 0)
|
| 347 |
+
for tool_name, stats in raw_tool_stats.items()
|
| 348 |
+
}
|
| 349 |
+
_append_jsonl(batch_output_file, {
|
| 350 |
+
"prompt_index": prompt_index,
|
| 351 |
+
"conversations": result["trajectory"],
|
| 352 |
+
"metadata": result["metadata"],
|
| 353 |
+
"completed": result["completed"],
|
| 354 |
+
"partial": result.get("partial", False), # True if stopped due to invalid tool calls
|
| 355 |
+
"api_calls": result["api_calls"],
|
| 356 |
+
"toolsets_used": result["toolsets_used"],
|
| 357 |
+
"tool_stats": _normalize_tool_stats(raw_tool_stats), # {tool: {count, success, failure}}
|
| 358 |
+
"tool_error_counts": _normalize_tool_error_counts(raw_error_counts) # {tool: failure_count}
|
| 359 |
+
})
|
| 360 |
+
_merge_tool_stats(batch_tool_stats, result.get("tool_stats", {}))
|
| 361 |
+
_merge_reasoning_stats(batch_reasoning_stats, result.get("reasoning_stats", {}))
|
| 362 |
+
|
| 363 |
+
# Only mark as completed if successfully saved (failed prompts can be retried on resume)
|
| 364 |
+
if result["success"] and result["trajectory"]:
|
| 365 |
+
completed_in_batch.append(prompt_index)
|
| 366 |
+
status = "⚠️ partial" if result.get("partial") else "✅"
|
| 367 |
+
print(f" {status} Prompt {prompt_index} completed")
|
| 368 |
+
else:
|
| 369 |
+
print(f" ❌ Prompt {prompt_index} failed (will retry on resume)")
|
| 370 |
+
print(f"✅ Batch {batch_num}: Completed ({len(prompts_to_process)} prompts processed)")
|
| 371 |
+
|
| 372 |
+
return {
|
| 373 |
+
"batch_num": batch_num,
|
| 374 |
+
"processed": len(prompts_to_process),
|
| 375 |
+
"skipped": len(batch_data) - len(prompts_to_process),
|
| 376 |
+
"tool_stats": batch_tool_stats,
|
| 377 |
+
"reasoning_stats": batch_reasoning_stats,
|
| 378 |
+
"discarded_no_reasoning": discarded_no_reasoning,
|
| 379 |
+
"completed_prompts": completed_in_batch
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _entry_prompt_text(entry: Dict) -> str:
|
| 384 |
+
"""Human prompt text from a dataset/trajectory entry: flat ``prompt``, ShareGPT
|
| 385 |
+
``conversations`` (from/value), chat ``conversations``/``messages`` (role/content),
|
| 386 |
+
or a no-reasoning discard tombstone."""
|
| 387 |
+
if not isinstance(entry, dict):
|
| 388 |
+
return ""
|
| 389 |
+
text = str(entry.get("prompt") or "").strip()
|
| 390 |
+
if text:
|
| 391 |
+
return text
|
| 392 |
+
for key in ("conversations", "messages"):
|
| 393 |
+
for msg in entry.get(key, []) or []:
|
| 394 |
+
if not isinstance(msg, dict):
|
| 395 |
+
continue
|
| 396 |
+
role = msg.get("role") or msg.get("from")
|
| 397 |
+
if role in {"user", "human"}:
|
| 398 |
+
text = str(msg.get("content") or msg.get("value") or "").strip()
|
| 399 |
+
if text:
|
| 400 |
+
return text
|
| 401 |
+
return ""
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _banner(title: str) -> None:
|
| 405 |
+
print("\n" + "=" * 70)
|
| 406 |
+
print(title)
|
| 407 |
+
print("=" * 70)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def _chunk(entries: List[Tuple[int, Dict[str, Any]]], size: int) -> List[List[Tuple[int, Dict[str, Any]]]]:
|
| 411 |
+
"""Split ``(index, entry)`` tuples into batches of *size*, preserving original indices."""
|
| 412 |
+
return [entries[i:i + size] for i in range(0, len(entries), size)]
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
class BatchRunner:
|
| 416 |
+
"""Manages batch processing of agent prompts with checkpointing and statistics."""
|
| 417 |
+
|
| 418 |
+
def __init__(
|
| 419 |
+
self,
|
| 420 |
+
dataset_file: str,
|
| 421 |
+
batch_size: int,
|
| 422 |
+
run_name: str,
|
| 423 |
+
distribution: str = "default",
|
| 424 |
+
max_iterations: int = 10,
|
| 425 |
+
base_url: str = None,
|
| 426 |
+
api_key: str = None,
|
| 427 |
+
model: str = "claude-opus-4-20250514",
|
| 428 |
+
num_workers: int = 4,
|
| 429 |
+
verbose: bool = False,
|
| 430 |
+
ephemeral_system_prompt: str = None,
|
| 431 |
+
log_prefix_chars: int = 100,
|
| 432 |
+
providers_allowed: List[str] = None,
|
| 433 |
+
providers_ignored: List[str] = None,
|
| 434 |
+
providers_order: List[str] = None,
|
| 435 |
+
provider_sort: str = None,
|
| 436 |
+
openrouter_min_coding_score: Optional[float] = None,
|
| 437 |
+
|
| 438 |
+
reasoning_config: Dict[str, Any] = None,
|
| 439 |
+
prefill_messages: List[Dict[str, Any]] = None,
|
| 440 |
+
max_samples: int = None,
|
| 441 |
+
):
|
| 442 |
+
"""Load the dataset (truncated to *max_samples*), validate *distribution*, create batches.
|
| 443 |
+
|
| 444 |
+
``ephemeral_system_prompt`` is used during execution but NOT saved to trajectories.
|
| 445 |
+
``prefill_messages`` are prepended as few-shot context; Anthropic Sonnet/Opus 4.6+
|
| 446 |
+
reject a trailing assistant-role prefill (400) — use user-role priming for those.
|
| 447 |
+
"""
|
| 448 |
+
params = dict(locals())
|
| 449 |
+
self.dataset_file = Path(dataset_file)
|
| 450 |
+
for name in _RUNNER_FIELDS:
|
| 451 |
+
setattr(self, name, params[name])
|
| 452 |
+
# Batch runs are their own execution surface; declaring it here keeps every
|
| 453 |
+
# worker's task-run telemetry attributable instead of falling back to "unknown".
|
| 454 |
+
self.platform = "batch"
|
| 455 |
+
|
| 456 |
+
if not validate_distribution(distribution):
|
| 457 |
+
raise ValueError(f"Unknown distribution: {distribution}. Available: {list(list_distributions().keys())}")
|
| 458 |
+
self.output_dir = Path("data") / run_name
|
| 459 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 460 |
+
self.checkpoint_file = self.output_dir / "checkpoint.json"
|
| 461 |
+
self.stats_file = self.output_dir / "statistics.json"
|
| 462 |
+
self.dataset = self._load_dataset()
|
| 463 |
+
if self.max_samples and self.max_samples < len(self.dataset):
|
| 464 |
+
full_count = len(self.dataset)
|
| 465 |
+
self.dataset = self.dataset[:self.max_samples]
|
| 466 |
+
print(f"✂️ Truncated dataset from {full_count} to {self.max_samples} samples (--max_samples)")
|
| 467 |
+
self.batches = self._create_batches()
|
| 468 |
+
print("📊 Batch Runner Initialized")
|
| 469 |
+
print(f" Dataset: {self.dataset_file} ({len(self.dataset)} prompts)")
|
| 470 |
+
print(f" Batch size: {self.batch_size}")
|
| 471 |
+
print(f" Total batches: {len(self.batches)}")
|
| 472 |
+
print(f" Run name: {self.run_name}")
|
| 473 |
+
print(f" Distribution: {self.distribution}")
|
| 474 |
+
print(f" Output directory: {self.output_dir}")
|
| 475 |
+
print(f" Workers: {self.num_workers}")
|
| 476 |
+
if self.ephemeral_system_prompt:
|
| 477 |
+
prompt_preview = self.ephemeral_system_prompt[:60] + "..." if len(self.ephemeral_system_prompt) > 60 else self.ephemeral_system_prompt
|
| 478 |
+
print(f" 🔒 Ephemeral system prompt: '{prompt_preview}'")
|
| 479 |
+
|
| 480 |
+
def _load_dataset(self) -> List[Dict[str, Any]]:
|
| 481 |
+
"""Load JSONL entries that have a ``prompt`` field; skip blank/invalid lines."""
|
| 482 |
+
if not self.dataset_file.exists():
|
| 483 |
+
raise FileNotFoundError(f"Dataset file not found: {self.dataset_file}")
|
| 484 |
+
dataset = []
|
| 485 |
+
with open(self.dataset_file, 'r', encoding='utf-8') as f:
|
| 486 |
+
for line_num, line in enumerate(f, 1):
|
| 487 |
+
line = line.strip()
|
| 488 |
+
if not line:
|
| 489 |
+
continue
|
| 490 |
+
|
| 491 |
+
try:
|
| 492 |
+
entry = json.loads(line)
|
| 493 |
+
if 'prompt' not in entry:
|
| 494 |
+
print(f"⚠️ Warning: Line {line_num} missing 'prompt' field, skipping")
|
| 495 |
+
continue
|
| 496 |
+
dataset.append(entry)
|
| 497 |
+
except json.JSONDecodeError as e:
|
| 498 |
+
print(f"⚠️ Warning: Invalid JSON on line {line_num}: {e}")
|
| 499 |
+
continue
|
| 500 |
+
|
| 501 |
+
if not dataset:
|
| 502 |
+
raise ValueError(f"No valid entries found in dataset file: {self.dataset_file}")
|
| 503 |
+
|
| 504 |
+
return dataset
|
| 505 |
+
|
| 506 |
+
def _create_batches(self) -> List[List[Tuple[int, Dict[str, Any]]]]:
|
| 507 |
+
"""Split the dataset into batches of ``(index, entry)`` tuples."""
|
| 508 |
+
return _chunk(list(enumerate(self.dataset)), self.batch_size)
|
| 509 |
+
|
| 510 |
+
def _empty_checkpoint(self) -> Dict[str, Any]:
|
| 511 |
+
return {"run_name": self.run_name, "completed_prompts": [], "batch_stats": {}, "last_updated": None}
|
| 512 |
+
|
| 513 |
+
def _load_checkpoint(self) -> Dict[str, Any]:
|
| 514 |
+
"""Checkpoint data (completed prompt indices), or an empty one if missing/unreadable."""
|
| 515 |
+
if not self.checkpoint_file.exists():
|
| 516 |
+
return self._empty_checkpoint()
|
| 517 |
+
|
| 518 |
+
try:
|
| 519 |
+
with open(self.checkpoint_file, 'r', encoding='utf-8') as f:
|
| 520 |
+
return json.load(f)
|
| 521 |
+
except Exception as e:
|
| 522 |
+
print(f"⚠️ Warning: Failed to load checkpoint: {e}")
|
| 523 |
+
return self._empty_checkpoint()
|
| 524 |
+
|
| 525 |
+
def _save_checkpoint(self, checkpoint_data: Dict[str, Any], lock: Optional[Lock] = None):
|
| 526 |
+
"""Atomically write *checkpoint_data* (stamped ``last_updated``), under *lock* if given."""
|
| 527 |
+
checkpoint_data["last_updated"] = datetime.now().isoformat()
|
| 528 |
+
from utils import atomic_json_write
|
| 529 |
+
if lock:
|
| 530 |
+
with lock:
|
| 531 |
+
atomic_json_write(self.checkpoint_file, checkpoint_data)
|
| 532 |
+
else:
|
| 533 |
+
atomic_json_write(self.checkpoint_file, checkpoint_data)
|
| 534 |
+
|
| 535 |
+
def _scan_completed_prompts_by_content(self) -> set:
|
| 536 |
+
"""Prompt texts already processed, scanned from every ``batch_*.jsonl``.
|
| 537 |
+
|
| 538 |
+
Matching on content rather than index lets resume recover even when indices
|
| 539 |
+
don't line up. Failed entries are skipped (retried); discard tombstones count
|
| 540 |
+
as completed (#93527) — re-running would just re-discard.
|
| 541 |
+
"""
|
| 542 |
+
completed_prompts = set()
|
| 543 |
+
batch_files = sorted(self.output_dir.glob("batch_*.jsonl"))
|
| 544 |
+
|
| 545 |
+
if not batch_files:
|
| 546 |
+
return completed_prompts
|
| 547 |
+
print(f"��� Scanning {len(batch_files)} batch files for completed prompts...")
|
| 548 |
+
|
| 549 |
+
for batch_file in batch_files:
|
| 550 |
+
try:
|
| 551 |
+
with open(batch_file, 'r', encoding='utf-8') as f:
|
| 552 |
+
for line in f:
|
| 553 |
+
try:
|
| 554 |
+
entry = json.loads(line.strip())
|
| 555 |
+
if entry.get("failed", False):
|
| 556 |
+
continue
|
| 557 |
+
prompt_text = _entry_prompt_text(entry)
|
| 558 |
+
if prompt_text:
|
| 559 |
+
completed_prompts.add(prompt_text)
|
| 560 |
+
except json.JSONDecodeError:
|
| 561 |
+
continue
|
| 562 |
+
except Exception as e:
|
| 563 |
+
print(f" ⚠️ Warning: Error reading {batch_file.name}: {e}")
|
| 564 |
+
|
| 565 |
+
return completed_prompts
|
| 566 |
+
|
| 567 |
+
def _filter_dataset_by_completed(self, completed_prompts: set) -> Tuple[List[Dict], List[int]]:
|
| 568 |
+
"""Return ``([(index, entry)] not yet completed, [skipped indices])``."""
|
| 569 |
+
filtered_dataset = []
|
| 570 |
+
skipped_indices = []
|
| 571 |
+
|
| 572 |
+
for idx, entry in enumerate(self.dataset):
|
| 573 |
+
prompt_text = entry.get("prompt", "").strip()
|
| 574 |
+
|
| 575 |
+
# Also check conversations format
|
| 576 |
+
if not prompt_text:
|
| 577 |
+
conversations = entry.get("conversations", [])
|
| 578 |
+
for msg in conversations:
|
| 579 |
+
role = msg.get("role") or msg.get("from")
|
| 580 |
+
if role in {"user", "human"}:
|
| 581 |
+
prompt_text = (msg.get("content") or msg.get("value", "")).strip()
|
| 582 |
+
break
|
| 583 |
+
|
| 584 |
+
if prompt_text in completed_prompts:
|
| 585 |
+
skipped_indices.append(idx)
|
| 586 |
+
else:
|
| 587 |
+
filtered_dataset.append((idx, entry))
|
| 588 |
+
|
| 589 |
+
return filtered_dataset, skipped_indices
|
| 590 |
+
|
| 591 |
+
def _apply_resume(self) -> bool:
|
| 592 |
+
"""Rebuild ``self.batches`` from unprocessed prompts. False when nothing is left to run."""
|
| 593 |
+
completed_prompt_texts = self._scan_completed_prompts_by_content()
|
| 594 |
+
if not completed_prompt_texts:
|
| 595 |
+
return True
|
| 596 |
+
print(f" Found {len(completed_prompt_texts)} already-completed prompts by content matching")
|
| 597 |
+
filtered_entries, skipped_indices = self._filter_dataset_by_completed(completed_prompt_texts)
|
| 598 |
+
|
| 599 |
+
if not filtered_entries:
|
| 600 |
+
print("\n✅ All prompts have already been processed!")
|
| 601 |
+
return False
|
| 602 |
+
self.batches = _chunk(filtered_entries, self.batch_size)
|
| 603 |
+
_banner("📊 RESUME SUMMARY")
|
| 604 |
+
print(f" Original dataset size: {len(self.dataset):,} prompts")
|
| 605 |
+
print(f" Already completed: {len(skipped_indices):,} prompts")
|
| 606 |
+
print(" ─────────────────────────────────────────")
|
| 607 |
+
print(f" 🎯 RESUMING WITH: {len(filtered_entries):,} prompts")
|
| 608 |
+
print(f" New batches created: {len(self.batches)}")
|
| 609 |
+
print("=" * 70 + "\n")
|
| 610 |
+
return True
|
| 611 |
+
|
| 612 |
+
def _worker_config(self) -> Dict[str, Any]:
|
| 613 |
+
"""Picklable agent configuration for worker processes.
|
| 614 |
+
|
| 615 |
+
``self.api_key`` may be a zero-arg callable (Azure Foundry Entra ID bearer provider
|
| 616 |
+
from ``agent.azure_identity_adapter``), which is not safely picklable across the
|
| 617 |
+
Pool boundary. Drop it and let each worker rebuild its own provider via
|
| 618 |
+
``resolve_runtime_provider()`` from ``model.auth_mode`` in config.yaml
|
| 619 |
+
(azure-identity caches in-process, so each worker gets its own short-lived cache).
|
| 620 |
+
"""
|
| 621 |
+
if callable(self.api_key) and not isinstance(self.api_key, str):
|
| 622 |
+
worker_api_key = None
|
| 623 |
+
print(
|
| 624 |
+
"ℹ️ Detected Entra ID bearer provider — workers will rebuild "
|
| 625 |
+
"credentials from config.yaml in each process.",
|
| 626 |
+
flush=True,
|
| 627 |
+
)
|
| 628 |
+
else:
|
| 629 |
+
worker_api_key = self.api_key
|
| 630 |
+
config = {key: getattr(self, key) for key in _AGENT_PASSTHROUGH}
|
| 631 |
+
config["api_key"] = worker_api_key
|
| 632 |
+
for key in ("distribution", "model", "max_iterations", "verbose", "log_prefix_chars"):
|
| 633 |
+
config[key] = getattr(self, key)
|
| 634 |
+
return config
|
| 635 |
+
|
| 636 |
+
def _run_pool(self, config, checkpoint_data, completed_prompts_set, checkpoint_lock) -> List[Dict[str, Any]]:
|
| 637 |
+
"""Process all batches in a worker pool, checkpointing after each result."""
|
| 638 |
+
print(f"\n🔧 Initializing {self.num_workers} worker processes...")
|
| 639 |
+
|
| 640 |
+
with Pool(processes=self.num_workers) as pool:
|
| 641 |
+
# output_dir as str for pickling
|
| 642 |
+
tasks = [
|
| 643 |
+
(batch_num, batch_data, str(self.output_dir), completed_prompts_set, config)
|
| 644 |
+
for batch_num, batch_data in enumerate(self.batches)
|
| 645 |
+
]
|
| 646 |
+
print(f"✅ Created {len(tasks)} batch tasks")
|
| 647 |
+
print("🚀 Starting parallel batch processing...\n")
|
| 648 |
+
|
| 649 |
+
# rich Progress gives a persistent bottom bar; stdout/stderr are NOT
|
| 650 |
+
# redirected so worker prints stay visible.
|
| 651 |
+
results = []
|
| 652 |
+
console = Console(force_terminal=True)
|
| 653 |
+
with Progress(
|
| 654 |
+
SpinnerColumn(), TextColumn("[bold blue]📦 Batches"), BarColumn(bar_width=40),
|
| 655 |
+
MofNCompleteColumn(), TextColumn("•"), TimeRemainingColumn(),
|
| 656 |
+
console=console, refresh_per_second=2, transient=False,
|
| 657 |
+
redirect_stdout=False, redirect_stderr=False,
|
| 658 |
+
) as progress:
|
| 659 |
+
task = progress.add_task("Processing", total=len(tasks))
|
| 660 |
+
|
| 661 |
+
# Temporarily suppress DEBUG logging to avoid bar interference
|
| 662 |
+
root_logger = logging.getLogger()
|
| 663 |
+
original_level = root_logger.level
|
| 664 |
+
root_logger.setLevel(logging.WARNING)
|
| 665 |
+
|
| 666 |
+
try:
|
| 667 |
+
for result in pool.imap_unordered(_process_batch_worker, tasks):
|
| 668 |
+
results.append(result)
|
| 669 |
+
progress.update(task, advance=1)
|
| 670 |
+
|
| 671 |
+
# Incremental checkpoint update (so resume works after crash)
|
| 672 |
+
try:
|
| 673 |
+
batch_num = result.get('batch_num')
|
| 674 |
+
completed = result.get('completed_prompts', []) or []
|
| 675 |
+
completed_prompts_set.update(completed)
|
| 676 |
+
|
| 677 |
+
if isinstance(batch_num, int):
|
| 678 |
+
checkpoint_data.setdefault('batch_stats', {})[str(batch_num)] = {
|
| 679 |
+
key: result.get(key, 0) for key in ('processed', 'skipped', 'discarded_no_reasoning')
|
| 680 |
+
}
|
| 681 |
+
checkpoint_data['completed_prompts'] = sorted(completed_prompts_set)
|
| 682 |
+
self._save_checkpoint(checkpoint_data, lock=checkpoint_lock)
|
| 683 |
+
except Exception as ckpt_err:
|
| 684 |
+
# Don't fail the run if checkpoint write fails
|
| 685 |
+
print(f"⚠️ Warning: Failed to save incremental checkpoint: {ckpt_err}")
|
| 686 |
+
except KeyboardInterrupt:
|
| 687 |
+
print("\n⚠️ Interrupted — terminating batch workers...")
|
| 688 |
+
pool.terminate()
|
| 689 |
+
pool.join()
|
| 690 |
+
raise
|
| 691 |
+
except Exception as e:
|
| 692 |
+
logger.error("Batch worker failed: %s", e, exc_info=True)
|
| 693 |
+
pool.terminate()
|
| 694 |
+
pool.join()
|
| 695 |
+
raise
|
| 696 |
+
finally:
|
| 697 |
+
root_logger.setLevel(original_level)
|
| 698 |
+
return results
|
| 699 |
+
|
| 700 |
+
def _combine_batch_files(self) -> Tuple[int, int]:
|
| 701 |
+
"""Merge ALL ``batch_*.jsonl`` (old runs + resume) into ``trajectories.jsonl``.
|
| 702 |
+
|
| 703 |
+
Drops corrupted entries (hallucinated tool names, invalid JSON) and discard
|
| 704 |
+
tombstones (#93527, resume bookkeeping only). Returns ``(kept, files_found)``.
|
| 705 |
+
"""
|
| 706 |
+
combined_file = self.output_dir / "trajectories.jsonl"
|
| 707 |
+
print(f"\n📦 Combining ALL batch files into {combined_file.name}...")
|
| 708 |
+
total_entries = 0
|
| 709 |
+
filtered_entries = 0
|
| 710 |
+
tombstone_entries = 0
|
| 711 |
+
batch_files_found = 0
|
| 712 |
+
all_batch_files = sorted(self.output_dir.glob("batch_*.jsonl"))
|
| 713 |
+
|
| 714 |
+
with open(combined_file, 'w', encoding='utf-8') as outfile:
|
| 715 |
+
for batch_file in all_batch_files:
|
| 716 |
+
batch_files_found += 1
|
| 717 |
+
batch_num = batch_file.stem.split("_")[1] # Extract batch number for logging
|
| 718 |
+
|
| 719 |
+
with open(batch_file, 'r', encoding='utf-8') as infile:
|
| 720 |
+
for line in infile:
|
| 721 |
+
total_entries += 1
|
| 722 |
+
try:
|
| 723 |
+
data = json.loads(line)
|
| 724 |
+
|
| 725 |
+
if data.get("discarded"):
|
| 726 |
+
tombstone_entries += 1
|
| 727 |
+
continue
|
| 728 |
+
tool_stats = data.get('tool_stats', {})
|
| 729 |
+
invalid_tools = [k for k in tool_stats if k not in ALL_POSSIBLE_TOOLS]
|
| 730 |
+
|
| 731 |
+
if invalid_tools:
|
| 732 |
+
filtered_entries += 1
|
| 733 |
+
invalid_preview = invalid_tools[0][:50] + "..." if len(invalid_tools[0]) > 50 else invalid_tools[0]
|
| 734 |
+
print(f" ⚠️ Filtering corrupted entry (batch {batch_num}): invalid tool '{invalid_preview}'")
|
| 735 |
+
continue
|
| 736 |
+
outfile.write(line)
|
| 737 |
+
except json.JSONDecodeError:
|
| 738 |
+
filtered_entries += 1
|
| 739 |
+
print(f" ⚠️ Filtering invalid JSON entry (batch {batch_num})")
|
| 740 |
+
|
| 741 |
+
if filtered_entries > 0:
|
| 742 |
+
print(f"⚠️ Filtered {filtered_entries} corrupted entries out of {total_entries} total")
|
| 743 |
+
kept = total_entries - filtered_entries - tombstone_entries
|
| 744 |
+
print(f"✅ Combined {batch_files_found} batch files into trajectories.jsonl ({kept} entries)")
|
| 745 |
+
return kept, batch_files_found
|
| 746 |
+
|
| 747 |
+
def _print_summary(self, results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time) -> None:
|
| 748 |
+
_banner("📊 BATCH PROCESSING COMPLETE")
|
| 749 |
+
print(f"✅ Prompts processed this run: {sum(r.get('processed', 0) for r in results)}")
|
| 750 |
+
print(f"✅ Total trajectories in merged file: {kept}")
|
| 751 |
+
print(f"✅ Total batch files merged: {batch_files_found}")
|
| 752 |
+
print(f"⏱️ Total duration: {round(time.time() - start_time, 2)}s")
|
| 753 |
+
print("\n📈 Tool Usage Statistics:")
|
| 754 |
+
print("-" * 70)
|
| 755 |
+
|
| 756 |
+
if total_tool_stats:
|
| 757 |
+
sorted_tools = sorted(total_tool_stats.items(), key=lambda x: x[1]["count"], reverse=True)
|
| 758 |
+
print(f"{'Tool Name':<25} {'Count':<10} {'Success':<10} {'Failure':<10} {'Success Rate':<12}")
|
| 759 |
+
print("-" * 70)
|
| 760 |
+
for tool_name, stats in sorted_tools:
|
| 761 |
+
print(f"{tool_name:<25} {stats['count']:<10} {stats['success']:<10} {stats['failure']:<10} {stats['success_rate']:.1f}%")
|
| 762 |
+
else:
|
| 763 |
+
print("No tool calls were made during this run.")
|
| 764 |
+
total_discarded = sum(r.get("discarded_no_reasoning", 0) for r in results)
|
| 765 |
+
|
| 766 |
+
print("\n🧠 Reasoning Coverage:")
|
| 767 |
+
print("-" * 70)
|
| 768 |
+
total_turns = total_reasoning_stats["total_assistant_turns"]
|
| 769 |
+
with_reasoning = total_reasoning_stats["turns_with_reasoning"]
|
| 770 |
+
without_reasoning = total_reasoning_stats["turns_without_reasoning"]
|
| 771 |
+
if total_turns > 0:
|
| 772 |
+
pct_with = round(with_reasoning / total_turns * 100, 1)
|
| 773 |
+
pct_without = round(without_reasoning / total_turns * 100, 1)
|
| 774 |
+
print(f" Total assistant turns: {total_turns:,}")
|
| 775 |
+
print(f" With reasoning: {with_reasoning:,} ({pct_with}%)")
|
| 776 |
+
print(f" Without reasoning: {without_reasoning:,} ({pct_without}%)")
|
| 777 |
+
else:
|
| 778 |
+
print(" No assistant turns recorded.")
|
| 779 |
+
if total_discarded > 0:
|
| 780 |
+
print(f" 🚫 Samples discarded (zero reasoning): {total_discarded:,}")
|
| 781 |
+
print(f"\n💾 Results saved to: {self.output_dir}")
|
| 782 |
+
print(" - Trajectories: trajectories.jsonl (combined)")
|
| 783 |
+
print(" - Individual batches: batch_*.jsonl (for debugging)")
|
| 784 |
+
print(f" - Statistics: {self.stats_file.name}")
|
| 785 |
+
print(f" - Checkpoint: {self.checkpoint_file.name}")
|
| 786 |
+
|
| 787 |
+
def run(self, resume: bool = False):
|
| 788 |
+
"""Run the batch pipeline; with *resume*, skip prompts already present in batch files."""
|
| 789 |
+
_banner("🚀 Starting Batch Processing")
|
| 790 |
+
|
| 791 |
+
if resume and not self._apply_resume():
|
| 792 |
+
return
|
| 793 |
+
|
| 794 |
+
# Load existing checkpoint (so resume doesn't clobber prior progress)
|
| 795 |
+
checkpoint_data = self._load_checkpoint()
|
| 796 |
+
if checkpoint_data.get("run_name") != self.run_name:
|
| 797 |
+
checkpoint_data = self._empty_checkpoint()
|
| 798 |
+
config = self._worker_config()
|
| 799 |
+
|
| 800 |
+
# Index tracking is secondary to content matching (backward compatibility).
|
| 801 |
+
completed_prompts_set = set(checkpoint_data.get("completed_prompts", []))
|
| 802 |
+
start_time = time.time()
|
| 803 |
+
|
| 804 |
+
# Checkpoint writes happen in the parent process; keep a lock for safety.
|
| 805 |
+
checkpoint_lock = Lock()
|
| 806 |
+
results = self._run_pool(config, checkpoint_data, completed_prompts_set, checkpoint_lock)
|
| 807 |
+
total_tool_stats = {}
|
| 808 |
+
total_reasoning_stats = dict.fromkeys(_REASONING_KEYS, 0)
|
| 809 |
+
for batch_result in results:
|
| 810 |
+
_merge_tool_stats(total_tool_stats, batch_result.get("tool_stats", {}))
|
| 811 |
+
_merge_reasoning_stats(total_reasoning_stats, batch_result.get("reasoning_stats", {}))
|
| 812 |
+
|
| 813 |
+
# Final checkpoint is best-effort; incremental writes already happened.
|
| 814 |
+
try:
|
| 815 |
+
checkpoint_data["completed_prompts"] = sorted(completed_prompts_set)
|
| 816 |
+
self._save_checkpoint(checkpoint_data, lock=checkpoint_lock)
|
| 817 |
+
except Exception as ckpt_err:
|
| 818 |
+
print(f"⚠️ Warning: Failed to save final checkpoint: {ckpt_err}")
|
| 819 |
+
|
| 820 |
+
for stats in total_tool_stats.values():
|
| 821 |
+
total_calls = stats["success"] + stats["failure"]
|
| 822 |
+
stats["success_rate"] = round(stats["success"] / total_calls * 100, 2) if total_calls > 0 else 0.0
|
| 823 |
+
stats["failure_rate"] = round(stats["failure"] / total_calls * 100, 2) if total_calls > 0 else 0.0
|
| 824 |
+
kept, batch_files_found = self._combine_batch_files()
|
| 825 |
+
final_stats = {
|
| 826 |
+
"run_name": self.run_name,
|
| 827 |
+
"distribution": self.distribution,
|
| 828 |
+
"total_prompts": len(self.dataset),
|
| 829 |
+
"total_batches": len(self.batches),
|
| 830 |
+
"batch_size": self.batch_size,
|
| 831 |
+
# Snapshot the CLI-level credential/runtime fields BEFORE mutating them so a failed in-place
|
| 832 |
+
# agent swap can roll the whole CLI back to the old working model. Otherwise the broken
|
| 833 |
+
# credentials staged below leak into the next turn's resolution even though the agent itself
|
| 834 |
+
# rolled back (#50163).
|
| 835 |
+
# Snapshot CLI-level fields before mutation so a failed in-place swap rolls the whole CLI back
|
| 836 |
+
# to the old working model (#50163).
|
| 837 |
+
"model": self.model,
|
| 838 |
+
"completed_at": datetime.now().isoformat(),
|
| 839 |
+
"duration_seconds": round(time.time() - start_time, 2),
|
| 840 |
+
"tool_statistics": total_tool_stats,
|
| 841 |
+
"reasoning_statistics": total_reasoning_stats,
|
| 842 |
+
"discarded_no_reasoning": sum(r.get("discarded_no_reasoning", 0) for r in results),
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
with open(self.stats_file, 'w', encoding='utf-8') as f:
|
| 846 |
+
json.dump(final_stats, f, indent=2, ensure_ascii=False)
|
| 847 |
+
self._print_summary(results, total_tool_stats, total_reasoning_stats, kept, batch_files_found, start_time)
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def _split_csv(value: Optional[str]) -> Optional[List[str]]:
|
| 851 |
+
"""Comma-separated CLI string to a list of stripped items; ``None`` when empty."""
|
| 852 |
+
return [p.strip() for p in value.split(",")] if value else None
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
def main(
|
| 856 |
+
dataset_file: str = None,
|
| 857 |
+
batch_size: int = None,
|
| 858 |
+
run_name: str = None,
|
| 859 |
+
distribution: str = "default",
|
| 860 |
+
model: str = "anthropic/claude-sonnet-4.6",
|
| 861 |
+
api_key: str = None,
|
| 862 |
+
base_url: str = "https://openrouter.ai/api/v1",
|
| 863 |
+
max_turns: int = 10,
|
| 864 |
+
num_workers: int = 4,
|
| 865 |
+
resume: bool = False,
|
| 866 |
+
verbose: bool = False,
|
| 867 |
+
list_distributions: bool = False,
|
| 868 |
+
ephemeral_system_prompt: str = None,
|
| 869 |
+
log_prefix_chars: int = 100,
|
| 870 |
+
providers_allowed: str = None,
|
| 871 |
+
providers_ignored: str = None,
|
| 872 |
+
providers_order: str = None,
|
| 873 |
+
provider_sort: str = None,
|
| 874 |
+
|
| 875 |
+
reasoning_effort: str = None,
|
| 876 |
+
reasoning_disabled: bool = False,
|
| 877 |
+
prefill_messages_file: str = None,
|
| 878 |
+
max_samples: int = None,
|
| 879 |
+
):
|
| 880 |
+
"""
|
| 881 |
+
Run batch processing of agent prompts from a dataset.
|
| 882 |
+
|
| 883 |
+
Args:
|
| 884 |
+
dataset_file (str): Path to JSONL file with 'prompt' field in each entry
|
| 885 |
+
batch_size (int): Number of prompts per batch
|
| 886 |
+
run_name (str): Name for this run (used for output and checkpointing)
|
| 887 |
+
distribution (str): Toolset distribution to use (default: "default")
|
| 888 |
+
model (str): Model name to use (default: "claude-opus-4-20250514")
|
| 889 |
+
api_key (str): API key for model authentication
|
| 890 |
+
base_url (str): Base URL for model API
|
| 891 |
+
max_turns (int): Maximum number of tool calling iterations per prompt (default: 10)
|
| 892 |
+
num_workers (int): Number of parallel worker processes (default: 4)
|
| 893 |
+
resume (bool): Resume from checkpoint if run was interrupted (default: False)
|
| 894 |
+
verbose (bool): Enable verbose logging (default: False)
|
| 895 |
+
list_distributions (bool): List available toolset distributions and exit
|
| 896 |
+
ephemeral_system_prompt (str): System prompt used during agent execution but NOT saved to trajectories (optional)
|
| 897 |
+
log_prefix_chars (int): Number of characters to show in log previews for tool calls/responses (default: 20)
|
| 898 |
+
providers_allowed (str): Comma-separated list of OpenRouter providers to allow (e.g. "anthropic,openai")
|
| 899 |
+
providers_ignored (str): Comma-separated list of OpenRouter providers to ignore (e.g. "together,deepinfra")
|
| 900 |
+
providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google")
|
| 901 |
+
provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only)
|
| 902 |
+
|
| 903 |
+
reasoning_effort (str): Reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra" (default: "medium")
|
| 904 |
+
reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False)
|
| 905 |
+
prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts)
|
| 906 |
+
max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set)
|
| 907 |
+
|
| 908 |
+
Examples:
|
| 909 |
+
# Basic usage
|
| 910 |
+
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run
|
| 911 |
+
|
| 912 |
+
# Resume interrupted run
|
| 913 |
+
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run --resume
|
| 914 |
+
|
| 915 |
+
# Use specific distribution
|
| 916 |
+
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=image_test --distribution=image_gen
|
| 917 |
+
|
| 918 |
+
# With disabled reasoning
|
| 919 |
+
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\
|
| 920 |
+
--reasoning_disabled
|
| 921 |
+
|
| 922 |
+
# With prefill messages from file
|
| 923 |
+
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run \\
|
| 924 |
+
--prefill_messages_file=configs/prefill_opus.json
|
| 925 |
+
|
| 926 |
+
# List available distributions
|
| 927 |
+
python batch_runner.py --list_distributions
|
| 928 |
+
"""
|
| 929 |
+
if list_distributions:
|
| 930 |
+
from toolset_distributions import print_distribution_info
|
| 931 |
+
print("📊 Available Toolset Distributions")
|
| 932 |
+
print("=" * 70)
|
| 933 |
+
all_dists = list_distributions()
|
| 934 |
+
for dist_name in sorted(all_dists.keys()):
|
| 935 |
+
print_distribution_info(dist_name)
|
| 936 |
+
|
| 937 |
+
print("\n💡 Usage:")
|
| 938 |
+
print(" python batch_runner.py --dataset_file=data.jsonl --batch_size=10 \\")
|
| 939 |
+
print(" --run_name=my_run --distribution=<name>")
|
| 940 |
+
return
|
| 941 |
+
|
| 942 |
+
for invalid, message in (
|
| 943 |
+
(not dataset_file, "--dataset_file is required"),
|
| 944 |
+
(not batch_size or batch_size < 1, "--batch_size must be a positive integer"),
|
| 945 |
+
(not run_name, "--run_name is required"),
|
| 946 |
+
):
|
| 947 |
+
if invalid:
|
| 948 |
+
print(f"❌ Error: {message}")
|
| 949 |
+
raise SystemExit(1)
|
| 950 |
+
|
| 951 |
+
# --reasoning_disabled takes priority, then --reasoning_effort, then default (medium)
|
| 952 |
+
reasoning_config = None
|
| 953 |
+
if reasoning_disabled:
|
| 954 |
+
reasoning_config = {"effort": "none"}
|
| 955 |
+
print("🧠 Reasoning: DISABLED (effort=none)")
|
| 956 |
+
elif reasoning_effort:
|
| 957 |
+
valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
| 958 |
+
if reasoning_effort not in valid_efforts:
|
| 959 |
+
print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}")
|
| 960 |
+
raise SystemExit(1)
|
| 961 |
+
reasoning_config = {"enabled": True, "effort": reasoning_effort}
|
| 962 |
+
print(f"🧠 Reasoning effort: {reasoning_effort}")
|
| 963 |
+
prefill_messages = None
|
| 964 |
+
if prefill_messages_file:
|
| 965 |
+
try:
|
| 966 |
+
with open(prefill_messages_file, 'r', encoding='utf-8') as f:
|
| 967 |
+
prefill_messages = json.load(f)
|
| 968 |
+
if not isinstance(prefill_messages, list):
|
| 969 |
+
print("❌ Error: prefill_messages_file must contain a JSON array of messages")
|
| 970 |
+
raise SystemExit(1)
|
| 971 |
+
print(f"💬 Loaded {len(prefill_messages)} prefill messages from {prefill_messages_file}")
|
| 972 |
+
except Exception as e:
|
| 973 |
+
print(f"❌ Error loading prefill messages: {e}")
|
| 974 |
+
raise SystemExit(1)
|
| 975 |
+
|
| 976 |
+
try:
|
| 977 |
+
runner = BatchRunner(
|
| 978 |
+
dataset_file=dataset_file,
|
| 979 |
+
batch_size=batch_size,
|
| 980 |
+
run_name=run_name,
|
| 981 |
+
distribution=distribution,
|
| 982 |
+
max_iterations=max_turns,
|
| 983 |
+
base_url=base_url,
|
| 984 |
+
api_key=api_key,
|
| 985 |
+
model=model,
|
| 986 |
+
num_workers=num_workers,
|
| 987 |
+
verbose=verbose,
|
| 988 |
+
ephemeral_system_prompt=ephemeral_system_prompt,
|
| 989 |
+
log_prefix_chars=log_prefix_chars,
|
| 990 |
+
providers_allowed=_split_csv(providers_allowed),
|
| 991 |
+
providers_ignored=_split_csv(providers_ignored),
|
| 992 |
+
providers_order=_split_csv(providers_order),
|
| 993 |
+
provider_sort=provider_sort,
|
| 994 |
+
|
| 995 |
+
reasoning_config=reasoning_config,
|
| 996 |
+
prefill_messages=prefill_messages,
|
| 997 |
+
max_samples=max_samples,
|
| 998 |
+
)
|
| 999 |
+
runner.run(resume=resume)
|
| 1000 |
+
except Exception as e:
|
| 1001 |
+
print(f"\n❌ Fatal error: {e}")
|
| 1002 |
+
if verbose:
|
| 1003 |
+
traceback.print_exc()
|
| 1004 |
+
raise SystemExit(1)
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
if __name__ == "__main__":
|
| 1008 |
+
fire.Fire(main)
|
cli-config.yaml.example
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
constraints-termux.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Termux / Android dependency constraints for Hermes Agent.
|
| 2 |
+
#
|
| 3 |
+
# Usage:
|
| 4 |
+
# python -m pip install -e '.[termux]' -c constraints-termux.txt
|
| 5 |
+
#
|
| 6 |
+
# These pins keep the tested Android install path stable when upstream packages
|
| 7 |
+
# move faster than Termux-compatible wheels / sdists.
|
| 8 |
+
|
| 9 |
+
ipython<10
|
| 10 |
+
jedi>=0.18.1,<0.20
|
| 11 |
+
parso>=0.8.4,<0.9
|
| 12 |
+
stack-data>=0.6,<0.7
|
| 13 |
+
pexpect>4.3,<5
|
| 14 |
+
matplotlib-inline>=0.1.7,<0.2
|
| 15 |
+
asttokens>=2.1,<3
|
docker-compose.windows.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#
|
| 2 |
+
# docker-compose.windows.yml — Windows Docker Desktop compatible
|
| 3 |
+
#
|
| 4 |
+
# Differences from docker-compose.yml:
|
| 5 |
+
# - Removes `network_mode: host` (not supported on Docker Desktop for Windows)
|
| 6 |
+
# - Uses explicit port mappings instead
|
| 7 |
+
# - Uses Windows-style volume path for ~/.hermes
|
| 8 |
+
#
|
| 9 |
+
# Usage:
|
| 10 |
+
# docker compose -f docker-compose.windows.yml up -d
|
| 11 |
+
#
|
| 12 |
+
services:
|
| 13 |
+
gateway:
|
| 14 |
+
image: nousresearch/hermes-agent:latest
|
| 15 |
+
container_name: hermes
|
| 16 |
+
restart: unless-stopped
|
| 17 |
+
volumes:
|
| 18 |
+
- ${USERPROFILE}/.hermes:/opt/data
|
| 19 |
+
environment:
|
| 20 |
+
- HERMES_UID=10000
|
| 21 |
+
- HERMES_GID=10000
|
| 22 |
+
command: ["gateway", "run"]
|
| 23 |
+
|
| 24 |
+
dashboard:
|
| 25 |
+
image: nousresearch/hermes-agent:latest
|
| 26 |
+
container_name: hermes-dashboard
|
| 27 |
+
restart: unless-stopped
|
| 28 |
+
depends_on:
|
| 29 |
+
- gateway
|
| 30 |
+
volumes:
|
| 31 |
+
- ${USERPROFILE}/.hermes:/opt/data
|
| 32 |
+
environment:
|
| 33 |
+
- HERMES_UID=10000
|
| 34 |
+
- HERMES_GID=10000
|
| 35 |
+
- HERMES_DASHBOARD_HOST=0.0.0.0
|
| 36 |
+
ports:
|
| 37 |
+
- "127.0.0.1:9119:9119"
|
| 38 |
+
command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--insecure"]
|
flake.lock
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nodes": {
|
| 3 |
+
"flake-parts": {
|
| 4 |
+
"inputs": {
|
| 5 |
+
"nixpkgs-lib": [
|
| 6 |
+
"nixpkgs"
|
| 7 |
+
]
|
| 8 |
+
},
|
| 9 |
+
"locked": {
|
| 10 |
+
"lastModified": 1782949081,
|
| 11 |
+
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
| 12 |
+
"owner": "hercules-ci",
|
| 13 |
+
"repo": "flake-parts",
|
| 14 |
+
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
| 15 |
+
"type": "github"
|
| 16 |
+
},
|
| 17 |
+
"original": {
|
| 18 |
+
"owner": "hercules-ci",
|
| 19 |
+
"repo": "flake-parts",
|
| 20 |
+
"type": "github"
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
"home-manager": {
|
| 24 |
+
"inputs": {
|
| 25 |
+
"nixpkgs": [
|
| 26 |
+
"nixpkgs"
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
"locked": {
|
| 30 |
+
"lastModified": 1786487128,
|
| 31 |
+
"narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=",
|
| 32 |
+
"owner": "nix-community",
|
| 33 |
+
"repo": "home-manager",
|
| 34 |
+
"rev": "f404edbfa4117810c96b97048299242fc50e5362",
|
| 35 |
+
"type": "github"
|
| 36 |
+
},
|
| 37 |
+
"original": {
|
| 38 |
+
"owner": "nix-community",
|
| 39 |
+
"repo": "home-manager",
|
| 40 |
+
"type": "github"
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"nixpkgs": {
|
| 44 |
+
"locked": {
|
| 45 |
+
"lastModified": 1785318670,
|
| 46 |
+
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
| 47 |
+
"owner": "NixOS",
|
| 48 |
+
"repo": "nixpkgs",
|
| 49 |
+
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
| 50 |
+
"type": "github"
|
| 51 |
+
},
|
| 52 |
+
"original": {
|
| 53 |
+
"owner": "NixOS",
|
| 54 |
+
"ref": "nixos-unstable",
|
| 55 |
+
"repo": "nixpkgs",
|
| 56 |
+
"type": "github"
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
"npm-lockfile-fix": {
|
| 60 |
+
"inputs": {
|
| 61 |
+
"nixpkgs": [
|
| 62 |
+
"nixpkgs"
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
"locked": {
|
| 66 |
+
"lastModified": 1775903712,
|
| 67 |
+
"narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
|
| 68 |
+
"owner": "jeslie0",
|
| 69 |
+
"repo": "npm-lockfile-fix",
|
| 70 |
+
"rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
|
| 71 |
+
"type": "github"
|
| 72 |
+
},
|
| 73 |
+
"original": {
|
| 74 |
+
"owner": "jeslie0",
|
| 75 |
+
"repo": "npm-lockfile-fix",
|
| 76 |
+
"type": "github"
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
"pyproject-build-systems": {
|
| 80 |
+
"inputs": {
|
| 81 |
+
"nixpkgs": [
|
| 82 |
+
"nixpkgs"
|
| 83 |
+
],
|
| 84 |
+
"pyproject-nix": [
|
| 85 |
+
"pyproject-nix"
|
| 86 |
+
],
|
| 87 |
+
"uv2nix": [
|
| 88 |
+
"uv2nix"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
"locked": {
|
| 92 |
+
"lastModified": 1785115949,
|
| 93 |
+
"narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=",
|
| 94 |
+
"owner": "pyproject-nix",
|
| 95 |
+
"repo": "build-system-pkgs",
|
| 96 |
+
"rev": "62c0d86027edb1c4f39a5facc09876348144f7c9",
|
| 97 |
+
"type": "github"
|
| 98 |
+
},
|
| 99 |
+
"original": {
|
| 100 |
+
"owner": "pyproject-nix",
|
| 101 |
+
"repo": "build-system-pkgs",
|
| 102 |
+
"type": "github"
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
"pyproject-nix": {
|
| 106 |
+
"inputs": {
|
| 107 |
+
"nixpkgs": [
|
| 108 |
+
"nixpkgs"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
"locked": {
|
| 112 |
+
"lastModified": 1784591072,
|
| 113 |
+
"narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=",
|
| 114 |
+
"owner": "pyproject-nix",
|
| 115 |
+
"repo": "pyproject.nix",
|
| 116 |
+
"rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3",
|
| 117 |
+
"type": "github"
|
| 118 |
+
},
|
| 119 |
+
"original": {
|
| 120 |
+
"owner": "pyproject-nix",
|
| 121 |
+
"repo": "pyproject.nix",
|
| 122 |
+
"type": "github"
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
"root": {
|
| 126 |
+
"inputs": {
|
| 127 |
+
"flake-parts": "flake-parts",
|
| 128 |
+
"home-manager": "home-manager",
|
| 129 |
+
"nixpkgs": "nixpkgs",
|
| 130 |
+
"npm-lockfile-fix": "npm-lockfile-fix",
|
| 131 |
+
"pyproject-build-systems": "pyproject-build-systems",
|
| 132 |
+
"pyproject-nix": "pyproject-nix",
|
| 133 |
+
"uv2nix": "uv2nix"
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
"uv2nix": {
|
| 137 |
+
"inputs": {
|
| 138 |
+
"nixpkgs": [
|
| 139 |
+
"nixpkgs"
|
| 140 |
+
],
|
| 141 |
+
"pyproject-nix": [
|
| 142 |
+
"pyproject-nix"
|
| 143 |
+
]
|
| 144 |
+
},
|
| 145 |
+
"locked": {
|
| 146 |
+
"lastModified": 1785277507,
|
| 147 |
+
"narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=",
|
| 148 |
+
"owner": "pyproject-nix",
|
| 149 |
+
"repo": "uv2nix",
|
| 150 |
+
"rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977",
|
| 151 |
+
"type": "github"
|
| 152 |
+
},
|
| 153 |
+
"original": {
|
| 154 |
+
"owner": "pyproject-nix",
|
| 155 |
+
"repo": "uv2nix",
|
| 156 |
+
"type": "github"
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
},
|
| 160 |
+
"root": "root",
|
| 161 |
+
"version": 7
|
| 162 |
+
}
|
hermes_bootstrap.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Windows UTF-8 bootstrap for Hermes entry points (no-op on POSIX).
|
| 2 |
+
|
| 3 |
+
Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises
|
| 4 |
+
``UnicodeEncodeError``, and Python children inherit the same default unless
|
| 5 |
+
``PYTHONUTF8``/``PYTHONIOENCODING`` are set. Import this module first in every entry
|
| 6 |
+
point (``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_runner``,
|
| 7 |
+
``cron/scheduler``). It does NOT re-exec with ``-X utf8``: ``open()`` in the current
|
| 8 |
+
process still needs an explicit ``encoding="utf-8"`` (ruff ``PLW1514``). POSIX is left
|
| 9 |
+
alone deliberately — users' ``LANG``/``LC_*`` choices are respected.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
_IS_WINDOWS = sys.platform == "win32"
|
| 18 |
+
_bootstrap_applied = False
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def apply_windows_utf8_bootstrap() -> bool:
|
| 22 |
+
"""Apply the Windows UTF-8 bootstrap once; True only when it was applied this call."""
|
| 23 |
+
global _bootstrap_applied
|
| 24 |
+
|
| 25 |
+
if not _IS_WINDOWS or _bootstrap_applied:
|
| 26 |
+
return False
|
| 27 |
+
|
| 28 |
+
# setdefault() so a user can opt out with PYTHONUTF8=0 / PYTHONIOENCODING=...
|
| 29 |
+
os.environ.setdefault("PYTHONUTF8", "1")
|
| 30 |
+
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
| 31 |
+
|
| 32 |
+
# os.environ changes don't rebind streams bound at interpreter startup, so
|
| 33 |
+
# reconfigure them in-process. errors="replace" keeps a non-UTF-8 legacy
|
| 34 |
+
# pipe on stdin from crashing us (U+FFFD instead of an exception).
|
| 35 |
+
# Non-TextIOWrapper streams (BytesIO in tests, embedded hosts) have no
|
| 36 |
+
# reconfigure(): skip — the env-var fix for children is the bigger win.
|
| 37 |
+
for stream_name in ("stdout", "stderr", "stdin"):
|
| 38 |
+
reconfigure = getattr(getattr(sys, stream_name, None), "reconfigure", None)
|
| 39 |
+
if reconfigure is None:
|
| 40 |
+
continue
|
| 41 |
+
try:
|
| 42 |
+
reconfigure(encoding="utf-8", errors="replace")
|
| 43 |
+
except (OSError, ValueError):
|
| 44 |
+
pass # closed, or replaced with something non-reconfigurable
|
| 45 |
+
|
| 46 |
+
_bootstrap_applied = True
|
| 47 |
+
return True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def suppress_platform_ver_console() -> None:
|
| 51 |
+
"""Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard.
|
| 52 |
+
|
| 53 |
+
``platform.win32_ver()`` (reached via ``platform.platform()``, which the OpenAI SDK
|
| 54 |
+
calls) shells out ``cmd /c ver`` with ``shell=True`` and no ``CREATE_NO_WINDOW``: a
|
| 55 |
+
windowless parent (pythonw gateway, slash/kanban workers) flashes a console per call,
|
| 56 |
+
and Python 3.11.0/3.11.1 (no ``encoding="locale"`` fix) strict-utf-8-decodes the OEM
|
| 57 |
+
code page output under PEP 540 mode and raises (#69413). Returning the inputs makes
|
| 58 |
+
``win32_ver()`` fall back to ``sys.getwindowsversion()`` — same data, no subprocess.
|
| 59 |
+
Mirrors ``hermes_cli._subprocess_compat.suppress_platform_ver_console`` for callers
|
| 60 |
+
that never import ``hermes_cli.main``; double application is harmless.
|
| 61 |
+
"""
|
| 62 |
+
if not _IS_WINDOWS:
|
| 63 |
+
return
|
| 64 |
+
try:
|
| 65 |
+
import platform
|
| 66 |
+
|
| 67 |
+
if hasattr(platform, "_syscmd_ver"):
|
| 68 |
+
def _quiet_syscmd_ver(system="", release="", version="",
|
| 69 |
+
supported_platforms=("win32", "win16", "dos")):
|
| 70 |
+
return system, release, version
|
| 71 |
+
|
| 72 |
+
platform._syscmd_ver = _quiet_syscmd_ver
|
| 73 |
+
except Exception:
|
| 74 |
+
pass # hardening only — never break an entry point
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def harden_import_path(src_root: str | None = None) -> None:
|
| 78 |
+
"""Stop a package in the current directory from shadowing Hermes modules.
|
| 79 |
+
|
| 80 |
+
Hermes ships top-level modules with common names (``utils``, ``proxy``, ``ui``); a
|
| 81 |
+
project with its own ``utils/`` launched from its directory would win the import.
|
| 82 |
+
The cwd reaches ``sys.path`` as ``""``/``"."`` (script/``-m`` launches) AND as an
|
| 83 |
+
absolute path (venv activation, PYTHONPATH), so both are handled: relative forms are
|
| 84 |
+
dropped and the Hermes root is *relocated* to the front, not merely inserted when
|
| 85 |
+
absent. ``src_root`` defaults to this module's directory (the repo root for every
|
| 86 |
+
shipped entry point), so no spawner env var is required.
|
| 87 |
+
"""
|
| 88 |
+
root = src_root or os.environ.get("HERMES_PYTHON_SRC_ROOT") or os.path.dirname(
|
| 89 |
+
os.path.abspath(__file__)
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
|
| 93 |
+
|
| 94 |
+
root_abs = os.path.abspath(root)
|
| 95 |
+
sys.path[:] = [p for p in sys.path if os.path.abspath(p) != root_abs]
|
| 96 |
+
sys.path.insert(0, root)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def activate_durable_lazy_target() -> None:
|
| 100 |
+
"""Put the durable lazy-install dir (``HERMES_LAZY_INSTALL_TARGET``) on ``sys.path``.
|
| 101 |
+
|
| 102 |
+
Immutable Docker images seal the venv and redirect lazy installs to the data volume;
|
| 103 |
+
packages installed there on a previous run must be importable before any backend
|
| 104 |
+
imports its SDK. Appends to the END of ``sys.path`` so the core venv always wins name
|
| 105 |
+
collisions (see ``tools.lazy_deps``). Never raises; unset target is a no-op.
|
| 106 |
+
"""
|
| 107 |
+
if not os.environ.get("HERMES_LAZY_INSTALL_TARGET", "").strip():
|
| 108 |
+
return
|
| 109 |
+
try:
|
| 110 |
+
from tools import lazy_deps
|
| 111 |
+
lazy_deps.activate_durable_lazy_target()
|
| 112 |
+
except Exception:
|
| 113 |
+
pass # a failed activation just leaves the backend reporting itself unavailable
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# Apply on import — entry points only need ``import hermes_bootstrap`` first.
|
| 117 |
+
apply_windows_utf8_bootstrap()
|
| 118 |
+
suppress_platform_ver_console()
|
| 119 |
+
activate_durable_lazy_target()
|
hermes_cli/_scan_venv_blockers.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone venv-process scan for JSON consumption (``python -m hermes_cli._scan_venv_blockers``).
|
| 2 |
+
|
| 3 |
+
Exits 0 for valid clear or blocked results. Non-zero exit signals probe failure (the detector itself
|
| 4 |
+
crashed, psutil unavailable, etc.). Exactly one JSON document on stdout; diagnostics on stderr only.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import PureWindowsPath
|
| 13 |
+
from typing import NoReturn
|
| 14 |
+
|
| 15 |
+
# Long CLI flags whose argument value must be redacted from the cmdline. Short flags (-t, -k, -p)
|
| 16 |
+
# are intentionally not redacted — ambiguous and useful diagnostics (toolset, port, profile).
|
| 17 |
+
_SENSITIVE_LONG_FLAGS: list[str] = [
|
| 18 |
+
"--token", "--api-key", "--password", "--secret", "--authorization", "--access-key",
|
| 19 |
+
"--private-key", "--session-key",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
_PYTHON_PROCESS_NAMES = {"python.exe", "pythonw.exe", "python", "pythonw"}
|
| 23 |
+
_UPDATER_STOPPABLE_PURPOSES = ("serve", "dashboard")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _probe_fail_json(diagnostic: str = "probe failed") -> str:
|
| 27 |
+
"""The standard probe-failure JSON document.
|
| 28 |
+
|
| 29 |
+
``ok: false`` plus ``probe_failed: true`` means the detector itself could not run — this is
|
| 30 |
+
*not* a clear scan. Callers must treat ``ok is not True`` / non-zero exit as probe failure,
|
| 31 |
+
never as ``blocked: false`` "clear".
|
| 32 |
+
|
| 33 |
+
See #83149.
|
| 34 |
+
"""
|
| 35 |
+
return json.dumps({"ok": False, "probe_failed": True, "blocked": False, "processes": [],
|
| 36 |
+
"error": diagnostic})
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _emit_probe_fail(diagnostic: str) -> NoReturn:
|
| 40 |
+
"""Print one JSON to stdout, diagnostic to stderr, exit non-zero."""
|
| 41 |
+
print(_probe_fail_json(diagnostic))
|
| 42 |
+
print(diagnostic, file=sys.stderr)
|
| 43 |
+
sys.exit(1)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _find_flag(text: str, flag: str) -> int:
|
| 47 |
+
"""Index of *flag* (case-insensitive) at string start or after a space; -1 if absent."""
|
| 48 |
+
low, fl, pos = text.lower(), flag.lower(), 0
|
| 49 |
+
while True:
|
| 50 |
+
idx = low.find(fl, pos)
|
| 51 |
+
if idx == -1 or idx == 0 or text[idx - 1] == " ":
|
| 52 |
+
return idx
|
| 53 |
+
pos = idx + 1
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _redact_sensitive_cmdline(cmdline: str) -> str:
|
| 57 |
+
"""Apply the shared secret redactor, then replace everything after a sensitive long flag."""
|
| 58 |
+
try:
|
| 59 |
+
from agent.redact import redact_sensitive_text # noqa: PLC0415
|
| 60 |
+
|
| 61 |
+
cmdline = redact_sensitive_text(cmdline, force=True)
|
| 62 |
+
except Exception:
|
| 63 |
+
return "<redacted>"
|
| 64 |
+
# --flag=value → preserve "--flag="; --flag value → preserve "--flag ".
|
| 65 |
+
earliest = len(cmdline)
|
| 66 |
+
for flag in _SENSITIVE_LONG_FLAGS:
|
| 67 |
+
for suffix in ("=", " "):
|
| 68 |
+
idx = _find_flag(cmdline, flag + suffix)
|
| 69 |
+
if idx != -1 and idx + len(flag) + 1 < earliest:
|
| 70 |
+
earliest = idx + len(flag) + 1
|
| 71 |
+
if earliest < len(cmdline):
|
| 72 |
+
return cmdline[:earliest] + "<redacted>"
|
| 73 |
+
return cmdline
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _classify_local_preview_args(args: object) -> dict[str, object]:
|
| 77 |
+
"""Safe UI metadata for an exact ``python -m http.server`` argv; ``{}`` otherwise.
|
| 78 |
+
|
| 79 |
+
The general holder detector truncates its diagnostic command line; reading argv separately
|
| 80 |
+
preserves a useful directory label without exposing an unbounded command line to the renderer.
|
| 81 |
+
"""
|
| 82 |
+
if not isinstance(args, (list, tuple)) or not all(isinstance(arg, str) for arg in args):
|
| 83 |
+
return {}
|
| 84 |
+
# ``-m`` must be the first argument after the executable: a later ``-m http.server`` can be
|
| 85 |
+
# data passed to an unrelated script and must never authorize termination.
|
| 86 |
+
if len(args) < 3 or args[1] != "-m" or args[2].lower() != "http.server":
|
| 87 |
+
return {}
|
| 88 |
+
port = 8000
|
| 89 |
+
if len(args) > 3 and args[3].isdigit() and 0 < int(args[3]) <= 65535:
|
| 90 |
+
port = int(args[3])
|
| 91 |
+
label = ""
|
| 92 |
+
try:
|
| 93 |
+
directory_index = args.index("--directory")
|
| 94 |
+
if directory_index + 1 < len(args):
|
| 95 |
+
label = PureWindowsPath(args[directory_index + 1]).name
|
| 96 |
+
except ValueError:
|
| 97 |
+
pass
|
| 98 |
+
metadata: dict[str, object] = {"kind": "local-preview", "safeToStop": True, "port": port}
|
| 99 |
+
if label:
|
| 100 |
+
metadata["label"] = label
|
| 101 |
+
return metadata
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _local_preview_metadata(pid: int, name: str) -> dict[str, object]:
|
| 105 |
+
if name.lower() not in _PYTHON_PROCESS_NAMES:
|
| 106 |
+
return {}
|
| 107 |
+
try:
|
| 108 |
+
import psutil # noqa: PLC0415
|
| 109 |
+
|
| 110 |
+
process = psutil.Process(pid)
|
| 111 |
+
metadata = _classify_local_preview_args(process.cmdline())
|
| 112 |
+
if metadata:
|
| 113 |
+
metadata["createTime"] = process.create_time()
|
| 114 |
+
return metadata
|
| 115 |
+
except Exception:
|
| 116 |
+
return {}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _terminate_safe_preview(
|
| 120 |
+
pid: int, expected_create_time: float, *, psutil_module: object | None = None,
|
| 121 |
+
) -> tuple[bool, str | None]:
|
| 122 |
+
"""Terminate one verified local preview process tree.
|
| 123 |
+
|
| 124 |
+
A fresh ``psutil.Process`` identity check and exact argv classification occur immediately before
|
| 125 |
+
termination; psutil guards mutating Process methods against PID reuse (no taskkill stale-PID
|
| 126 |
+
race).
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
if psutil_module is None:
|
| 130 |
+
import psutil as psutil_module # type: ignore[no-redef] # noqa: PLC0415
|
| 131 |
+
|
| 132 |
+
process = psutil_module.Process(pid) # type: ignore[attr-defined]
|
| 133 |
+
if abs(process.create_time() - expected_create_time) > 0.001:
|
| 134 |
+
return False, "process identity changed"
|
| 135 |
+
if not _classify_local_preview_args(process.cmdline()):
|
| 136 |
+
return False, "process is no longer a local preview"
|
| 137 |
+
targets = [*reversed(process.children(recursive=True)), process]
|
| 138 |
+
for target in targets:
|
| 139 |
+
target.terminate()
|
| 140 |
+
_gone, alive = psutil_module.wait_procs(targets, timeout=3) # type: ignore[attr-defined]
|
| 141 |
+
for target in alive:
|
| 142 |
+
target.kill()
|
| 143 |
+
if alive:
|
| 144 |
+
psutil_module.wait_procs(alive, timeout=2) # type: ignore[attr-defined]
|
| 145 |
+
return True, None
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
return False, f"termination failed: {type(exc).__name__}"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _is_pausable_gateway(cmdline: str) -> bool:
|
| 151 |
+
"""True when *cmdline* is a gateway process the updater can pause.
|
| 152 |
+
|
| 153 |
+
Only gateway invocations are exempted; anything else running from the venv has no pause
|
| 154 |
+
machinery downstream and must keep blocking the handoff. Delegates to the canonical
|
| 155 |
+
``gateway run`` matcher so this exemption, pause discovery and the updater's guard share one
|
| 156 |
+
parser.
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
from gateway.status import looks_like_gateway_command_line # noqa: PLC0415
|
| 160 |
+
except Exception:
|
| 161 |
+
return False
|
| 162 |
+
return looks_like_gateway_command_line(cmdline)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _is_updater_owned_backend(pid: int, cmdline: str) -> bool:
|
| 166 |
+
"""True when *pid* is a Hermes backend the CLI updater can stop (positive ledger identity).
|
| 167 |
+
|
| 168 |
+
The gateway exemption above keeps ``gateway run`` holders out of the blocker list because the updater's
|
| 169 |
+
own pause machinery stops and resumes them. ``hermes serve`` / ``hermes dashboard`` backends had no such
|
| 170 |
+
deferral, so a leaked serve child (or a Desktop-owned backend the teardown lost track of) dead-ended the
|
| 171 |
+
hand-off with ``venv-blocked`` — or, worse, survived the hand-off and made the shim quarantine fail with
|
| 172 |
+
``os error 32`` (#98336) — even though the updater downstream owns exactly this case with its ledger
|
| 173 |
+
rungs (`_ledger_reapable_backend_pids` reaps dead-spawner orphans; `_ledger_manual_serve_holders` stops
|
| 174 |
+
manual serves and relaunches them on their recorded host/port).
|
| 175 |
+
Positive identity only — never name/substring matching (#90778, and the 99558 identity-guard contract):
|
| 176 |
+
"""
|
| 177 |
+
return _updater_owned_backend_entry(pid, cmdline) is not None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None:
|
| 181 |
+
"""Ledger entry for a deferred serve/dashboard backend, or ``None`` when it must block.
|
| 182 |
+
|
| 183 |
+
Returning the entry lets ``main()`` emit sanitized decision evidence — structured identity
|
| 184 |
+
fields only, never argv, which can carry tokens or private endpoints.
|
| 185 |
+
|
| 186 |
+
See #98350.
|
| 187 |
+
"""
|
| 188 |
+
try:
|
| 189 |
+
from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415
|
| 190 |
+
|
| 191 |
+
purpose = _hermes_holder_subcommand(cmdline)
|
| 192 |
+
except Exception:
|
| 193 |
+
return None
|
| 194 |
+
if purpose not in _UPDATER_STOPPABLE_PURPOSES:
|
| 195 |
+
return None
|
| 196 |
+
try:
|
| 197 |
+
from hermes_cli.process_identity import ledger_entries, spawner_is_dead # noqa: PLC0415
|
| 198 |
+
|
| 199 |
+
entries = ledger_entries()
|
| 200 |
+
except Exception:
|
| 201 |
+
return None
|
| 202 |
+
for entry in entries:
|
| 203 |
+
if entry.get("pid") != pid:
|
| 204 |
+
continue
|
| 205 |
+
if entry.get("purpose") not in _UPDATER_STOPPABLE_PURPOSES:
|
| 206 |
+
return None
|
| 207 |
+
# Spawner dead, unrecorded, or unprovable-but-registered: the updater's ledger rungs own
|
| 208 |
+
# this holder (reap or stop+relaunch).
|
| 209 |
+
if spawner_is_dead(entry) is not False or _spawner_is_this_handoff_desktop(entry):
|
| 210 |
+
return entry
|
| 211 |
+
return None
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _deferred_backend_evidence(entries: list[dict]) -> list[dict]:
|
| 216 |
+
"""Sanitized evidence (pid, purpose, recorded port — never argv) for deferred backends.
|
| 217 |
+
|
| 218 |
+
Structured ledger fields only — pid, purpose, recorded port — never the command line, which can carry
|
| 219 |
+
tokens or private endpoints. Lets the scan result explain *why* a holder disappeared from ``processes``
|
| 220 |
+
without echoing argv (#98350).
|
| 221 |
+
"""
|
| 222 |
+
return [{"pid": entry.get("pid"), "purpose": entry.get("purpose"), "port": entry.get("port")}
|
| 223 |
+
for entry in entries if isinstance(entry.get("pid"), int)]
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _spawner_is_this_handoff_desktop(entry: dict) -> bool:
|
| 227 |
+
"""True when the entry's live spawner is an ancestor of this scan.
|
| 228 |
+
|
| 229 |
+
The scan is spawned by the Desktop app's update preflight, so the Desktop performing the
|
| 230 |
+
hand-off is in our ancestor chain. Identity is ``(pid, create_time)`` — a recycled PID cannot
|
| 231 |
+
forge the pair.
|
| 232 |
+
"""
|
| 233 |
+
spawner_pid = entry.get("spawner_pid")
|
| 234 |
+
if not isinstance(spawner_pid, int) or spawner_pid <= 0:
|
| 235 |
+
return False
|
| 236 |
+
try:
|
| 237 |
+
import psutil # noqa: PLC0415
|
| 238 |
+
|
| 239 |
+
for ancestor in psutil.Process().parents():
|
| 240 |
+
if ancestor.pid != spawner_pid:
|
| 241 |
+
continue
|
| 242 |
+
expected = entry.get("spawner_create")
|
| 243 |
+
if expected is None:
|
| 244 |
+
return True
|
| 245 |
+
return abs(float(ancestor.create_time()) - float(expected)) < 2.0
|
| 246 |
+
except Exception:
|
| 247 |
+
return False
|
| 248 |
+
return False
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def main() -> None:
|
| 252 |
+
"""Entry point. Prints one JSON doc to stdout. Exits 0 for valid scan."""
|
| 253 |
+
try:
|
| 254 |
+
import psutil # noqa: PLC0415, F401
|
| 255 |
+
except Exception as exc:
|
| 256 |
+
_emit_probe_fail(f"psutil is not available: {exc}")
|
| 257 |
+
try:
|
| 258 |
+
from hermes_cli.update_cmd import _detect_venv_python_processes
|
| 259 |
+
|
| 260 |
+
matches = _detect_venv_python_processes()
|
| 261 |
+
except Exception as exc:
|
| 262 |
+
_emit_probe_fail(f"scan aborted: {exc}")
|
| 263 |
+
|
| 264 |
+
processes = []
|
| 265 |
+
exempted_gateways = 0
|
| 266 |
+
deferred_entries: list[dict] = []
|
| 267 |
+
for pid, name, cmdline in matches:
|
| 268 |
+
if _is_pausable_gateway(cmdline):
|
| 269 |
+
exempted_gateways += 1
|
| 270 |
+
continue
|
| 271 |
+
deferred_entry = _updater_owned_backend_entry(pid, cmdline)
|
| 272 |
+
if deferred_entry is not None:
|
| 273 |
+
# Ledger-verified backend the updater's own rungs stop (and relaunch) downstream —
|
| 274 |
+
# reporting it here would dead-end the hand-off before that machinery can run.
|
| 275 |
+
# See #98336.
|
| 276 |
+
deferred_entries.append(deferred_entry)
|
| 277 |
+
continue
|
| 278 |
+
# Truncate for display AFTER the gateway exemption has seen the full cmdline (long
|
| 279 |
+
# managed-runtime interpreter paths would otherwise swallow the `gateway run` argv).
|
| 280 |
+
process = {"pid": pid, "name": name, "cmdline": _redact_sensitive_cmdline(cmdline)[:120]}
|
| 281 |
+
process.update(_local_preview_metadata(pid, name))
|
| 282 |
+
processes.append(process)
|
| 283 |
+
|
| 284 |
+
# pausable_gateways / deferred_backends / deferred_backend_evidence are diagnostic only.
|
| 285 |
+
data = {
|
| 286 |
+
"ok": True,
|
| 287 |
+
"blocked": bool(processes),
|
| 288 |
+
"processes": processes,
|
| 289 |
+
"pausable_gateways": exempted_gateways,
|
| 290 |
+
# Diagnostic only: ledger-verified serve/dashboard backends deferred to the updater's stop/relaunch
|
| 291 |
+
# rungs (#98336).
|
| 292 |
+
"deferred_backends": len(deferred_entries),
|
| 293 |
+
# Diagnostic only: sanitized evidence (structured ledger identity, never argv) explaining which
|
| 294 |
+
# holders the deferral consumed (#98350).
|
| 295 |
+
"deferred_backend_evidence": _deferred_backend_evidence(deferred_entries),
|
| 296 |
+
}
|
| 297 |
+
print(json.dumps(data))
|
| 298 |
+
sys.exit(0)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _terminate_safe_main(argv: list[str]) -> NoReturn:
|
| 302 |
+
if len(argv) != 2:
|
| 303 |
+
print(json.dumps({"ok": False, "error": "expected pid and create time"}))
|
| 304 |
+
raise SystemExit(2)
|
| 305 |
+
try:
|
| 306 |
+
pid = int(argv[0])
|
| 307 |
+
create_time = float(argv[1])
|
| 308 |
+
if pid <= 0 or not math.isfinite(create_time) or create_time <= 0:
|
| 309 |
+
raise ValueError
|
| 310 |
+
except ValueError:
|
| 311 |
+
print(json.dumps({"ok": False, "error": "invalid process identity"}))
|
| 312 |
+
raise SystemExit(2)
|
| 313 |
+
stopped, error = _terminate_safe_preview(pid, create_time)
|
| 314 |
+
print(json.dumps({"ok": stopped, "pid": pid, "error": error}))
|
| 315 |
+
raise SystemExit(0 if stopped else 1)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if __name__ == "__main__":
|
| 319 |
+
if len(sys.argv) > 1 and sys.argv[1] == "--terminate-safe":
|
| 320 |
+
_terminate_safe_main(sys.argv[2:])
|
| 321 |
+
main()
|
hermes_cli/anon_auth.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
|
| 2 |
+
|
| 3 |
+
The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only
|
| 4 |
+
while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous
|
| 5 |
+
account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
|
| 6 |
+
JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``; it
|
| 7 |
+
becomes ``active_provider`` only when the bootstrap's inventory found nothing else usable, so an
|
| 8 |
+
install with its own key keeps that key for inference and uses the identity for connectors only. In
|
| 9 |
+
the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
|
| 10 |
+
implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter
|
| 11 |
+
pool, a logged-in ``active_provider``) beats it, and the ladder never creates one.
|
| 12 |
+
|
| 13 |
+
Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``:
|
| 14 |
+
token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing
|
| 15 |
+
(the welcome inference host, single model ``nous/welcome``).
|
| 16 |
+
|
| 17 |
+
Users are never shown the words guest / anonymous / account for this state: surfaces say
|
| 18 |
+
"Nous · free tier". Two user-facing verbs reach the same flow, both keeping the identity's
|
| 19 |
+
connectors: ``hermes auth upgrade`` in a terminal and ``/login`` inside a chat.
|
| 20 |
+
|
| 21 |
+
Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already
|
| 22 |
+
holds, else mint under the shared-store lock. It is the only minter; nothing else calls
|
| 23 |
+
:func:`mint_guest`.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
import os
|
| 30 |
+
import time
|
| 31 |
+
from datetime import datetime, timedelta, timezone
|
| 32 |
+
from typing import Any, Callable, Dict, Optional
|
| 33 |
+
|
| 34 |
+
from agent.retry_utils import parse_retry_after_seconds
|
| 35 |
+
from hermes_cli.auth_constants import (
|
| 36 |
+
AuthError, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_WELCOME_URL, _decode_jwt_claims, httpx)
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger("hermes_cli.auth")
|
| 39 |
+
|
| 40 |
+
ANON_AUTH_METHOD = "anonymous"
|
| 41 |
+
ANON_CLIENT_ID = "nas-anonymous"
|
| 42 |
+
ANON_ACCOUNT_TIER = "anonymous"
|
| 43 |
+
GUEST_MODEL = "nous/welcome"
|
| 44 |
+
ANON_SECRET_HEADER = "x-anonymous-api-secret"
|
| 45 |
+
# The shared secret gates the anonymous surface during its integration phase. It is a deployment
|
| 46 |
+
# secret (Sid's), read from the environment only.
|
| 47 |
+
ANON_SECRET_ENV = "HERMES_ANON_API_SECRET"
|
| 48 |
+
# Launch gate for the whole free tier while it is pre-GA: exactly "1" turns it on for this process
|
| 49 |
+
# (CLI, gateway, serve backend alike); anything else leaves every surface behaving as if the free
|
| 50 |
+
# tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to
|
| 51 |
+
# config.yaml or .env, never shown in setup. Deleted at GA together with this comment.
|
| 52 |
+
GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING"
|
| 53 |
+
GUEST_MINT_TIMEOUT_SECONDS = 5.0
|
| 54 |
+
# Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account.
|
| 55 |
+
FREE_TIER_LABEL = "Nous · free tier"
|
| 56 |
+
UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account, or /login inside a chat."
|
| 57 |
+
FREE_TIER_NOT_SIGNED_IN = (
|
| 58 |
+
"You're not signed in. Free inference and connectors are always on. "
|
| 59 |
+
"Run `hermes auth` to sign in with a Nous account.")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class AnonCredentialDead(AuthError):
|
| 63 |
+
"""NAS no longer knows this ``anon_`` credential (reaped, or claimed into a real account).
|
| 64 |
+
|
| 65 |
+
The one client rule for reap AND claim: mark dead, re-mint on the next need.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _anon_err(message: str, code: str) -> AuthError:
|
| 70 |
+
return AuthError(message, code=code)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def guest_enabled() -> bool:
|
| 74 |
+
"""The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default
|
| 75 |
+
True) has not switched it off. The only place either is read."""
|
| 76 |
+
if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1":
|
| 77 |
+
return False
|
| 78 |
+
try:
|
| 79 |
+
from hermes_cli.config import load_config_readonly
|
| 80 |
+
nous_cfg = load_config_readonly().get("nous")
|
| 81 |
+
except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint
|
| 82 |
+
logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc)
|
| 83 |
+
return False
|
| 84 |
+
if not isinstance(nous_cfg, dict):
|
| 85 |
+
return True
|
| 86 |
+
return bool(nous_cfg.get("guest", True))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def is_guest_state(state: Any) -> bool:
|
| 90 |
+
return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def current_nous_state() -> Optional[Dict[str, Any]]:
|
| 94 |
+
"""The profile's ``providers.nous`` state without locking or network (status/picker reads)."""
|
| 95 |
+
from hermes_cli.auth import _load_auth_store, _load_provider_state
|
| 96 |
+
try:
|
| 97 |
+
return _load_provider_state(_load_auth_store(), "nous")
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
logger.debug("guest: auth store unreadable: %s", exc)
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def has_guest() -> bool:
|
| 104 |
+
return is_guest_state(current_nous_state())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def guest_carries_inference() -> bool:
|
| 108 |
+
"""True when the profile's Nous identity is the free tier and the free tier is on.
|
| 109 |
+
|
| 110 |
+
Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a
|
| 111 |
+
request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a
|
| 112 |
+
credential-pool entry can pick a paid Nous key while the profile singleton is still a guest.
|
| 113 |
+
"""
|
| 114 |
+
return guest_enabled() and has_guest()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
WELCOME_HOSTS = frozenset({"welcome-api.nousresearch.com"})
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def pin_model_for_route(provider: Any, base_url: Any, model: Any) -> Any:
|
| 121 |
+
"""Model policy at agent START: on the Nous welcome host the model is ``nous/welcome``; anywhere
|
| 122 |
+
else the caller's model stands. Used once, when the route is first finalized. Mid-conversation
|
| 123 |
+
route changes go through :func:`route_can_serve_model` instead: a conversation's model is never
|
| 124 |
+
silently rewritten by a credential rotation.
|
| 125 |
+
"""
|
| 126 |
+
if provider == "nous" and route_is_welcome_host(base_url):
|
| 127 |
+
if model and model != GUEST_MODEL:
|
| 128 |
+
logger.info("Nous free tier: using %s instead of configured model %s", GUEST_MODEL, model)
|
| 129 |
+
return GUEST_MODEL
|
| 130 |
+
return model
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def route_can_serve_model(provider: Any, base_url: Any, model: Any) -> bool:
|
| 134 |
+
"""Eligibility for a credential ROTATION: the welcome host serves only ``nous/welcome``, so a
|
| 135 |
+
conversation on any other model must not be rotated onto it (and a ``nous/welcome`` conversation
|
| 136 |
+
may move to the portal host, which serves it too). Non-Nous routes are always eligible."""
|
| 137 |
+
if provider != "nous" or not route_is_welcome_host(base_url):
|
| 138 |
+
return True
|
| 139 |
+
return not model or model == GUEST_MODEL
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def route_is_welcome_host(base_url: Any) -> bool:
|
| 143 |
+
"""The routing predicate for the free tier: the welcome host serves exactly ``nous/welcome``.
|
| 144 |
+
|
| 145 |
+
Keyed on the resolved endpoint, never on profile state, so a paid pool credential routed to the
|
| 146 |
+
portal host keeps its model even when a guest singleton exists beside it.
|
| 147 |
+
"""
|
| 148 |
+
from urllib.parse import urlparse
|
| 149 |
+
try:
|
| 150 |
+
host = (urlparse(str(base_url or "")).hostname or "").lower()
|
| 151 |
+
except ValueError:
|
| 152 |
+
return False
|
| 153 |
+
return host in WELCOME_HOSTS
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def anon_secret() -> str:
|
| 157 |
+
return (os.environ.get(ANON_SECRET_ENV) or "").strip()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _anon_headers() -> Dict[str, str]:
|
| 161 |
+
headers = {"content-type": "application/json"}
|
| 162 |
+
if secret := anon_secret():
|
| 163 |
+
headers[ANON_SECRET_HEADER] = secret
|
| 164 |
+
return headers
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]:
|
| 168 |
+
try:
|
| 169 |
+
payload = response.json()
|
| 170 |
+
except ValueError:
|
| 171 |
+
payload = {}
|
| 172 |
+
if not isinstance(payload, dict):
|
| 173 |
+
payload = {}
|
| 174 |
+
error = str(payload.get("error") or "")
|
| 175 |
+
if response.status_code in (200, 201):
|
| 176 |
+
return payload
|
| 177 |
+
if response.status_code == 404 and error == "unknown_token":
|
| 178 |
+
raise AnonCredentialDead("Nous free-tier credential is no longer valid.", code="anon_credential_dead")
|
| 179 |
+
if response.status_code == 401 and error == "invalid_shared_secret":
|
| 180 |
+
raise _anon_err("Nous free tier is not open on this portal.", "anon_gate_closed")
|
| 181 |
+
if response.status_code == 401:
|
| 182 |
+
raise AnonCredentialDead("Nous free-tier credential was revoked.", code="anon_credential_dead")
|
| 183 |
+
if response.status_code == 429:
|
| 184 |
+
raise _anon_err("Nous free tier is rate limited; try again shortly.", "anon_rate_limited")
|
| 185 |
+
if response.status_code == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}:
|
| 186 |
+
raise _anon_err("Nous free tier is currently disabled.", "anon_gate_closed")
|
| 187 |
+
raise _anon_err(
|
| 188 |
+
f"Nous free tier {action} failed ({response.status_code}{': ' + error if error else ''}).",
|
| 189 |
+
"anon_server_error")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]:
|
| 193 |
+
"""``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once."""
|
| 194 |
+
response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={})
|
| 195 |
+
payload = _raise_for_anon_status(response, action="sign-up")
|
| 196 |
+
token = payload.get("token")
|
| 197 |
+
if not isinstance(token, str) or not token.startswith("anon_"):
|
| 198 |
+
raise _anon_err("Nous free tier sign-up returned no credential.", "anon_server_error")
|
| 199 |
+
return payload
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def exchange_anon_jwt(client: httpx.Client, portal_base_url: str, anon_token: str) -> Dict[str, Any]:
|
| 203 |
+
"""``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``.
|
| 204 |
+
|
| 205 |
+
Raises :class:`AnonCredentialDead` on 404 ``unknown_token`` / 401 (reaped or claimed).
|
| 206 |
+
"""
|
| 207 |
+
response = client.post(
|
| 208 |
+
f"{portal_base_url.rstrip('/')}/api/anonymous/token", headers=_anon_headers(), json={"token": anon_token})
|
| 209 |
+
payload = _raise_for_anon_status(response, action="token exchange")
|
| 210 |
+
if not isinstance(payload.get("access_token"), str) or not payload["access_token"]:
|
| 211 |
+
raise _anon_err("Nous free tier token exchange returned no token.", "anon_server_error")
|
| 212 |
+
return payload
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> None:
|
| 216 |
+
"""Write a fresh exchange result into a guest state in place (token, expiry, routing)."""
|
| 217 |
+
from hermes_cli.auth_nous import _validate_nous_inference_url_from_network
|
| 218 |
+
access_token = exchanged["access_token"]
|
| 219 |
+
claims = _decode_jwt_claims(access_token)
|
| 220 |
+
now = datetime.now(timezone.utc)
|
| 221 |
+
exp = claims.get("exp")
|
| 222 |
+
if isinstance(exp, (int, float)):
|
| 223 |
+
expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc)
|
| 224 |
+
else:
|
| 225 |
+
expires_at = now + timedelta(seconds=int(exchanged.get("expires_in") or 900))
|
| 226 |
+
# NAS names the welcome host on every exchange; absent (older NAS) or outside the allowlist
|
| 227 |
+
# (a staging host without NOUS_INFERENCE_BASE_URL set), the literal stands in. Never the paid
|
| 228 |
+
# host: the gateway cross-refuses an anonymous JWT there.
|
| 229 |
+
inference_url = (_validate_nous_inference_url_from_network(exchanged.get("inference_base_url"))
|
| 230 |
+
or DEFAULT_NOUS_WELCOME_URL)
|
| 231 |
+
scope = claims.get("scope") or claims.get("scp") or state.get("scope")
|
| 232 |
+
if isinstance(scope, (list, tuple)):
|
| 233 |
+
scope = " ".join(str(s) for s in scope)
|
| 234 |
+
state.update(
|
| 235 |
+
access_token=access_token, token_type="Bearer", scope=scope,
|
| 236 |
+
obtained_at=now.isoformat(), expires_at=expires_at.isoformat(),
|
| 237 |
+
expires_in=max(0, int((expires_at - now).total_seconds())),
|
| 238 |
+
account_tier=str(claims.get("account_tier") or ANON_ACCOUNT_TIER))
|
| 239 |
+
state["inference_base_url"] = inference_url
|
| 240 |
+
for key in ("user_id", "org_id"):
|
| 241 |
+
if exchanged.get(key):
|
| 242 |
+
state[key] = exchanged[key]
|
| 243 |
+
state.pop("refresh_token", None)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _portal_base_url() -> str:
|
| 247 |
+
from hermes_cli.auth_nous import _nous_portal_env_override
|
| 248 |
+
return (_nous_portal_env_override() or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _shared_identity_key(state: Any) -> Optional[str]:
|
| 252 |
+
"""Stable identity of a Nous credential: the anon_ token for a guest, the refresh token for an
|
| 253 |
+
account. Used to decide whether two stores hold the SAME identity."""
|
| 254 |
+
if not isinstance(state, dict):
|
| 255 |
+
return None
|
| 256 |
+
return state.get("anon_token") if is_guest_state(state) else state.get("refresh_token")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _mint_locked(
|
| 260 |
+
client: httpx.Client, portal: str, auth_store: Dict[str, Any], *, carries_inference: bool = True,
|
| 261 |
+
) -> Dict[str, Any]:
|
| 262 |
+
"""Mint under the caller's locks. The identity is persisted as soon as ``create`` succeeds, BEFORE
|
| 263 |
+
the exchange: a 429 or timeout on the exchange must not lose a credential NAS still honours (the
|
| 264 |
+
next attempt exchanges the stored one instead of minting again).
|
| 265 |
+
|
| 266 |
+
``carries_inference`` decides whether the new identity also becomes ``active_provider``. The
|
| 267 |
+
bootstrap passes False when its inventory found another usable provider: the identity exists for
|
| 268 |
+
connectors, the user's own provider keeps carrying inference (NS-845 Q1.3)."""
|
| 269 |
+
from hermes_cli.auth import _store_provider_state, _save_auth_store
|
| 270 |
+
from hermes_cli.auth_nous import _write_shared_nous_state
|
| 271 |
+
minted = mint_guest(client, portal)
|
| 272 |
+
state: Dict[str, Any] = {
|
| 273 |
+
"auth_method": ANON_AUTH_METHOD, "account_tier": ANON_ACCOUNT_TIER,
|
| 274 |
+
"anon_token": minted["token"], "client_id": ANON_CLIENT_ID,
|
| 275 |
+
"portal_base_url": portal.rstrip("/"),
|
| 276 |
+
"user_id": minted.get("user_id"), "org_id": minted.get("org_id"),
|
| 277 |
+
"idle_ttl_days": minted.get("idle_ttl_days"),
|
| 278 |
+
}
|
| 279 |
+
_store_provider_state(auth_store, "nous", state, set_active=carries_inference)
|
| 280 |
+
_save_auth_store(auth_store)
|
| 281 |
+
_write_shared_nous_state(state)
|
| 282 |
+
logger.info("Nous free tier ready (identity minted)")
|
| 283 |
+
return state
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# Per-process memo: one failed mint is enough for a process (a 429 or a closed gate must not be hit
|
| 287 |
+
# twice); ``clear_dead_guest`` resets it because a retired credential is a reason to mint again.
|
| 288 |
+
# The bool is the unscoped (launch profile) slot; routed multiplex profiles each get their own entry
|
| 289 |
+
# in the set — profile A's 429 must not stop profile B from ever getting an identity.
|
| 290 |
+
_mint_failed = False
|
| 291 |
+
_mint_failed_homes: set[str] = set()
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _mint_failed_for_profile() -> bool:
|
| 295 |
+
from hermes_constants import get_hermes_home_override, hermes_home_key
|
| 296 |
+
if get_hermes_home_override() is None:
|
| 297 |
+
return _mint_failed
|
| 298 |
+
return hermes_home_key() in _mint_failed_homes
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _set_mint_failed(failed: bool) -> None:
|
| 302 |
+
global _mint_failed
|
| 303 |
+
from hermes_constants import get_hermes_home_override, hermes_home_key
|
| 304 |
+
if get_hermes_home_override() is None:
|
| 305 |
+
_mint_failed = failed
|
| 306 |
+
elif failed:
|
| 307 |
+
_mint_failed_homes.add(hermes_home_key())
|
| 308 |
+
else:
|
| 309 |
+
_mint_failed_homes.discard(hermes_home_key())
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _reconcile_and_provision(*, timeout_seconds: float, carries_inference: bool = True) -> Optional[Dict[str, Any]]:
|
| 313 |
+
"""The lifecycle body, run under profile lock THEN shared lock (the documented order).
|
| 314 |
+
|
| 315 |
+
1. The shared store is the identity of record for this Hermes root. If it holds an identity
|
| 316 |
+
that differs from the profile's, the profile adopts it (a stale guest never outlives a
|
| 317 |
+
sibling profile's sign-in, and never overwrites it). An adopted free-tier identity claims
|
| 318 |
+
``active_provider`` under the same rule as a mint; an adopted ACCOUNT always does (the user
|
| 319 |
+
signed in somewhere on this machine).
|
| 320 |
+
2. Otherwise the profile's own identity stands.
|
| 321 |
+
3. Nothing anywhere: mint, persisting the credential before exchanging it.
|
| 322 |
+
"""
|
| 323 |
+
from hermes_cli.auth import (
|
| 324 |
+
_auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store,
|
| 325 |
+
_store_provider_state, _resolve_verify)
|
| 326 |
+
from hermes_cli.auth_nous import (
|
| 327 |
+
_nous_http_client, _nous_shared_store_lock, _read_shared_nous_state, _write_shared_nous_state)
|
| 328 |
+
portal = _portal_base_url()
|
| 329 |
+
with _auth_store_lock():
|
| 330 |
+
auth_store = _load_auth_store()
|
| 331 |
+
profile_state = _load_provider_state(auth_store, "nous")
|
| 332 |
+
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds, 5.0)):
|
| 333 |
+
shared = _read_shared_nous_state()
|
| 334 |
+
if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state):
|
| 335 |
+
state = dict(shared)
|
| 336 |
+
_store_provider_state(
|
| 337 |
+
auth_store, "nous", state,
|
| 338 |
+
set_active=carries_inference or not is_guest_state(state))
|
| 339 |
+
_save_auth_store(auth_store)
|
| 340 |
+
logger.debug("Nous identity adopted from the shared store")
|
| 341 |
+
return state
|
| 342 |
+
if profile_state:
|
| 343 |
+
if not shared:
|
| 344 |
+
_write_shared_nous_state(profile_state)
|
| 345 |
+
return profile_state
|
| 346 |
+
verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None)
|
| 347 |
+
with _nous_http_client(timeout_seconds, verify) as client:
|
| 348 |
+
return _mint_locked(client, portal, auth_store, carries_inference=carries_inference)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def ensure_portal_identity(
|
| 352 |
+
*, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS,
|
| 353 |
+
carries_inference: bool = True,
|
| 354 |
+
) -> Optional[Dict[str, Any]]:
|
| 355 |
+
"""Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
|
| 356 |
+
store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
|
| 357 |
+
|
| 358 |
+
``explicit`` is required and must be True: the only callers are the boot bootstrap
|
| 359 |
+
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, and the
|
| 360 |
+
dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``,
|
| 361 |
+
``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect
|
| 362 |
+
of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2).
|
| 363 |
+
|
| 364 |
+
Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile
|
| 365 |
+
first, then shared, matching every other Nous path. ``carries_inference=False`` leaves
|
| 366 |
+
``active_provider`` alone (the identity is for connectors; another provider does inference).
|
| 367 |
+
Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
|
| 368 |
+
"""
|
| 369 |
+
if not explicit:
|
| 370 |
+
raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
|
| 371 |
+
if not guest_enabled():
|
| 372 |
+
return None
|
| 373 |
+
if _mint_failed_for_profile() and not current_nous_state():
|
| 374 |
+
return None # this profile already tried and failed in this process; do not hammer the portal
|
| 375 |
+
try:
|
| 376 |
+
return _reconcile_and_provision(
|
| 377 |
+
timeout_seconds=timeout_seconds, carries_inference=carries_inference)
|
| 378 |
+
except Exception:
|
| 379 |
+
_set_mint_failed(True)
|
| 380 |
+
raise
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None:
|
| 384 |
+
"""Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place.
|
| 385 |
+
|
| 386 |
+
The portal URL is the resolver's canonical one (env override, else the validated stored URL,
|
| 387 |
+
else the default), never a raw stored value on its own.
|
| 388 |
+
Raises :class:`AnonCredentialDead` when NAS no longer knows the credential; the caller owns
|
| 389 |
+
re-minting (:func:`ensure_portal_identity` after :func:`clear_dead_guest`).
|
| 390 |
+
"""
|
| 391 |
+
anon_token = state.get("anon_token")
|
| 392 |
+
if not isinstance(anon_token, str) or not anon_token:
|
| 393 |
+
raise AnonCredentialDead("Nous free-tier credential is missing.", code="anon_credential_dead")
|
| 394 |
+
from hermes_cli.auth import _nous_portal_base_url
|
| 395 |
+
apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token))
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None:
|
| 399 |
+
"""Drop a dead guest so the next need re-mints.
|
| 400 |
+
|
| 401 |
+
Only the identity that actually failed is removed: a stale profile whose credential NAS rejected
|
| 402 |
+
must not erase a sibling profile's newer sign-in or replacement guest from the shared store. When
|
| 403 |
+
*dead_token* is None the profile's current guest is treated as the failed one.
|
| 404 |
+
"""
|
| 405 |
+
from hermes_cli.auth import (
|
| 406 |
+
_auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_section)
|
| 407 |
+
from hermes_cli.auth_nous import _clear_shared_nous_state, _nous_shared_store_lock, _read_shared_nous_state
|
| 408 |
+
with _auth_store_lock():
|
| 409 |
+
auth_store = _load_auth_store()
|
| 410 |
+
state = _load_provider_state(auth_store, "nous")
|
| 411 |
+
if is_guest_state(state):
|
| 412 |
+
token = dead_token or state.get("anon_token")
|
| 413 |
+
if state.get("anon_token") == token:
|
| 414 |
+
_store_section(auth_store, "providers").pop("nous", None)
|
| 415 |
+
_store_section(auth_store, "credential_pool").pop("nous", None)
|
| 416 |
+
if auth_store.get("active_provider") == "nous":
|
| 417 |
+
auth_store["active_provider"] = None
|
| 418 |
+
_save_auth_store(auth_store)
|
| 419 |
+
else:
|
| 420 |
+
token = dead_token
|
| 421 |
+
with _nous_shared_store_lock():
|
| 422 |
+
shared = _read_shared_nous_state()
|
| 423 |
+
if token and is_guest_state(shared) and shared.get("anon_token") == token:
|
| 424 |
+
_clear_shared_nous_state(reason)
|
| 425 |
+
_set_mint_failed(False)
|
| 426 |
+
logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# --- Gateway welcome-tier contract: structured refusals and the model-switch header ------------------
|
| 430 |
+
#
|
| 431 |
+
# The inference gateway answers a welcome-tier request it will not serve with a structured 429
|
| 432 |
+
# (``{status, message, reason, retry_after, alternates?, upgrade_url?}``), and a request on the wrong
|
| 433 |
+
# host with a 400 (or a 403 while the tier is dark) whose message names the right host. A NAMED
|
| 434 |
+
# account that still asks for ``nous/welcome`` is served the id's backing model and told what to
|
| 435 |
+
# switch to in the ``x-nous-model-switch`` response header. Every rule for reading those lives here;
|
| 436 |
+
# the error classifier and the turn loop only call in.
|
| 437 |
+
|
| 438 |
+
MODEL_SWITCH_HEADER = "x-nous-model-switch"
|
| 439 |
+
# Fairshare refusal reasons the welcome tier can answer with (api ``FairshareRefusalReason``).
|
| 440 |
+
WELCOME_REFUSAL_REASONS = frozenset(
|
| 441 |
+
{"model_not_free", "feature_not_free", "at_capacity", "admission_closed", "rate_limited"})
|
| 442 |
+
# Reasons that mean "not on this tier, ever": no retry helps, only a sign-in or another provider.
|
| 443 |
+
WELCOME_TIER_GATE_REASONS = frozenset({"model_not_free", "feature_not_free"})
|
| 444 |
+
# Gateway messages (lowercased substrings) for a request on the wrong host or a dark tier.
|
| 445 |
+
_WELCOME_ROUTE_REFUSALS = (
|
| 446 |
+
("anonymous accounts must use", "anon_on_paid_host"),
|
| 447 |
+
("serves anonymous hermes agent accounts only", "named_on_welcome_host"),
|
| 448 |
+
("anonymous accounts are not accepted", "tier_disabled"),
|
| 449 |
+
)
|
| 450 |
+
_WELCOME_ROUTE_COPY = {
|
| 451 |
+
"anon_on_paid_host": "The Nous free tier must use its own inference host ({host}); "
|
| 452 |
+
"Hermes is pointed at the paid one. Restart Hermes to re-read the route, "
|
| 453 |
+
"or unset NOUS_INFERENCE_BASE_URL if you set it.",
|
| 454 |
+
"named_on_welcome_host": "This Nous account must use the Nous Portal inference host, "
|
| 455 |
+
"not the free tier's. Run /model and pick the Nous row again.",
|
| 456 |
+
"tier_disabled": "The Nous free tier is switched off right now. {signin}",
|
| 457 |
+
}
|
| 458 |
+
_SIGNIN_CHAT = "Sign in with a Nous account for the full catalog: /login."
|
| 459 |
+
_SIGNIN_TERMINAL = "Sign in with a Nous account for the full catalog: `hermes auth upgrade`."
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]:
|
| 463 |
+
"""The structured welcome-tier refusal in a gateway 429 body, or None for any other shape.
|
| 464 |
+
|
| 465 |
+
Returns ``{"reason", "retry_after", "alternates", "upgrade_url"}`` with ``retry_after`` an int
|
| 466 |
+
of whole seconds (0 when the gateway sent none) and ``alternates`` a list of model ids.
|
| 467 |
+
"""
|
| 468 |
+
if not isinstance(body, dict):
|
| 469 |
+
return None
|
| 470 |
+
reason = body.get("reason")
|
| 471 |
+
if not isinstance(reason, str) or reason not in WELCOME_REFUSAL_REASONS:
|
| 472 |
+
return None
|
| 473 |
+
raw_retry = body.get("retry_after")
|
| 474 |
+
try:
|
| 475 |
+
retry_after = max(0, int(float(raw_retry))) if raw_retry not in (None, "") else 0
|
| 476 |
+
except (TypeError, ValueError):
|
| 477 |
+
retry_after = 0
|
| 478 |
+
raw_alternates = body.get("alternates")
|
| 479 |
+
alternates = [str(a) for a in raw_alternates if isinstance(a, str) and a] if isinstance(raw_alternates, list) else []
|
| 480 |
+
upgrade_url = body.get("upgrade_url")
|
| 481 |
+
return {"reason": reason, "retry_after": retry_after, "alternates": alternates,
|
| 482 |
+
"upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""}
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str:
|
| 486 |
+
"""User copy for a structured welcome-tier refusal: what happened and the one way forward.
|
| 487 |
+
|
| 488 |
+
Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb."""
|
| 489 |
+
signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL
|
| 490 |
+
reason = str(refusal.get("reason") or "")
|
| 491 |
+
alternates = refusal.get("alternates") or []
|
| 492 |
+
serves = alternates[0] if alternates else GUEST_MODEL
|
| 493 |
+
retry = int(refusal.get("retry_after") or 0)
|
| 494 |
+
wait = f"Retrying in {retry}s." if retry > 0 else "Try again shortly."
|
| 495 |
+
if reason == "model_not_free":
|
| 496 |
+
what = f"{model} isn't on the Nous free tier" if model else "That model isn't on the Nous free tier"
|
| 497 |
+
return f"{what}; it serves {serves} only. {signin}"
|
| 498 |
+
if reason == "feature_not_free":
|
| 499 |
+
return f"This feature isn't on the Nous free tier. {signin}"
|
| 500 |
+
if reason == "at_capacity":
|
| 501 |
+
return f"The Nous free tier is at capacity and briefly paused. {wait} {signin}"
|
| 502 |
+
if reason == "admission_closed":
|
| 503 |
+
return f"The Nous free tier isn't admitting new sessions right now. {wait} {signin}"
|
| 504 |
+
if reason == "rate_limited":
|
| 505 |
+
return f"Nous free tier rate limit active \u2014 resets in {retry}s. {signin}"
|
| 506 |
+
return f"The Nous free tier refused this request ({reason}). {signin}"
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def welcome_route_refusal(status: Any, message: Any) -> Optional[str]:
|
| 510 |
+
"""Which host cross-refusal a gateway 400/403 is, by its message; None for any other error.
|
| 511 |
+
|
| 512 |
+
``"anon_on_paid_host"``: a free-tier JWT reached the paid host. ``"named_on_welcome_host"``: an
|
| 513 |
+
account or API key reached the free tier's host. ``"tier_disabled"``: the tier is dark
|
| 514 |
+
(``WELCOME_MODE=off``). Each is deterministic for the request: retrying cannot help."""
|
| 515 |
+
if status not in (400, 403):
|
| 516 |
+
return None
|
| 517 |
+
text = str(message or "").lower()
|
| 518 |
+
return next((kind for needle, kind in _WELCOME_ROUTE_REFUSALS if needle in text), None)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str:
|
| 522 |
+
template = _WELCOME_ROUTE_COPY.get(kind) or "The Nous inference gateway refused this route."
|
| 523 |
+
return template.format(
|
| 524 |
+
host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL)
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def note_model_switch(agent: Any, headers: Any) -> Optional[str]:
|
| 528 |
+
"""Record the gateway's ``x-nous-model-switch`` header on *agent* for the next call, if present.
|
| 529 |
+
|
| 530 |
+
The header arrives on a NAMED account's response that asked for ``nous/welcome`` (the gateway
|
| 531 |
+
served the backing model and billed it normally): the free tier's model no longer belongs in
|
| 532 |
+
this install's configuration. Recorded here, applied by :func:`apply_model_switch` between
|
| 533 |
+
calls so a response still streaming is never re-labelled under itself. Returns the backing id.
|
| 534 |
+
"""
|
| 535 |
+
if headers is None:
|
| 536 |
+
return None
|
| 537 |
+
value = None
|
| 538 |
+
try:
|
| 539 |
+
value = headers.get(MODEL_SWITCH_HEADER)
|
| 540 |
+
if value is None and hasattr(headers, "items"):
|
| 541 |
+
value = next((v for k, v in headers.items() if str(k).lower() == MODEL_SWITCH_HEADER), None)
|
| 542 |
+
except Exception:
|
| 543 |
+
return None
|
| 544 |
+
backing = str(value or "").strip()
|
| 545 |
+
if not backing:
|
| 546 |
+
return None
|
| 547 |
+
requested = str(getattr(agent, "model", "") or "")
|
| 548 |
+
if backing == requested:
|
| 549 |
+
return None
|
| 550 |
+
try:
|
| 551 |
+
agent._nous_pending_model_switch = (requested, backing)
|
| 552 |
+
except Exception:
|
| 553 |
+
return None
|
| 554 |
+
return backing
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def apply_model_switch(agent: Any) -> Optional[str]:
|
| 558 |
+
"""Move *agent* (and the config default, when it still names the switched id) to the backing
|
| 559 |
+
model the gateway named. Returns the new model, or None when nothing was pending.
|
| 560 |
+
|
| 561 |
+
Runs once per recorded header, between calls. The conversation keeps its history; only the id
|
| 562 |
+
the next request carries changes, so a promoted account stops relying on the gateway's reverse
|
| 563 |
+
map. The config write is the same one a sign-in completion uses, so ``hermes model`` and the
|
| 564 |
+
gateway's config re-read agree with the live session.
|
| 565 |
+
"""
|
| 566 |
+
pending = getattr(agent, "_nous_pending_model_switch", None)
|
| 567 |
+
if not pending:
|
| 568 |
+
return None
|
| 569 |
+
agent._nous_pending_model_switch = None
|
| 570 |
+
requested, backing = pending
|
| 571 |
+
if str(getattr(agent, "model", "") or "") != requested:
|
| 572 |
+
return None # the session already moved (a /model, a sign-in sweep)
|
| 573 |
+
agent.model = backing
|
| 574 |
+
# The gateway's cache check compares agent.model with the config default and evicts on a
|
| 575 |
+
# mismatch it did not cause; this pair names the move so the check can recognise exactly this
|
| 576 |
+
# server-driven switch even when the config write below did not land.
|
| 577 |
+
agent._nous_model_switch = (requested, backing)
|
| 578 |
+
logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing)
|
| 579 |
+
try:
|
| 580 |
+
from hermes_cli.config import load_config_readonly
|
| 581 |
+
raw = load_config_readonly().get("model")
|
| 582 |
+
model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {})
|
| 583 |
+
if str(model_cfg.get("default") or "").strip() == requested:
|
| 584 |
+
from hermes_cli.auth import _update_config_for_provider
|
| 585 |
+
_update_config_for_provider(
|
| 586 |
+
"nous", str(getattr(agent, "base_url", "") or ""), default_model=backing)
|
| 587 |
+
logger.info("Config default model moved %s -> %s", requested, backing)
|
| 588 |
+
except Exception as exc:
|
| 589 |
+
logger.debug("model switch: config default left as is: %s", exc)
|
| 590 |
+
status = getattr(agent, "_buffer_status", None)
|
| 591 |
+
if callable(status):
|
| 592 |
+
try:
|
| 593 |
+
status(f"Model is now {backing} (your account's model; {requested} is the free tier's).")
|
| 594 |
+
except Exception:
|
| 595 |
+
pass
|
| 596 |
+
return backing
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
# One-time CLI notice: an install whose inference is carried by an explicit provider learns once that
|
| 600 |
+
# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it
|
| 601 |
+
# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more.
|
| 602 |
+
GUEST_NOTICE_FLAG = "guest_notice_shown"
|
| 603 |
+
FREE_TIER_AVAILABLE_NOTICE = (
|
| 604 |
+
"Free Nous inference and connectors are now available. "
|
| 605 |
+
"/model to try them, /login to sign in.")
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def guest_notice_pending() -> bool:
|
| 609 |
+
"""True when a guest identity exists and the one-time availability notice has not been shown."""
|
| 610 |
+
state = current_nous_state()
|
| 611 |
+
return is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG))
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def mark_guest_notice_shown() -> bool:
|
| 615 |
+
"""Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it).
|
| 616 |
+
|
| 617 |
+
Returns True when a flag was written; False when there is no guest to mark."""
|
| 618 |
+
from hermes_cli.auth import (
|
| 619 |
+
_auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store,
|
| 620 |
+
_store_section)
|
| 621 |
+
with _provider_state_transaction("nous") as (auth_store, state, source_path):
|
| 622 |
+
if not is_guest_state(state) or source_path is None:
|
| 623 |
+
return False
|
| 624 |
+
if state.get(GUEST_NOTICE_FLAG):
|
| 625 |
+
return True
|
| 626 |
+
state = dict(state)
|
| 627 |
+
state[GUEST_NOTICE_FLAG] = True
|
| 628 |
+
if _same_path(source_path, _auth_file_path()):
|
| 629 |
+
_store_section(auth_store, "providers")["nous"] = state
|
| 630 |
+
_save_auth_store(auth_store)
|
| 631 |
+
else:
|
| 632 |
+
source_store = _load_auth_store(source_path)
|
| 633 |
+
_store_section(source_store, "providers")["nous"] = state
|
| 634 |
+
_save_auth_store(source_store, target_path=source_path)
|
| 635 |
+
return True
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors ---------
|
| 639 |
+
#
|
| 640 |
+
# Wire: the normal device-code flow, with a promotion intent registered on NAS BETWEEN the code
|
| 641 |
+
# request and the token poll (``POST /api/anonymous/promotion-intent {token, user_code, device_code}``).
|
| 642 |
+
# NAS then transfers the guest's connectors into whichever account approves that device code. We
|
| 643 |
+
# watch ``POST /api/anonymous/promotion-status {claim_code}`` until it leaves ``pending``; only a
|
| 644 |
+
# ``completed`` promotion is followed by the token grant, which ``persist_nous_credentials`` writes
|
| 645 |
+
# over the guest singleton and the shared store. The server never reports expiry: our own
|
| 646 |
+
# ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim.
|
| 647 |
+
|
| 648 |
+
UPGRADED_AUTH_METHOD = "oauth_device_code"
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def register_promotion_intent(
|
| 652 |
+
client: httpx.Client, portal_base_url: str, anon_token: str, *, user_code: str, device_code: str,
|
| 653 |
+
) -> Dict[str, Any]:
|
| 654 |
+
"""``POST /api/anonymous/promotion-intent`` -> ``{claim_code, claim_url, expires_in, interval}``."""
|
| 655 |
+
response = client.post(
|
| 656 |
+
f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-intent", headers=_anon_headers(),
|
| 657 |
+
json={"token": anon_token, "user_code": user_code, "device_code": device_code})
|
| 658 |
+
payload = _raise_for_anon_status(response, action="sign-in")
|
| 659 |
+
if not isinstance(payload.get("claim_code"), str) or not payload["claim_code"]:
|
| 660 |
+
raise _anon_err("Nous free tier sign-in returned no transfer code.", "anon_server_error")
|
| 661 |
+
return payload
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _retry_after_seconds(response: httpx.Response, default: float) -> float:
|
| 665 |
+
seconds = parse_retry_after_seconds(response.headers)
|
| 666 |
+
return default if seconds is None else seconds
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def _sleep_until(wake: float, cancelled: Optional[Callable[[], bool]]) -> bool:
|
| 670 |
+
"""Sleep until the monotonic time *wake*. Returns True when *cancelled* fired first.
|
| 671 |
+
|
| 672 |
+
Without a hook this is one plain :func:`time.sleep`. With one the sleep is cut into <= 1 s
|
| 673 |
+
ticks so an attempt stopped from outside ends in about a second instead of blocking to the
|
| 674 |
+
sign-in code's own expiry.
|
| 675 |
+
"""
|
| 676 |
+
if cancelled is None:
|
| 677 |
+
remaining = wake - time.monotonic()
|
| 678 |
+
if remaining > 0:
|
| 679 |
+
time.sleep(remaining)
|
| 680 |
+
return False
|
| 681 |
+
while True:
|
| 682 |
+
if cancelled():
|
| 683 |
+
return True
|
| 684 |
+
remaining = wake - time.monotonic()
|
| 685 |
+
if remaining <= 0:
|
| 686 |
+
return False
|
| 687 |
+
time.sleep(min(1.0, remaining))
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def wait_for_promotion(
|
| 691 |
+
client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int,
|
| 692 |
+
cancelled: Optional[Callable[[], bool]] = None,
|
| 693 |
+
) -> Dict[str, Any]:
|
| 694 |
+
"""Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out.
|
| 695 |
+
|
| 696 |
+
Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours
|
| 697 |
+
``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`.
|
| 698 |
+
|
| 699 |
+
*cancelled* is an optional hook a surface passes to stop an attempt it no longer wants (a newer
|
| 700 |
+
sign-in replaced it, the user cancelled, the process is shutting down). It is polled at the top
|
| 701 |
+
of every iteration and on a <= 1 s tick while sleeping; once it has fired this call returns
|
| 702 |
+
``{"status": "cancelled"}`` for every outcome except a ``completed`` transfer already in hand,
|
| 703 |
+
which is reported so the caller's ``cancel_wins_after_promotion`` ruling can decide it.
|
| 704 |
+
Passing nothing is today's behaviour.
|
| 705 |
+
"""
|
| 706 |
+
deadline = time.monotonic() + max(1, int(expires_in))
|
| 707 |
+
wait = max(0, int(interval))
|
| 708 |
+
while time.monotonic() < deadline:
|
| 709 |
+
if cancelled is not None and cancelled():
|
| 710 |
+
return {"status": "cancelled"}
|
| 711 |
+
response = client.post(
|
| 712 |
+
f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(),
|
| 713 |
+
json={"claim_code": claim_code})
|
| 714 |
+
if response.status_code == 429:
|
| 715 |
+
retry = min(_retry_after_seconds(response, default=max(1, wait)),
|
| 716 |
+
max(0.0, deadline - time.monotonic()))
|
| 717 |
+
if _sleep_until(time.monotonic() + retry, cancelled):
|
| 718 |
+
return {"status": "cancelled"}
|
| 719 |
+
continue
|
| 720 |
+
payload = _raise_for_anon_status(response, action="sign-in")
|
| 721 |
+
status = str(payload.get("status") or "unknown")
|
| 722 |
+
if status != "pending":
|
| 723 |
+
# A completed transfer is already committed on the account service; report it even when
|
| 724 |
+
# the hook fired during this request. run_sign_in's cancel_wins_after_promotion
|
| 725 |
+
# rules what each surface does with it. Every other terminal outcome loses to a cancel.
|
| 726 |
+
if status != "completed" and cancelled is not None and cancelled():
|
| 727 |
+
return {"status": "cancelled"}
|
| 728 |
+
return payload
|
| 729 |
+
if _sleep_until(time.monotonic() + wait, cancelled):
|
| 730 |
+
return {"status": "cancelled"}
|
| 731 |
+
return {"status": "timeout"}
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _account_state_from_token(
|
| 735 |
+
token_data: Dict[str, Any], *, portal_base_url: str, client_id: str, scope: Optional[str], verify: Any,
|
| 736 |
+
timeout_seconds: float,
|
| 737 |
+
) -> Dict[str, Any]:
|
| 738 |
+
"""The ``providers.nous`` shape for the signed-in account (same fields the device-code login writes)."""
|
| 739 |
+
from hermes_cli.auth import PROVIDER_REGISTRY, _coerce_ttl_seconds, _optional_base_url, _tls_state_from_verify
|
| 740 |
+
from hermes_cli.auth_nous import _NOUS_EMPTY_AGENT_KEY_FIELDS, _iso_after, refresh_nous_oauth_from_state
|
| 741 |
+
now = datetime.now(timezone.utc)
|
| 742 |
+
ttl = _coerce_ttl_seconds(token_data.get("expires_in", 0))
|
| 743 |
+
inference_url = (
|
| 744 |
+
_optional_base_url(token_data.get("inference_base_url"))
|
| 745 |
+
or PROVIDER_REGISTRY["nous"].inference_base_url.rstrip("/"))
|
| 746 |
+
state = {
|
| 747 |
+
"portal_base_url": portal_base_url, "inference_base_url": inference_url,
|
| 748 |
+
"client_id": client_id, "scope": token_data.get("scope") or scope,
|
| 749 |
+
"token_type": token_data.get("token_type", "Bearer"),
|
| 750 |
+
"access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"),
|
| 751 |
+
"obtained_at": now.isoformat(), "expires_at": _iso_after(now, ttl), "expires_in": ttl,
|
| 752 |
+
"tls": _tls_state_from_verify(verify), **_NOUS_EMPTY_AGENT_KEY_FIELDS}
|
| 753 |
+
state = refresh_nous_oauth_from_state(state, timeout_seconds=timeout_seconds, force_refresh=False)
|
| 754 |
+
state["auth_method"] = UPGRADED_AUTH_METHOD
|
| 755 |
+
return state
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def settle_after_upgrade(account_state: Dict[str, Any]) -> Dict[str, Any]:
|
| 759 |
+
"""After a sign-in from the free tier persisted the account: move the config off the free tier's route.
|
| 760 |
+
|
| 761 |
+
Picking the free-tier row may have written ``model.default: nous/welcome`` and ``model.base_url``
|
| 762 |
+
= welcome host. An account cannot keep either: the welcome host refuses account tokens, and the
|
| 763 |
+
portal host serves ``nous/welcome`` as a paid model. When the config is on the free tier's route,
|
| 764 |
+
``model.base_url`` becomes the account's inference host and ``model.default`` the recommended
|
| 765 |
+
default for the account's tier (:func:`hermes_cli.models.recommended_nous_default_model`, the
|
| 766 |
+
same pick as ``GET /api/model/recommended-default``), through the same config write a plain Nous
|
| 767 |
+
login uses. A config on the user's own model and host is left alone.
|
| 768 |
+
|
| 769 |
+
Every sign-in completion (CLI ``hermes auth upgrade``, the desktop poller) calls this once, after
|
| 770 |
+
``persist_nous_credentials``. Returns ``{"model": str, "changed": bool}``: ``model`` is the default
|
| 771 |
+
the config now carries (``""`` when it carries none); ``changed`` says whether this call wrote it.
|
| 772 |
+
Never raises: a failed pick or write is logged and reported as ``changed: False`` so the sign-in
|
| 773 |
+
itself still counts.
|
| 774 |
+
"""
|
| 775 |
+
from hermes_cli.config import load_config_readonly
|
| 776 |
+
try:
|
| 777 |
+
raw = load_config_readonly().get("model")
|
| 778 |
+
except Exception as exc:
|
| 779 |
+
logger.warning("sign-in completion: config unreadable, default model left as is: %s", exc)
|
| 780 |
+
return {"model": "", "changed": False}
|
| 781 |
+
model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {})
|
| 782 |
+
current = str(model_cfg.get("default") or "").strip()
|
| 783 |
+
on_welcome_model = current == GUEST_MODEL
|
| 784 |
+
on_welcome_host = route_is_welcome_host(model_cfg.get("base_url"))
|
| 785 |
+
if not (on_welcome_model or on_welcome_host):
|
| 786 |
+
return {"model": current, "changed": False}
|
| 787 |
+
model = current
|
| 788 |
+
if on_welcome_model:
|
| 789 |
+
from hermes_cli.models import recommended_nous_default_model
|
| 790 |
+
try:
|
| 791 |
+
model = str(recommended_nous_default_model().get("model") or "")
|
| 792 |
+
except Exception as exc:
|
| 793 |
+
logger.debug("sign-in completion: recommended default unavailable: %s", exc)
|
| 794 |
+
model = ""
|
| 795 |
+
try:
|
| 796 |
+
from hermes_cli.auth import _update_config_for_provider
|
| 797 |
+
# One write: host and default move together, so a failure leaves the config as it was
|
| 798 |
+
# rather than the account host paired with the welcome model. No eligible recommendation
|
| 799 |
+
# (Portal unreachable, or the plan and org policy admit nothing) clears the default in that
|
| 800 |
+
# same write; the runtime's silent default applies until the user picks one with `hermes model`.
|
| 801 |
+
_update_config_for_provider(
|
| 802 |
+
"nous", str(account_state.get("inference_base_url") or ""),
|
| 803 |
+
default_model=model if on_welcome_model else None,
|
| 804 |
+
clear_default=on_welcome_model and not model)
|
| 805 |
+
except Exception as exc:
|
| 806 |
+
logger.warning("sign-in completion: could not update the default model: %s", exc)
|
| 807 |
+
return {"model": current, "changed": False}
|
| 808 |
+
return {"model": model, "changed": True}
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def _poll_for_token(*args, **kwargs) -> Dict[str, Any]:
|
| 812 |
+
"""Keep both the sign-in module seam and the device-flow seam live at call time."""
|
| 813 |
+
from hermes_cli.auth_device_flow import _poll_for_token as poll
|
| 814 |
+
return poll(*args, **kwargs)
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def persist_nous_credentials(*args, **kwargs):
|
| 818 |
+
"""Keep the existing auth_nous persistence seam behind the sign-in entry point."""
|
| 819 |
+
from hermes_cli.auth_nous import persist_nous_credentials as persist
|
| 820 |
+
return persist(*args, **kwargs)
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
# Public sign-in imports remain here for existing callers and module-attribute patches.
|
| 824 |
+
# The flow imports this module only inside calls, so either module can be imported first.
|
| 825 |
+
from hermes_cli.anon_sign_in import ( # noqa: E402
|
| 826 |
+
AlreadySignedIn as AlreadySignedIn,
|
| 827 |
+
Code as Code,
|
| 828 |
+
Completed as Completed,
|
| 829 |
+
Declined as Declined,
|
| 830 |
+
FREE_TIER_RATE_LIMIT_CHAT as FREE_TIER_RATE_LIMIT_CHAT,
|
| 831 |
+
Failed as Failed,
|
| 832 |
+
LOGIN_BUSY_ELSEWHERE as LOGIN_BUSY_ELSEWHERE,
|
| 833 |
+
LOGIN_COMMAND as LOGIN_COMMAND,
|
| 834 |
+
LOGIN_DM_ONLY as LOGIN_DM_ONLY,
|
| 835 |
+
LOGIN_NOT_ALLOWED as LOGIN_NOT_ALLOWED,
|
| 836 |
+
LOGIN_STARTING as LOGIN_STARTING,
|
| 837 |
+
Retired as Retired,
|
| 838 |
+
SignInState as SignInState,
|
| 839 |
+
Superseded as Superseded,
|
| 840 |
+
TimedOut as TimedOut,
|
| 841 |
+
UPGRADE_ALREADY_SIGNED_IN as UPGRADE_ALREADY_SIGNED_IN,
|
| 842 |
+
UPGRADE_CANCELLED as UPGRADE_CANCELLED,
|
| 843 |
+
UPGRADE_DO_NOT_SHARE as UPGRADE_DO_NOT_SHARE,
|
| 844 |
+
UPGRADE_NOT_COMPLETED as UPGRADE_NOT_COMPLETED,
|
| 845 |
+
UPGRADE_NO_DEFAULT_CHAT as UPGRADE_NO_DEFAULT_CHAT,
|
| 846 |
+
UPGRADE_NO_DEFAULT_TERMINAL as UPGRADE_NO_DEFAULT_TERMINAL,
|
| 847 |
+
UPGRADE_REASON_COPY as UPGRADE_REASON_COPY,
|
| 848 |
+
UPGRADE_START as UPGRADE_START,
|
| 849 |
+
UPGRADE_TIMED_OUT as UPGRADE_TIMED_OUT,
|
| 850 |
+
UPGRADE_UNAVAILABLE as UPGRADE_UNAVAILABLE,
|
| 851 |
+
UPGRADE_UNAVAILABLE_CHAT as UPGRADE_UNAVAILABLE_CHAT,
|
| 852 |
+
UPGRADE_WAITING as UPGRADE_WAITING,
|
| 853 |
+
UPGRADE_WAITING_UP_TO as UPGRADE_WAITING_UP_TO,
|
| 854 |
+
Unavailable as Unavailable,
|
| 855 |
+
Waiting as Waiting,
|
| 856 |
+
_RETIRED_REASONS as _RETIRED_REASONS,
|
| 857 |
+
_default_persist_guard as _default_persist_guard,
|
| 858 |
+
_outcome_state as _outcome_state,
|
| 859 |
+
format_wait_line as format_wait_line,
|
| 860 |
+
run_sign_in as run_sign_in,
|
| 861 |
+
)
|
| 862 |
+
from hermes_cli.anon_sign_in_cli import ( # noqa: E402
|
| 863 |
+
drain_sign_in_copy as drain_sign_in_copy,
|
| 864 |
+
render_sign_in_cli as render_sign_in_cli,
|
| 865 |
+
render_sign_in_cli_code as render_sign_in_cli_code,
|
| 866 |
+
upgrade_guest as upgrade_guest,
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in"
|
hermes_cli/auth_xai.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""xAI Grok OAuth: token store, discovery, refresh, device-code login.
|
| 2 |
+
|
| 3 |
+
Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily per function so
|
| 4 |
+
``hermes_cli.auth.<helper>`` patches still intercept and no cycle forms.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import base64
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
| 16 |
+
from urllib.parse import urlparse
|
| 17 |
+
from hermes_cli.auth_codex import _load_auth_store_maybe_locked, _refresh_payload_access_token
|
| 18 |
+
from hermes_cli.auth_constants import (
|
| 19 |
+
AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_XAI_OAUTH_BASE_URL, DEVICE_CODE_GRANT_TYPE,
|
| 20 |
+
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_DEVICE_CODE_URL,
|
| 21 |
+
XAI_OAUTH_DISCOVERY_URL, XAI_OAUTH_SCOPE, _FORM_JSON_HEADERS, _xai_err, httpx,
|
| 22 |
+
)
|
| 23 |
+
from utils import env_float
|
| 24 |
+
|
| 25 |
+
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
|
| 26 |
+
from hermes_cli.auth import ProviderConfig
|
| 27 |
+
logger = logging.getLogger("hermes_cli.auth")
|
| 28 |
+
|
| 29 |
+
_RELOGIN = "Re-authenticate with `hermes model`."
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _clean(value: Any) -> str:
|
| 33 |
+
return str(value or "").strip()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _token_pair(tokens: Any) -> tuple[str, str]:
|
| 37 |
+
"""``(access_token, refresh_token)`` stripped; empty strings when *tokens* is not a dict."""
|
| 38 |
+
if not isinstance(tokens, dict):
|
| 39 |
+
return "", ""
|
| 40 |
+
return _clean(tokens.get("access_token")), _clean(tokens.get("refresh_token"))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 44 |
+
"""Return usable xAI OAuth state from provider state or credential pool."""
|
| 45 |
+
from hermes_cli.auth import _load_provider_state
|
| 46 |
+
state = _load_provider_state(auth_store, "xai-oauth")
|
| 47 |
+
if isinstance(state, dict) and all(_token_pair(state.get("tokens"))):
|
| 48 |
+
return state
|
| 49 |
+
|
| 50 |
+
credential_pool = auth_store.get("credential_pool")
|
| 51 |
+
entries = credential_pool.get("xai-oauth") if isinstance(credential_pool, dict) else None
|
| 52 |
+
for entry in entries if isinstance(entries, list) else ():
|
| 53 |
+
access_token, refresh_token = _token_pair(entry)
|
| 54 |
+
if not access_token or not refresh_token:
|
| 55 |
+
continue
|
| 56 |
+
merged = dict(state or {})
|
| 57 |
+
merged["tokens"] = {
|
| 58 |
+
"access_token": access_token, "refresh_token": refresh_token,
|
| 59 |
+
"token_type": str(entry.get("token_type") or "Bearer"),
|
| 60 |
+
}
|
| 61 |
+
if entry.get("last_refresh"):
|
| 62 |
+
merged["last_refresh"] = entry.get("last_refresh")
|
| 63 |
+
merged.setdefault("auth_mode", "oauth_pkce")
|
| 64 |
+
return merged
|
| 65 |
+
|
| 66 |
+
return state if isinstance(state, dict) else None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
|
| 70 |
+
return isinstance(state, dict) and all(_token_pair(state.get("tokens")))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
| 74 |
+
from hermes_cli.auth import _load_global_auth_store
|
| 75 |
+
state = _xai_oauth_state_from_store(_load_auth_store_maybe_locked(_lock))
|
| 76 |
+
if not _xai_oauth_state_has_usable_tokens(state):
|
| 77 |
+
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
|
| 78 |
+
if _xai_oauth_state_has_usable_tokens(global_state):
|
| 79 |
+
state = global_state
|
| 80 |
+
if not state:
|
| 81 |
+
raise _xai_err(
|
| 82 |
+
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
| 83 |
+
"xai_auth_missing", relogin=True,
|
| 84 |
+
)
|
| 85 |
+
tokens = state.get("tokens")
|
| 86 |
+
if not isinstance(tokens, dict):
|
| 87 |
+
raise _xai_err(f"xAI OAuth state is missing tokens. {_RELOGIN}", "xai_auth_invalid_shape", relogin=True)
|
| 88 |
+
access_token, refresh_token = _token_pair(tokens)
|
| 89 |
+
for value, field in ((access_token, "access_token"), (refresh_token, "refresh_token")):
|
| 90 |
+
if not value:
|
| 91 |
+
raise _xai_err(
|
| 92 |
+
f"xAI OAuth state is missing {field}. {_RELOGIN}", f"xai_auth_missing_{field}", relogin=True,
|
| 93 |
+
)
|
| 94 |
+
return {
|
| 95 |
+
"tokens": tokens, "last_refresh": state.get("last_refresh"),
|
| 96 |
+
"discovery": state.get("discovery") or {}, "redirect_uri": state.get("redirect_uri"),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
|
| 101 |
+
"""Best-effort persist of a rotated xAI grant into the global-root auth.json.
|
| 102 |
+
|
| 103 |
+
xAI rotates refresh_token on every refresh, so a profile that refreshed a root-resolved grant
|
| 104 |
+
must write the chain back to root. Touches only root ``providers.xai-oauth``; swallows all
|
| 105 |
+
errors (root-stale is better than breaking the profile's own save).
|
| 106 |
+
"""
|
| 107 |
+
from hermes_cli.auth import _global_auth_file_path, _persist_provider_state_to_store
|
| 108 |
+
global_path = _global_auth_file_path()
|
| 109 |
+
if global_path is None: # classic mode (profile == root); the profile save already hit root
|
| 110 |
+
return
|
| 111 |
+
# Seat belt: under pytest never write the real ~/.hermes/auth.json (mirrors the read-side guard
|
| 112 |
+
# in _load_global_auth_store). Uses raw HOME, not Path.home(), which fixtures may monkeypatch.
|
| 113 |
+
real_home_env = os.environ.get("HOME", "") if os.environ.get("PYTEST_CURRENT_TEST") else ""
|
| 114 |
+
if real_home_env:
|
| 115 |
+
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
| 116 |
+
try:
|
| 117 |
+
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
| 118 |
+
return
|
| 119 |
+
except Exception:
|
| 120 |
+
return
|
| 121 |
+
try:
|
| 122 |
+
_persist_provider_state_to_store("xai-oauth", state, global_path, set_active=False)
|
| 123 |
+
except Exception as exc: # pragma: no cover - best effort
|
| 124 |
+
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _save_xai_oauth_tokens(
|
| 128 |
+
tokens: Dict[str, Any], *, discovery: Optional[Dict[str, Any]] = None, redirect_uri: str = "",
|
| 129 |
+
last_refresh: Optional[str] = None, auth_mode: str = "oauth_device_code",
|
| 130 |
+
set_active: bool = True,
|
| 131 |
+
) -> None:
|
| 132 |
+
"""Persist xAI OAuth tokens; *set_active* also promotes ``xai-oauth`` to ``active_provider``.
|
| 133 |
+
|
| 134 |
+
Pass ``set_active=False`` for side-tool bootstrap (TTS/setup, tools config, dashboard, refresh)
|
| 135 |
+
so inference routing is unchanged.
|
| 136 |
+
"""
|
| 137 |
+
from hermes_cli.auth import _auth_store_lock, _global_auth_file_path, _load_auth_store, _load_provider_state_with_source, _same_path, _save_auth_store, _store_provider_state, _utc_now_z, _write_through_xai_oauth_to_global_root
|
| 138 |
+
if last_refresh is None:
|
| 139 |
+
last_refresh = _utc_now_z()
|
| 140 |
+
with _auth_store_lock():
|
| 141 |
+
auth_store = _load_auth_store()
|
| 142 |
+
# A profile lacking its own xai-oauth block reads root's grant via fallback; refreshing it
|
| 143 |
+
# must write the rotated chain back to root or root keeps a revoked refresh token. Decide by
|
| 144 |
+
# where the grant was resolved FROM (key presence lies: _store_provider_state creates it).
|
| 145 |
+
state, source_path = _load_provider_state_with_source(auth_store, "xai-oauth")
|
| 146 |
+
state = state if state is not None else {}
|
| 147 |
+
state.update(tokens=tokens, last_refresh=last_refresh, auth_mode=auth_mode)
|
| 148 |
+
if discovery:
|
| 149 |
+
state["discovery"] = discovery
|
| 150 |
+
if redirect_uri:
|
| 151 |
+
state["redirect_uri"] = redirect_uri
|
| 152 |
+
global_root = _global_auth_file_path()
|
| 153 |
+
if source_path is not None and global_root is not None and _same_path(source_path, global_root):
|
| 154 |
+
# Root-only write-back: a profile copy would shadow root and disable write-through.
|
| 155 |
+
_write_through_xai_oauth_to_global_root(state)
|
| 156 |
+
else:
|
| 157 |
+
_store_provider_state(auth_store, "xai-oauth", state, set_active=set_active)
|
| 158 |
+
_save_auth_store(auth_store)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _xai_jwt_exp(access_token: Any) -> Optional[float]:
|
| 162 |
+
"""``exp`` claim of a JWT-shaped access token, or None when absent/undecodable."""
|
| 163 |
+
if not isinstance(access_token, str) or "." not in access_token:
|
| 164 |
+
return None
|
| 165 |
+
try:
|
| 166 |
+
parts = access_token.split(".")
|
| 167 |
+
if len(parts) < 2:
|
| 168 |
+
return None
|
| 169 |
+
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
| 170 |
+
exp = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")).get("exp")
|
| 171 |
+
return float(exp) if isinstance(exp, (int, float)) else None
|
| 172 |
+
except Exception:
|
| 173 |
+
return None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
| 177 |
+
exp = _xai_jwt_exp(access_token)
|
| 178 |
+
return exp is not None and exp <= time.time() + max(0, int(skew_seconds))
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _xai_proactive_refresh_skew_seconds(access_token: str) -> int:
|
| 182 |
+
"""Proactive-refresh lead time before JWT ``exp``.
|
| 183 |
+
|
| 184 |
+
Device-code logins often return ~15-minute JWTs; the full hour-long skew would refresh on every
|
| 185 |
+
resolution, burning single-use refresh tokens and racing callers into ``invalid_grant``.
|
| 186 |
+
"""
|
| 187 |
+
max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
| 188 |
+
exp = _xai_jwt_exp(access_token)
|
| 189 |
+
if exp is None:
|
| 190 |
+
return max_skew
|
| 191 |
+
remaining = exp - time.time()
|
| 192 |
+
return min(120, max_skew) if 0 < remaining <= 45 * 60 else max_skew
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _is_xai_origin_host(host: str) -> bool:
|
| 196 |
+
"""``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted."""
|
| 197 |
+
return host == "x.ai" or host.endswith(".x.ai")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _xai_url_problem(url: str) -> tuple[Optional[str], str]:
|
| 201 |
+
"""``(problem, host)`` — problem is ``"scheme"``, ``"host"``, ``"origin"`` or None when *url* is HTTPS on x.ai."""
|
| 202 |
+
parsed = urlparse(url)
|
| 203 |
+
host = (parsed.hostname or "").lower()
|
| 204 |
+
if parsed.scheme != "https":
|
| 205 |
+
return "scheme", host
|
| 206 |
+
if not host:
|
| 207 |
+
return "host", host
|
| 208 |
+
if not _is_xai_origin_host(host):
|
| 209 |
+
return "origin", host
|
| 210 |
+
return None, host
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str:
|
| 214 |
+
"""Refuse a discovery endpoint that isn't HTTPS on the xAI origin.
|
| 215 |
+
|
| 216 |
+
Discovery is cached in auth.json, so one MITM at login could plant a ``token_endpoint`` that
|
| 217 |
+
receives the refresh_token forever; pinning scheme + host (RFC 8414 §2) removes that.
|
| 218 |
+
"""
|
| 219 |
+
problem, host = _xai_url_problem(url)
|
| 220 |
+
if problem is None:
|
| 221 |
+
return url
|
| 222 |
+
message = {
|
| 223 |
+
"scheme": f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.",
|
| 224 |
+
"host": f"xAI OIDC discovery {field} is missing a hostname: {url!r}.",
|
| 225 |
+
"origin": (
|
| 226 |
+
f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin "
|
| 227 |
+
f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached "
|
| 228 |
+
f"endpoint that may have been substituted by a MITM during initial "
|
| 229 |
+
f"discovery; re-authenticate with `hermes model` to re-fetch."
|
| 230 |
+
),
|
| 231 |
+
}[problem]
|
| 232 |
+
raise _xai_err(message, "xai_discovery_invalid")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str:
|
| 236 |
+
"""Pin the OAuth inference base_url to ``*.x.ai``; warn and use *fallback* on rejection.
|
| 237 |
+
|
| 238 |
+
Warn-not-raise: a bad env var must not deadlock auth, but the bearer must never leak elsewhere.
|
| 239 |
+
"""
|
| 240 |
+
candidate = (value or "").strip().rstrip("/")
|
| 241 |
+
if not candidate:
|
| 242 |
+
return fallback
|
| 243 |
+
try:
|
| 244 |
+
problem, host = _xai_url_problem(candidate)
|
| 245 |
+
except Exception:
|
| 246 |
+
logger.warning("Ignoring malformed xAI base_url override %r; using %s instead.", candidate, fallback)
|
| 247 |
+
return fallback
|
| 248 |
+
if problem is None:
|
| 249 |
+
return candidate
|
| 250 |
+
if problem == "scheme":
|
| 251 |
+
logger.warning(
|
| 252 |
+
"Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would "
|
| 253 |
+
"be sent in cleartext); falling back to %s.",
|
| 254 |
+
candidate, fallback,
|
| 255 |
+
)
|
| 256 |
+
elif problem == "host":
|
| 257 |
+
logger.warning("Ignoring xAI base_url override %r with no hostname; using %s instead.", candidate, fallback)
|
| 258 |
+
else:
|
| 259 |
+
logger.warning(
|
| 260 |
+
"Refusing xAI base_url override %r — host %r is not on the xAI origin "
|
| 261 |
+
"(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only "
|
| 262 |
+
"valid against xAI's inference API; sending it elsewhere would leak "
|
| 263 |
+
"the credential. Falling back to %s.",
|
| 264 |
+
candidate, host, fallback,
|
| 265 |
+
)
|
| 266 |
+
return fallback
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]:
|
| 270 |
+
try:
|
| 271 |
+
response = httpx.get(XAI_OAUTH_DISCOVERY_URL, headers={"Accept": "application/json"}, timeout=timeout_seconds)
|
| 272 |
+
except Exception as exc:
|
| 273 |
+
raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc
|
| 274 |
+
if response.status_code != 200:
|
| 275 |
+
raise _xai_err(f"xAI OIDC discovery returned status {response.status_code}.", "xai_discovery_failed")
|
| 276 |
+
try:
|
| 277 |
+
payload = response.json()
|
| 278 |
+
except Exception as exc:
|
| 279 |
+
raise _xai_err(f"xAI OIDC discovery returned invalid JSON: {exc}", "xai_discovery_invalid_json") from exc
|
| 280 |
+
if not isinstance(payload, dict):
|
| 281 |
+
raise _xai_err("xAI OIDC discovery response was not a JSON object.", "xai_discovery_incomplete")
|
| 282 |
+
endpoints = {k: _clean(payload.get(k)) for k in ("authorization_endpoint", "token_endpoint")}
|
| 283 |
+
if not all(endpoints.values()):
|
| 284 |
+
raise _xai_err("xAI OIDC discovery response was missing required endpoints.", "xai_discovery_incomplete")
|
| 285 |
+
for field, url in endpoints.items():
|
| 286 |
+
_xai_validate_oauth_endpoint(url, field=field)
|
| 287 |
+
return endpoints
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]:
|
| 291 |
+
"""Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in."""
|
| 292 |
+
return {
|
| 293 |
+
"access_token": access_token,
|
| 294 |
+
"refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(),
|
| 295 |
+
"id_token": _clean(payload.get("id_token")), "expires_in": payload.get("expires_in"),
|
| 296 |
+
"token_type": _clean(payload.get("token_type") or "Bearer") or "Bearer",
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def refresh_xai_oauth_pure(
|
| 301 |
+
access_token: str, refresh_token: str, *, token_endpoint: str = "",
|
| 302 |
+
timeout_seconds: float = 20.0,
|
| 303 |
+
) -> Dict[str, Any]:
|
| 304 |
+
from hermes_cli.auth import _nonempty_str, _utc_now_z, _xai_oauth_discovery
|
| 305 |
+
del access_token
|
| 306 |
+
if not _nonempty_str(refresh_token):
|
| 307 |
+
raise _xai_err(
|
| 308 |
+
f"xAI OAuth is missing refresh_token. {_RELOGIN}", "xai_auth_missing_refresh_token", relogin=True,
|
| 309 |
+
)
|
| 310 |
+
endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"]
|
| 311 |
+
# Re-validate cached endpoints: an old/hand-edited auth.json may carry a non-xAI token_endpoint
|
| 312 |
+
# that would otherwise receive every future refresh_token.
|
| 313 |
+
_xai_validate_oauth_endpoint(endpoint, field="token_endpoint")
|
| 314 |
+
timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
|
| 315 |
+
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
|
| 316 |
+
response = client.post(
|
| 317 |
+
endpoint, headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 318 |
+
data={"grant_type": "refresh_token", "client_id": XAI_OAUTH_CLIENT_ID, "refresh_token": refresh_token},
|
| 319 |
+
)
|
| 320 |
+
if response.status_code != 200:
|
| 321 |
+
detail = response.text.strip()
|
| 322 |
+
suffix = f" Response: {detail}" if detail else ""
|
| 323 |
+
# 403 is almost always a tier/entitlement gate; re-login won't fix it, so use a separate
|
| 324 |
+
# code and format_auth_error skips the re-authenticate hint.
|
| 325 |
+
# ``403`` from xAI's token endpoint is almost always a tier / entitlement gate (the OAuth grant
|
| 326 |
+
# exists but the account isn't on the allowlist for API access). Re-running ``hermes model`` won't
|
| 327 |
+
# fix that — surface a separate error code so ``format_auth_error`` doesn't append a misleading
|
| 328 |
+
# re-authenticate hint, and point users at the ``XAI_API_KEY`` fallback. See #26847.
|
| 329 |
+
if response.status_code == 403:
|
| 330 |
+
raise _xai_err(
|
| 331 |
+
"xAI token refresh failed with HTTP 403." + suffix
|
| 332 |
+
+ " This OAuth account is not authorized for xAI API"
|
| 333 |
+
" access — xAI may be restricting API/OAuth use to"
|
| 334 |
+
" specific SuperGrok tiers despite the in-app"
|
| 335 |
+
" subscription being active. Re-logging in won't"
|
| 336 |
+
" change that; set ``XAI_API_KEY`` and switch to"
|
| 337 |
+
" ``provider: xai`` (API-key path) if available, or"
|
| 338 |
+
" upgrade your subscription at https://x.ai/grok.",
|
| 339 |
+
"xai_oauth_tier_denied", relogin=False,
|
| 340 |
+
)
|
| 341 |
+
raise _xai_err(
|
| 342 |
+
"xAI token refresh failed." + suffix, "xai_refresh_failed", relogin=response.status_code in {400, 401},
|
| 343 |
+
)
|
| 344 |
+
payload, refreshed_access = _refresh_payload_access_token(
|
| 345 |
+
response, provider="xai-oauth",
|
| 346 |
+
invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"),
|
| 347 |
+
invalid_json_relogin=False, strict_str=False,
|
| 348 |
+
invalid_response=("xAI token refresh response was not a JSON object.", "xai_refresh_invalid_response"),
|
| 349 |
+
missing_access=("xAI token refresh response was missing access_token.", "xai_refresh_missing_access_token"),
|
| 350 |
+
)
|
| 351 |
+
return {**_xai_tokens_from_payload(payload, refreshed_access, refresh_token), "last_refresh": _utc_now_z()}
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _refresh_xai_oauth_tokens(
|
| 355 |
+
tokens: Dict[str, Any], *, token_endpoint: str, redirect_uri: str = "", timeout_seconds: float
|
| 356 |
+
) -> Dict[str, Any]:
|
| 357 |
+
# Keep the stored auth_mode (legacy logins may carry ``oauth_pkce``): refresh must not relabel it.
|
| 358 |
+
from hermes_cli.auth import _load_auth_store, _load_provider_state, refresh_xai_oauth_pure
|
| 359 |
+
try:
|
| 360 |
+
state = _load_provider_state(_load_auth_store(), "xai-oauth") or {}
|
| 361 |
+
auth_mode = str(state.get("auth_mode") or "oauth_device_code")
|
| 362 |
+
except Exception:
|
| 363 |
+
auth_mode = "oauth_device_code"
|
| 364 |
+
refreshed = refresh_xai_oauth_pure(
|
| 365 |
+
_clean(tokens.get("access_token")), _clean(tokens.get("refresh_token")),
|
| 366 |
+
token_endpoint=token_endpoint, timeout_seconds=timeout_seconds,
|
| 367 |
+
)
|
| 368 |
+
updated_tokens = dict(tokens)
|
| 369 |
+
updated_tokens["access_token"] = refreshed["access_token"]
|
| 370 |
+
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
| 371 |
+
if refreshed.get("id_token"):
|
| 372 |
+
updated_tokens["id_token"] = refreshed["id_token"]
|
| 373 |
+
if refreshed.get("expires_in") is not None:
|
| 374 |
+
updated_tokens["expires_in"] = refreshed["expires_in"]
|
| 375 |
+
if refreshed.get("token_type"):
|
| 376 |
+
updated_tokens["token_type"] = refreshed["token_type"]
|
| 377 |
+
# set_active=False: side tools (TTS) refresh xAI tokens while chat routes elsewhere.
|
| 378 |
+
_save_xai_oauth_tokens(
|
| 379 |
+
updated_tokens, discovery={"token_endpoint": token_endpoint}, redirect_uri=redirect_uri,
|
| 380 |
+
last_refresh=refreshed["last_refresh"], auth_mode=auth_mode, set_active=False,
|
| 381 |
+
)
|
| 382 |
+
return updated_tokens
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _quarantine_xai_oauth_tokens(exc: AuthError) -> None:
|
| 386 |
+
"""Clear dead xAI tokens after a terminal (400/401/403) refresh failure so later sessions fail fast.
|
| 387 |
+
|
| 388 |
+
Best-effort: persistence failures are logged and swallowed; the caller re-raises regardless.
|
| 389 |
+
"""
|
| 390 |
+
from hermes_cli.auth import _last_auth_error_marker, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state
|
| 391 |
+
try:
|
| 392 |
+
store = _load_auth_store()
|
| 393 |
+
state = _load_provider_state(store, "xai-oauth") or {}
|
| 394 |
+
tokens = dict(state.get("tokens") or {})
|
| 395 |
+
tokens.pop("access_token", None)
|
| 396 |
+
tokens.pop("refresh_token", None)
|
| 397 |
+
# Capture the previous singleton tokens BEFORE overwriting them. The pool-sync step uses this to
|
| 398 |
+
# distinguish legacy singleton-aliases (which should be refreshed) from independent accounts that
|
| 399 |
+
# ``hermes auth add openai-codex`` created (which must not be overwritten — see #39236).
|
| 400 |
+
state["tokens"] = tokens
|
| 401 |
+
state["last_auth_error"] = _last_auth_error_marker(
|
| 402 |
+
"xai-oauth", exc, reason="runtime_refresh_failure", default_code="xai_refresh_failed",
|
| 403 |
+
)
|
| 404 |
+
_store_provider_state(store, "xai-oauth", state, set_active=False)
|
| 405 |
+
_save_auth_store(store)
|
| 406 |
+
except Exception as save_exc:
|
| 407 |
+
logger.debug("xAI OAuth: failed to persist quarantined state: %s", save_exc)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def _xai_oauth_inference_base_url() -> str:
|
| 411 |
+
return _xai_validate_inference_base_url(
|
| 412 |
+
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
|
| 413 |
+
fallback=DEFAULT_XAI_OAUTH_BASE_URL,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def resolve_xai_oauth_runtime_credentials(
|
| 418 |
+
*, force_refresh: bool = False, refresh_if_expiring: bool = True,
|
| 419 |
+
refresh_skew_seconds: Optional[int] = None,
|
| 420 |
+
) -> Dict[str, Any]:
|
| 421 |
+
from hermes_cli.auth import _auth_store_lock, _is_terminal_xai_oauth_refresh_error, _refresh_xai_oauth_tokens, _xai_oauth_discovery
|
| 422 |
+
|
| 423 |
+
def _should_refresh(data: Dict[str, Any]) -> bool:
|
| 424 |
+
access_token = _clean(data["tokens"].get("access_token"))
|
| 425 |
+
skew = (
|
| 426 |
+
int(refresh_skew_seconds) if refresh_skew_seconds is not None
|
| 427 |
+
else _xai_proactive_refresh_skew_seconds(access_token)
|
| 428 |
+
)
|
| 429 |
+
return bool(force_refresh) or bool(
|
| 430 |
+
refresh_if_expiring and _xai_access_token_is_expiring(access_token, skew)
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
data = _read_xai_oauth_tokens()
|
| 434 |
+
tokens = dict(data["tokens"])
|
| 435 |
+
refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20)
|
| 436 |
+
if _should_refresh(data):
|
| 437 |
+
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
|
| 438 |
+
# Re-read under the lock: a concurrent caller may already have rotated the grant.
|
| 439 |
+
data = _read_xai_oauth_tokens(_lock=False)
|
| 440 |
+
tokens = dict(data["tokens"])
|
| 441 |
+
if _should_refresh(data):
|
| 442 |
+
token_endpoint = (
|
| 443 |
+
_clean(dict(data.get("discovery") or {}).get("token_endpoint"))
|
| 444 |
+
or _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"]
|
| 445 |
+
)
|
| 446 |
+
try:
|
| 447 |
+
tokens = _refresh_xai_oauth_tokens(
|
| 448 |
+
tokens, token_endpoint=token_endpoint, redirect_uri=_clean(data.get("redirect_uri")),
|
| 449 |
+
timeout_seconds=refresh_timeout_seconds,
|
| 450 |
+
)
|
| 451 |
+
except AuthError as exc:
|
| 452 |
+
if _is_terminal_xai_oauth_refresh_error(exc):
|
| 453 |
+
_quarantine_xai_oauth_tokens(exc)
|
| 454 |
+
raise
|
| 455 |
+
|
| 456 |
+
return {
|
| 457 |
+
"provider": "xai-oauth",
|
| 458 |
+
"base_url": _xai_oauth_inference_base_url(),
|
| 459 |
+
"api_key": _clean(tokens.get("access_token")),
|
| 460 |
+
"source": "hermes-auth-store",
|
| 461 |
+
"last_refresh": data.get("last_refresh"),
|
| 462 |
+
# Display only; auth.json may still carry a legacy ``oauth_pkce`` label.
|
| 463 |
+
"auth_mode": "oauth_device_code",
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _login_xai_oauth(args, pconfig: ProviderConfig, *, force_new_login: bool = False) -> None:
|
| 468 |
+
from hermes_cli.auth import _is_remote_session, _offer_existing_oauth_credentials, _print_login_success, _update_config_for_provider, _xai_oauth_device_code_login, resolve_xai_oauth_runtime_credentials, unsuppress_credential_source
|
| 469 |
+
del pconfig
|
| 470 |
+
|
| 471 |
+
if not force_new_login and _offer_existing_oauth_credentials(
|
| 472 |
+
"xai-oauth",
|
| 473 |
+
resolve=resolve_xai_oauth_runtime_credentials,
|
| 474 |
+
is_expiring=_xai_access_token_is_expiring,
|
| 475 |
+
display_name="xAI OAuth",
|
| 476 |
+
default_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
| 477 |
+
):
|
| 478 |
+
return
|
| 479 |
+
|
| 480 |
+
print()
|
| 481 |
+
print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
|
| 482 |
+
print("(Hermes creates its own local OAuth session)")
|
| 483 |
+
print()
|
| 484 |
+
|
| 485 |
+
timeout_seconds = float(getattr(args, "timeout", None) or 20.0)
|
| 486 |
+
open_browser = not getattr(args, "no_browser", False)
|
| 487 |
+
if _is_remote_session():
|
| 488 |
+
open_browser = False
|
| 489 |
+
|
| 490 |
+
creds = _xai_oauth_device_code_login(timeout_seconds=timeout_seconds, open_browser=open_browser)
|
| 491 |
+
_save_xai_oauth_tokens(
|
| 492 |
+
creds["tokens"], discovery=creds.get("discovery"),
|
| 493 |
+
redirect_uri=creds.get("redirect_uri", ""), last_refresh=creds.get("last_refresh"),
|
| 494 |
+
auth_mode="oauth_device_code",
|
| 495 |
+
)
|
| 496 |
+
# Explicit re-login re-enables the credential: clear the ``device_code`` suppression marker left
|
| 497 |
+
# by ``hermes auth remove xai-oauth``. Deliberately NOT inside _save_xai_oauth_tokens — the
|
| 498 |
+
# refresh hot path shares that helper and must never mutate suppression state.
|
| 499 |
+
unsuppress_credential_source("xai-oauth", "device_code")
|
| 500 |
+
config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL))
|
| 501 |
+
_print_login_success("xai-oauth", config_path, show_auth_state=True)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _xai_oauth_request_device_code(client: httpx.Client, *, scope: str = XAI_OAUTH_SCOPE) -> Dict[str, Any]:
|
| 505 |
+
response = client.post(
|
| 506 |
+
XAI_OAUTH_DEVICE_CODE_URL, headers=_FORM_JSON_HEADERS, data={"client_id": XAI_OAUTH_CLIENT_ID, "scope": scope},
|
| 507 |
+
)
|
| 508 |
+
if response.status_code != 200:
|
| 509 |
+
raise _xai_err(
|
| 510 |
+
f"xAI device-code request failed (HTTP {response.status_code})."
|
| 511 |
+
+ (f" Response: {response.text.strip()}" if response.text else ""),
|
| 512 |
+
"device_code_request_failed",
|
| 513 |
+
)
|
| 514 |
+
payload = response.json()
|
| 515 |
+
required = ("device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval")
|
| 516 |
+
missing = [key for key in required if key not in payload]
|
| 517 |
+
if missing:
|
| 518 |
+
raise _xai_err(f"xAI device-code response missing fields: {', '.join(missing)}", "device_code_invalid")
|
| 519 |
+
return payload
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def _xai_oauth_poll_device_token(
|
| 523 |
+
client: httpx.Client, *, token_endpoint: str, device_code: str, expires_in: int,
|
| 524 |
+
poll_interval: int,
|
| 525 |
+
) -> Dict[str, Any]:
|
| 526 |
+
from hermes_cli.auth import _poll_device_token_generic
|
| 527 |
+
def _validate(payload: Dict[str, Any]) -> None:
|
| 528 |
+
for field_name, article in (("access_token", "an"), ("refresh_token", "a")):
|
| 529 |
+
if not payload.get(field_name):
|
| 530 |
+
raise _xai_err(
|
| 531 |
+
f"xAI device-code token response did not include {article} {field_name}.",
|
| 532 |
+
"xai_device_token_invalid",
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
def _error(response, error_payload) -> Exception:
|
| 536 |
+
description = error_payload.get("error_description") or error_payload.get("error") or response.text
|
| 537 |
+
return _xai_err(f"xAI device-code token polling failed: {description}", "xai_device_token_failed")
|
| 538 |
+
|
| 539 |
+
return _poll_device_token_generic(
|
| 540 |
+
lambda: client.post(
|
| 541 |
+
token_endpoint, headers=_FORM_JSON_HEADERS,
|
| 542 |
+
data={"grant_type": DEVICE_CODE_GRANT_TYPE, "client_id": XAI_OAUTH_CLIENT_ID, "device_code": device_code},
|
| 543 |
+
),
|
| 544 |
+
expires_in=int(expires_in),
|
| 545 |
+
poll_interval=max(1, int(poll_interval)),
|
| 546 |
+
validate_success=_validate,
|
| 547 |
+
on_non_json_error=lambda _r: _xai_err(
|
| 548 |
+
"xAI device-code token polling returned a non-JSON error response.", "xai_device_token_failed",
|
| 549 |
+
),
|
| 550 |
+
on_error=_error,
|
| 551 |
+
on_timeout=lambda: _xai_err("Timed out waiting for xAI device authorization.", "device_code_timeout"),
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _xai_oauth_device_code_login(*, timeout_seconds: float = 20.0, open_browser: bool = True) -> Dict[str, Any]:
|
| 556 |
+
from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session, _print_device_code_instructions, _utc_now_z, _xai_oauth_discovery, _xai_oauth_poll_device_token
|
| 557 |
+
discovery = _xai_oauth_discovery(timeout_seconds)
|
| 558 |
+
timeout = httpx.Timeout(max(20.0, timeout_seconds))
|
| 559 |
+
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
|
| 560 |
+
device_data = _xai_oauth_request_device_code(client)
|
| 561 |
+
interval = int(device_data["interval"])
|
| 562 |
+
_print_device_code_instructions(
|
| 563 |
+
str(device_data.get("verification_uri_complete") or device_data["verification_uri"]),
|
| 564 |
+
str(device_data["user_code"]),
|
| 565 |
+
open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(),
|
| 566 |
+
swallow_open_errors=True,
|
| 567 |
+
)
|
| 568 |
+
print(f"Waiting for approval (polling every {max(1, interval)}s)...")
|
| 569 |
+
payload = _xai_oauth_poll_device_token(
|
| 570 |
+
client, token_endpoint=discovery["token_endpoint"],
|
| 571 |
+
device_code=str(device_data["device_code"]), expires_in=int(device_data["expires_in"]),
|
| 572 |
+
poll_interval=interval,
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
access_token, refresh_token = _token_pair(payload)
|
| 576 |
+
if not access_token or not refresh_token:
|
| 577 |
+
raise _xai_err("xAI device-code token response was missing required tokens.", "xai_device_token_invalid")
|
| 578 |
+
return {
|
| 579 |
+
"tokens": _xai_tokens_from_payload(payload, access_token, refresh_token),
|
| 580 |
+
"discovery": discovery, "redirect_uri": "", "base_url": _xai_oauth_inference_base_url(),
|
| 581 |
+
"last_refresh": _utc_now_z(), "source": "oauth-device-code",
|
| 582 |
+
}
|
hermes_cli/cli_output.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared CLI output helpers (``print_*`` + ``prompt()``) for the setup/config wizards."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
from hermes_cli.colors import Colors, color
|
| 6 |
+
from hermes_cli.secret_prompt import masked_secret_prompt
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def print_info(text: str) -> None:
|
| 10 |
+
print(color(f" {text}", Colors.DIM))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def print_success(text: str) -> None:
|
| 14 |
+
print(color(f"✓ {text}", Colors.GREEN))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def print_warning(text: str) -> None:
|
| 18 |
+
print(color(f"⚠ {text}", Colors.YELLOW))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def print_error(text: str) -> None:
|
| 22 |
+
print(color(f"✗ {text}", Colors.RED))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def print_header(text: str) -> None:
|
| 26 |
+
print(color(f"\n {text}", Colors.YELLOW))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def line_input(prompt_text: str) -> str:
|
| 30 |
+
"""Read non-secret text with normal cursor-editing keys on a real TTY.
|
| 31 |
+
|
| 32 |
+
Setup/model-selection commands run outside the chat's prompt_toolkit application, so a
|
| 33 |
+
short-lived prompt is safe here. Redirected stdin/stdout keep the built-in ``input`` used by
|
| 34 |
+
scripts, tests and numbered fallbacks.
|
| 35 |
+
"""
|
| 36 |
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
| 37 |
+
return input(prompt_text)
|
| 38 |
+
try:
|
| 39 |
+
from prompt_toolkit import prompt as prompt_toolkit_prompt
|
| 40 |
+
from prompt_toolkit.formatted_text import ANSI
|
| 41 |
+
except ImportError:
|
| 42 |
+
return input(prompt_text)
|
| 43 |
+
try:
|
| 44 |
+
return prompt_toolkit_prompt(ANSI(prompt_text))
|
| 45 |
+
except (KeyboardInterrupt, EOFError):
|
| 46 |
+
raise
|
| 47 |
+
except Exception:
|
| 48 |
+
# Some terminals report isatty() yet reject registering stdin with the asyncio selector
|
| 49 |
+
# (macOS kqueue raises EINVAL for fd 0). Any prompt_toolkit runtime failure degrades to
|
| 50 |
+
# the built-in reader, which needs no selector — the wizard proceeds instead of crashing.
|
| 51 |
+
return input(prompt_text)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def prompt(question: str, default: str | None = None, password: bool = False) -> str:
|
| 55 |
+
"""Prompt for input (stripped), or ``default`` on plain Enter; "" on Ctrl-C/EOF."""
|
| 56 |
+
suffix = f" [{default}]" if default else ""
|
| 57 |
+
display = color(f" {question}{suffix}: ", Colors.YELLOW)
|
| 58 |
+
try:
|
| 59 |
+
value = (masked_secret_prompt(display) if password else line_input(display)).strip()
|
| 60 |
+
return value if value else (default or "")
|
| 61 |
+
except (KeyboardInterrupt, EOFError):
|
| 62 |
+
print()
|
| 63 |
+
return ""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
| 67 |
+
answer = prompt(f"{question} ({'Y/n' if default else 'y/N'})")
|
| 68 |
+
if not answer:
|
| 69 |
+
return default
|
| 70 |
+
return answer.lower().startswith("y")
|
hermes_cli/colors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared ANSI color utilities for Hermes CLI modules."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def should_use_color() -> bool:
|
| 8 |
+
"""Return True when colored output is appropriate."""
|
| 9 |
+
if os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb":
|
| 10 |
+
return False
|
| 11 |
+
return bool(sys.stdout.isatty())
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Colors:
|
| 15 |
+
RESET = "\033[0m"
|
| 16 |
+
BOLD = "\033[1m"
|
| 17 |
+
DIM = "\033[2m"
|
| 18 |
+
RED = "\033[31m"
|
| 19 |
+
GREEN = "\033[32m"
|
| 20 |
+
YELLOW = "\033[33m"
|
| 21 |
+
BLUE = "\033[34m"
|
| 22 |
+
MAGENTA = "\033[35m"
|
| 23 |
+
CYAN = "\033[36m"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def color(text: str, *codes) -> str:
|
| 27 |
+
"""Apply color codes to text (only when color output is appropriate)."""
|
| 28 |
+
if not should_use_color():
|
| 29 |
+
return text
|
| 30 |
+
return "".join(codes) + text + Colors.RESET
|
hermes_cli/curator.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI subcommand: `hermes curator <subcommand>`."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _parse_ts(ts) -> Optional[datetime]:
|
| 13 |
+
"""ISO timestamp -> aware UTC datetime, or None when unparseable."""
|
| 14 |
+
try:
|
| 15 |
+
dt = datetime.fromisoformat(ts)
|
| 16 |
+
except (TypeError, ValueError):
|
| 17 |
+
return None
|
| 18 |
+
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _fmt_ts(ts: Optional[str]) -> str:
|
| 22 |
+
if not ts:
|
| 23 |
+
return "never"
|
| 24 |
+
dt = _parse_ts(ts)
|
| 25 |
+
if dt is None:
|
| 26 |
+
return str(ts)
|
| 27 |
+
secs = int((datetime.now(timezone.utc) - dt).total_seconds())
|
| 28 |
+
for unit, div, limit in (("s", 1, 60), ("m", 60, 3600), ("h", 3600, 86400)):
|
| 29 |
+
if secs < limit:
|
| 30 |
+
return f"{secs // div}{unit} ago"
|
| 31 |
+
return f"{secs // 86400}d ago"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _confirm(prompt: str, cancel: str = "cancelled", eof_prefix: str = "\n") -> bool:
|
| 35 |
+
"""Ask ``prompt``; print ``cancel`` (prefixed on EOF/Ctrl-C) and return False unless y/yes."""
|
| 36 |
+
try:
|
| 37 |
+
if input(prompt).strip().lower() in {"y", "yes"}:
|
| 38 |
+
return True
|
| 39 |
+
except (EOFError, KeyboardInterrupt):
|
| 40 |
+
print(eof_prefix, end="")
|
| 41 |
+
print(cancel)
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _print_skill_rows(title: str, rows: list) -> None:
|
| 46 |
+
print(f"\n{title}:")
|
| 47 |
+
for r in rows:
|
| 48 |
+
print(
|
| 49 |
+
f" {r['name']:40s} "
|
| 50 |
+
f"activity={r.get('activity_count', 0):3d} "
|
| 51 |
+
f"use={r.get('use_count', 0):3d} "
|
| 52 |
+
f"view={r.get('view_count', 0):3d} "
|
| 53 |
+
f"patches={r.get('patch_count', 0):3d} "
|
| 54 |
+
f"last_activity={_fmt_ts(r.get('last_activity_at'))}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _print_unmanaged_summary() -> None:
|
| 58 |
+
"""Report curation-eligible skills that carry no provenance marker: only background-review
|
| 59 |
+
creations get ``created_by: agent``; older skills and every foreground ``skill_manage(create)``
|
| 60 |
+
are eligible but unmanaged, so no automatic transition touches them."""
|
| 61 |
+
from tools import skill_usage
|
| 62 |
+
try:
|
| 63 |
+
unmanaged = skill_usage.unmanaged_report()
|
| 64 |
+
except Exception:
|
| 65 |
+
return
|
| 66 |
+
if not unmanaged:
|
| 67 |
+
return
|
| 68 |
+
legacy = sum(1 for r in unmanaged if not r.get("has_provenance_key"))
|
| 69 |
+
foreground = len(unmanaged) - legacy
|
| 70 |
+
print(f"\nunmanaged (no provenance marker): {len(unmanaged)} total")
|
| 71 |
+
print(f" pre-dates marker {legacy}")
|
| 72 |
+
print(f" foreground-created {foreground}")
|
| 73 |
+
print(" never auto-staled or archived — `hermes curator adopt <name>` hands one over")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _print_curator_config(curator) -> None:
|
| 77 |
+
state = curator.load_state()
|
| 78 |
+
status_line = (
|
| 79 |
+
"PAUSED" if state.get("paused", False)
|
| 80 |
+
else "ENABLED" if curator.is_enabled() else "DISABLED")
|
| 81 |
+
print(f"curator: {status_line}")
|
| 82 |
+
print(f" runs: {state.get('run_count', 0)}")
|
| 83 |
+
print(f" last run: {_fmt_ts(state.get('last_run_at'))}")
|
| 84 |
+
# Multi-line when the curator archived skills (rename map appended as `name → umbrella`
|
| 85 |
+
# lines); indent continuation lines so the block reads as one field.
|
| 86 |
+
summary = state.get("last_run_summary") or "(none)"
|
| 87 |
+
first, *rest = summary.splitlines() if "\n" in summary else [summary]
|
| 88 |
+
print(f" last summary: {first}")
|
| 89 |
+
for line in rest:
|
| 90 |
+
print(f" {line}")
|
| 91 |
+
report = state.get("last_report_path")
|
| 92 |
+
if report:
|
| 93 |
+
print(f" last report: {report}{'' if Path(report).exists() else ' (missing)'}")
|
| 94 |
+
ih = curator.get_interval_hours()
|
| 95 |
+
print(f" interval: every {f'{ih // 24}d' if ih % 24 == 0 and ih >= 24 else f'{ih}h'}")
|
| 96 |
+
print(f" stale after: {curator.get_stale_after_days()}d unused")
|
| 97 |
+
print(f" archive after: {curator.get_archive_after_days()}d unused")
|
| 98 |
+
consolidate = curator.get_consolidate()
|
| 99 |
+
print(
|
| 100 |
+
f" consolidate: {'on' if consolidate else 'off'}"
|
| 101 |
+
f"{'' if consolidate else ' (prune-only; LLM merge pass opt-in)'}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _cmd_status(args) -> int:
|
| 105 |
+
from agent import curator
|
| 106 |
+
from tools import skill_usage
|
| 107 |
+
_print_curator_config(curator)
|
| 108 |
+
rows = skill_usage.curated_report()
|
| 109 |
+
if not rows:
|
| 110 |
+
print("\nno curator-managed skills")
|
| 111 |
+
_print_unmanaged_summary()
|
| 112 |
+
return 0
|
| 113 |
+
by_state: dict = {}
|
| 114 |
+
for r in rows:
|
| 115 |
+
by_state.setdefault(r.get("state", "active"), []).append(r)
|
| 116 |
+
pinned = [r["name"] for r in rows if r.get("pinned")]
|
| 117 |
+
provenance = [r.get("provenance", "agent") for r in rows]
|
| 118 |
+
print(f"\ncurator-managed skills: {len(rows)} total "
|
| 119 |
+
f"(agent-created={provenance.count('agent')} bundled={provenance.count('bundled')})")
|
| 120 |
+
for state_name in ("active", "stale", "archived"):
|
| 121 |
+
print(f" {state_name:10s} {len(by_state.get(state_name, []))}")
|
| 122 |
+
if pinned:
|
| 123 |
+
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
|
| 124 |
+
_print_unmanaged_summary() # the curation blind spot matters on the managed path too
|
| 125 |
+
# Views and edits count as activity: a skill must not read as "never used" right after
|
| 126 |
+
# skill_view()/skill_manage() touched it. Recency (last_activity_at) and frequency
|
| 127 |
+
# (activity_count) are different signals, so both top-5s are shown.
|
| 128 |
+
active_all = by_state.get("active", [])
|
| 129 |
+
if not active_all:
|
| 130 |
+
return 0
|
| 131 |
+
recency = sorted(
|
| 132 |
+
active_all, key=lambda r: r.get("last_activity_at") or r.get("created_at") or "")
|
| 133 |
+
_print_skill_rows("least recently active (top 5)", recency[:5])
|
| 134 |
+
|
| 135 |
+
def _freq(r):
|
| 136 |
+
return (r.get("activity_count") or 0, r.get("last_activity_at") or "")
|
| 137 |
+
|
| 138 |
+
most_active = sorted(active_all, key=_freq, reverse=True)[:5]
|
| 139 |
+
if (most_active[0].get("activity_count") or 0) > 0:
|
| 140 |
+
_print_skill_rows("most active (top 5)", most_active)
|
| 141 |
+
_print_skill_rows("least active (top 5)", sorted(active_all, key=_freq)[:5])
|
| 142 |
+
return 0
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _cmd_run(args) -> int:
|
| 146 |
+
from agent import curator
|
| 147 |
+
if not curator.is_enabled():
|
| 148 |
+
print("curator: disabled via config; enable with `curator.enabled: true`")
|
| 149 |
+
return 1
|
| 150 |
+
dry = bool(getattr(args, "dry_run", False))
|
| 151 |
+
background = bool(getattr(args, "background", False))
|
| 152 |
+
synchronous = bool(getattr(args, "synchronous", False)) or not background
|
| 153 |
+
# --consolidate forces the LLM umbrella pass on; absent => None so run_curator_review reads
|
| 154 |
+
# curator.consolidate from config.
|
| 155 |
+
consolidate = True if getattr(args, "consolidate", False) else None
|
| 156 |
+
print(
|
| 157 |
+
"curator: running DRY-RUN (report only, no mutations)..." if dry
|
| 158 |
+
else "curator: running review pass...")
|
| 159 |
+
if consolidate is None and not curator.get_consolidate():
|
| 160 |
+
print(
|
| 161 |
+
"curator: consolidation is off — running prune-only "
|
| 162 |
+
"(deterministic stale/archive). Pass --consolidate or set "
|
| 163 |
+
"`curator.consolidate: true` to enable the LLM merge pass.")
|
| 164 |
+
result = curator.run_curator_review(
|
| 165 |
+
on_summary=print, synchronous=synchronous, dry_run=dry, consolidate=consolidate)
|
| 166 |
+
auto = result.get("auto_transitions", {})
|
| 167 |
+
if auto and dry:
|
| 168 |
+
print(
|
| 169 |
+
f"auto (preview): {auto.get('checked', 0)} candidate skill(s) "
|
| 170 |
+
"— no transitions applied in dry-run")
|
| 171 |
+
elif auto:
|
| 172 |
+
print(
|
| 173 |
+
f"auto: checked={auto.get('checked', 0)} "
|
| 174 |
+
f"stale={auto.get('marked_stale', 0)} "
|
| 175 |
+
f"archived={auto.get('archived', 0)} "
|
| 176 |
+
f"reactivated={auto.get('reactivated', 0)}")
|
| 177 |
+
if not synchronous:
|
| 178 |
+
print("llm pass running in background — check `hermes curator status` later")
|
| 179 |
+
if dry:
|
| 180 |
+
print(
|
| 181 |
+
"dry-run: no changes applied. Read the report with "
|
| 182 |
+
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
| 183 |
+
if synchronous else
|
| 184 |
+
"dry-run: no changes applied. When the report lands, read it with "
|
| 185 |
+
"`hermes curator status` and run `hermes curator run` (no flag) to apply.")
|
| 186 |
+
return 0
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _set_paused(paused: bool) -> int:
|
| 190 |
+
from agent import curator
|
| 191 |
+
curator.set_paused(paused)
|
| 192 |
+
print("curator: paused" if paused else "curator: resumed")
|
| 193 |
+
return 0
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _cmd_pause(args) -> int: return _set_paused(True)
|
| 197 |
+
def _cmd_resume(args) -> int: return _set_paused(False)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
_PIN_MESSAGES = {
|
| 201 |
+
True: (
|
| 202 |
+
"cannot pin (only agent-created skills participate in curation)",
|
| 203 |
+
"could not pin '{skill}' — the skill is not curation-eligible (protected built-in or "
|
| 204 |
+
"external). `hermes curator list-unmanaged` shows which skills the curator tracks.",
|
| 205 |
+
# Unmanaged skills are never auto-transitioned, so the pin is recorded but only
|
| 206 |
+
# becomes protective once the skill is adopted — say so and point at `adopt`.
|
| 207 |
+
"pinned '{skill}' (recorded; this skill is unmanaged — auto-transitions never consider "
|
| 208 |
+
"it. Run `hermes curator adopt {skill}` to put it under curator management)",
|
| 209 |
+
"pinned '{skill}' (will bypass auto-transitions)"),
|
| 210 |
+
False: (
|
| 211 |
+
"there's nothing to unpin (curator only tracks agent-created skills)",
|
| 212 |
+
"could not unpin '{skill}' — the skill is not curation-eligible (protected built-in or "
|
| 213 |
+
"external).",
|
| 214 |
+
"unpinned '{skill}' (recorded; this skill is unmanaged — it was never under "
|
| 215 |
+
"auto-transitions to begin with)",
|
| 216 |
+
"unpinned '{skill}'")}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _set_pin(args, pinned: bool) -> int:
|
| 220 |
+
from tools import skill_usage
|
| 221 |
+
not_agent, not_eligible, unmanaged, done = _PIN_MESSAGES[pinned]
|
| 222 |
+
skill = args.skill
|
| 223 |
+
if not skill_usage.is_agent_created(skill):
|
| 224 |
+
print(f"curator: '{skill}' is bundled or hub-installed — {not_agent}")
|
| 225 |
+
return 1
|
| 226 |
+
if not skill_usage.set_pinned(skill, pinned):
|
| 227 |
+
print("curator: " + not_eligible.replace("{skill}", skill))
|
| 228 |
+
return 1
|
| 229 |
+
if not skill_usage.is_curator_managed(skill):
|
| 230 |
+
print("curator: " + unmanaged.replace("{skill}", skill))
|
| 231 |
+
return 0
|
| 232 |
+
print("curator: " + done.replace("{skill}", skill))
|
| 233 |
+
return 0
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _cmd_pin(args) -> int: return _set_pin(args, True)
|
| 237 |
+
def _cmd_unpin(args) -> int: return _set_pin(args, False)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _cmd_list_unmanaged(args) -> int:
|
| 241 |
+
"""Itemize the unmanaged population that `status` summarizes (input for `adopt`)."""
|
| 242 |
+
from tools import skill_usage
|
| 243 |
+
rows = skill_usage.unmanaged_report()
|
| 244 |
+
if not rows:
|
| 245 |
+
print("curator: no unmanaged skills — every eligible skill is managed")
|
| 246 |
+
return 0
|
| 247 |
+
print(f"unmanaged skills ({len(rows)}):")
|
| 248 |
+
for r in sorted(rows, key=lambda x: x["name"]):
|
| 249 |
+
why = "created_by:null" if r.get("has_provenance_key") else "no marker"
|
| 250 |
+
print(
|
| 251 |
+
f" {r['name']:44s} activity={r.get('activity_count', 0):4d} "
|
| 252 |
+
f"last_activity={_fmt_ts(r.get('last_activity_at')):14s} ({why})")
|
| 253 |
+
print("\nadopt one with `hermes curator adopt <name>`, "
|
| 254 |
+
"or all with `hermes curator adopt --all-unmanaged`")
|
| 255 |
+
return 0
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _cmd_adopt(args) -> int:
|
| 259 |
+
"""Hand unmanaged skills to the curator by explicit user declaration: provenance cannot be
|
| 260 |
+
inferred from telemetry (a high patch count proves the agent MAINTAINS a skill, not that it
|
| 261 |
+
AUTHORED it)."""
|
| 262 |
+
from tools import skill_usage
|
| 263 |
+
names = list(getattr(args, "skill", None) or [])
|
| 264 |
+
adopt_all = bool(getattr(args, "all_unmanaged", False))
|
| 265 |
+
if adopt_all:
|
| 266 |
+
if names:
|
| 267 |
+
print("curator: pass either skill names or --all-unmanaged, not both")
|
| 268 |
+
return 1
|
| 269 |
+
names = skill_usage.list_unmanaged_skill_names()
|
| 270 |
+
if not names:
|
| 271 |
+
print("curator: no unmanaged skills to adopt")
|
| 272 |
+
return 0
|
| 273 |
+
if not names:
|
| 274 |
+
print("curator: name a skill to adopt, or pass --all-unmanaged")
|
| 275 |
+
return 1
|
| 276 |
+
if getattr(args, "dry_run", False):
|
| 277 |
+
print(f"curator: would adopt {len(names)} skill(s) (dry run):")
|
| 278 |
+
for n in names:
|
| 279 |
+
print(f" + {n}")
|
| 280 |
+
return 0
|
| 281 |
+
# Bulk adoption is a lifecycle change (adopted skills become archivable): confirm.
|
| 282 |
+
if adopt_all and not getattr(args, "yes", False):
|
| 283 |
+
print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?")
|
| 284 |
+
print(" they become eligible for automatic staleness + archival")
|
| 285 |
+
if not _confirm(" proceed? [y/N] ", "curator: aborted", eof_prefix=""):
|
| 286 |
+
return 1
|
| 287 |
+
failed = 0
|
| 288 |
+
for n in names:
|
| 289 |
+
ok, msg = skill_usage.adopt_skill(n)
|
| 290 |
+
print(f"curator: {msg}")
|
| 291 |
+
failed += not ok
|
| 292 |
+
if len(names) > 1:
|
| 293 |
+
print(f"curator: adopted {len(names) - failed}/{len(names)}")
|
| 294 |
+
return 1 if failed else 0
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _as_user(fn, skill: str) -> int:
|
| 298 |
+
"""Run a skill mutation with the ledger actor set to ``user``; print and map its result."""
|
| 299 |
+
from tools import skill_ledger
|
| 300 |
+
tok = skill_ledger.set_ledger_actor("user")
|
| 301 |
+
try:
|
| 302 |
+
ok, msg = fn(skill)
|
| 303 |
+
finally:
|
| 304 |
+
skill_ledger.reset_ledger_actor(tok)
|
| 305 |
+
print(f"curator: {msg}")
|
| 306 |
+
return 0 if ok else 1
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _cmd_restore(args) -> int:
|
| 310 |
+
from tools import skill_usage
|
| 311 |
+
return _as_user(skill_usage.restore_skill, args.skill)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _cmd_archive(args) -> int:
|
| 315 |
+
"""Manually archive an agent-created skill. Refuses if pinned."""
|
| 316 |
+
from tools import skill_usage
|
| 317 |
+
if skill_usage.get_record(args.skill).get("pinned"):
|
| 318 |
+
print(
|
| 319 |
+
f"curator: '{args.skill}' is pinned — unpin first with "
|
| 320 |
+
f"`hermes curator unpin {args.skill}`")
|
| 321 |
+
return 1
|
| 322 |
+
return _as_user(skill_usage.archive_skill, args.skill)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _idle_days(record: dict) -> Optional[int]:
|
| 326 |
+
"""Days since last activity, falling back to ``created_at`` so never-used skills aren't
|
| 327 |
+
immortal; None only when both fields are missing or unparseable."""
|
| 328 |
+
ts = record.get("last_activity_at") or record.get("created_at")
|
| 329 |
+
dt = _parse_ts(str(ts)) if ts else None
|
| 330 |
+
return None if dt is None else max(0, (datetime.now(timezone.utc) - dt).days)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _cmd_prune(args) -> int:
|
| 334 |
+
"""Bulk-archive curator-managed skills idle for >= N days (pinned exempt, archived skipped)."""
|
| 335 |
+
from agent import curator
|
| 336 |
+
from tools import skill_usage
|
| 337 |
+
days = getattr(args, "days", None)
|
| 338 |
+
if days is None:
|
| 339 |
+
days = curator.get_archive_after_days()
|
| 340 |
+
if days < 1:
|
| 341 |
+
print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr)
|
| 342 |
+
return 2
|
| 343 |
+
candidates = [
|
| 344 |
+
(r["name"], idle) for r in skill_usage.curated_report()
|
| 345 |
+
if not (r.get("pinned") or r.get("state") == skill_usage.STATE_ARCHIVED)
|
| 346 |
+
and (idle := _idle_days(r)) is not None and idle >= days]
|
| 347 |
+
if not candidates:
|
| 348 |
+
print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)")
|
| 349 |
+
return 0
|
| 350 |
+
candidates.sort(key=lambda c: -c[1])
|
| 351 |
+
print(f"curator: {len(candidates)} skill(s) idle >= {days}d:")
|
| 352 |
+
for name, idle in candidates:
|
| 353 |
+
print(f" {name:40s} idle {idle}d")
|
| 354 |
+
if getattr(args, "dry_run", False):
|
| 355 |
+
print("\n(dry run — no changes made)")
|
| 356 |
+
return 0
|
| 357 |
+
if not getattr(args, "yes", False) and not _confirm(
|
| 358 |
+
f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"):
|
| 359 |
+
return 1
|
| 360 |
+
results = [(name, *skill_usage.archive_skill(name)) for name, _ in candidates]
|
| 361 |
+
failures = [(name, msg) for name, ok, msg in results if not ok]
|
| 362 |
+
print(f"\ncurator: archived {len(results) - len(failures)}/{len(candidates)}")
|
| 363 |
+
if failures:
|
| 364 |
+
print("failures:")
|
| 365 |
+
for name, msg in failures:
|
| 366 |
+
print(f" {name}: {msg}")
|
| 367 |
+
return 1
|
| 368 |
+
return 0
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _cmd_backup(args) -> int:
|
| 372 |
+
"""Manual skills-tree snapshot (same mechanism as the automatic pre-run snapshot)."""
|
| 373 |
+
from agent import curator_backup
|
| 374 |
+
if not curator_backup.is_enabled():
|
| 375 |
+
print(
|
| 376 |
+
"curator: backups are disabled via config "
|
| 377 |
+
"(`curator.backup.enabled: false`); re-enable to snapshot")
|
| 378 |
+
return 1
|
| 379 |
+
snap = curator_backup.snapshot_skills(reason=getattr(args, "reason", None) or "manual")
|
| 380 |
+
if snap is None:
|
| 381 |
+
print("curator: snapshot failed — check logs (backup disabled or IO error)")
|
| 382 |
+
return 1
|
| 383 |
+
print(f"curator: snapshot created at ~/.hermes/skills/.curator_backups/{snap.name}")
|
| 384 |
+
return 0
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _cmd_ledger(args) -> int:
|
| 388 |
+
"""List per-mutation audit ledger entries (newest first)."""
|
| 389 |
+
from tools import skill_ledger
|
| 390 |
+
rows = skill_ledger.list_entries(
|
| 391 |
+
skill=getattr(args, "skill", None), limit=getattr(args, "limit", None) or 20)
|
| 392 |
+
if not rows:
|
| 393 |
+
print("curator: ledger is empty (or skills.ledger is disabled).")
|
| 394 |
+
return 0
|
| 395 |
+
print(f"{'id':<14} {'when':<12} {'actor':<8} {'action':<12} skill")
|
| 396 |
+
for r in rows:
|
| 397 |
+
evidence = r.get("evidence") or {}
|
| 398 |
+
extra = ""
|
| 399 |
+
if evidence.get("absorbed_into"):
|
| 400 |
+
extra = f" → absorbed into '{evidence['absorbed_into']}'"
|
| 401 |
+
elif evidence.get("rollback_target"):
|
| 402 |
+
extra = f" → rollback of {evidence['rollback_target']}"
|
| 403 |
+
print(
|
| 404 |
+
f"{r.get('id', '?'):<14} {_fmt_ts(r.get('ts')):<12} "
|
| 405 |
+
f"{r.get('actor', '?'):<8} {r.get('action', '?'):<12} "
|
| 406 |
+
f"{r.get('skill', '?')}{extra}")
|
| 407 |
+
print(
|
| 408 |
+
"\nRoll back a single mutation with `hermes curator rollback <id>`; "
|
| 409 |
+
"whole-tree snapshots remain available via `hermes curator rollback --list`.")
|
| 410 |
+
return 0
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _cmd_purge(args) -> int:
|
| 414 |
+
"""Delete archived skills older than curator.archive_ttl_days. Explicit only — never
|
| 415 |
+
automatic; each purged skill is captured (before-blobs) and recorded as a 'purge' ledger
|
| 416 |
+
entry, so even a purge is auditable and blob-recoverable."""
|
| 417 |
+
import shutil
|
| 418 |
+
import time
|
| 419 |
+
from hermes_cli.config import cfg_get, load_config
|
| 420 |
+
from tools import skill_ledger
|
| 421 |
+
from tools.skill_usage import _archive_dir
|
| 422 |
+
ttl_days = getattr(args, "days", None)
|
| 423 |
+
if ttl_days is None:
|
| 424 |
+
ttl_days = int(cfg_get(load_config(), "curator", "archive_ttl_days", default=0) or 0)
|
| 425 |
+
if ttl_days <= 0:
|
| 426 |
+
print(
|
| 427 |
+
"curator: purge disabled (curator.archive_ttl_days is 0). Set the "
|
| 428 |
+
"config key or pass --days N to purge archives older than N days.")
|
| 429 |
+
return 1
|
| 430 |
+
archive_root = _archive_dir()
|
| 431 |
+
if not archive_root.exists():
|
| 432 |
+
print("curator: no archive directory — nothing to purge.")
|
| 433 |
+
return 0
|
| 434 |
+
cutoff = time.time() - ttl_days * 86400
|
| 435 |
+
candidates = sorted(
|
| 436 |
+
p for p in archive_root.iterdir() if p.is_dir() and p.stat().st_mtime < cutoff)
|
| 437 |
+
if not candidates:
|
| 438 |
+
print(f"curator: no archived skills older than {ttl_days}d.")
|
| 439 |
+
return 0
|
| 440 |
+
print(f"Archived skills older than {ttl_days}d:")
|
| 441 |
+
for p in candidates:
|
| 442 |
+
print(f" {p.name}")
|
| 443 |
+
if getattr(args, "dry_run", False):
|
| 444 |
+
print("(dry run — nothing deleted)")
|
| 445 |
+
return 0
|
| 446 |
+
if not getattr(args, "yes", False) and not _confirm(
|
| 447 |
+
f"Permanently delete {len(candidates)} archived skill(s)? [y/N] "):
|
| 448 |
+
return 1
|
| 449 |
+
purged = 0
|
| 450 |
+
for p in candidates:
|
| 451 |
+
before = skill_ledger.capture_before(p, complete_package=True, skill=p.name)
|
| 452 |
+
try:
|
| 453 |
+
shutil.rmtree(p)
|
| 454 |
+
except OSError as e:
|
| 455 |
+
print(f"curator: failed to purge {p.name}: {e}")
|
| 456 |
+
continue
|
| 457 |
+
skill_ledger.append_entry(
|
| 458 |
+
"purge", p.name, before=before or [], after=[], actor="user",
|
| 459 |
+
evidence={"ttl_days": ttl_days})
|
| 460 |
+
purged += 1
|
| 461 |
+
print(f"curator: purged {purged} archived skill(s). Ledger entries recorded.")
|
| 462 |
+
return 0
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def _report_rollback(ok: bool, msg: str) -> int:
|
| 466 |
+
print(f"curator: {msg}" if ok else f"curator: rollback failed — {msg}")
|
| 467 |
+
return 0 if ok else 1
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _rollback_ledger_entry(args, entry_id: str) -> int:
|
| 471 |
+
"""Restore exactly the files touched by one ledger entry (from content-addressed blobs); a
|
| 472 |
+
pre-rollback safety ledger entry is taken first and it fails closed if that capture fails."""
|
| 473 |
+
from tools import skill_ledger
|
| 474 |
+
entry = skill_ledger.get_entry(entry_id)
|
| 475 |
+
if entry is None:
|
| 476 |
+
print(
|
| 477 |
+
f"curator: no ledger entry '{entry_id}'. "
|
| 478 |
+
"See `hermes curator ledger` for entry ids, or use "
|
| 479 |
+
"`--id <snapshot>` for whole-tree snapshot rollback.")
|
| 480 |
+
return 1
|
| 481 |
+
print(f"Rollback target: ledger entry {entry_id}")
|
| 482 |
+
print(f" action: {entry.get('action', '?')}")
|
| 483 |
+
print(f" skill: {entry.get('skill', '?')}")
|
| 484 |
+
print(f" actor: {entry.get('actor', '?')}")
|
| 485 |
+
print(f" when: {entry.get('ts', '?')}")
|
| 486 |
+
touched = {i.get("path") for i in (entry.get("before") or []) + (entry.get("after") or [])}
|
| 487 |
+
print(f" files: {len(touched)}")
|
| 488 |
+
if not getattr(args, "yes", False) and not _confirm(
|
| 489 |
+
"Restore this mutation's before-state? [y/N] "):
|
| 490 |
+
return 1
|
| 491 |
+
return _report_rollback(*skill_ledger.rollback_entry(entry_id))
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _cmd_rollback(args) -> int:
|
| 495 |
+
"""Restore the skills tree from a snapshot, or a single mutation from the audit ledger."""
|
| 496 |
+
from agent import curator_backup
|
| 497 |
+
entry_id = getattr(args, "entry_id", None)
|
| 498 |
+
if entry_id:
|
| 499 |
+
return _rollback_ledger_entry(args, entry_id)
|
| 500 |
+
if getattr(args, "list", False):
|
| 501 |
+
print(curator_backup.summarize_backups())
|
| 502 |
+
return 0
|
| 503 |
+
backup_id = getattr(args, "backup_id", None)
|
| 504 |
+
target_path = curator_backup._resolve_backup(backup_id)
|
| 505 |
+
if target_path is None:
|
| 506 |
+
if not curator_backup.list_backups():
|
| 507 |
+
print(
|
| 508 |
+
"curator: no snapshots exist yet. Take one with "
|
| 509 |
+
"`hermes curator backup` or wait for the next curator run.")
|
| 510 |
+
else:
|
| 511 |
+
print(
|
| 512 |
+
f"curator: no snapshot matching "
|
| 513 |
+
f"{'id ' + repr(backup_id) if backup_id else 'your query'}.")
|
| 514 |
+
print("Available:")
|
| 515 |
+
print(curator_backup.summarize_backups())
|
| 516 |
+
return 1
|
| 517 |
+
manifest = curator_backup._read_manifest(target_path)
|
| 518 |
+
print(f"Rollback target: {target_path.name}")
|
| 519 |
+
if manifest:
|
| 520 |
+
print(f" reason: {manifest.get('reason', '?')}")
|
| 521 |
+
print(f" created_at: {manifest.get('created_at', '?')}")
|
| 522 |
+
print(f" skill files: {manifest.get('skill_files', '?')}")
|
| 523 |
+
cron = manifest.get("cron_jobs") or {}
|
| 524 |
+
if isinstance(cron, dict):
|
| 525 |
+
if cron.get("backed_up"):
|
| 526 |
+
print(
|
| 527 |
+
f" cron jobs: {cron.get('jobs_count', 0)} "
|
| 528 |
+
f"(will be restored for skill-link fields only)")
|
| 529 |
+
else:
|
| 530 |
+
print(f" cron jobs: not in snapshot ({cron.get('reason', 'not captured')})")
|
| 531 |
+
print(
|
| 532 |
+
"\nThis will replace the current ~/.hermes/skills/ tree (a safety "
|
| 533 |
+
"snapshot of the current state is taken first so this is undoable). "
|
| 534 |
+
"Cron jobs that still exist will have their skills/skill fields "
|
| 535 |
+
"restored from the snapshot; all other cron fields are left alone.")
|
| 536 |
+
if not getattr(args, "yes", False) and not _confirm("Proceed? [y/N] "):
|
| 537 |
+
return 1
|
| 538 |
+
ok, msg, _ = curator_backup.rollback(backup_id=target_path.name)
|
| 539 |
+
return _report_rollback(ok, msg)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def _cmd_list_archived(args) -> int:
|
| 543 |
+
"""List archived (recoverable) skills."""
|
| 544 |
+
from tools import skill_usage
|
| 545 |
+
names = skill_usage.list_archived_skill_names()
|
| 546 |
+
print("\n".join(names) if names else "curator: no archived skills")
|
| 547 |
+
return 0
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
_USAGE_SORTS = {
|
| 551 |
+
"name": (lambda r: r["name"], False),
|
| 552 |
+
"recent": (lambda r: r.get("last_activity_at") or "", True),
|
| 553 |
+
"activity": (lambda r: r.get("activity_count", 0), True)}
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _cmd_usage(args) -> int:
|
| 557 |
+
"""Usage telemetry for ALL skills on disk (bundled + hub included), with provenance."""
|
| 558 |
+
import json as _json
|
| 559 |
+
from tools import skill_usage
|
| 560 |
+
rows = skill_usage.usage_report()
|
| 561 |
+
prov_filter = getattr(args, "provenance", None)
|
| 562 |
+
if prov_filter:
|
| 563 |
+
rows = [r for r in rows if r.get("provenance") == prov_filter]
|
| 564 |
+
# name: alphabetical; recent: most-recently-active first (never-active sinks); activity
|
| 565 |
+
# (default): most-used first.
|
| 566 |
+
key, reverse = _USAGE_SORTS.get(getattr(args, "sort", "activity"), _USAGE_SORTS["activity"])
|
| 567 |
+
rows.sort(key=key, reverse=reverse)
|
| 568 |
+
if getattr(args, "json", False):
|
| 569 |
+
print(_json.dumps(rows, indent=2, ensure_ascii=False))
|
| 570 |
+
return 0
|
| 571 |
+
if not rows:
|
| 572 |
+
print("curator: no skills found")
|
| 573 |
+
return 0
|
| 574 |
+
provenance = [r.get("provenance", "agent") for r in rows]
|
| 575 |
+
counts = {k: provenance.count(k) for k in ("agent", "bundled", "hub")}
|
| 576 |
+
print(
|
| 577 |
+
f"skills: {len(rows)} total "
|
| 578 |
+
f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})\n")
|
| 579 |
+
print(
|
| 580 |
+
f" {'skill':40s} {'origin':8s} "
|
| 581 |
+
f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity")
|
| 582 |
+
for r in rows:
|
| 583 |
+
print(
|
| 584 |
+
f" {r['name'][:40]:40s} "
|
| 585 |
+
f"{r.get('provenance', 'agent'):8s} "
|
| 586 |
+
f"{r.get('use_count', 0):>4d} "
|
| 587 |
+
f"{r.get('view_count', 0):>4d} "
|
| 588 |
+
f"{r.get('patch_count', 0):>5d} "
|
| 589 |
+
f"{r.get('activity_count', 0):>4d} "
|
| 590 |
+
f"{_fmt_ts(r.get('last_activity_at'))}")
|
| 591 |
+
return 0
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _arg(*flags, **kwargs):
|
| 595 |
+
return flags, kwargs
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
_SKILL = _arg("skill", help="Skill name")
|
| 599 |
+
_YES = _arg("-y", "--yes", action="store_true", help="Skip the confirmation prompt")
|
| 600 |
+
_STORE_TRUE = dict(action="store_true")
|
| 601 |
+
|
| 602 |
+
# argparse wiring: (name, help, handler, *(flags, add_argument kwargs))
|
| 603 |
+
_SUBCOMMANDS = (
|
| 604 |
+
("status", "Show curator status and skill stats", _cmd_status),
|
| 605 |
+
(
|
| 606 |
+
"usage", "Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
|
| 607 |
+
_cmd_usage,
|
| 608 |
+
_arg("--sort", choices=("activity", "recent", "name"), default="activity",
|
| 609 |
+
help="Sort order: activity (most-used first, default), recent "
|
| 610 |
+
"(most-recently-active first), or name (alphabetical)"),
|
| 611 |
+
_arg("--provenance", choices=("agent", "bundled", "hub"), default=None,
|
| 612 |
+
help="Only show skills of this origin"),
|
| 613 |
+
_arg("--json", **_STORE_TRUE, help="Emit the full report as JSON instead of a table")),
|
| 614 |
+
(
|
| 615 |
+
"run", "Trigger a curator review now", _cmd_run,
|
| 616 |
+
_arg("--sync", "--synchronous", dest="synchronous", **_STORE_TRUE,
|
| 617 |
+
help="Wait for the LLM review pass to finish (default for manual runs)"),
|
| 618 |
+
_arg("--background", dest="background", **_STORE_TRUE,
|
| 619 |
+
help="Start the LLM review pass in a background thread and return immediately"),
|
| 620 |
+
_arg("--dry-run", dest="dry_run", **_STORE_TRUE,
|
| 621 |
+
help="Report only — no state changes, no archives, no consolidation "
|
| 622 |
+
"(use this to preview what curator would do)"),
|
| 623 |
+
_arg("--consolidate", dest="consolidate", **_STORE_TRUE,
|
| 624 |
+
help="Force the LLM umbrella-building consolidation pass on for this "
|
| 625 |
+
"run, overriding the config default (off). Without this flag the "
|
| 626 |
+
"run is prune-only unless `curator.consolidate: true` is set.")),
|
| 627 |
+
("pause", "Pause the curator until resumed", _cmd_pause),
|
| 628 |
+
("resume", "Resume a paused curator", _cmd_resume),
|
| 629 |
+
("pin", "Pin a skill so the curator never auto-transitions it", _cmd_pin, _SKILL),
|
| 630 |
+
("unpin", "Unpin a skill", _cmd_unpin, _SKILL),
|
| 631 |
+
("list-unmanaged", "List curation-eligible skills with no provenance marker",
|
| 632 |
+
_cmd_list_unmanaged),
|
| 633 |
+
(
|
| 634 |
+
"adopt", "Hand unmanaged skills to the curator (provenance is a user declaration)",
|
| 635 |
+
_cmd_adopt,
|
| 636 |
+
_arg("skill", nargs="*", help="Skill name(s) to adopt. Omit when using --all-unmanaged."),
|
| 637 |
+
_arg("--all-unmanaged", **_STORE_TRUE,
|
| 638 |
+
help="Adopt every curation-eligible skill that has no provenance marker"),
|
| 639 |
+
_arg("--dry-run", **_STORE_TRUE,
|
| 640 |
+
help="List what would be adopted without writing anything"),
|
| 641 |
+
_arg("--yes", **_STORE_TRUE, help="Skip the confirmation prompt for --all-unmanaged")),
|
| 642 |
+
("restore", "Restore an archived skill", _cmd_restore, _SKILL),
|
| 643 |
+
("list-archived", "List archived skills", _cmd_list_archived),
|
| 644 |
+
("archive", "Manually archive a skill (move to .archive/, excluded from prompt)", _cmd_archive,
|
| 645 |
+
_SKILL),
|
| 646 |
+
(
|
| 647 |
+
"prune", "Bulk-archive curator-managed skills idle for >= N days (default: curator.archive_after_days)",
|
| 648 |
+
_cmd_prune,
|
| 649 |
+
_arg("--days", type=int, default=None,
|
| 650 |
+
help="Archive skills idle for at least N days (default: curator.archive_after_days, 30)"),
|
| 651 |
+
_YES,
|
| 652 |
+
_arg("--dry-run", dest="dry_run", **_STORE_TRUE,
|
| 653 |
+
help="Show what would be archived without doing it")),
|
| 654 |
+
(
|
| 655 |
+
"backup",
|
| 656 |
+
"Take a manual tar.gz snapshot of ~/.hermes/skills/ "
|
| 657 |
+
"(curator also does this automatically before every real run)",
|
| 658 |
+
_cmd_backup,
|
| 659 |
+
_arg("--reason", default=None,
|
| 660 |
+
help="Free-text label stored in manifest.json (default: 'manual')")),
|
| 661 |
+
(
|
| 662 |
+
"rollback",
|
| 663 |
+
"Restore ~/.hermes/skills/ from a curator snapshot, or a single "
|
| 664 |
+
"mutation by ledger entry id (see `hermes curator ledger`)",
|
| 665 |
+
_cmd_rollback,
|
| 666 |
+
_arg("entry_id", nargs="?", default=None,
|
| 667 |
+
help="Ledger entry id for single-mutation rollback (from "
|
| 668 |
+
"`hermes curator ledger`). Omit for whole-tree snapshot rollback."),
|
| 669 |
+
_arg("--list", **_STORE_TRUE, help="List available snapshots and exit without restoring"),
|
| 670 |
+
_arg("--id", dest="backup_id", default=None,
|
| 671 |
+
help="Snapshot id to restore (see `--list`); default: newest"),
|
| 672 |
+
_arg("-y", "--yes", **_STORE_TRUE, help="Skip confirmation prompt")),
|
| 673 |
+
(
|
| 674 |
+
"ledger", "List the per-mutation skill audit ledger (all actors: curator/agent/user)",
|
| 675 |
+
_cmd_ledger,
|
| 676 |
+
_arg("--skill", default=None, help="Only show entries for this skill"),
|
| 677 |
+
_arg("--limit", type=int, default=20, help="Max entries to show (default: 20)")),
|
| 678 |
+
(
|
| 679 |
+
"purge",
|
| 680 |
+
"Delete archived skills older than curator.archive_ttl_days "
|
| 681 |
+
"(explicit only — never automatic; recorded in the ledger)",
|
| 682 |
+
_cmd_purge,
|
| 683 |
+
_arg("--days", type=int, default=None,
|
| 684 |
+
help="Override curator.archive_ttl_days for this invocation"),
|
| 685 |
+
_arg("--dry-run", dest="dry_run", **_STORE_TRUE,
|
| 686 |
+
help="Show what would be purged without deleting"),
|
| 687 |
+
_YES))
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def register_cli(parent: argparse.ArgumentParser) -> None:
|
| 691 |
+
"""Attach `curator` subcommands to *parent*."""
|
| 692 |
+
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
| 693 |
+
subs = parent.add_subparsers(dest="curator_command")
|
| 694 |
+
for name, help_text, handler, *arguments in _SUBCOMMANDS:
|
| 695 |
+
sub = subs.add_parser(name, help=help_text)
|
| 696 |
+
for flags, arg_kwargs in arguments:
|
| 697 |
+
sub.add_argument(*flags, **arg_kwargs)
|
| 698 |
+
sub.set_defaults(func=handler)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def cli_main(argv=None) -> int:
|
| 702 |
+
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
| 703 |
+
parser = argparse.ArgumentParser(prog="hermes curator")
|
| 704 |
+
register_cli(parser)
|
| 705 |
+
args = parser.parse_args(argv)
|
| 706 |
+
return int(args.func(args) or 0)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
if __name__ == "__main__": # pragma: no cover
|
| 710 |
+
sys.exit(cli_main())
|
hermes_cli/desktop_update_verify.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read-only verification at the Windows Desktop handoff receipt boundary."""
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path, PurePosixPath
|
| 4 |
+
import re
|
| 5 |
+
import struct
|
| 6 |
+
|
| 7 |
+
from hermes_cli.main_desktop import (
|
| 8 |
+
_HTML_TAG_WITH_URL,
|
| 9 |
+
_MODULE_TAG,
|
| 10 |
+
_desktop_build_needed,
|
| 11 |
+
_desktop_exe_integrity_error,
|
| 12 |
+
_desktop_packaged_executable,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _verify_packaged_entry(resources: Path) -> None:
|
| 17 |
+
"""Read ASAR's Pickle header and the entry declared by packaged package.json.
|
| 18 |
+
|
| 19 |
+
dist/** is unpacked by electron-builder. No Node install or application
|
| 20 |
+
launch is needed at this boundary; this is not a full dependency audit.
|
| 21 |
+
"""
|
| 22 |
+
archive = resources / "app.asar"
|
| 23 |
+
try:
|
| 24 |
+
with archive.open("rb") as stream:
|
| 25 |
+
size, header_size, payload_size, json_size = struct.unpack("<4I", stream.read(16))
|
| 26 |
+
if (size != 4 or header_size != payload_size + 4
|
| 27 |
+
or payload_size != 4 + ((json_size + 3) // 4) * 4
|
| 28 |
+
or not 0 < json_size <= 64 * 1024 * 1024
|
| 29 |
+
or 8 + header_size > archive.stat().st_size):
|
| 30 |
+
raise ValueError("invalid ASAR header")
|
| 31 |
+
header = json.loads(stream.read(json_size))
|
| 32 |
+
|
| 33 |
+
def read_member(name: str) -> bytes:
|
| 34 |
+
path = PurePosixPath(name)
|
| 35 |
+
if not name or path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name:
|
| 36 |
+
raise ValueError("invalid ASAR entry path")
|
| 37 |
+
node = header
|
| 38 |
+
for part in path.parts:
|
| 39 |
+
node = node["files"][part]
|
| 40 |
+
length = node["size"]
|
| 41 |
+
if not isinstance(length, int) or length <= 0:
|
| 42 |
+
raise ValueError(f"empty ASAR entry: {name}")
|
| 43 |
+
if node.get("unpacked"):
|
| 44 |
+
data = (resources / "app.asar.unpacked" / path).read_bytes()
|
| 45 |
+
else:
|
| 46 |
+
offset = int(node["offset"])
|
| 47 |
+
if offset < 0 or 8 + header_size + offset + length > archive.stat().st_size:
|
| 48 |
+
raise ValueError(f"truncated ASAR entry: {name}")
|
| 49 |
+
stream.seek(8 + header_size + offset)
|
| 50 |
+
data = stream.read(length)
|
| 51 |
+
if len(data) != length or not data.strip():
|
| 52 |
+
raise ValueError(f"incomplete ASAR entry: {name}")
|
| 53 |
+
return data
|
| 54 |
+
|
| 55 |
+
package = json.loads(read_member("package.json"))
|
| 56 |
+
read_member(package["main"]).decode("utf-8")
|
| 57 |
+
except (OSError, ValueError, KeyError, TypeError, struct.error) as exc:
|
| 58 |
+
raise RuntimeError(f"The updated Desktop archive or main entry is invalid: {exc}") from exc
|
| 59 |
+
|
| 60 |
+
index = resources / "app.asar.unpacked" / "dist" / "index.html"
|
| 61 |
+
try:
|
| 62 |
+
html = index.read_text(encoding="utf-8")
|
| 63 |
+
if not any(_MODULE_TAG.search(match.group(0))
|
| 64 |
+
and match.group(0).lower().startswith("<script")
|
| 65 |
+
and not re.match(r"^[a-z]+:|^//", match.group(1), re.IGNORECASE)
|
| 66 |
+
for match in _HTML_TAG_WITH_URL.finditer(html)):
|
| 67 |
+
raise ValueError("renderer has no local module entry")
|
| 68 |
+
except (OSError, ValueError) as exc:
|
| 69 |
+
raise RuntimeError(f"The updated Desktop renderer entry is invalid: {exc}") from exc
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def checkout_root() -> Path:
|
| 73 |
+
"""The checkout this module was imported from: the only root the receipt may describe."""
|
| 74 |
+
return Path(__file__).resolve().parent.parent
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def verify_windows_desktop_update(project_root: Path | None = None) -> None:
|
| 78 |
+
"""Raise when a zero-exit updater left an incomplete or stale packaged app.
|
| 79 |
+
|
| 80 |
+
The root defaults to the imported checkout, never the caller's cwd: the hand-off
|
| 81 |
+
is spawned from HERMES_HOME by the pre-update Desktop, and a cwd-derived root
|
| 82 |
+
reported a healthy install as "Desktop executable is missing" (Sep 2026).
|
| 83 |
+
"""
|
| 84 |
+
if project_root is None:
|
| 85 |
+
project_root = checkout_root()
|
| 86 |
+
desktop = project_root / "apps" / "desktop"
|
| 87 |
+
executable = _desktop_packaged_executable(desktop)
|
| 88 |
+
if executable is None:
|
| 89 |
+
raise RuntimeError("The updated Desktop executable is missing")
|
| 90 |
+
error = _desktop_exe_integrity_error(executable)
|
| 91 |
+
if error:
|
| 92 |
+
raise RuntimeError(f"The updated Desktop executable is invalid: {error}")
|
| 93 |
+
_verify_packaged_entry(executable.parent / "resources")
|
| 94 |
+
if _desktop_build_needed(desktop, project_root, source_mode=False):
|
| 95 |
+
raise RuntimeError("The updated Desktop build is stale, unstamped, or incomplete")
|
hermes_cli/focus_view.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Focus view — display-only reduced output: "just my prompt and the answer, and tell me what you hid".
|
| 2 |
+
ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the configured mode so the *existing* suppression
|
| 3 |
+
path does the hiding; OFF restores it verbatim. Focus adds a per-turn hidden-line count with a recovery
|
| 4 |
+
hint and a persistent ``focus`` status-bar segment."""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
FOCUS_CONFIG_KEY = "display.focus_view" # plain boolean under ``display``, like /battery /timestamps /footer
|
| 11 |
+
FOCUS_TOOL_PROGRESS_MODE = "off" # the SAME value ``/verbose off`` uses so both features share one suppression path
|
| 12 |
+
# Modes in which the CLI commits a per-tool scrollback line. Mirrors the gate in
|
| 13 |
+
# ``HermesCLI._on_tool_progress`` so the hidden-line counter and the renderer never drift apart.
|
| 14 |
+
TOOL_PROGRESS_VISIBLE_MODES = frozenset({"new", "all", "verbose"})
|
| 15 |
+
TOOL_PROGRESS_MODES = ("off", "new", "all", "verbose") # ``log`` is a gateway-only extra step
|
| 16 |
+
FOCUS_STATUSBAR_LABEL = "◉ focus" # short on purpose — the bar is width-constrained
|
| 17 |
+
|
| 18 |
+
# /focus argument words -> (action, target); bare /focus toggles like /footer, /battery, /timestamps.
|
| 19 |
+
_FOCUS_WORDS = {
|
| 20 |
+
**dict.fromkeys(("status", "show", "?"), ("status", None)),
|
| 21 |
+
**dict.fromkeys(("on", "enable", "enabled", "true", "yes", "1"), ("set", True)),
|
| 22 |
+
**dict.fromkeys(("off", "disable", "disabled", "false", "no", "0"), ("set", False)),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def normalize_tool_progress_mode(mode: object, default: str = "all") -> str:
|
| 27 |
+
"""Coerce a raw config/attr value into a known tool-progress mode."""
|
| 28 |
+
if mode is False:
|
| 29 |
+
return "off"
|
| 30 |
+
if mode is True:
|
| 31 |
+
return "all"
|
| 32 |
+
text = str(mode or "").strip().lower()
|
| 33 |
+
# ``log`` is a real gateway mode; any other unknown value becomes the default.
|
| 34 |
+
return text if text in TOOL_PROGRESS_MODES or text == "log" else default
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]:
|
| 38 |
+
"""Map a ``/focus`` argument onto ``(action, target)``: action is ``"set"``, ``"status"`` or ``"usage"``;
|
| 39 |
+
target is the requested enabled-state for ``"set"`` and ``None`` otherwise."""
|
| 40 |
+
text = str(arg or "").strip().lower()
|
| 41 |
+
if text in ("", "toggle"):
|
| 42 |
+
return "set", not bool(current)
|
| 43 |
+
return _FOCUS_WORDS.get(text, ("usage", None))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def would_display_tool_line(mode: object, function_name: str, last_tool_name: Optional[str] = None) -> bool:
|
| 47 |
+
"""Would the CLI have committed a scrollback line for this tool call? Counts honestly: with ``/verbose off``
|
| 48 |
+
focus view hides nothing extra and must not claim otherwise. ``new`` mode skips consecutive repeats of the
|
| 49 |
+
same tool, so the counter does too."""
|
| 50 |
+
if not function_name:
|
| 51 |
+
return False
|
| 52 |
+
normalized = normalize_tool_progress_mode(mode)
|
| 53 |
+
return normalized in TOOL_PROGRESS_VISIBLE_MODES and not (normalized == "new" and function_name == last_tool_name)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def format_hidden_line(count: int) -> Optional[str]:
|
| 57 |
+
"""Dim post-turn recovery line, or ``None`` when nothing was hidden."""
|
| 58 |
+
try:
|
| 59 |
+
n = int(count)
|
| 60 |
+
except (TypeError, ValueError):
|
| 61 |
+
return None
|
| 62 |
+
return f"⋯ {n} {'tool line' if n == 1 else 'tool lines'} hidden · /focus off to show" if n > 0 else None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def focus_statusbar_segment(enabled: bool) -> str:
|
| 66 |
+
"""Status-bar segment text for focus view (empty when off)."""
|
| 67 |
+
return FOCUS_STATUSBAR_LABEL if enabled else ""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def format_focus_status(enabled: bool, configured_mode: object) -> str:
|
| 71 |
+
"""Human-readable ``/focus status`` body (no ANSI — callers colour it)."""
|
| 72 |
+
mode = normalize_tool_progress_mode(configured_mode).upper()
|
| 73 |
+
if enabled:
|
| 74 |
+
return f"Focus view: ON — only your prompt and the final response.\n /focus off restores tool progress: {mode}"
|
| 75 |
+
return f"Focus view: OFF — tool progress: {mode}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str:
|
| 79 |
+
"""Confirmation line printed when focus view is switched (no ANSI)."""
|
| 80 |
+
if enabled:
|
| 81 |
+
return "Focus view enabled — just your prompt and the final response"
|
| 82 |
+
return f"Focus view disabled — tool progress: {normalize_tool_progress_mode(configured_mode).upper()}"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 86 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 87 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 88 |
+
# The whole block is removed by reverting the commit that added it.
|
| 89 |
+
|
| 90 |
+
FOCUS_USAGE = "Usage: /focus [on|off|status]"
|
| 91 |
+
|
| 92 |
+
def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str:
|
| 93 |
+
"""Return the tool-progress mode that should actually be in force.
|
| 94 |
+
|
| 95 |
+
Focus view wins while it is on (it *is* "tool progress off" plus reporting).
|
| 96 |
+
When focus is off the user's configured mode is returned untouched — this is
|
| 97 |
+
what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering
|
| 98 |
+
it to ``all``.
|
| 99 |
+
"""
|
| 100 |
+
normalized = normalize_tool_progress_mode(configured_mode)
|
| 101 |
+
if focus_enabled:
|
| 102 |
+
return FOCUS_TOOL_PROGRESS_MODE
|
| 103 |
+
return normalized
|
| 104 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_cli/goal_command.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The CLI's /goal semantics, shared by every goal command surface.
|
| 2 |
+
|
| 3 |
+
Adapters supply authorization and schedule the returned prompt; this module alone
|
| 4 |
+
parses subcommands and mutates goal state. It never changes conversation history.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Callable
|
| 11 |
+
|
| 12 |
+
from hermes_cli import goals
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class GoalCommandResult:
|
| 19 |
+
output: str
|
| 20 |
+
prompt: str | None = None
|
| 21 |
+
kickoff: bool = False
|
| 22 |
+
clear_pending: str | None = None
|
| 23 |
+
error: bool = False
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _english(key, default, **values):
|
| 27 |
+
return default.format(**values)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _status(mgr, arg, render):
|
| 31 |
+
return GoalCommandResult(mgr.status_line())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _show(mgr, arg, render):
|
| 35 |
+
return GoalCommandResult(f"{mgr.status_line()}\n{mgr.render_contract()}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _pause(mgr, arg, render):
|
| 39 |
+
state = mgr.pause(reason="user-paused")
|
| 40 |
+
return GoalCommandResult(
|
| 41 |
+
render("gateway.goal.paused", "⏸ Goal paused: {goal}", goal=state.goal) if state
|
| 42 |
+
else render("gateway.goal.no_goal_set", "No goal set."),
|
| 43 |
+
clear_pending="pause" if state else None,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _resume(mgr, arg, render):
|
| 48 |
+
state = mgr.resume()
|
| 49 |
+
if state is None:
|
| 50 |
+
return GoalCommandResult(render("gateway.goal.no_resume", "No goal to resume."))
|
| 51 |
+
return GoalCommandResult(render("gateway.goal.resumed", "▶ Goal resumed: {goal}", goal=state.goal),
|
| 52 |
+
prompt=mgr.next_continuation_prompt())
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _clear(mgr, arg, render):
|
| 56 |
+
had = mgr.has_goal()
|
| 57 |
+
mgr.clear()
|
| 58 |
+
return GoalCommandResult(render("gateway.goal_cleared", "✓ Goal cleared.") if had else
|
| 59 |
+
render("gateway.no_active_goal", "No active goal."), clear_pending="clear")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _unwait(mgr, arg, render):
|
| 63 |
+
return GoalCommandResult("▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting()
|
| 64 |
+
else "No wait barrier set.")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _wait(mgr, arg):
|
| 68 |
+
if not arg:
|
| 69 |
+
return GoalCommandResult("Usage: /goal wait <pid> [reason]", error=True)
|
| 70 |
+
tokens = arg.split(None, 1)
|
| 71 |
+
try:
|
| 72 |
+
pid = int(tokens[0])
|
| 73 |
+
except ValueError:
|
| 74 |
+
return GoalCommandResult("/goal wait: <pid> must be an integer process id.", error=True)
|
| 75 |
+
reason = tokens[1].strip() if len(tokens) > 1 else ""
|
| 76 |
+
mgr.wait_on(pid, reason=reason)
|
| 77 |
+
suffix = f" ({reason})" if reason else ""
|
| 78 |
+
return GoalCommandResult(f"⏳ Goal parked on pid {pid}{suffix}. Loop pauses until it exits.")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _gate_add(mgr, arg):
|
| 82 |
+
gate = mgr.add_gate(arg)
|
| 83 |
+
return GoalCommandResult(f"⚿ Gate added: $ {gate.command} "
|
| 84 |
+
f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). "
|
| 85 |
+
"It must pass before the goal can complete.")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _gate_remove(mgr, arg):
|
| 89 |
+
return GoalCommandResult(f"✓ Gate removed: $ {mgr.remove_gate(int(arg))}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _gate_clear(mgr, arg):
|
| 93 |
+
count = mgr.clear_gates()
|
| 94 |
+
return GoalCommandResult(f"✓ Cleared {count} gate{'s' if count != 1 else ''}.")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
_GATE_HANDLERS = {"add": _gate_add, "remove": _gate_remove, "rm": _gate_remove, "clear": _gate_clear}
|
| 98 |
+
_EXACT_HANDLERS = {
|
| 99 |
+
"": _status, "status": _status, "show": _show, "pause": _pause,
|
| 100 |
+
"resume": _resume, "clear": _clear, "stop": _clear, "done": _clear, "unwait": _unwait,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _gate(mgr, arg, authorize_gate):
|
| 105 |
+
if not arg or arg.lower() == "list":
|
| 106 |
+
return GoalCommandResult(mgr.render_gates())
|
| 107 |
+
tokens = arg.split(None, 1)
|
| 108 |
+
verb, rest = tokens[0].lower(), tokens[1].strip() if len(tokens) > 1 else ""
|
| 109 |
+
handler = _GATE_HANDLERS.get(verb)
|
| 110 |
+
if handler is None or (verb == "clear" and rest) or (verb != "clear" and not rest):
|
| 111 |
+
return GoalCommandResult("Usage: /goal gate [list | add <command> | remove <N> | clear]", error=True)
|
| 112 |
+
# Gates run shell commands without a later approval. The adapter must explicitly
|
| 113 |
+
# authorize creation; recovery commands remain available to non-admin senders.
|
| 114 |
+
if verb == "add" and (denial := authorize_gate()):
|
| 115 |
+
return GoalCommandResult(denial, error=True)
|
| 116 |
+
try:
|
| 117 |
+
return handler(mgr, rest)
|
| 118 |
+
except (RuntimeError, ValueError, IndexError) as exc:
|
| 119 |
+
operation = "remove" if verb == "rm" else verb
|
| 120 |
+
return GoalCommandResult(f"/goal gate {operation}: {exc}", error=True)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _set(mgr, arg, *, drafting, last_user_message, render, progress):
|
| 124 |
+
if drafting:
|
| 125 |
+
if not arg:
|
| 126 |
+
return GoalCommandResult("Usage: /goal draft <objective in plain language>", error=True)
|
| 127 |
+
if progress is not None:
|
| 128 |
+
progress("Drafting completion contract…")
|
| 129 |
+
try:
|
| 130 |
+
contract = goals.draft_contract(arg)
|
| 131 |
+
except Exception as exc:
|
| 132 |
+
logger.debug("goal draft failed: %s", exc)
|
| 133 |
+
contract = None
|
| 134 |
+
headline = arg
|
| 135 |
+
else:
|
| 136 |
+
headline, contract = goals.parse_contract(arg)
|
| 137 |
+
contract = contract if not contract.is_empty() else None
|
| 138 |
+
state = mgr.set(headline or arg, contract=contract)
|
| 139 |
+
output = render("gateway.goal.set", "⊙ Goal set ({budget}-turn budget): {goal}",
|
| 140 |
+
budget=state.max_turns, goal=state.goal)
|
| 141 |
+
if state.has_contract():
|
| 142 |
+
label = "Drafted completion contract:" if drafting else "Completion contract:"
|
| 143 |
+
output += f"\n{label}\n{state.contract.render_block()}"
|
| 144 |
+
if drafting:
|
| 145 |
+
output += ("\nTighten any field by re-setting the goal with inline lines "
|
| 146 |
+
"(e.g. verify: <command>), then /goal resume. Use /goal show to review."
|
| 147 |
+
if state.has_contract() else
|
| 148 |
+
"\nCouldn't draft a contract (aux model unavailable) — running as a "
|
| 149 |
+
"free-form goal. The per-turn judge still applies.")
|
| 150 |
+
else:
|
| 151 |
+
against = " against the contract above" if state.has_contract() else ""
|
| 152 |
+
output += (f"\nAfter each turn, a judge model checks if the goal is done{against}. "
|
| 153 |
+
"Hermes keeps working until it is, you pause/clear it, or the budget is "
|
| 154 |
+
"exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.")
|
| 155 |
+
return GoalCommandResult(output, goals.goal_kick_prompt(state.goal, last_user_message), kickoff=True)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def is_goal_control(arg: str) -> bool:
|
| 159 |
+
"""Whether this command controls an existing goal rather than replacing it."""
|
| 160 |
+
normalized = arg.strip().lower()
|
| 161 |
+
return normalized in _EXACT_HANDLERS or normalized.split(None, 1)[0] in {"wait", "gate"}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def dispatch_goal_command(
|
| 165 |
+
mgr: goals.GoalManager, arg: str, *, authorize_gate: Callable[[], str | None],
|
| 166 |
+
last_user_message=None, render: Callable = _english,
|
| 167 |
+
progress: Callable[[str], None] | None = None,
|
| 168 |
+
) -> GoalCommandResult:
|
| 169 |
+
"""Apply one command. ``authorize_gate`` returns a denial or None (explicit approval).
|
| 170 |
+
|
| 171 |
+
Synchronous like GoalManager: async adapters run this off-loop with copied
|
| 172 |
+
ContextVars so draft credentials and persisted I/O stay in the caller's profile.
|
| 173 |
+
"""
|
| 174 |
+
arg = arg.strip()
|
| 175 |
+
tokens = arg.split(None, 1)
|
| 176 |
+
verb = tokens[0].lower() if tokens else ""
|
| 177 |
+
rest = tokens[1].strip() if len(tokens) > 1 else ""
|
| 178 |
+
prefix = "Invalid goal"
|
| 179 |
+
try:
|
| 180 |
+
if handler := _EXACT_HANDLERS.get(arg.lower()):
|
| 181 |
+
return handler(mgr, "", render)
|
| 182 |
+
if verb == "wait":
|
| 183 |
+
prefix = "/goal wait"
|
| 184 |
+
return _wait(mgr, rest)
|
| 185 |
+
if verb == "gate":
|
| 186 |
+
prefix = "/goal gate"
|
| 187 |
+
return _gate(mgr, rest, authorize_gate)
|
| 188 |
+
return _set(mgr, rest if verb == "draft" else arg,
|
| 189 |
+
drafting=verb == "draft", last_user_message=last_user_message,
|
| 190 |
+
render=render, progress=progress)
|
| 191 |
+
except (RuntimeError, ValueError, IndexError) as exc:
|
| 192 |
+
output = (render("gateway.goal.invalid", "Invalid goal: {error}", error=str(exc))
|
| 193 |
+
if prefix == "Invalid goal" else f"{prefix}: {exc}")
|
| 194 |
+
return GoalCommandResult(output, error=True)
|
hermes_cli/inventory.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider/model inventory — shared substrate for the dashboard ``/api/model/options``, the TUI
|
| 2 |
+
``model.options``/``model.save_key`` RPC handlers, and the interactive picker."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from contextvars import copy_context
|
| 7 |
+
from dataclasses import dataclass, replace
|
| 8 |
+
from threading import Lock, Thread, current_thread
|
| 9 |
+
from typing import Any, Optional
|
| 10 |
+
|
| 11 |
+
_pricing_prewarm_lock = Lock()
|
| 12 |
+
_pricing_prewarm_threads: dict[tuple[str, tuple[tuple[str, str], ...]], Thread] = {}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class ConfigContext:
|
| 17 |
+
"""Disk-config snapshot (``load_picker_context()``); the TUI overlays live agent state via
|
| 18 |
+
``with_overrides()``."""
|
| 19 |
+
|
| 20 |
+
current_provider: str
|
| 21 |
+
current_model: str
|
| 22 |
+
current_base_url: str
|
| 23 |
+
user_providers: dict
|
| 24 |
+
custom_providers: list
|
| 25 |
+
excluded_providers: list = None
|
| 26 |
+
|
| 27 |
+
def with_overrides(
|
| 28 |
+
self, *, current_provider: Optional[str] = None, current_model: Optional[str] = None,
|
| 29 |
+
current_base_url: Optional[str] = None,
|
| 30 |
+
) -> "ConfigContext":
|
| 31 |
+
"""Copy with TRUTHY overrides applied: the TUI reads agent attributes that may be empty strings
|
| 32 |
+
before an agent is spawned — empties must not clobber the disk-config values."""
|
| 33 |
+
overrides = (("current_provider", current_provider), ("current_model", current_model),
|
| 34 |
+
("current_base_url", current_base_url))
|
| 35 |
+
kw = {k: v for k, v in overrides if v}
|
| 36 |
+
return replace(self, **kw) if kw else self
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_picker_context() -> ConfigContext:
|
| 40 |
+
"""Load the disk-config snapshot every consumer needs."""
|
| 41 |
+
from hermes_cli.config import (
|
| 42 |
+
coerce_provider_id, get_compatible_custom_providers, load_config, stringify_provider_map,
|
| 43 |
+
)
|
| 44 |
+
cfg = load_config()
|
| 45 |
+
model_cfg = cfg.get("model", {})
|
| 46 |
+
if isinstance(model_cfg, dict):
|
| 47 |
+
# PyYAML parses unquoted scalars as int (`provider: 2070`); keep strings so picker/options
|
| 48 |
+
# paths never call `.strip()` on an int.
|
| 49 |
+
current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "")
|
| 50 |
+
current_provider = coerce_provider_id(model_cfg.get("provider", ""))
|
| 51 |
+
current_base_url = str(model_cfg.get("base_url", "") or "")
|
| 52 |
+
else: # config.model can be a bare string in older configs
|
| 53 |
+
current_model, current_provider, current_base_url = (str(model_cfg) if model_cfg else ""), "", ""
|
| 54 |
+
excluded = cfg.get("model_catalog", {}).get("excluded_providers") or []
|
| 55 |
+
return ConfigContext(
|
| 56 |
+
current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
|
| 57 |
+
user_providers=stringify_provider_map(cfg.get("providers")),
|
| 58 |
+
custom_providers=get_compatible_custom_providers(cfg),
|
| 59 |
+
excluded_providers=excluded if isinstance(excluded, list) else [],
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _slug(row: dict) -> str:
|
| 64 |
+
return str(row.get("slug") or "").strip().lower()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _without_slug(rows: list[dict], slug: str) -> list[dict]:
|
| 68 |
+
return [r for r in rows if _slug(r) != slug]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ─── Public: payload builder ────────────────────────────────────────────
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_models_payload(
|
| 75 |
+
ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
|
| 76 |
+
picker_hints: bool = False, canonical_order: bool = False, pricing: bool = False,
|
| 77 |
+
pricing_cache_only: bool = False,
|
| 78 |
+
capabilities: bool = False, featured: bool = False, force_fresh_nous_tier: bool = False,
|
| 79 |
+
refresh: bool = False, probe_custom_providers: bool = True, probe_current_custom_provider: bool = False,
|
| 80 |
+
for_picker: bool = False, max_models: int | None = None,
|
| 81 |
+
) -> dict:
|
| 82 |
+
"""Build the ``{providers, model, provider}`` shape every consumer needs. ``explicit_only`` keeps
|
| 83 |
+
only providers the user explicitly configured — hides ambient/auto-seeded credentials from
|
| 84 |
+
desktop chat pickers. ``pricing_cache_only``: with ``pricing``, use only values already resident
|
| 85 |
+
in process caches (normal picker opens, while a background worker warms cold endpoints)."""
|
| 86 |
+
from hermes_cli.model_switch import list_authenticated_providers
|
| 87 |
+
|
| 88 |
+
rows = list_authenticated_providers(
|
| 89 |
+
current_provider=ctx.current_provider, current_base_url=ctx.current_base_url,
|
| 90 |
+
current_model=ctx.current_model, user_providers=ctx.user_providers,
|
| 91 |
+
custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier,
|
| 92 |
+
max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers,
|
| 93 |
+
probe_current_custom_provider=probe_current_custom_provider, for_picker=for_picker,
|
| 94 |
+
excluded_providers=ctx.excluded_providers or [],
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Managed local runtime: staged GGUFs are selectable like any provider's models, but
|
| 98 |
+
# list_authenticated_providers can't know about them (no credential — reachability is the
|
| 99 |
+
# credential), so inject the row here where every picker surface inherits it.
|
| 100 |
+
local_row = _local_runtime_row(ctx)
|
| 101 |
+
if local_row is not None:
|
| 102 |
+
rows = _without_slug(rows, "llamacpp") + [local_row]
|
| 103 |
+
# A live session on the managed server reports provider "custom" (raw base_url label), which
|
| 104 |
+
# would materialize a duplicate "Custom endpoint" row with the same staged models stealing the
|
| 105 |
+
# checkmark. The Local row owns the managed server's identity — drop such custom rows.
|
| 106 |
+
if local_row.get("is_current"):
|
| 107 |
+
staged = set(local_row["models"])
|
| 108 |
+
|
| 109 |
+
def _is_managed_custom(row: dict) -> bool:
|
| 110 |
+
models = {str(m) for m in (row.get("models") or [])}
|
| 111 |
+
return _slug(row) == "custom" and bool(models) and models <= staged
|
| 112 |
+
|
| 113 |
+
rows = [r for r in rows if not _is_managed_custom(r)]
|
| 114 |
+
|
| 115 |
+
moa_row = _moa_provider_row(ctx.current_provider)
|
| 116 |
+
if moa_row is not None:
|
| 117 |
+
rows = [moa_row] + _without_slug(rows, "moa")
|
| 118 |
+
|
| 119 |
+
if explicit_only:
|
| 120 |
+
rows = _filter_explicit_provider_rows(rows, ctx)
|
| 121 |
+
# If the current provider lost its credential, list_authenticated_providers() omits it; keep
|
| 122 |
+
# that one row so the UI shows the saved selection + a re-auth affordance instead of appearing
|
| 123 |
+
# to jump providers. Exception: a "custom" current on the managed local server is already
|
| 124 |
+
# represented by the Local row — the skeleton would resurrect the duplicate removed above.
|
| 125 |
+
_local_owns_current = bool(local_row and local_row.get("is_current")
|
| 126 |
+
and (ctx.current_provider or "").lower() == "custom")
|
| 127 |
+
if not _local_owns_current:
|
| 128 |
+
rows = list(rows) + _append_unconfigured_rows(rows, ctx, current_only=True)
|
| 129 |
+
|
| 130 |
+
# A local proxy serving a model also in an aggregator's catalog would show under both, and picking
|
| 131 |
+
# the aggregator row silently breaks the call — aggregators only list models no specific provider has.
|
| 132 |
+
_strip_aggregator_overlaps(rows)
|
| 133 |
+
|
| 134 |
+
if include_unconfigured:
|
| 135 |
+
rows = list(rows) + _without_slug(_append_unconfigured_rows(rows, ctx), "moa")
|
| 136 |
+
if picker_hints:
|
| 137 |
+
_apply_picker_hints(rows)
|
| 138 |
+
if canonical_order:
|
| 139 |
+
rows = _reorder_canonical(rows)
|
| 140 |
+
if pricing:
|
| 141 |
+
_apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier, cached_only=pricing_cache_only)
|
| 142 |
+
if capabilities:
|
| 143 |
+
_apply_capabilities(rows)
|
| 144 |
+
if featured:
|
| 145 |
+
_apply_featured(rows)
|
| 146 |
+
_apply_custom_aliases(rows)
|
| 147 |
+
|
| 148 |
+
return {"providers": rows, "model": ctx.current_model, "provider": ctx.current_provider}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _strip_aggregator_overlaps(rows: list[dict]) -> None:
|
| 152 |
+
"""Drop models from TRUE routing aggregators (OpenRouter, custom:* proxies) that a user-defined
|
| 153 |
+
provider also serves. The is_user_defined guard matters: is_routing_aggregator() is True for every
|
| 154 |
+
custom:* slug, so without it the dedup would empty a user's own custom row. Flat-namespace
|
| 155 |
+
resellers (opencode-go/zen) serve every model first-party and keep shared names."""
|
| 156 |
+
try:
|
| 157 |
+
from hermes_cli.providers import is_routing_aggregator
|
| 158 |
+
except Exception:
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
user_models: set[str] = set()
|
| 162 |
+
for row in rows:
|
| 163 |
+
if row.get("is_user_defined"):
|
| 164 |
+
user_models.update(m.lower() for m in (row.get("models") or []))
|
| 165 |
+
if not user_models:
|
| 166 |
+
return
|
| 167 |
+
for row in rows:
|
| 168 |
+
if row.get("is_user_defined") or not is_routing_aggregator(row.get("slug", "")):
|
| 169 |
+
continue
|
| 170 |
+
# Only strip overlaps from TRUE routing aggregators (OpenRouter, custom:* proxies). Flat-namespace
|
| 171 |
+
# resellers (opencode-go / opencode-zen) serve every listed model as a first-party model, so their
|
| 172 |
+
# rows must keep models that a user's proxy happens to share a name with — otherwise a subscription
|
| 173 |
+
# provider's own catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently gutted in the
|
| 174 |
+
# picker. (#47077)
|
| 175 |
+
original = row.get("models") or []
|
| 176 |
+
filtered = [m for m in original if m.lower() not in user_models]
|
| 177 |
+
if len(filtered) < len(original):
|
| 178 |
+
row["models"] = filtered
|
| 179 |
+
row["total_models"] = len(filtered)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def build_model_options_payload(
|
| 183 |
+
ctx: ConfigContext, *, explicit_only: bool = False, include_unconfigured: bool = False,
|
| 184 |
+
refresh: bool = False,
|
| 185 |
+
) -> dict:
|
| 186 |
+
"""Shared API-server/dashboard/TUI payload. Normal open probes only the current custom provider so
|
| 187 |
+
offline saved endpoints don't block the picker; explicit refresh probes all and busts the cache."""
|
| 188 |
+
refresh = bool(refresh)
|
| 189 |
+
payload = build_models_payload(
|
| 190 |
+
ctx, explicit_only=bool(explicit_only), include_unconfigured=bool(include_unconfigured),
|
| 191 |
+
picker_hints=True, canonical_order=True, pricing=True, pricing_cache_only=not refresh,
|
| 192 |
+
capabilities=True, featured=True,
|
| 193 |
+
refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh,
|
| 194 |
+
)
|
| 195 |
+
if not refresh:
|
| 196 |
+
_prewarm_pricing_async(payload["providers"], current_provider=ctx.current_provider,
|
| 197 |
+
current_base_url=ctx.current_base_url)
|
| 198 |
+
return payload
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ─── Public: auxiliary-task pickers ─────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def build_aux_picker_rows(
|
| 205 |
+
*, current_provider: str = "", current_model: str = "", current_base_url: str = "",
|
| 206 |
+
max_models: int | None = None,
|
| 207 |
+
) -> list[dict]:
|
| 208 |
+
"""Provider rows for any auxiliary-task picker (vision, compression, …). Honours
|
| 209 |
+
``excluded_providers``; exhausted-pool providers stay visible (``for_picker``); only the active
|
| 210 |
+
custom endpoint is probed. ``moa`` is excluded: auxiliary_client unwraps it to its aggregator
|
| 211 |
+
anyway, so offering it would be a choice silently rewritten.
|
| 212 |
+
|
| 213 |
+
Aux pickers kept re-deriving their own kwargs and each one silently dropped a different slice of the
|
| 214 |
+
user's configuration. Two independent contributor PRs landed against the same two call sites for exactly
|
| 215 |
+
this: 52642 (user ``providers:`` / ``custom_providers:`` entries never appeared) and #66624 (providers
|
| 216 |
+
with an exhausted credential pool were hidden). Both were per-site kwarg patches, so the next aux picker
|
| 217 |
+
would have reintroduced the same gap. Routing through one function makes the correct behaviour the
|
| 218 |
+
default that a new caller cannot forget:
|
| 219 |
+
"""
|
| 220 |
+
ctx = load_picker_context().with_overrides(
|
| 221 |
+
current_provider=current_provider, current_model=current_model, current_base_url=current_base_url,
|
| 222 |
+
)
|
| 223 |
+
rows = build_models_payload(
|
| 224 |
+
ctx, for_picker=True, probe_custom_providers=False, probe_current_custom_provider=True,
|
| 225 |
+
max_models=max_models,
|
| 226 |
+
)["providers"]
|
| 227 |
+
return _without_slug(rows, "moa")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def format_aux_picker_entries(
|
| 231 |
+
rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
|
| 232 |
+
) -> list[tuple[str, str, list[str]]]:
|
| 233 |
+
"""Render aux-picker rows as ``(slug, label, models)``. A raw ``base_url`` custom endpoint is
|
| 234 |
+
"current" only through that URL, never a slug — so with ``current_base_url`` set no row is marked."""
|
| 235 |
+
entries: list[tuple[str, str, list[str]]] = []
|
| 236 |
+
current_slug = str(current_provider or "").strip().lower()
|
| 237 |
+
has_base_url = bool(str(current_base_url or "").strip())
|
| 238 |
+
for row in rows:
|
| 239 |
+
slug = str(row.get("slug") or "")
|
| 240 |
+
name = row.get("name") or slug
|
| 241 |
+
total = row.get("total_models") or len(row.get("models") or [])
|
| 242 |
+
model_hint = f" — {total} models" if total else ""
|
| 243 |
+
marker = " ← current" if slug.lower() == current_slug and current_slug and not has_base_url else ""
|
| 244 |
+
entries.append((slug, f"{name}{model_hint}{marker}", list(row.get("models") or [])))
|
| 245 |
+
return entries
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _reasoning_catalog_reader(slug: str):
|
| 249 |
+
"""Per-model reasoning-capability reader for aggregators that publish one. Cache-only — the picker
|
| 250 |
+
must never block on HTTP; a cold cache warms in the background and reports no restriction until then."""
|
| 251 |
+
try:
|
| 252 |
+
from hermes_cli.models_reasoning_caps import (
|
| 253 |
+
nous_model_reasoning_capabilities,
|
| 254 |
+
openrouter_model_reasoning_capabilities,
|
| 255 |
+
warm_nous_reasoning_caps_async,
|
| 256 |
+
warm_openrouter_reasoning_caps_async,
|
| 257 |
+
)
|
| 258 |
+
except Exception:
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
readers = {
|
| 262 |
+
"nous": (warm_nous_reasoning_caps_async, nous_model_reasoning_capabilities),
|
| 263 |
+
"openrouter": (warm_openrouter_reasoning_caps_async, openrouter_model_reasoning_capabilities),
|
| 264 |
+
}
|
| 265 |
+
if slug not in readers:
|
| 266 |
+
return None
|
| 267 |
+
warm, read = readers[slug]
|
| 268 |
+
warm()
|
| 269 |
+
return read
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _apply_capabilities(rows: list[dict]) -> None:
|
| 273 |
+
"""Attach ``{model: {fast, reasoning, ...}}`` per row. ``reasoning`` defaults True when the catalog is
|
| 274 |
+
silent (the dial is a no-op on models that ignore it; hiding it from a capable model is worse). A
|
| 275 |
+
serving aggregator's detail overrides models.dev (adds ``can_disable_reasoning``). ``supported_efforts``
|
| 276 |
+
is deliberately NOT forwarded — it under-reports levels that work."""
|
| 277 |
+
from hermes_cli.models import model_supports_fast_mode
|
| 278 |
+
|
| 279 |
+
try:
|
| 280 |
+
from agent.models_dev import get_model_capabilities
|
| 281 |
+
except Exception:
|
| 282 |
+
get_model_capabilities = None # type: ignore[assignment]
|
| 283 |
+
|
| 284 |
+
for row in rows:
|
| 285 |
+
slug = row.get("slug") or ""
|
| 286 |
+
caps: dict[str, dict[str, Any]] = {}
|
| 287 |
+
read_reasoning_catalog = _reasoning_catalog_reader(slug.lower())
|
| 288 |
+
|
| 289 |
+
for model in row.get("models") or []:
|
| 290 |
+
reasoning = True
|
| 291 |
+
if get_model_capabilities is not None and slug:
|
| 292 |
+
try:
|
| 293 |
+
meta = get_model_capabilities(slug, model)
|
| 294 |
+
if meta is not None:
|
| 295 |
+
reasoning = bool(meta.supports_reasoning)
|
| 296 |
+
except Exception:
|
| 297 |
+
reasoning = True
|
| 298 |
+
|
| 299 |
+
entry: dict[str, Any] = {"fast": bool(model_supports_fast_mode(model)), "reasoning": reasoning}
|
| 300 |
+
|
| 301 |
+
if reasoning and read_reasoning_catalog is not None:
|
| 302 |
+
try:
|
| 303 |
+
detail = read_reasoning_catalog(model)
|
| 304 |
+
except Exception:
|
| 305 |
+
detail = None
|
| 306 |
+
if detail and not detail.get("supports_reasoning"):
|
| 307 |
+
# Aggregator catalog beats models.dev for a route it serves: no reasoning param
|
| 308 |
+
# means no reasoning controls, so no disable to describe either.
|
| 309 |
+
entry["reasoning"] = False
|
| 310 |
+
elif detail:
|
| 311 |
+
entry["can_disable_reasoning"] = not detail.get("mandatory")
|
| 312 |
+
|
| 313 |
+
caps[model] = entry
|
| 314 |
+
|
| 315 |
+
row["capabilities"] = caps
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# Newest N models per lab an aggregator row features by default (older tail behind search/show-all);
|
| 319 |
+
# 5 keeps a lab's headliners without letting a prolific vendor flood the view.
|
| 320 |
+
_FEATURED_PER_LAB = 5
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _apply_featured(rows: list[dict]) -> None:
|
| 324 |
+
"""Attach a ``featured_models`` shortlist to each aggregator row: newest ``_FEATURED_PER_LAB`` per
|
| 325 |
+
vendor by models.dev ``release_date`` (ranked within the row, never vs. today, so it is stable);
|
| 326 |
+
ties keep curated order. Non-aggregators get an empty list and keep top-N behaviour."""
|
| 327 |
+
try:
|
| 328 |
+
from agent.models_dev import get_model_info
|
| 329 |
+
except Exception:
|
| 330 |
+
get_model_info = None # type: ignore[assignment]
|
| 331 |
+
|
| 332 |
+
for row in rows:
|
| 333 |
+
slug = str(row.get("slug") or "").strip().lower()
|
| 334 |
+
models = row.get("models") or []
|
| 335 |
+
|
| 336 |
+
by_lab: dict[str, list[tuple[int, str, str]]] = {} # only multi-lab aggregators get a shortlist
|
| 337 |
+
for pos, model in enumerate(models):
|
| 338 |
+
lab = model.split("/", 1)[0] if "/" in model else ""
|
| 339 |
+
if not lab: # no vendor prefix → single-namespace provider, not an aggregator
|
| 340 |
+
by_lab = {}
|
| 341 |
+
break
|
| 342 |
+
date = ""
|
| 343 |
+
if get_model_info is not None:
|
| 344 |
+
info = get_model_info(slug, model) or get_model_info("openrouter", model)
|
| 345 |
+
date = getattr(info, "release_date", "") if info else ""
|
| 346 |
+
by_lab.setdefault(lab, []).append((pos, date, model))
|
| 347 |
+
|
| 348 |
+
if len(by_lab) < 2:
|
| 349 |
+
row["featured_models"] = []
|
| 350 |
+
continue
|
| 351 |
+
|
| 352 |
+
featured: list[str] = []
|
| 353 |
+
for entries in by_lab.values():
|
| 354 |
+
# Newest release_date first; earlier list position breaks ties (sole key when undated).
|
| 355 |
+
ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True)
|
| 356 |
+
featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB])
|
| 357 |
+
order = {m: i for i, m in enumerate(models)} # keep the row's model order for stable rendering
|
| 358 |
+
row["featured_models"] = sorted(featured, key=lambda m: order[m])
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _apply_custom_aliases(rows: list[dict]) -> None:
|
| 362 |
+
"""Attach the accepted identity set to each user-defined row: ``model.options`` reports the canonical
|
| 363 |
+
``custom:<key>`` while rows carry the bare key as ``slug``, so GUI exact-match never finds the row.
|
| 364 |
+
|
| 365 |
+
GUI pickers compare the two to decide which row is active; exact equality never matches for custom
|
| 366 |
+
providers (#87035). Exposing ``aliases`` — every current and legacy spelling from
|
| 367 |
+
:func:`hermes_cli.providers.custom_provider_aliases` — lets the frontend do a membership check instead.
|
| 368 |
+
"""
|
| 369 |
+
from hermes_cli.providers import custom_provider_aliases
|
| 370 |
+
|
| 371 |
+
for row in rows:
|
| 372 |
+
if not row.get("is_user_defined"):
|
| 373 |
+
continue
|
| 374 |
+
try:
|
| 375 |
+
row["aliases"] = sorted(
|
| 376 |
+
custom_provider_aliases(str(row.get("name", "")), str(row.get("slug", ""))))
|
| 377 |
+
except Exception:
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# ─── Internal: row post-processing ──────────────────────────────────────
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _provider_auth_hint(slug: str) -> tuple[str, str]:
|
| 385 |
+
"""``(auth_type, key_env)`` for a canonical provider (``("api_key", "")`` when unregistered)."""
|
| 386 |
+
from hermes_cli.auth import PROVIDER_REGISTRY
|
| 387 |
+
|
| 388 |
+
cfg = PROVIDER_REGISTRY.get(slug)
|
| 389 |
+
auth_type = cfg.auth_type if cfg else "api_key"
|
| 390 |
+
key_env = cfg.api_key_env_vars[0] if (cfg and cfg.api_key_env_vars) else ""
|
| 391 |
+
return auth_type, key_env
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _row(slug: str, name: str, is_current: bool, **extra: Any) -> dict:
|
| 395 |
+
return {"slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, **extra}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _canonical_row(entry, cur: str, **extra: Any) -> dict:
|
| 399 |
+
from hermes_cli.models import _PROVIDER_LABELS
|
| 400 |
+
|
| 401 |
+
return _row(entry.slug, _PROVIDER_LABELS.get(entry.slug, entry.label), entry.slug.lower() == cur, **extra)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _append_unconfigured_rows(
|
| 405 |
+
rows: list[dict], ctx: ConfigContext, *, current_only: bool = False,
|
| 406 |
+
) -> list[dict]:
|
| 407 |
+
"""Empty setup skeletons for canonical providers missing from ``rows`` — except the *current* one:
|
| 408 |
+
if config.yaml still points at it but credentials are gone, keep a row carrying the saved model so
|
| 409 |
+
GUI pickers don't silently snap to another provider."""
|
| 410 |
+
from hermes_cli.models import CANONICAL_PROVIDERS, _model_requires_account_discovery
|
| 411 |
+
|
| 412 |
+
seen = {r["slug"].lower() for r in rows}
|
| 413 |
+
cur = (ctx.current_provider or "").lower()
|
| 414 |
+
cur_model = str(ctx.current_model or "").strip()
|
| 415 |
+
extras: list[dict] = []
|
| 416 |
+
for entry in CANONICAL_PROVIDERS:
|
| 417 |
+
if entry.slug.lower() in seen:
|
| 418 |
+
continue
|
| 419 |
+
if current_only and entry.slug.lower() != cur:
|
| 420 |
+
continue
|
| 421 |
+
if entry.slug.lower() == cur:
|
| 422 |
+
saved_model = "" if _model_requires_account_discovery(entry.slug, cur_model) else cur_model
|
| 423 |
+
auth_type, key_env = _provider_auth_hint(entry.slug)
|
| 424 |
+
tail = (
|
| 425 |
+
"Astra requires successful account-scoped model discovery."
|
| 426 |
+
if cur_model and not saved_model else "Showing the saved model only."
|
| 427 |
+
)
|
| 428 |
+
warning = (
|
| 429 |
+
f"Configured provider missing usable credentials; paste {key_env} to reactivate. {tail}"
|
| 430 |
+
if auth_type == "api_key" and key_env
|
| 431 |
+
else f"Configured provider is not authenticated; run `hermes model` to reactivate. {tail}"
|
| 432 |
+
)
|
| 433 |
+
extras.append(_canonical_row(
|
| 434 |
+
entry, cur, models=[saved_model] if saved_model else [], total_models=1 if saved_model else 0,
|
| 435 |
+
source="configured-current", authenticated=False, auth_type=auth_type, key_env=key_env,
|
| 436 |
+
warning=warning,
|
| 437 |
+
))
|
| 438 |
+
continue
|
| 439 |
+
extras.append(_canonical_row(entry, cur, models=[], total_models=0, source="canonical"))
|
| 440 |
+
return extras
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _anthropic_oauth_credentials_present() -> bool:
|
| 444 |
+
"""True when the user explicitly authenticated Anthropic via OAuth (Hermes device flow or Claude Code
|
| 445 |
+
login) — those leave no trace in active_provider / model.provider / API-key env vars."""
|
| 446 |
+
try:
|
| 447 |
+
from agent.anthropic_credentials import read_claude_code_credentials, read_hermes_oauth_credentials
|
| 448 |
+
|
| 449 |
+
readers = (read_hermes_oauth_credentials, read_claude_code_credentials)
|
| 450 |
+
if any((read() or {}).get("accessToken") for read in readers):
|
| 451 |
+
return True
|
| 452 |
+
except Exception:
|
| 453 |
+
return False
|
| 454 |
+
# Pool-only OAuth entries (auth.json credential_pool.anthropic) are equally deliberate — discovery
|
| 455 |
+
# accepts them via pool.has_credentials(), so the filter must too or those rows are built then
|
| 456 |
+
# silently dropped. Read-only (no load_pool) so a picker open never mutates auth.json.
|
| 457 |
+
try:
|
| 458 |
+
from agent.credential_pool import AUTH_TYPE_OAUTH
|
| 459 |
+
from hermes_cli.auth import read_credential_pool
|
| 460 |
+
|
| 461 |
+
for entry in read_credential_pool("anthropic"):
|
| 462 |
+
if (isinstance(entry, dict) and entry.get("auth_type") == AUTH_TYPE_OAUTH
|
| 463 |
+
and str(entry.get("access_token") or "").strip()):
|
| 464 |
+
return True
|
| 465 |
+
except Exception:
|
| 466 |
+
pass
|
| 467 |
+
return False
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
|
| 471 |
+
"""Keep only rows backed by explicit user configuration — ``list_authenticated_providers`` also
|
| 472 |
+
discovers ambient credentials (e.g. GitHub CLI -> Copilot) Desktop chat pickers must not show."""
|
| 473 |
+
from hermes_cli.auth import is_provider_explicitly_configured
|
| 474 |
+
|
| 475 |
+
current_slug = str(ctx.current_provider or "").strip().lower()
|
| 476 |
+
|
| 477 |
+
def _is_explicit(row: dict, slug: str) -> bool:
|
| 478 |
+
# Managed local models are explicit configuration by existence (gigabytes downloaded into the
|
| 479 |
+
# machine-scoped dir); there is deliberately no config credential, so without the source clause
|
| 480 |
+
# the row would only survive on the profile where Use was last clicked.
|
| 481 |
+
if (row.get("is_user_defined") or (current_slug and slug == current_slug)
|
| 482 |
+
or row.get("source") == "local-runtime"):
|
| 483 |
+
return True
|
| 484 |
+
if slug == "moa":
|
| 485 |
+
# Virtual routing mode, not a configured provider: hide unless current (above) or the user
|
| 486 |
+
# wrote an enabled preset into RAW config (the DEFAULT_CONFIG preset must not show MoA).
|
| 487 |
+
return _raw_config_has_enabled_moa_preset()
|
| 488 |
+
return (
|
| 489 |
+
_provider_is_keyless(slug) # zero-setup providers need no configuration at all
|
| 490 |
+
# Anthropic OAuth (device flow / Claude Code) and external-process CLIs (copilot-acp) are
|
| 491 |
+
# deliberate sign-ins that leave no trace in config/env; keep the rows discovery accepted.
|
| 492 |
+
or (slug == "anthropic" and _anthropic_oauth_credentials_present())
|
| 493 |
+
or _external_process_signed_in(slug)
|
| 494 |
+
or is_provider_explicitly_configured(slug)
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
return [row for row in rows
|
| 498 |
+
if (slug := str(row.get("slug", "")).strip().lower()) and _is_explicit(row, slug)]
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _external_process_signed_in(slug: str) -> bool:
|
| 502 |
+
"""True when an external-process provider has verified CLI credentials."""
|
| 503 |
+
try:
|
| 504 |
+
from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status
|
| 505 |
+
pconfig = PROVIDER_REGISTRY.get(slug)
|
| 506 |
+
return bool(pconfig and pconfig.auth_type == "external_process"
|
| 507 |
+
and get_external_process_provider_status(slug).get("auth_verified"))
|
| 508 |
+
except Exception:
|
| 509 |
+
return False
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def _provider_is_keyless(slug: str) -> bool:
|
| 513 |
+
"""True when the provider's Hermes overlay declares it keyless."""
|
| 514 |
+
try:
|
| 515 |
+
from hermes_cli.providers import HERMES_OVERLAYS
|
| 516 |
+
overlay = HERMES_OVERLAYS.get(slug)
|
| 517 |
+
return bool(overlay is not None and getattr(overlay, "keyless", False))
|
| 518 |
+
except Exception:
|
| 519 |
+
return False
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def _raw_config_has_enabled_moa_preset() -> bool:
|
| 523 |
+
"""True when the user's RAW config enables MoA: ``load_config()`` merges the DEFAULT_CONFIG preset for
|
| 524 |
+
everyone, which is not a user choice; visible once one enabled preset (or legacy flat config) is saved."""
|
| 525 |
+
try:
|
| 526 |
+
from hermes_cli.config import read_raw_config
|
| 527 |
+
|
| 528 |
+
raw = read_raw_config()
|
| 529 |
+
except Exception:
|
| 530 |
+
return False
|
| 531 |
+
|
| 532 |
+
moa = raw.get("moa") if isinstance(raw, dict) else None
|
| 533 |
+
if not isinstance(moa, dict):
|
| 534 |
+
return False
|
| 535 |
+
|
| 536 |
+
presets = moa.get("presets")
|
| 537 |
+
if isinstance(presets, dict):
|
| 538 |
+
return any(
|
| 539 |
+
not isinstance(preset, dict) or preset.get("enabled", True)
|
| 540 |
+
for name, preset in presets.items() if str(name or "").strip()
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
legacy_keys = {"reference_models", "aggregator", "reference_temperature", "aggregator_temperature",
|
| 544 |
+
"max_tokens", "reference_max_tokens", "fanout"}
|
| 545 |
+
return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True))
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def _apply_picker_hints(rows: list[dict]) -> None:
|
| 549 |
+
"""Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row."""
|
| 550 |
+
for row in rows:
|
| 551 |
+
if "authenticated" in row:
|
| 552 |
+
continue
|
| 553 |
+
# Skeleton rows (_append_unconfigured_rows) have empty `models` AND source="canonical".
|
| 554 |
+
is_skeleton = row.get("source") == "canonical" and not row.get("models")
|
| 555 |
+
row["authenticated"] = not is_skeleton
|
| 556 |
+
if not is_skeleton or row.get("is_user_defined"):
|
| 557 |
+
continue
|
| 558 |
+
auth_type, key_env = _provider_auth_hint(row["slug"])
|
| 559 |
+
row["auth_type"] = auth_type
|
| 560 |
+
row["key_env"] = key_env
|
| 561 |
+
row["warning"] = (f"paste {key_env} to activate" if auth_type == "api_key" and key_env
|
| 562 |
+
else f"run `hermes model` to configure ({auth_type})")
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
| 566 |
+
"""Canonical slugs in ``CANONICAL_PROVIDERS`` order, truly-custom rows last. Keys on slug membership,
|
| 567 |
+
NOT ``is_user_defined`` — ``providers:`` config rows carry that flag even for canonical slugs."""
|
| 568 |
+
from hermes_cli.models import CANONICAL_PROVIDERS
|
| 569 |
+
|
| 570 |
+
order = {e.slug: i for i, e in enumerate(CANONICAL_PROVIDERS)}
|
| 571 |
+
canon = sorted((r for r in rows if r["slug"] in order), key=lambda r: order[r["slug"]])
|
| 572 |
+
extras = [r for r in rows if r["slug"] not in order]
|
| 573 |
+
return canon + extras
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cached_only: bool = False) -> None:
|
| 577 |
+
"""Set ``row["pricing"] = {model_id: {input, output, cache | None, free}}``; for Nous also
|
| 578 |
+
``free_tier`` (account is free-tier) and ``unavailable_models`` (paid models a free user can't pick).
|
| 579 |
+
``cached_only`` never hits the network: unknown Nous entitlement fails closed (``free_tier_pending``,
|
| 580 |
+
all models locked) and missing pricing is marked ``pricing_pending``."""
|
| 581 |
+
from hermes_cli.models_pricing import (
|
| 582 |
+
_format_price_per_mtok,
|
| 583 |
+
compute_sale_discount,
|
| 584 |
+
get_pricing_for_provider,
|
| 585 |
+
)
|
| 586 |
+
from hermes_cli.models import (
|
| 587 |
+
check_nous_free_tier,
|
| 588 |
+
get_cached_nous_free_tier,
|
| 589 |
+
partition_nous_models_by_tier,
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
nous_free_tier: Optional[bool] = None # resolved once (cached in models.py for the TTL window)
|
| 593 |
+
|
| 594 |
+
for row in rows:
|
| 595 |
+
slug = str(row.get("slug", "")).lower()
|
| 596 |
+
models = row.get("models") or []
|
| 597 |
+
if not models:
|
| 598 |
+
continue
|
| 599 |
+
if row.get("free_tier_row"):
|
| 600 |
+
# The free tier's one model has no Portal pricing and no entitlement to read: pricing
|
| 601 |
+
# it would lock the only row a free-tier install can select.
|
| 602 |
+
continue
|
| 603 |
+
try:
|
| 604 |
+
pricing_kwargs = {"cached_only": True} if cached_only else {}
|
| 605 |
+
raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {}
|
| 606 |
+
except Exception:
|
| 607 |
+
raw_pricing = {}
|
| 608 |
+
cached_nous_tier: Optional[bool] = None
|
| 609 |
+
if slug == "nous" and cached_only:
|
| 610 |
+
cached_nous_tier = get_cached_nous_free_tier()
|
| 611 |
+
if cached_nous_tier is None:
|
| 612 |
+
# Entitlement unknown: stay nonblocking but fail closed until the prewarm has populated
|
| 613 |
+
# both caches, else a free account could briefly select paid models on first open.
|
| 614 |
+
row["free_tier_pending"] = True
|
| 615 |
+
row["unavailable_models"] = list(models)
|
| 616 |
+
if not row.get("warning"): # say why every model renders locked
|
| 617 |
+
row["warning"] = ("Checking Nous plan entitlement… models unlock on the "
|
| 618 |
+
"next picker open or refresh.")
|
| 619 |
+
continue
|
| 620 |
+
if not raw_pricing:
|
| 621 |
+
if slug == "nous":
|
| 622 |
+
row["free_tier"] = bool(cached_nous_tier)
|
| 623 |
+
row["pricing_pending"] = True
|
| 624 |
+
row["unavailable_models"] = list(models) if cached_nous_tier else []
|
| 625 |
+
continue
|
| 626 |
+
|
| 627 |
+
formatted: dict[str, dict] = {}
|
| 628 |
+
for mid in models:
|
| 629 |
+
p = raw_pricing.get(mid)
|
| 630 |
+
if not p:
|
| 631 |
+
continue
|
| 632 |
+
inp_raw, out_raw = p.get("prompt", ""), p.get("completion", "")
|
| 633 |
+
cache_raw = p.get("input_cache_read", "")
|
| 634 |
+
inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
|
| 635 |
+
out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
|
| 636 |
+
entry: dict = {
|
| 637 |
+
"input": inp, "output": out,
|
| 638 |
+
"cache": _format_price_per_mtok(cache_raw) if cache_raw else None,
|
| 639 |
+
"free": inp == "free" and out in ("free", ""), # both input and output cost nothing
|
| 640 |
+
}
|
| 641 |
+
# Sale chrome is Nous Portal-only (other catalogs' nested pricing.original is ignored); free
|
| 642 |
+
# models get flat -100% chrome, was_* only when the gateway served an original.
|
| 643 |
+
if slug == "nous":
|
| 644 |
+
sale = compute_sale_discount(inp_raw, out_raw, p.get("original"))
|
| 645 |
+
if sale is not None:
|
| 646 |
+
discount_percent, was_prompt_raw, was_out_raw = sale
|
| 647 |
+
entry["discount_percent"] = discount_percent
|
| 648 |
+
for key, was_raw in (("was_input", was_prompt_raw), ("was_output", was_out_raw)):
|
| 649 |
+
if was_raw != "":
|
| 650 |
+
entry[key] = _format_price_per_mtok(was_raw)
|
| 651 |
+
formatted[mid] = entry
|
| 652 |
+
|
| 653 |
+
if formatted:
|
| 654 |
+
row["pricing"] = formatted
|
| 655 |
+
|
| 656 |
+
if slug == "nous":
|
| 657 |
+
try:
|
| 658 |
+
if nous_free_tier is None:
|
| 659 |
+
nous_free_tier = (cached_nous_tier if cached_only
|
| 660 |
+
else check_nous_free_tier(force_fresh=force_fresh_nous_tier))
|
| 661 |
+
row["free_tier"] = bool(nous_free_tier)
|
| 662 |
+
row["unavailable_models"] = (
|
| 663 |
+
partition_nous_models_by_tier(list(models), raw_pricing, free_tier=True)[1]
|
| 664 |
+
if nous_free_tier else [])
|
| 665 |
+
except Exception: # tier detection failed — fail open (no gating)
|
| 666 |
+
row["free_tier"] = False
|
| 667 |
+
row["unavailable_models"] = []
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def _local_runtime_row(ctx: "ConfigContext") -> dict | None:
|
| 671 |
+
"""The ``llamacpp`` row from staged GGUFs (``None`` when none) — downloaded models must be selectable
|
| 672 |
+
before the server runs (selection starts it via the runtime_provider seam)."""
|
| 673 |
+
try:
|
| 674 |
+
from hermes_cli.local_runtime.bootstrap import staged_model_ids
|
| 675 |
+
|
| 676 |
+
staged = staged_model_ids()
|
| 677 |
+
if not staged:
|
| 678 |
+
return None
|
| 679 |
+
current = (ctx.current_provider or "").strip().lower() in ("llamacpp", "llama.cpp", "llama-cpp")
|
| 680 |
+
if not current:
|
| 681 |
+
# A LIVE session on the managed server reports provider "custom" with the managed base_url;
|
| 682 |
+
# match on the endpoint so the session being chatted in still shows a selection.
|
| 683 |
+
try:
|
| 684 |
+
from hermes_cli.local_runtime.endpoint import _state_endpoint
|
| 685 |
+
|
| 686 |
+
managed = _state_endpoint()
|
| 687 |
+
current = bool(managed and (ctx.current_base_url or "").strip().rstrip("/")
|
| 688 |
+
== managed["base_url"].rstrip("/"))
|
| 689 |
+
except Exception:
|
| 690 |
+
current = False
|
| 691 |
+
# Bare "Local" user-facing (engine name is an implementation detail); authenticated = reachability.
|
| 692 |
+
return _row("llamacpp", "Local", current, models=staged, total_models=len(staged),
|
| 693 |
+
source="local-runtime", authenticated=True, auth_type="local", warning=None)
|
| 694 |
+
except Exception:
|
| 695 |
+
return None
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def _prewarm_pricing_async(
|
| 699 |
+
rows: list[dict], *, current_provider: str = "", current_base_url: str = "",
|
| 700 |
+
) -> Optional[Thread]:
|
| 701 |
+
"""Warm picker pricing caches without delaying the current payload (one worker per
|
| 702 |
+
profile + endpoint scope; a live worker is reused)."""
|
| 703 |
+
from hermes_constants import hermes_home_key
|
| 704 |
+
from hermes_cli.models_pricing import pricing_cache_scope
|
| 705 |
+
|
| 706 |
+
slugs = {str(row.get("slug") or "").lower() for row in rows if row.get("slug")}
|
| 707 |
+
endpoint_scope = tuple(sorted(
|
| 708 |
+
(slug, pricing_cache_scope(slug, current_provider=current_provider, current_base_url=current_base_url))
|
| 709 |
+
for slug in slugs))
|
| 710 |
+
prewarm_key = (hermes_home_key(), endpoint_scope)
|
| 711 |
+
|
| 712 |
+
with _pricing_prewarm_lock:
|
| 713 |
+
current = _pricing_prewarm_threads.get(prewarm_key)
|
| 714 |
+
if current is not None and current.is_alive():
|
| 715 |
+
return current
|
| 716 |
+
# The worker mutates only private copies; the pricing helpers populate shared process caches.
|
| 717 |
+
worker_rows = [{**row, "models": list(row.get("models") or [])} for row in rows]
|
| 718 |
+
|
| 719 |
+
def _worker() -> None:
|
| 720 |
+
try:
|
| 721 |
+
_apply_pricing(worker_rows)
|
| 722 |
+
finally:
|
| 723 |
+
with _pricing_prewarm_lock:
|
| 724 |
+
if _pricing_prewarm_threads.get(prewarm_key) is current_thread():
|
| 725 |
+
_pricing_prewarm_threads.pop(prewarm_key, None)
|
| 726 |
+
|
| 727 |
+
thread = Thread(target=copy_context().run, args=(_worker,),
|
| 728 |
+
name="hermes-picker-pricing-prewarm", daemon=True)
|
| 729 |
+
_pricing_prewarm_threads[prewarm_key] = thread
|
| 730 |
+
thread.start()
|
| 731 |
+
return thread
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _moa_provider_row(current_provider: str = "") -> dict | None:
|
| 735 |
+
"""The virtual ``moa`` row shared by the CLI inventory and gateway picker; ``None`` without presets."""
|
| 736 |
+
try:
|
| 737 |
+
from hermes_cli.config import load_config
|
| 738 |
+
from hermes_cli.moa_config import normalize_moa_config
|
| 739 |
+
|
| 740 |
+
cfg = normalize_moa_config(load_config().get("moa") or {})
|
| 741 |
+
models = list(cfg.get("presets", {}).keys())
|
| 742 |
+
if not models:
|
| 743 |
+
return None
|
| 744 |
+
return _row(
|
| 745 |
+
"moa", "Mixture of Agents", (current_provider or "").lower() == "moa", models=models,
|
| 746 |
+
total_models=len(models), source="virtual", authenticated=True, auth_type="virtual",
|
| 747 |
+
warning="Aggregator acts as the selected model; references provide analysis before each call.")
|
| 748 |
+
except Exception:
|
| 749 |
+
return None
|
hermes_cli/kanban_db_dispatch.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
hermes_cli/kanban_pr_acceptance_store.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persist acceptance with the same ownership snapshot as the terminal write."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hermes_cli.kanban_db_connect import write_txn
|
| 5 |
+
from hermes_cli.kanban_pr_acceptance import _PR, collect_acceptance
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _snapshot(conn, task_id):
|
| 9 |
+
row = conn.execute("SELECT current_run_id, status, completion_contract FROM tasks WHERE id=?", (task_id,)).fetchone()
|
| 10 |
+
return tuple(row) if row else None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def prepare_acceptance(conn, task_id, expected_run_id, metadata):
|
| 14 |
+
snapshot = _snapshot(conn, task_id)
|
| 15 |
+
if snapshot is None:
|
| 16 |
+
return False
|
| 17 |
+
run_id, status, contract = snapshot
|
| 18 |
+
if not contract or contract == "local-only":
|
| 19 |
+
return None
|
| 20 |
+
if status not in {"running", "ready", "blocked", "review"} or (expected_run_id is not None and run_id != expected_run_id):
|
| 21 |
+
return False
|
| 22 |
+
published_pr = metadata.get("published_pr") if isinstance(metadata, dict) else None
|
| 23 |
+
match = _PR.fullmatch(published_pr) if isinstance(published_pr, str) else None
|
| 24 |
+
# Publication binds once. Retrying cannot replace the task's PR with a green sibling.
|
| 25 |
+
if match and contract == match[1]:
|
| 26 |
+
with write_txn(conn):
|
| 27 |
+
if _snapshot(conn, task_id) != snapshot:
|
| 28 |
+
return False
|
| 29 |
+
conn.execute("UPDATE tasks SET completion_contract=? WHERE id=?", (published_pr, task_id))
|
| 30 |
+
snapshot = (run_id, status, published_pr)
|
| 31 |
+
contract = published_pr
|
| 32 |
+
return snapshot, collect_acceptance(contract, published_pr)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def record_acceptance(conn, task_id, acceptance):
|
| 36 |
+
"""Called under complete_task's write_txn, before its terminal UPDATE."""
|
| 37 |
+
from hermes_cli.kanban_db import _append_event
|
| 38 |
+
snapshot, receipt = acceptance
|
| 39 |
+
if _snapshot(conn, task_id) != snapshot:
|
| 40 |
+
return False
|
| 41 |
+
_append_event(conn, task_id, "pr_acceptance", receipt, run_id=snapshot[0])
|
| 42 |
+
if not receipt["ok"]:
|
| 43 |
+
detail = f"PR acceptance {receipt['classification']}: {receipt.get('detail', '')} {receipt['recovery']}"
|
| 44 |
+
conn.execute("UPDATE tasks SET last_failure_error=? WHERE id=?", (detail, task_id))
|
| 45 |
+
return receipt["ok"]
|
hermes_cli/main_install_repair.py
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification.
|
| 2 |
+
|
| 3 |
+
Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...)
|
| 4 |
+
are imported lazily inside the functions that use them (avoids an import cycle).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import contextlib
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import shlex
|
| 11 |
+
import shutil
|
| 12 |
+
import subprocess
|
| 13 |
+
import sys
|
| 14 |
+
import threading
|
| 15 |
+
import time as _time
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from hermes_cli import _early_recovery as _early_recovery_mod
|
| 19 |
+
|
| 20 |
+
# Log-record parity with the origin module.
|
| 21 |
+
logger = logging.getLogger("hermes_cli.main")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _pyproject_project(debug_fmt: str | None = None) -> dict | None:
|
| 25 |
+
"""``[project]`` table of pyproject.toml, or ``None`` when absent/unreadable."""
|
| 26 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 27 |
+
pyproject = PROJECT_ROOT / "pyproject.toml"
|
| 28 |
+
if not pyproject.is_file():
|
| 29 |
+
return None
|
| 30 |
+
try:
|
| 31 |
+
import tomllib
|
| 32 |
+
with pyproject.open("rb") as handle:
|
| 33 |
+
project = tomllib.load(handle).get("project", {})
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
if debug_fmt:
|
| 36 |
+
logger.debug(debug_fmt, exc)
|
| 37 |
+
return None
|
| 38 |
+
return project if isinstance(project, dict) else None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _naive_requirement(spec: str) -> tuple[str, str]:
|
| 42 |
+
"""``(name, head)`` of a ``name OP version ; marker`` spec without ``packaging``."""
|
| 43 |
+
head = spec.split(";", 1)[0].strip()
|
| 44 |
+
bare = head
|
| 45 |
+
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
|
| 46 |
+
if op in bare:
|
| 47 |
+
bare = bare.split(op, 1)[0]
|
| 48 |
+
break
|
| 49 |
+
return bare.strip().split("[", 1)[0].strip(), head
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _parse_requirements(raw_deps: list[str]) -> list[tuple[str, "object | None", str]]:
|
| 53 |
+
"""``(name, marker, head)`` per dep spec — ``packaging`` when importable, else a naive split."""
|
| 54 |
+
parsed: list[tuple[str, "object | None", str]] = []
|
| 55 |
+
try:
|
| 56 |
+
from packaging.requirements import Requirement # type: ignore
|
| 57 |
+
for spec in raw_deps:
|
| 58 |
+
try:
|
| 59 |
+
req = Requirement(spec)
|
| 60 |
+
except Exception:
|
| 61 |
+
continue
|
| 62 |
+
parsed.append((req.name, req.marker, spec.split(";", 1)[0].strip()))
|
| 63 |
+
except Exception:
|
| 64 |
+
for spec in raw_deps:
|
| 65 |
+
name, head = _naive_requirement(spec)
|
| 66 |
+
if name:
|
| 67 |
+
parsed.append((name, None, head))
|
| 68 |
+
return parsed
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _load_installable_optional_extras(group: str = "all") -> list[str]:
|
| 72 |
+
"""Return optional extras referenced by a dependency group (``all`` or ``termux-all``)."""
|
| 73 |
+
optional_deps = (_pyproject_project() or {}).get("optional-dependencies", {})
|
| 74 |
+
if not isinstance(optional_deps, dict):
|
| 75 |
+
return []
|
| 76 |
+
referenced: list[str] = []
|
| 77 |
+
for ref in optional_deps.get(group, []):
|
| 78 |
+
if "[" in ref and "]" in ref:
|
| 79 |
+
name = ref.split("[", 1)[1].split("]", 1)[0]
|
| 80 |
+
if name in optional_deps:
|
| 81 |
+
referenced.append(name)
|
| 82 |
+
return referenced
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME)
|
| 86 |
+
# because the venv is shared across profiles.
|
| 87 |
+
# ``.update-incomplete`` — generic core ``.[all]`` install was interrupted;
|
| 88 |
+
# cleared only after a confirmed full dependency reinstall/recovery.
|
| 89 |
+
# ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted packages;
|
| 90 |
+
# cleared only after import-probe repair confirms healthy (never on indeterminate).
|
| 91 |
+
# Narrow lazy probes must NEVER clear the generic core marker.
|
| 92 |
+
# See #58004.
|
| 93 |
+
def _update_marker_path() -> Path:
|
| 94 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 95 |
+
return PROJECT_ROOT / ".update-incomplete"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _lazy_refresh_marker_path() -> Path:
|
| 99 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 100 |
+
return PROJECT_ROOT / ".lazy-refresh-incomplete"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _pytest_owns_live_checkout(root: Path) -> bool:
|
| 104 |
+
"""True under pytest when ``root`` is this checkout: unsandboxed update/recovery tests must
|
| 105 |
+
neither litter the live repo root with breadcrumbs (false-arming the developer's next launch)
|
| 106 |
+
nor run a real reinstall against the executing venv (cf. ``managed_scope._under_pytest``)."""
|
| 107 |
+
return "PYTEST_CURRENT_TEST" in os.environ and root == Path(__file__).resolve().parent.parent
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _clear_marker_file(path: Path, *, label: str) -> None:
|
| 111 |
+
"""Remove an update-recovery breadcrumb. Never raises."""
|
| 112 |
+
try:
|
| 113 |
+
path.unlink()
|
| 114 |
+
except FileNotFoundError:
|
| 115 |
+
pass
|
| 116 |
+
except OSError as exc:
|
| 117 |
+
logger.debug("Could not clear %s marker: %s", label, exc)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _clear_update_incomplete_marker() -> None:
|
| 121 |
+
"""Remove the interrupted core-install breadcrumb. Never raises."""
|
| 122 |
+
_clear_marker_file(_update_marker_path(), label="update-incomplete")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _clear_lazy_refresh_incomplete_marker() -> None:
|
| 126 |
+
"""Remove the interrupted lazy-refresh breadcrumb. Never raises."""
|
| 127 |
+
_clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _claim_recovery_lock(lock_path: Path) -> bool:
|
| 131 |
+
"""Atomically claim the single-flight recovery lock; False when another process holds it.
|
| 132 |
+
|
| 133 |
+
A crashed holder's stale lock is broken after an hour (well past any realistic install).
|
| 134 |
+
Failing to CREATE the lock (read-only fs, perms) proceeds unlocked — the install itself
|
| 135 |
+
will surface the real problem."""
|
| 136 |
+
try:
|
| 137 |
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
| 138 |
+
os.write(fd, f"{os.getpid()}\n".encode())
|
| 139 |
+
os.close(fd)
|
| 140 |
+
except FileExistsError:
|
| 141 |
+
try:
|
| 142 |
+
if _time.time() - lock_path.stat().st_mtime > 3600:
|
| 143 |
+
lock_path.unlink()
|
| 144 |
+
except OSError:
|
| 145 |
+
pass
|
| 146 |
+
return False
|
| 147 |
+
except OSError as exc:
|
| 148 |
+
logger.debug("Could not create install-recovery lock: %s", exc)
|
| 149 |
+
return True
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@contextlib.contextmanager
|
| 153 |
+
def _stdout_to_stderr():
|
| 154 |
+
"""Route Python prints AND the fd 1 that pip/uv inherit to stderr: launches whose stdout is
|
| 155 |
+
a protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get install noise."""
|
| 156 |
+
saved_stdout_fd = None
|
| 157 |
+
saved_sys_stdout = sys.stdout
|
| 158 |
+
try:
|
| 159 |
+
saved_stdout_fd = os.dup(1)
|
| 160 |
+
os.dup2(2, 1)
|
| 161 |
+
except OSError:
|
| 162 |
+
saved_stdout_fd = None
|
| 163 |
+
sys.stdout = sys.stderr
|
| 164 |
+
try:
|
| 165 |
+
yield
|
| 166 |
+
finally:
|
| 167 |
+
sys.stdout = saved_sys_stdout
|
| 168 |
+
if saved_stdout_fd is not None:
|
| 169 |
+
try:
|
| 170 |
+
os.dup2(saved_stdout_fd, 1)
|
| 171 |
+
os.close(saved_stdout_fd)
|
| 172 |
+
except OSError:
|
| 173 |
+
pass
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _recover_from_interrupted_install() -> None:
|
| 177 |
+
"""Finish update work left half-done by a prior ``hermes update``.
|
| 178 |
+
|
| 179 |
+
``.update-incomplete`` recovers via full quarantined reinstall; ``.lazy-refresh-incomplete``
|
| 180 |
+
via package-only import probes (cleared only when probes confirm healthy/repaired). Never
|
| 181 |
+
raises: on failure it prints the manual command and leaves the marker for the next launch.
|
| 182 |
+
Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process
|
| 183 |
+
recover while the others skip.
|
| 184 |
+
"""
|
| 185 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 186 |
+
if _pytest_owns_live_checkout(PROJECT_ROOT):
|
| 187 |
+
return
|
| 188 |
+
lazy_marker = _lazy_refresh_marker_path().exists()
|
| 189 |
+
if not lazy_marker and not _update_marker_path().exists():
|
| 190 |
+
return
|
| 191 |
+
# Managed/Docker installs and git-less PyPI installs never run the source-tree
|
| 192 |
+
# update path, so a stray marker is not ours to act on. Just clear it.
|
| 193 |
+
if not (PROJECT_ROOT / "pyproject.toml").is_file():
|
| 194 |
+
_clear_update_incomplete_marker()
|
| 195 |
+
_clear_lazy_refresh_incomplete_marker()
|
| 196 |
+
return
|
| 197 |
+
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
|
| 198 |
+
if not _claim_recovery_lock(lock_path):
|
| 199 |
+
return
|
| 200 |
+
try:
|
| 201 |
+
with _stdout_to_stderr():
|
| 202 |
+
if lazy_marker:
|
| 203 |
+
_recover_lazy_refresh_marker_locked()
|
| 204 |
+
if _update_marker_path().exists():
|
| 205 |
+
_recover_core_update_marker_locked()
|
| 206 |
+
finally:
|
| 207 |
+
try:
|
| 208 |
+
lock_path.unlink()
|
| 209 |
+
except OSError:
|
| 210 |
+
pass
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _recover_lazy_refresh_marker_locked() -> None:
|
| 214 |
+
"""Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair."""
|
| 215 |
+
print(
|
| 216 |
+
"⚠ A previous lazy-backend refresh may have left the venv unhealthy — "
|
| 217 |
+
"running import-based package repair...")
|
| 218 |
+
install_prefix, install_env = _default_venv_install_target()
|
| 219 |
+
status = _repair_venv_via_import_probes(install_prefix, env=install_env)
|
| 220 |
+
if status in ("healthy", "repaired"):
|
| 221 |
+
_clear_lazy_refresh_incomplete_marker()
|
| 222 |
+
print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.")
|
| 223 |
+
return
|
| 224 |
+
indeterminate = status == "indeterminate"
|
| 225 |
+
problem = (
|
| 226 |
+
"Import probes unavailable — cannot confirm venv health." if indeterminate
|
| 227 |
+
else "Lazy-refresh package repair incomplete.")
|
| 228 |
+
print(f" ⚠ {problem} Leaving `.lazy-refresh-incomplete` for the next launch.")
|
| 229 |
+
if indeterminate:
|
| 230 |
+
return
|
| 231 |
+
print(" Recover manually with:")
|
| 232 |
+
all_specs = _lazy_refresh_repair_specs(sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values())))
|
| 233 |
+
print(
|
| 234 |
+
f" {' '.join(install_prefix)} install --force-reinstall "
|
| 235 |
+
+ " ".join(shlex.quote(s) for s in all_specs))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _recover_core_update_marker_locked() -> None:
|
| 239 |
+
"""Heal ``.update-incomplete`` via full ``.[all]`` reinstall only.
|
| 240 |
+
|
| 241 |
+
Narrow lazy-refresh import probes are not proof that a generic interrupted core
|
| 242 |
+
install finished — a missing dep outside that probe set would look healthy and
|
| 243 |
+
clear the breadcrumb too early.
|
| 244 |
+
"""
|
| 245 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 246 |
+
print(
|
| 247 |
+
"⚠ A previous `hermes update` was interrupted mid-install — "
|
| 248 |
+
"finishing dependency installation now...")
|
| 249 |
+
|
| 250 |
+
# Windows: a ``hermes.exe`` launch has the launcher as an ancestor; the quarantined full
|
| 251 |
+
# reinstall can still replace it. Package-only repair is first aid and NEVER clears the marker.
|
| 252 |
+
# Full editable reinstall uses quarantine so the live shim can still be replaced. Package-only import
|
| 253 |
+
# repair may help as first aid but must NEVER clear this core marker on its own (#58004 review).
|
| 254 |
+
self_locked = _windows_running_hermes_launcher_locked()
|
| 255 |
+
if self_locked:
|
| 256 |
+
install_prefix, install_env = _default_venv_install_target()
|
| 257 |
+
print(
|
| 258 |
+
" → Running from hermes.exe; applying package-only first aid, "
|
| 259 |
+
"then quarantined full reinstall (core marker stays until that "
|
| 260 |
+
"succeeds)...")
|
| 261 |
+
_repair_venv_via_import_probes(install_prefix, env=install_env)
|
| 262 |
+
try:
|
| 263 |
+
from hermes_cli import _install_repair as _ir
|
| 264 |
+
|
| 265 |
+
# ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
|
| 266 |
+
# cannot), so a venv whose uv vanished mid-update still heals.
|
| 267 |
+
from hermes_cli.managed_uv import ensure_uv
|
| 268 |
+
ensure_uv()
|
| 269 |
+
# Shared stdlib executor: late path and pre-import early pass run exactly the same
|
| 270 |
+
# reinstall. Its own stdout→stderr redirect nests harmlessly inside ours.
|
| 271 |
+
_ir.run_core_install(PROJECT_ROOT)
|
| 272 |
+
_clear_update_incomplete_marker()
|
| 273 |
+
print("✓ Dependency installation recovered — your install is healthy again.")
|
| 274 |
+
except Exception as exc:
|
| 275 |
+
# Leave the marker so the next launch retries; give the exact manual command.
|
| 276 |
+
logger.debug("Interrupted-install recovery failed: %s", exc)
|
| 277 |
+
print("✗ Could not auto-recover the interrupted install.")
|
| 278 |
+
manual = (
|
| 279 |
+
" Hermes is still running from the launcher that needs "
|
| 280 |
+
"replacing. Close other Hermes windows, restart from a "
|
| 281 |
+
"different terminal, then run:",
|
| 282 |
+
f' cd /d "{PROJECT_ROOT}"',
|
| 283 |
+
f' "{sys.executable}" -m pip install -e ".[all]"',
|
| 284 |
+
) if self_locked else (
|
| 285 |
+
" Recover manually with:",
|
| 286 |
+
f" cd {PROJECT_ROOT}",
|
| 287 |
+
f" {sys.executable} -m ensurepip --upgrade",
|
| 288 |
+
f" {sys.executable} -m pip install -e '.[all]'",
|
| 289 |
+
)
|
| 290 |
+
for line in manual:
|
| 291 |
+
print(line)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _norm_exe_path(path) -> str:
|
| 295 |
+
"""Case-folded resolved path, for comparing executables on Windows."""
|
| 296 |
+
try:
|
| 297 |
+
return str(Path(path).resolve()).lower()
|
| 298 |
+
except OSError:
|
| 299 |
+
return str(path).lower()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _windows_shim_in_process_chain() -> Path | None:
|
| 303 |
+
"""The venv console shim this process runs from or under, if any.
|
| 304 |
+
|
| 305 |
+
``venv\\Scripts\\hermes.exe`` holds itself open (no ``FILE_SHARE_DELETE``) for the whole
|
| 306 |
+
process lifetime, so an editable install run from one can never rewrite it. Two probes, since
|
| 307 |
+
either can come up empty: own launch paths (argv[0], ``__main__`` file/spec origin — runpy/
|
| 308 |
+
zipapp puts ``<shim>\\__main__.py`` there) and psutil ancestry. Candidates are intersected
|
| 309 |
+
with the project venv's own shims so a foreign ``hermes.exe`` never matches.
|
| 310 |
+
|
| 311 |
+
See #88838, #89599.
|
| 312 |
+
"""
|
| 313 |
+
if not _is_windows():
|
| 314 |
+
return None
|
| 315 |
+
scripts_dir = _venv_scripts_dir()
|
| 316 |
+
if scripts_dir is None:
|
| 317 |
+
return None
|
| 318 |
+
shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)}
|
| 319 |
+
if not shims:
|
| 320 |
+
return None
|
| 321 |
+
|
| 322 |
+
def _match(candidate) -> Path | None:
|
| 323 |
+
path = Path(candidate)
|
| 324 |
+
if path.name.lower() == "__main__.py":
|
| 325 |
+
path = path.parent
|
| 326 |
+
return shims.get(_norm_exe_path(path))
|
| 327 |
+
|
| 328 |
+
main_mod = sys.modules.get("__main__")
|
| 329 |
+
candidates = [*sys.argv[:1], *filter(None, (
|
| 330 |
+
getattr(main_mod, "__file__", None),
|
| 331 |
+
getattr(getattr(main_mod, "__spec__", None), "origin", None)))]
|
| 332 |
+
for candidate in candidates:
|
| 333 |
+
matched = _match(candidate)
|
| 334 |
+
if matched is not None:
|
| 335 |
+
return matched
|
| 336 |
+
|
| 337 |
+
with contextlib.suppress(Exception):
|
| 338 |
+
import psutil
|
| 339 |
+
me = psutil.Process()
|
| 340 |
+
for proc in [me] + list(me.parents()):
|
| 341 |
+
try:
|
| 342 |
+
matched = _match(proc.exe())
|
| 343 |
+
except Exception:
|
| 344 |
+
continue
|
| 345 |
+
if matched is not None:
|
| 346 |
+
return matched
|
| 347 |
+
return None
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _windows_running_hermes_launcher_locked() -> bool:
|
| 351 |
+
"""True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort)."""
|
| 352 |
+
return _windows_shim_in_process_chain() is not None
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# Set on the re-exec'd child so it can never spawn another one.
|
| 356 |
+
_UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _reexec_dependency_sync_off_windows_shim() -> bool:
|
| 360 |
+
"""Hand the dependency sync to the venv interpreter, off the console shim.
|
| 361 |
+
|
| 362 |
+
Returns True when a child was spawned and the caller must exit at once (releasing the
|
| 363 |
+
shim before the child reaches ``pip install -e .``); False to continue in-process.
|
| 364 |
+
|
| 365 |
+
Called at the dependency-sync boundary, NOT at the top of the command: by then the code swap
|
| 366 |
+
is done and every interactive question has been answered; only the venv rewrite — the one
|
| 367 |
+
step that cannot run inside the shim — remains. Earlier would detach every run (even the
|
| 368 |
+
``Already up to date!`` no-op) and take the prompts along. Waiting on the child deadlocks
|
| 369 |
+
(we hold the handle it needs) and Windows has no exec, so the shell returns; the child keeps
|
| 370 |
+
the console, prints its own result, and ``--gateway`` writes the true exit code to
|
| 371 |
+
``.update_exit_code``. The child re-runs ``hermes update`` so the sync and its tail happen
|
| 372 |
+
exactly once; ``_UPDATE_REEXEC_ENV`` stops it spawning again and stops the "already up to
|
| 373 |
+
date" early return from swallowing the sync. ``.update-incomplete`` is already written, so
|
| 374 |
+
a child that dies mid-install is finished by the next launch's recovery.
|
| 375 |
+
|
| 376 |
+
Called at the dependency-sync boundary, NOT at the top of the command — the same placement rule as the
|
| 377 |
+
native-module deferral beside it, and for the same reason (#86735): a hand-off that fires before the
|
| 378 |
+
fetch detaches every run, including the ``Already up to date!`` no-op that never touches the venv at
|
| 379 |
+
all, and it takes the interactive prompts with it. By the time we reach here the code swap is done and
|
| 380 |
+
every question — stash, branch switch, config migration — has already been asked and answered in the
|
| 381 |
+
user's own console.
|
| 382 |
+
``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with the shim as its script and
|
| 383 |
+
holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused
|
| 384 |
+
and uv fails to replace it with os error 32 (#88838, #89599).
|
| 385 |
+
"""
|
| 386 |
+
if os.environ.get(_UPDATE_REEXEC_ENV) == "1":
|
| 387 |
+
return False
|
| 388 |
+
shim = _windows_shim_in_process_chain()
|
| 389 |
+
if shim is None:
|
| 390 |
+
return False
|
| 391 |
+
from hermes_constants import venv_python_path
|
| 392 |
+
python_exe = venv_python_path(shim.parent.parent, windows=True)
|
| 393 |
+
cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]]
|
| 394 |
+
if python_exe.is_file():
|
| 395 |
+
try:
|
| 396 |
+
subprocess.Popen(
|
| 397 |
+
cmd, env={**os.environ, _UPDATE_REEXEC_ENV: "1"}, stdin=subprocess.DEVNULL)
|
| 398 |
+
print(
|
| 399 |
+
f"→ Windows: {shim.name} cannot replace itself while it runs; "
|
| 400 |
+
"finishing the dependency install under the venv Python.")
|
| 401 |
+
print(
|
| 402 |
+
" The code update is already applied. The install continues "
|
| 403 |
+
"below and this shell returns right away.")
|
| 404 |
+
return True
|
| 405 |
+
except OSError as exc:
|
| 406 |
+
logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc)
|
| 407 |
+
print(f" ⚠ Could not hand the dependency install off {shim.name}.")
|
| 408 |
+
print(" Continuing in-process; if it cannot replace the shim, run:")
|
| 409 |
+
print(f" {subprocess.list2cmdline(cmd)}")
|
| 410 |
+
return False
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
|
| 414 |
+
"""Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
|
| 415 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 416 |
+
try:
|
| 417 |
+
from hermes_cli.managed_uv import ensure_uv
|
| 418 |
+
uv_bin = ensure_uv()
|
| 419 |
+
except Exception:
|
| 420 |
+
uv_bin = None
|
| 421 |
+
if uv_bin:
|
| 422 |
+
from hermes_constants import project_venv_dir
|
| 423 |
+
venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv"
|
| 424 |
+
env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)}
|
| 425 |
+
if _is_termux_env(env):
|
| 426 |
+
env.pop("PYTHONPATH", None)
|
| 427 |
+
env.pop("PYTHONHOME", None)
|
| 428 |
+
return [uv_bin, "pip"], env
|
| 429 |
+
return [sys.executable, "-m", "pip"], None
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _run_install_with_heartbeat(
|
| 433 |
+
cmd: list[str], *, env: dict[str, str] | None = None, heartbeat_interval_seconds: int = 30
|
| 434 |
+
) -> None:
|
| 435 |
+
"""Run a dependency install, printing an elapsed-time heartbeat while pip/uv is silent.
|
| 436 |
+
|
| 437 |
+
Resolvers/build backends compiling Rust/C extensions can stay quiet for minutes.
|
| 438 |
+
"""
|
| 439 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 440 |
+
done = threading.Event()
|
| 441 |
+
start = _time.time()
|
| 442 |
+
|
| 443 |
+
def _heartbeat() -> None:
|
| 444 |
+
# Wait first, then print, so short installs don't emit noise.
|
| 445 |
+
while not done.wait(heartbeat_interval_seconds):
|
| 446 |
+
elapsed = int(_time.time() - start)
|
| 447 |
+
print(
|
| 448 |
+
f" … still installing dependencies ({elapsed}s elapsed)"
|
| 449 |
+
" — compiling Rust/C extensions can take several minutes",
|
| 450 |
+
flush=True)
|
| 451 |
+
|
| 452 |
+
t = threading.Thread(target=_heartbeat, daemon=True)
|
| 453 |
+
t.start()
|
| 454 |
+
try:
|
| 455 |
+
# stderr=STDOUT: uv/pip write progress to stderr. Legacy desktop
|
| 456 |
+
# hand-offs (pre scripts/desktop-update/windows.ps1, which drains
|
| 457 |
+
# both pipes) only drain the child's stdout, so a full stderr
|
| 458 |
+
# pipe (~64KB) blocks the installer forever. Merged into stdout,
|
| 459 |
+
# the output rides the pipe old hand-offs DO drain. This module
|
| 460 |
+
# is imported when `hermes update` starts, so an update running
|
| 461 |
+
# from an old base executes the old copy regardless of the git
|
| 462 |
+
# reset — this protects updates initiated from bases that ship
|
| 463 |
+
# it. (managed_uv.py gets the same fix AND is imported lazily
|
| 464 |
+
# after the reset, so its sync is protected even on old bases.)
|
| 465 |
+
subprocess.run(cmd, cwd=PROJECT_ROOT, check=True, env=env, stderr=subprocess.STDOUT)
|
| 466 |
+
finally:
|
| 467 |
+
done.set()
|
| 468 |
+
t.join(timeout=0.2)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _run_repair_step(run, cmd: list[str], *, log_msg: str, fail_msg: str | None, **kwargs) -> bool:
|
| 472 |
+
"""``run(cmd, **kwargs)``; on ``CalledProcessError`` log + print the failure and return False."""
|
| 473 |
+
try:
|
| 474 |
+
run(cmd, **kwargs)
|
| 475 |
+
except subprocess.CalledProcessError as e:
|
| 476 |
+
logger.warning(log_msg, e)
|
| 477 |
+
if fail_msg is not None:
|
| 478 |
+
print(fail_msg)
|
| 479 |
+
return False
|
| 480 |
+
return True
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _report_still_missing(missing: list[str], hint: str, *, ok: str) -> None:
|
| 484 |
+
if missing:
|
| 485 |
+
print(f" ⚠ Still missing after repair: {', '.join(missing)}. {hint}")
|
| 486 |
+
else:
|
| 487 |
+
print(ok)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _is_windows() -> bool:
|
| 491 |
+
return sys.platform == "win32"
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _venv_scripts_dir() -> Path | None:
|
| 495 |
+
"""Return the venv Scripts directory if we're running inside the project venv."""
|
| 496 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 497 |
+
from hermes_constants import project_venv_dir, venv_bin_dir
|
| 498 |
+
venv_dir = project_venv_dir(PROJECT_ROOT)
|
| 499 |
+
if venv_dir is None:
|
| 500 |
+
return None
|
| 501 |
+
scripts = venv_bin_dir(venv_dir, windows=_is_windows())
|
| 502 |
+
return scripts if scripts.is_dir() else None
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
|
| 506 |
+
"""Entry-point shims uv may rewrite during ``pip install -e .`` — Windows .exe launchers
|
| 507 |
+
only; POSIX shims are plain scripts replaced atomically."""
|
| 508 |
+
if not _is_windows():
|
| 509 |
+
return []
|
| 510 |
+
names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
|
| 511 |
+
# Not a [project.scripts] entry point, but older update/install paths still
|
| 512 |
+
# rewrite and quarantine it.
|
| 513 |
+
names.add("hermes-gateway")
|
| 514 |
+
return [scripts_dir / f"{name}.exe" for name in sorted(names)]
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
_QUARANTINE_BACKOFF_MS = (0, 100, 250, 500, 1000)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _rename_with_backoff(source: Path, target: Path, attempts: int) -> OSError | None:
|
| 521 |
+
"""Rename with the quarantine backoff ladder; returns the last ``OSError`` or ``None``."""
|
| 522 |
+
for delay_ms in _QUARANTINE_BACKOFF_MS[:attempts]:
|
| 523 |
+
if delay_ms:
|
| 524 |
+
_time.sleep(delay_ms / 1000.0)
|
| 525 |
+
try:
|
| 526 |
+
source.rename(target)
|
| 527 |
+
return None
|
| 528 |
+
except OSError as e:
|
| 529 |
+
last_exc = e
|
| 530 |
+
return last_exc
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _quarantine_running_hermes_exe(
|
| 534 |
+
scripts_dir: Path, *, max_attempts: int = 4, failed_out: list[str] | None = None
|
| 535 |
+
) -> list[tuple[Path, Path]]:
|
| 536 |
+
"""Pre-empt the Windows file lock on the running ``hermes.exe``.
|
| 537 |
+
|
| 538 |
+
Windows allows RENAMING a running executable but blocks DELETE/REPLACE (uv fails with
|
| 539 |
+
``Access is denied. (os error 5)``), so live shims are renamed to ``<shim>.old.<unix-ms>``
|
| 540 |
+
first; ``_cleanup_quarantined_exes`` sweeps the ``.old`` files next invocation. Rename can
|
| 541 |
+
still fail when another process holds the .exe without ``FILE_SHARE_DELETE`` (AV scanner:
|
| 542 |
+
transient; Hermes Desktop backend child: until closed) — retry with backoff, then warn
|
| 543 |
+
naming the likely culprit. Returns ``(original, quarantined)`` pairs for rollback;
|
| 544 |
+
``failed_out`` collects shims whose rename failed every attempt so the update dependency
|
| 545 |
+
sync can refuse instead of stranding a half-broken venv.
|
| 546 |
+
|
| 547 |
+
See #87331.
|
| 548 |
+
"""
|
| 549 |
+
moved: list[tuple[Path, Path]] = []
|
| 550 |
+
if not _is_windows():
|
| 551 |
+
return moved
|
| 552 |
+
stamp = int(_time.time() * 1000)
|
| 553 |
+
# First attempt immediate; 100/250/500ms covers the typical AV re-scan window.
|
| 554 |
+
attempts = max(1, min(max_attempts, len(_QUARANTINE_BACKOFF_MS)))
|
| 555 |
+
for shim in _hermes_exe_shims(scripts_dir):
|
| 556 |
+
if not shim.exists():
|
| 557 |
+
continue
|
| 558 |
+
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
|
| 559 |
+
last_exc = _rename_with_backoff(shim, target, attempts)
|
| 560 |
+
if last_exc is None:
|
| 561 |
+
moved.append((shim, target))
|
| 562 |
+
continue
|
| 563 |
+
|
| 564 |
+
# Every rename failed. MOVEFILE_DELAY_UNTIL_REBOOT is no fallback (needs elevation, frees
|
| 565 |
+
# nothing now, moves a later repaired shim aside at boot). Report; let uv try its luck.
|
| 566 |
+
print(
|
| 567 |
+
f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: "
|
| 568 |
+
f"another process is holding it open).")
|
| 569 |
+
print(
|
| 570 |
+
" Close Hermes Desktop, exit other `hermes` REPLs, stop the "
|
| 571 |
+
"gateway, or pause AV scanning, then re-run `hermes update`.")
|
| 572 |
+
if failed_out is not None:
|
| 573 |
+
failed_out.append(shim.name)
|
| 574 |
+
|
| 575 |
+
return moved
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
_PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager"
|
| 579 |
+
_PENDING_RENAME_VALUE = "PendingFileRenameOperations"
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def _filter_pending_shim_renames(entries: list[str], shims: list[Path]) -> tuple[list[str], int]:
|
| 583 |
+
"""Drop our ``<shim>`` -> ``<shim>.old.<stamp>`` pairs from a PendingFileRenameOperations
|
| 584 |
+
value (a flat REG_MULTI_SZ of (source, target) pairs shared with other installers).
|
| 585 |
+
Returns the entries to keep and how many pairs were dropped."""
|
| 586 |
+
import ntpath
|
| 587 |
+
|
| 588 |
+
def _norm(value: str) -> str:
|
| 589 |
+
path = str(value).lstrip("!")
|
| 590 |
+
if path.startswith("\\??\\"):
|
| 591 |
+
path = path[4:]
|
| 592 |
+
return ntpath.normcase(ntpath.normpath(path))
|
| 593 |
+
|
| 594 |
+
shim_paths = {_norm(str(shim)) for shim in shims}
|
| 595 |
+
kept: list[str] = []
|
| 596 |
+
removed = 0
|
| 597 |
+
for index in range(0, len(entries) - 1, 2):
|
| 598 |
+
source, target = entries[index], entries[index + 1]
|
| 599 |
+
source_norm = _norm(source)
|
| 600 |
+
if source_norm in shim_paths and _norm(target).startswith(f"{source_norm}.old."):
|
| 601 |
+
removed += 1
|
| 602 |
+
else:
|
| 603 |
+
kept.extend((source, target))
|
| 604 |
+
if len(entries) % 2:
|
| 605 |
+
kept.append(entries[-1])
|
| 606 |
+
return kept, removed
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _cleanup_pending_shim_renames(scripts_dir: Path) -> int:
|
| 610 |
+
"""Drop reboot renames older Hermes versions queued for our shims: ``MOVEFILE_DELAY_UNTIL_REBOOT``
|
| 611 |
+
fallbacks outlive the update that queued them and move away whatever sits at the shim path
|
| 612 |
+
at next boot — even a shim a later repair just wrote. Needs elevation; a no-op otherwise."""
|
| 613 |
+
if not _is_windows():
|
| 614 |
+
return 0
|
| 615 |
+
try:
|
| 616 |
+
import winreg
|
| 617 |
+
with winreg.OpenKey(
|
| 618 |
+
winreg.HKEY_LOCAL_MACHINE, _PENDING_RENAME_KEY, 0,
|
| 619 |
+
winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE,
|
| 620 |
+
) as key:
|
| 621 |
+
entries, value_type = winreg.QueryValueEx(key, _PENDING_RENAME_VALUE)
|
| 622 |
+
if value_type != winreg.REG_MULTI_SZ or not isinstance(entries, list):
|
| 623 |
+
return 0
|
| 624 |
+
kept, removed = _filter_pending_shim_renames(entries, _hermes_exe_shims(scripts_dir))
|
| 625 |
+
if not removed:
|
| 626 |
+
return 0
|
| 627 |
+
if kept:
|
| 628 |
+
winreg.SetValueEx(key, _PENDING_RENAME_VALUE, 0, winreg.REG_MULTI_SZ, kept)
|
| 629 |
+
else:
|
| 630 |
+
winreg.DeleteValue(key, _PENDING_RENAME_VALUE)
|
| 631 |
+
return removed
|
| 632 |
+
except (OSError, ValueError):
|
| 633 |
+
return 0
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
|
| 637 |
+
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. Safety-
|
| 638 |
+
critical: a failed quarantine only aborts an update; a failed restore leaves no ``hermes``
|
| 639 |
+
on PATH. Delegates to the stdlib-only retrying helper shared with ``_install_repair``.
|
| 640 |
+
|
| 641 |
+
The outbound rename already retries a lock, so this one must too rather than swallow the first
|
| 642 |
+
``OSError`` in silence. See #75584.
|
| 643 |
+
"""
|
| 644 |
+
_early_recovery_mod.restore_quarantined_shims(moved)
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
class ShimQuarantineError(RuntimeError):
|
| 648 |
+
"""A live ``hermes*.exe`` shim could not be renamed aside. Raised by
|
| 649 |
+
:func:`_run_quarantined_install` in ``strict_quarantine`` mode BEFORE the install runs: a
|
| 650 |
+
process holds the venv hard enough that the sync would die partway — refuse, don't warn.
|
| 651 |
+
|
| 652 |
+
See #87331.
|
| 653 |
+
"""
|
| 654 |
+
|
| 655 |
+
def __init__(self, failed_shims: list[str]):
|
| 656 |
+
self.failed_shims = list(failed_shims)
|
| 657 |
+
super().__init__("could not quarantine live shim(s): " + ", ".join(self.failed_shims))
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _run_quarantined_install(
|
| 661 |
+
cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None,
|
| 662 |
+
strict_quarantine: bool = False,
|
| 663 |
+
) -> None:
|
| 664 |
+
"""Run an editable install, quarantining the running ``hermes.exe`` first.
|
| 665 |
+
|
| 666 |
+
Every editable install rewrites the entry-point shims; on Windows the live ``hermes.exe``
|
| 667 |
+
can be neither deleted nor overwritten, so without quarantine ``hermes`` drops off PATH.
|
| 668 |
+
``strict_quarantine=True`` (the update dependency sync): a shim whose rename failed every
|
| 669 |
+
retry proves a hard venv hold — the install WILL hit the same lock on .pyd files — so roll
|
| 670 |
+
back and raise :class:`ShimQuarantineError` without installing. Non-strict callers already
|
| 671 |
+
mutated the venv, so refusing buys nothing. ``scripts_dir is None`` is a pass-through.
|
| 672 |
+
|
| 673 |
+
See #87331.
|
| 674 |
+
"""
|
| 675 |
+
moved: list[tuple[Path, Path]] = []
|
| 676 |
+
failed: list[str] = []
|
| 677 |
+
if scripts_dir is not None:
|
| 678 |
+
moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed)
|
| 679 |
+
if strict_quarantine and failed:
|
| 680 |
+
_restore_quarantined_exes(moved)
|
| 681 |
+
raise ShimQuarantineError(failed)
|
| 682 |
+
try:
|
| 683 |
+
_run_install_with_heartbeat(cmd, env=env)
|
| 684 |
+
finally:
|
| 685 |
+
# Restore on FAILURE and SUCCESS: an already-satisfied editable install is a uv no-op
|
| 686 |
+
# that rewrites no entry points. Skips shims the installer replaced; finally re-raises.
|
| 687 |
+
if scripts_dir is not None:
|
| 688 |
+
_restore_quarantined_exes(moved)
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
# A quarantine file younger than this may belong to an update running RIGHT NOW in
|
| 692 |
+
# another process, whose restore step still needs it — the only copy of that shim.
|
| 693 |
+
# Restore shims when the installer didn't write replacements — on FAILURE (install died before the
|
| 694 |
+
# entry-points step) and on SUCCESS too: uv audits an already-satisfied editable install as a no-op and
|
| 695 |
+
# rewrites no entry points, which would otherwise leave the shims quarantined aside and `hermes` missing
|
| 696 |
+
# from PATH after a green install (#75584). _restore_quarantined_exes skips any shim the installer actually
|
| 697 |
+
# replaced, so this never clobbers fresh output. Errors are not swallowed — the finally re-raises whatever
|
| 698 |
+
# escaped.
|
| 699 |
+
_QUARANTINE_GRACE_SECONDS = 15 * 60
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _quarantine_stamp_ms(stale: Path) -> int | None:
|
| 703 |
+
"""The ``.old.<unix-ms>`` stamp in a quarantine filename; ``None`` (not ours — neither rescued
|
| 704 |
+
nor deleted) otherwise. Parsed from the NAME, not ``st_mtime``: ``rename`` preserves the
|
| 705 |
+
shim's mtime (when uv wrote it), not when it was quarantined."""
|
| 706 |
+
try:
|
| 707 |
+
return int(stale.name.rsplit(".old.", 1)[1])
|
| 708 |
+
except (IndexError, ValueError):
|
| 709 |
+
return None
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
|
| 713 |
+
"""Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates.
|
| 714 |
+
|
| 715 |
+
Called early on every invocation. Two cases an unconditional ``unlink()`` gets wrong:
|
| 716 |
+
(1) orphan rescue — ``hermes.exe`` missing while ``hermes.exe.old.*`` exists means the .old
|
| 717 |
+
file is the ONLY surviving copy (update died between rename and uv's write); put it back via
|
| 718 |
+
the same retry-and-report helper the update-time restore uses. (2) concurrency — a fresh
|
| 719 |
+
quarantine file may belong to an update in flight in another process; leave anything inside
|
| 720 |
+
the grace window alone. Silent no-op on non-Windows, nothing to do, or locked/permission errors.
|
| 721 |
+
|
| 722 |
+
Deleting it converts a one-rename recovery into a full reinstall. See #75584.
|
| 723 |
+
"""
|
| 724 |
+
if not _is_windows():
|
| 725 |
+
return
|
| 726 |
+
scripts_dir = scripts_dir if scripts_dir is not None else _venv_scripts_dir()
|
| 727 |
+
if scripts_dir is None:
|
| 728 |
+
return
|
| 729 |
+
_cleanup_pending_shim_renames(scripts_dir)
|
| 730 |
+
now = _time.time()
|
| 731 |
+
try:
|
| 732 |
+
candidates = [
|
| 733 |
+
(stamp, stale) for stale in scripts_dir.glob("*.exe.old.*")
|
| 734 |
+
if (stamp := _quarantine_stamp_ms(stale)) is not None]
|
| 735 |
+
except OSError:
|
| 736 |
+
return
|
| 737 |
+
# Newest first by PARSED stamp: lexicographic order breaks when a stray ``.old.999`` exists.
|
| 738 |
+
candidates.sort(key=lambda pair: pair[0], reverse=True)
|
| 739 |
+
for stamp, stale in candidates:
|
| 740 |
+
try:
|
| 741 |
+
original = stale.with_name(stale.name.rsplit(".old.", 1)[0])
|
| 742 |
+
if not original.exists():
|
| 743 |
+
# Orphan rescue: last copy of the shim — retry ladder + recovery message.
|
| 744 |
+
_early_recovery_mod.restore_quarantined_shims([(original, stale)])
|
| 745 |
+
continue
|
| 746 |
+
if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS:
|
| 747 |
+
continue # may be a live quarantine from a concurrent update
|
| 748 |
+
stale.unlink()
|
| 749 |
+
except OSError:
|
| 750 |
+
pass # still locked or in use — try again next run
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
# Import probes for venv corruption after a failed lazy ``uv pip install`` (metadata can
|
| 754 |
+
# look fine while ``.py`` files were removed mid-install). Canonical tables live in the
|
| 755 |
+
# stdlib-only ``_early_recovery`` module so the early and full recovery layers never drift.
|
| 756 |
+
# See #57828.
|
| 757 |
+
_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
|
| 758 |
+
_early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES)
|
| 759 |
+
_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = _early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = None) -> None:
|
| 763 |
+
"""Package-only pip/uv install — no shim quarantine: ``--force-reinstall <pkg>`` never rewrites
|
| 764 |
+
``hermes.exe``, and the quarantine path would rename shims uv then never recreates.
|
| 765 |
+
|
| 766 |
+
See #57828.
|
| 767 |
+
"""
|
| 768 |
+
_run_install_with_heartbeat(cmd, env=env)
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
|
| 772 |
+
"""Map repair package names to their declared pin specs in pyproject.toml."""
|
| 773 |
+
project = _pyproject_project("lazy refresh repair spec lookup failed: %s")
|
| 774 |
+
if project is None:
|
| 775 |
+
return packages
|
| 776 |
+
name_to_spec = {
|
| 777 |
+
name.lower(): head
|
| 778 |
+
for name, _, head in _parse_requirements(project.get("dependencies", []) or [])}
|
| 779 |
+
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _venv_probe(venv_python: Path, script: str, *args: str, env: dict[str, str] | None):
|
| 783 |
+
"""Run ``script`` in the target venv's interpreter, capturing UTF-8 stdout."""
|
| 784 |
+
return subprocess.run(
|
| 785 |
+
[str(venv_python), "-c", script, *args],
|
| 786 |
+
capture_output=True,
|
| 787 |
+
text=True, encoding="utf-8", errors="replace",
|
| 788 |
+
check=False,
|
| 789 |
+
env=env)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def _nonblank_lines(text: str) -> list[str]:
|
| 793 |
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _detect_broken_lazy_refresh_imports(
|
| 797 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[str] | None:
|
| 798 |
+
"""Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures,
|
| 799 |
+
``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit)
|
| 800 |
+
— *indeterminate*, not healthy."""
|
| 801 |
+
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
|
| 802 |
+
if venv_python is None:
|
| 803 |
+
return None
|
| 804 |
+
probe_lines = "\n".join(
|
| 805 |
+
f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES)
|
| 806 |
+
check_script = (
|
| 807 |
+
"import os\n"
|
| 808 |
+
"import sys\n"
|
| 809 |
+
"probes = [\n"
|
| 810 |
+
f"{probe_lines}\n"
|
| 811 |
+
"]\n"
|
| 812 |
+
"broken = []\n"
|
| 813 |
+
"for mod, attr in probes:\n"
|
| 814 |
+
" try:\n"
|
| 815 |
+
" imported = __import__(mod)\n"
|
| 816 |
+
" if not hasattr(imported, attr):\n"
|
| 817 |
+
" broken.append(mod)\n"
|
| 818 |
+
" elif mod == 'certifi':\n"
|
| 819 |
+
" # The module can import cleanly while cacert.pem is\n"
|
| 820 |
+
" # missing/corrupt (brew Python upgrade, interrupted venv\n"
|
| 821 |
+
" # rebuild) - every TLS call then fails (#29866).\n"
|
| 822 |
+
" bundle = imported.where()\n"
|
| 823 |
+
" if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n"
|
| 824 |
+
" broken.append(mod)\n"
|
| 825 |
+
" except Exception:\n"
|
| 826 |
+
" broken.append(mod)\n"
|
| 827 |
+
"print('\\n'.join(broken))\n")
|
| 828 |
+
try:
|
| 829 |
+
result = _venv_probe(venv_python, check_script, env=env)
|
| 830 |
+
except Exception as exc:
|
| 831 |
+
logger.debug("lazy refresh import probe failed: %s", exc)
|
| 832 |
+
return None
|
| 833 |
+
if result.returncode != 0:
|
| 834 |
+
logger.debug("lazy refresh import probe exited %s: %s",
|
| 835 |
+
result.returncode, (result.stderr or "")[:200])
|
| 836 |
+
return None
|
| 837 |
+
packages: list[str] = []
|
| 838 |
+
for mod in _nonblank_lines(result.stdout):
|
| 839 |
+
pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
|
| 840 |
+
if pkg and pkg not in packages:
|
| 841 |
+
packages.append(pkg)
|
| 842 |
+
return packages
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def _repair_broken_lazy_refresh_imports(
|
| 846 |
+
install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None
|
| 847 |
+
) -> bool:
|
| 848 |
+
"""Force-reinstall ``packages`` and re-probe imports. Never raises."""
|
| 849 |
+
if not packages:
|
| 850 |
+
return True
|
| 851 |
+
specs = _lazy_refresh_repair_specs(packages)
|
| 852 |
+
if not _run_repair_step(
|
| 853 |
+
_run_package_only_install, install_cmd_prefix + ["install", "--force-reinstall", *specs],
|
| 854 |
+
env=env, log_msg="lazy refresh venv repair failed: %s", fail_msg=None):
|
| 855 |
+
return False
|
| 856 |
+
# Indeterminate re-probe is not confirmed success.
|
| 857 |
+
return _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) == []
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
def _repair_venv_via_import_probes(
|
| 861 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> str:
|
| 862 |
+
"""Probe imports and force-reinstall any broken lazy-refresh packages.
|
| 863 |
+
|
| 864 |
+
Real ``import`` checks (not distribution metadata) catch a venv where METADATA remains
|
| 865 |
+
but ``.py`` files were wiped mid-install. Package-only reinstall — never rewrites
|
| 866 |
+
``hermes.exe``. Never raises. Returns ``"healthy"``, ``"repaired"``, ``"failed"``
|
| 867 |
+
(repair did not confirm clean) or ``"indeterminate"`` (probes could not run; NOT healthy).
|
| 868 |
+
|
| 869 |
+
See #57828.
|
| 870 |
+
"""
|
| 871 |
+
broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
|
| 872 |
+
if broken is None:
|
| 873 |
+
print(" ⚠ Import probes unavailable — cannot confirm venv package health.")
|
| 874 |
+
return "indeterminate"
|
| 875 |
+
if not broken:
|
| 876 |
+
return "healthy"
|
| 877 |
+
print(
|
| 878 |
+
" → Detected corrupted venv packages via import probes: "
|
| 879 |
+
f"{', '.join(broken)}; repairing...")
|
| 880 |
+
if _repair_broken_lazy_refresh_imports(install_cmd_prefix, broken, env=env):
|
| 881 |
+
print(" ✓ Venv repair succeeded")
|
| 882 |
+
return "repaired"
|
| 883 |
+
manual = " ".join(shlex.quote(s) for s in _lazy_refresh_repair_specs(broken))
|
| 884 |
+
print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
|
| 885 |
+
print(f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}")
|
| 886 |
+
return "failed"
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
def _is_uv_command(install_cmd_prefix: list[str]) -> bool:
|
| 890 |
+
"""True for a uv/uvx binary (bare or path) or ``python -m uv`` / ``python -m uvx``."""
|
| 891 |
+
if not install_cmd_prefix:
|
| 892 |
+
return False
|
| 893 |
+
first = str(install_cmd_prefix[0]).lower()
|
| 894 |
+
if "uv" in Path(first).name:
|
| 895 |
+
return True
|
| 896 |
+
return (
|
| 897 |
+
len(install_cmd_prefix) >= 3
|
| 898 |
+
and first.endswith(("python", "python.exe"))
|
| 899 |
+
and install_cmd_prefix[1] == "-m"
|
| 900 |
+
and install_cmd_prefix[2] in ("uv", "uvx"))
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def _insert_python_pin(args: list[str]) -> list[str]:
|
| 904 |
+
"""Insert ``--python <sys.executable>`` into a uv command line; an explicit caller ``--python`` wins."""
|
| 905 |
+
if "--python" in args:
|
| 906 |
+
return args
|
| 907 |
+
return [args[0], "--python", str(sys.executable), *args[1:]]
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def _interpreter_scripts_dir() -> Path | None:
|
| 911 |
+
"""Scripts/bin dir of ``sys.executable``: on a site-packages install ``PROJECT_ROOT/venv``
|
| 912 |
+
does not exist and the shims uv rewrites live next to the interpreter. Layout via the
|
| 913 |
+
canonical ``venv_bin_dir`` (hand-rolling Scripts/bin is lint-tested against).
|
| 914 |
+
|
| 915 |
+
See #76105.
|
| 916 |
+
"""
|
| 917 |
+
from hermes_constants import venv_bin_dir
|
| 918 |
+
exe = Path(sys.executable)
|
| 919 |
+
# sys.executable lives IN the bin/Scripts dir; parent.parent is the env root.
|
| 920 |
+
cand = venv_bin_dir(exe.parent.parent, windows=_is_windows())
|
| 921 |
+
if cand.is_dir():
|
| 922 |
+
return cand
|
| 923 |
+
return exe.parent if exe.parent.is_dir() else None
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def _install_python_dependencies_with_optional_fallback(
|
| 927 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
|
| 928 |
+
) -> None:
|
| 929 |
+
"""Install base deps plus as many optional extras as the environment supports.
|
| 930 |
+
|
| 931 |
+
Targets ``.[all]`` by default; Termux callers pass ``group='termux-all'``. On Windows every
|
| 932 |
+
attempt quarantines the live ``hermes*.exe`` shims first. When ``env`` carries a
|
| 933 |
+
``VIRTUAL_ENV`` that does not exist (pip / site-packages install), ``uv pip`` fails with
|
| 934 |
+
``Failed to inspect Python interpreter from active virtual environment`` before doing any
|
| 935 |
+
work — pin the install at the running interpreter instead.
|
| 936 |
+
|
| 937 |
+
Pin the install at the running interpreter instead so the update/recovery path succeeds on those
|
| 938 |
+
installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the
|
| 939 |
+
remaining callers).
|
| 940 |
+
"""
|
| 941 |
+
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
| 942 |
+
|
| 943 |
+
# Only uv needs the explicit pin; pip resolves the target from sys.executable itself.
|
| 944 |
+
pin_python = bool(
|
| 945 |
+
env and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir()
|
| 946 |
+
and install_cmd_prefix and _is_uv_command(install_cmd_prefix))
|
| 947 |
+
if pin_python:
|
| 948 |
+
env = {**env}
|
| 949 |
+
env.pop("VIRTUAL_ENV", None)
|
| 950 |
+
# Pinned to sys.executable, the shims uv rewrites live in THAT interpreter's Scripts
|
| 951 |
+
# dir, not PROJECT_ROOT/venv; quarantining the wrong dir leaves hermes.exe locked.
|
| 952 |
+
if scripts_dir is None and _is_windows():
|
| 953 |
+
scripts_dir = _interpreter_scripts_dir()
|
| 954 |
+
|
| 955 |
+
def _install(args: list[str]) -> None:
|
| 956 |
+
if pin_python:
|
| 957 |
+
args = _insert_python_pin(args)
|
| 958 |
+
# strict_quarantine: this is the UPDATE dependency sync; ShimQuarantineError propagates
|
| 959 |
+
# to the sync boundary, which defers via the update-incomplete marker instead.
|
| 960 |
+
# A shim that cannot be renamed aside proves a hard venv hold; running uv anyway is how installs
|
| 961 |
+
# strand half-updated (#87331).
|
| 962 |
+
_run_quarantined_install(
|
| 963 |
+
install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, strict_quarantine=True)
|
| 964 |
+
|
| 965 |
+
try:
|
| 966 |
+
_install(["install", "-e", f".[{group}]"])
|
| 967 |
+
_verify_console_scripts_installed(install_cmd_prefix, env=env)
|
| 968 |
+
return
|
| 969 |
+
except subprocess.CalledProcessError:
|
| 970 |
+
print(
|
| 971 |
+
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
|
| 972 |
+
)
|
| 973 |
+
|
| 974 |
+
_install(["install", "-e", "."])
|
| 975 |
+
failed_extras: list[str] = []
|
| 976 |
+
installed_extras: list[str] = []
|
| 977 |
+
for extra in _load_installable_optional_extras(group=group):
|
| 978 |
+
try:
|
| 979 |
+
_install(["install", "-e", f".[{extra}]"])
|
| 980 |
+
installed_extras.append(extra)
|
| 981 |
+
except subprocess.CalledProcessError:
|
| 982 |
+
failed_extras.append(extra)
|
| 983 |
+
if installed_extras:
|
| 984 |
+
print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}")
|
| 985 |
+
if failed_extras:
|
| 986 |
+
print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}")
|
| 987 |
+
_warn_configured_features_missing_deps(install_cmd_prefix, env=env)
|
| 988 |
+
# uv's incremental resolver has left newly added base deps silently missing on a half-stale
|
| 989 |
+
# venv, surfacing hours later as a downstream ModuleNotFoundError. Verify here instead.
|
| 990 |
+
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
|
| 991 |
+
_verify_console_scripts_installed(install_cmd_prefix, env=env)
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
_CONFIGURED_FEATURES_SCRIPT = (
|
| 995 |
+
"import importlib.util\n"
|
| 996 |
+
"try:\n"
|
| 997 |
+
" from gateway.config import load_gateway_config\n"
|
| 998 |
+
" from gateway.platform_registry import platform_registry\n"
|
| 999 |
+
" for platform in load_gateway_config().get_connected_platforms():\n"
|
| 1000 |
+
" entry = platform_registry.get(platform.value)\n"
|
| 1001 |
+
" if entry is not None and not entry.check_fn():\n"
|
| 1002 |
+
" print(entry.label + '\\t' + (entry.install_hint or 'reinstall the %r extra' % platform.value))\n"
|
| 1003 |
+
"except Exception:\n"
|
| 1004 |
+
" pass\n"
|
| 1005 |
+
"try:\n"
|
| 1006 |
+
" from hermes_cli.config import load_config\n"
|
| 1007 |
+
" if (load_config().get('mcp_servers') or {}) and importlib.util.find_spec('mcp') is None:\n"
|
| 1008 |
+
" print('MCP servers\\tinstall the ' + repr('mcp') + ' extra')\n"
|
| 1009 |
+
"except Exception:\n"
|
| 1010 |
+
" pass\n"
|
| 1011 |
+
)
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
def _configured_features_missing_deps(
|
| 1015 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> list[tuple[str, str]]:
|
| 1016 |
+
"""``(feature, hint)`` for every configured platform / MCP whose optional deps are not importable
|
| 1017 |
+
in the TARGET venv. A running gateway masks the gap until its next restart (#10651), so the
|
| 1018 |
+
update has to say which configured feature will fail to load. Probed in a fresh interpreter:
|
| 1019 |
+
the updater's own process may be the outer Python and its import caches predate the install,
|
| 1020 |
+
so an in-process ``check_fn`` reports stale state."""
|
| 1021 |
+
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
|
| 1022 |
+
if venv_python is None:
|
| 1023 |
+
return []
|
| 1024 |
+
try:
|
| 1025 |
+
result = _venv_probe(venv_python, _CONFIGURED_FEATURES_SCRIPT, env=env)
|
| 1026 |
+
except Exception as exc: # the update must finish even when the probe cannot run
|
| 1027 |
+
logger.debug("configured-feature dependency check skipped: %s", exc)
|
| 1028 |
+
return []
|
| 1029 |
+
missing: list[tuple[str, str]] = []
|
| 1030 |
+
for line in _nonblank_lines(result.stdout):
|
| 1031 |
+
feature, _, hint = line.partition("\t")
|
| 1032 |
+
missing.append((feature, hint or "reinstall its optional extra"))
|
| 1033 |
+
return missing
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
def _warn_configured_features_missing_deps(install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
|
| 1037 |
+
missing = _configured_features_missing_deps(install_cmd_prefix, env=env)
|
| 1038 |
+
if not missing:
|
| 1039 |
+
return
|
| 1040 |
+
prefix = " ".join(shlex.quote(part) for part in install_cmd_prefix)
|
| 1041 |
+
print(" ⚠ Configured features whose dependencies are still missing — the gateway will fail to load them on restart:")
|
| 1042 |
+
for feature, hint in missing:
|
| 1043 |
+
print(f" - {feature}: {hint}")
|
| 1044 |
+
print(f" Retry with: {prefix} install -e '.[all]'")
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def _load_console_script_names() -> list[str]:
|
| 1048 |
+
"""Return ``[project.scripts]`` entry-point names from pyproject.toml."""
|
| 1049 |
+
project = _pyproject_project("console script verification: failed to read pyproject.toml: %s")
|
| 1050 |
+
scripts = (project or {}).get("scripts", {}) or {}
|
| 1051 |
+
return [str(name) for name in scripts if name]
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
def _verify_console_scripts_installed(
|
| 1055 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
|
| 1056 |
+
"""Ensure every declared console_script shim exists on disk after install.
|
| 1057 |
+
|
| 1058 |
+
On Windows ``uv pip install -e .`` can register ``hermes.exe`` in the wheel RECORD while the
|
| 1059 |
+
file never lands (live shim locked, launcher write skipped), so ``hermes`` drops off PATH
|
| 1060 |
+
after a "successful" install. Missing shims get ``--reinstall -e .`` under quarantine.
|
| 1061 |
+
|
| 1062 |
+
The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so
|
| 1063 |
+
``hermes`` drops off PATH even though the install reported success (issue #52931).
|
| 1064 |
+
"""
|
| 1065 |
+
if not _is_windows():
|
| 1066 |
+
return
|
| 1067 |
+
scripts_dir = _venv_scripts_dir()
|
| 1068 |
+
names = _load_console_script_names() if scripts_dir is not None else []
|
| 1069 |
+
if not names:
|
| 1070 |
+
return
|
| 1071 |
+
|
| 1072 |
+
def _missing() -> list[str]:
|
| 1073 |
+
return [name for name in names if not (scripts_dir / f"{name}.exe").is_file()]
|
| 1074 |
+
|
| 1075 |
+
missing = _missing()
|
| 1076 |
+
if not missing:
|
| 1077 |
+
return
|
| 1078 |
+
print(
|
| 1079 |
+
f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
|
| 1080 |
+
f"{', '.join(missing)}")
|
| 1081 |
+
print(" → Reinstalling entry points with --reinstall...")
|
| 1082 |
+
if not _run_repair_step(
|
| 1083 |
+
_run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
|
| 1084 |
+
env=env, scripts_dir=scripts_dir,
|
| 1085 |
+
log_msg="console script verification: repair install failed: %s",
|
| 1086 |
+
fail_msg=(
|
| 1087 |
+
" ⚠ Entry point repair failed; try `hermes update --force` after "
|
| 1088 |
+
"closing other hermes processes.")):
|
| 1089 |
+
return
|
| 1090 |
+
_report_still_missing(
|
| 1091 |
+
_missing(), "Workaround: python -m hermes_cli.main <command>",
|
| 1092 |
+
ok=" ✓ All console entry points restored")
|
| 1093 |
+
|
| 1094 |
+
|
| 1095 |
+
def _applicable_dependency_names(raw_deps: list[str]) -> list[str]:
|
| 1096 |
+
"""Declared dep names whose ``;`` markers apply here (else ``ptyprocess ; sys_platform !=
|
| 1097 |
+
'win32'`` would false-positive on Windows). An unevaluable marker counts as applicable."""
|
| 1098 |
+
applicable: list[str] = []
|
| 1099 |
+
for name, marker, _ in _parse_requirements(raw_deps):
|
| 1100 |
+
try:
|
| 1101 |
+
if marker is None or marker.evaluate(): # type: ignore[union-attr]
|
| 1102 |
+
applicable.append(name)
|
| 1103 |
+
except Exception:
|
| 1104 |
+
applicable.append(name)
|
| 1105 |
+
return applicable
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
_MISSING_DEPS_SCRIPT = (
|
| 1109 |
+
"import importlib.metadata as md, sys\n"
|
| 1110 |
+
"missing=[]\n"
|
| 1111 |
+
"for name in sys.argv[1:]:\n"
|
| 1112 |
+
" try: md.version(name)\n"
|
| 1113 |
+
" except md.PackageNotFoundError: missing.append(name)\n"
|
| 1114 |
+
"print('\\n'.join(missing))\n")
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
def _verify_core_dependencies_installed(
|
| 1118 |
+
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, group: str = "all"
|
| 1119 |
+
) -> None:
|
| 1120 |
+
"""Check that every base dep from pyproject.toml is installed in the target venv; if not, retry.
|
| 1121 |
+
|
| 1122 |
+
Reads ``pyproject.toml`` directly (not the venv's stale metadata), drops deps whose ``;``
|
| 1123 |
+
markers don't apply here, and probes ``importlib.metadata.version()`` in the venv
|
| 1124 |
+
interpreter. Missing deps trigger a base-group ``--reinstall``, then a per-package force
|
| 1125 |
+
install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't
|
| 1126 |
+
block an otherwise-successful update — but the partial install is visible where it happened.
|
| 1127 |
+
"""
|
| 1128 |
+
project = _pyproject_project("dep verification: failed to read pyproject.toml: %s")
|
| 1129 |
+
if project is None:
|
| 1130 |
+
return
|
| 1131 |
+
raw_deps = project.get("dependencies", []) or []
|
| 1132 |
+
applicable = _applicable_dependency_names(raw_deps)
|
| 1133 |
+
if not applicable:
|
| 1134 |
+
return
|
| 1135 |
+
# Probe inside the venv Python — sys.executable may be the outer Python that drove
|
| 1136 |
+
# ``hermes update``; the install prefix/env encode which environment we targeted.
|
| 1137 |
+
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
|
| 1138 |
+
if venv_python is None:
|
| 1139 |
+
return
|
| 1140 |
+
|
| 1141 |
+
def _missing_deps() -> list[str]:
|
| 1142 |
+
try:
|
| 1143 |
+
result = _venv_probe(venv_python, _MISSING_DEPS_SCRIPT, *applicable, env=env)
|
| 1144 |
+
except Exception as e:
|
| 1145 |
+
logger.debug("dep verification: subprocess failed: %s", e)
|
| 1146 |
+
return []
|
| 1147 |
+
return _nonblank_lines(result.stdout)
|
| 1148 |
+
|
| 1149 |
+
missing = _missing_deps()
|
| 1150 |
+
if not missing:
|
| 1151 |
+
return
|
| 1152 |
+
print(
|
| 1153 |
+
f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
|
| 1154 |
+
f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}")
|
| 1155 |
+
print(" → Reinstalling base group with --reinstall to repair...")
|
| 1156 |
+
# Base group only, not ``[{group}]``: the missing dep is a *base* dep and the all-extras
|
| 1157 |
+
# install costs minutes. Quarantine first: ``--reinstall -e .`` rewrites the shims.
|
| 1158 |
+
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
| 1159 |
+
if not _run_repair_step(
|
| 1160 |
+
_run_quarantined_install, install_cmd_prefix + ["install", "--reinstall", "-e", "."],
|
| 1161 |
+
env=env, scripts_dir=scripts_dir,
|
| 1162 |
+
log_msg="dep verification: repair install failed: %s",
|
| 1163 |
+
fail_msg=" ⚠ Repair install failed; check `hermes update` output above."):
|
| 1164 |
+
return
|
| 1165 |
+
still_missing = _missing_deps()
|
| 1166 |
+
if not still_missing:
|
| 1167 |
+
print(" ✓ All declared core dependencies now installed")
|
| 1168 |
+
return
|
| 1169 |
+
# Last-ditch: install each remaining missing dep with its pin directly — uv's
|
| 1170 |
+
# resolver can think the env is satisfied while on-disk metadata disagrees.
|
| 1171 |
+
name_to_spec = dict(_naive_requirement(spec) for spec in raw_deps)
|
| 1172 |
+
specs = [name_to_spec.get(n, n) for n in still_missing]
|
| 1173 |
+
print(f" → Force-installing remaining missing dep(s): {', '.join(specs)}")
|
| 1174 |
+
if not _run_repair_step(
|
| 1175 |
+
_run_install_with_heartbeat, install_cmd_prefix + ["install", "--reinstall", *specs],
|
| 1176 |
+
env=env,
|
| 1177 |
+
log_msg="dep verification: per-package repair failed: %s",
|
| 1178 |
+
fail_msg=(
|
| 1179 |
+
f" ⚠ Could not install: {', '.join(still_missing)}. "
|
| 1180 |
+
"Run `hermes update --force` after closing other hermes processes.")):
|
| 1181 |
+
return
|
| 1182 |
+
_report_still_missing(
|
| 1183 |
+
_missing_deps(), "Run `hermes update --force` after closing other hermes processes.",
|
| 1184 |
+
ok=" ✓ All declared core dependencies now installed")
|
| 1185 |
+
|
| 1186 |
+
|
| 1187 |
+
def _resolve_install_target_python(
|
| 1188 |
+
install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None:
|
| 1189 |
+
"""Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the
|
| 1190 |
+
``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``."""
|
| 1191 |
+
if env and "VIRTUAL_ENV" in env:
|
| 1192 |
+
from hermes_constants import venv_python_path
|
| 1193 |
+
candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows())
|
| 1194 |
+
if candidate.exists():
|
| 1195 |
+
return candidate
|
| 1196 |
+
if install_cmd_prefix:
|
| 1197 |
+
first = Path(install_cmd_prefix[0])
|
| 1198 |
+
if first.exists() and "uv" not in first.name.lower():
|
| 1199 |
+
return first
|
| 1200 |
+
return None
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
+
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
|
| 1204 |
+
from hermes_cli.main import _is_termux_startup_environment
|
| 1205 |
+
return _is_termux_startup_environment(env)
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
def _is_windows_npm_path(npm_path: str) -> bool:
|
| 1209 |
+
"""True if ``npm_path`` points at a Windows npm shim (WSL ``/mnt/c`` interop, ``.cmd``/``.exe``, UNC).
|
| 1210 |
+
|
| 1211 |
+
Callers use this only on a POSIX host — on native Windows ``npm.cmd`` is correct.
|
| 1212 |
+
"""
|
| 1213 |
+
low = npm_path.lower()
|
| 1214 |
+
return low.endswith((".exe", ".cmd", ".bat")) or low.startswith("/mnt/") or "\\" in npm_path
|
| 1215 |
+
|
| 1216 |
+
|
| 1217 |
+
def _resolve_node_runtime_npm() -> str | None:
|
| 1218 |
+
"""Resolve an npm executable that belongs to the host's Node runtime.
|
| 1219 |
+
|
| 1220 |
+
On WSL, PATH interop can hand back a Windows npm that fails with EISDIR / symlink errors over
|
| 1221 |
+
``\\\\wsl.localhost\\...`` UNC paths. Refuse it on a POSIX host and re-scan PATH minus the
|
| 1222 |
+
``/mnt/*`` drive mounts. ``None`` when no suitable npm is reachable.
|
| 1223 |
+
|
| 1224 |
+
On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271.
|
| 1225 |
+
"""
|
| 1226 |
+
from hermes_constants import find_node_executable
|
| 1227 |
+
npm = find_node_executable("npm")
|
| 1228 |
+
if _is_windows():
|
| 1229 |
+
return npm
|
| 1230 |
+
if not npm:
|
| 1231 |
+
return None
|
| 1232 |
+
if not _is_windows_npm_path(npm):
|
| 1233 |
+
return npm
|
| 1234 |
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
| 1235 |
+
if not directory or directory.lower().startswith("/mnt/"):
|
| 1236 |
+
continue
|
| 1237 |
+
candidate = shutil.which("npm", path=directory)
|
| 1238 |
+
if candidate and not _is_windows_npm_path(candidate):
|
| 1239 |
+
return candidate
|
| 1240 |
+
return None
|
| 1241 |
+
|
| 1242 |
+
|
| 1243 |
+
def _resolve_update_branch(args) -> str:
|
| 1244 |
+
"""Normalize ``args.branch`` to a non-empty name (default ``main``; blank/whitespace = default)."""
|
| 1245 |
+
return (getattr(args, "branch", None) or "main").strip() or "main"
|
hermes_cli/main_provider_setup.py
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider setup wizard helpers: provider picker, custom-provider save/remove, auxiliary-model
|
| 2 |
+
routing menu, API-key/reasoning prompts, Anthropic OAuth.
|
| 3 |
+
|
| 4 |
+
Split out of ``hermes_cli/main.py``. Names that still live in main are imported lazily at call time.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import contextlib
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from hermes_cli.model_setup_flows_common import _ask, _ensure_dict_section, _print_numbered, _radiolist, _say
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _is_profile_api_key_provider(provider_id: str) -> bool:
|
| 14 |
+
"""True when *provider_id* maps to a profile with ``auth_type='api_key'`` — the catch-all
|
| 15 |
+
in select_provider_and_model() so plugin providers dispatch to the generic key flow."""
|
| 16 |
+
try:
|
| 17 |
+
from providers import get_provider_profile
|
| 18 |
+
_p = get_provider_profile(provider_id)
|
| 19 |
+
return _p is not None and _p.auth_type == "api_key"
|
| 20 |
+
except Exception:
|
| 21 |
+
return False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_GENERIC_API_KEY_PROVIDERS = frozenset({
|
| 25 |
+
"openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn",
|
| 26 |
+
"minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go",
|
| 27 |
+
"opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi",
|
| 28 |
+
"nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio"})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _short_url(url: str) -> str:
|
| 32 |
+
"""``https://host/path/`` -> ``host/path`` for menu labels."""
|
| 33 |
+
return url.replace("https://", "").replace("http://", "").rstrip("/")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _clear_stale_openai_base_url():
|
| 37 |
+
"""Remove OPENAI_BASE_URL from ~/.hermes/.env unless the active provider is 'custom' — a
|
| 38 |
+
leftover value routes provider:auto auxiliary clients to the old custom endpoint."""
|
| 39 |
+
from hermes_cli.config import get_env_value, save_env_value, load_config
|
| 40 |
+
model_cfg = load_config().get("model", {})
|
| 41 |
+
provider = (model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""
|
| 42 |
+
if provider == "custom" or not provider:
|
| 43 |
+
return # custom provider legitimately uses OPENAI_BASE_URL
|
| 44 |
+
|
| 45 |
+
stale_url = get_env_value("OPENAI_BASE_URL")
|
| 46 |
+
if stale_url:
|
| 47 |
+
save_env_value("OPENAI_BASE_URL", "")
|
| 48 |
+
shown = f"{stale_url[:40]}..." if len(stale_url) > 40 else stale_url
|
| 49 |
+
print(f"Cleared stale OPENAI_BASE_URL from .env (was: {shown})")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# (task_key, display_name, short_description)
|
| 53 |
+
_AUX_TASKS: list[tuple[str, str, str]] = [
|
| 54 |
+
("vision", "Vision", "image/screenshot analysis"),
|
| 55 |
+
("compression", "Compression", "context summarization"),
|
| 56 |
+
("approval", "Approval", "smart command approval"),
|
| 57 |
+
("mcp", "MCP", "MCP tool reasoning"),
|
| 58 |
+
("title_generation", "Title generation", "session titles"),
|
| 59 |
+
("review", "Review", "/review reviewer subagent"),
|
| 60 |
+
("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"),
|
| 61 |
+
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
|
| 62 |
+
("skills_hub", "Skills hub", "skills search/install"),
|
| 63 |
+
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
|
| 64 |
+
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
|
| 65 |
+
("profile_describer", "Profile describer", "auto profile descriptions"),
|
| 66 |
+
("curator", "Curator", "skill-usage review pass")]
|
| 67 |
+
|
| 68 |
+
# Special non-auxiliary task surfaced in the same picker: subagent delegation. Routing lives
|
| 69 |
+
# under top-level `delegation.*` (NOT `auxiliary.delegation`) because delegate_task spawns full
|
| 70 |
+
# child agents via tools/delegate_tool.py::_resolve_delegation_credentials(), which reads that
|
| 71 |
+
# section directly. "auto" means "inherit the parent agent" and is stored as empty strings —
|
| 72 |
+
# never persist the literal "auto", or it would be resolved as a provider name.
|
| 73 |
+
_DELEGATION_TASK_KEY = "delegation"
|
| 74 |
+
_DELEGATION_TASK_NAME = "Delegation"
|
| 75 |
+
_DELEGATION_TASK_DESC = "subagent model (delegate_task)"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _all_aux_tasks() -> list[tuple[str, str, str]]:
|
| 79 |
+
"""Built-in aux tasks (in order) followed by plugin-registered ones
|
| 80 |
+
(:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`)."""
|
| 81 |
+
tasks = list(_AUX_TASKS)
|
| 82 |
+
# Plugin discovery failure must not break the aux config UI.
|
| 83 |
+
with contextlib.suppress(Exception):
|
| 84 |
+
from hermes_cli.plugins import get_plugin_auxiliary_tasks
|
| 85 |
+
for entry in get_plugin_auxiliary_tasks():
|
| 86 |
+
tasks.append((entry["key"], entry["display_name"], entry["description"]))
|
| 87 |
+
return tasks
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _format_aux_current(task_cfg: dict) -> str:
|
| 91 |
+
"""Render the current aux config for display in the task menu."""
|
| 92 |
+
if not isinstance(task_cfg, dict):
|
| 93 |
+
return "auto"
|
| 94 |
+
base_url = str(task_cfg.get("base_url") or "").strip()
|
| 95 |
+
provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
|
| 96 |
+
model = str(task_cfg.get("model") or "").strip()
|
| 97 |
+
effort = _aux_effort_word(task_cfg)
|
| 98 |
+
suffix = (f" · {model}" if model else "") + (f" · {effort}" if effort else "")
|
| 99 |
+
if base_url:
|
| 100 |
+
return f"custom ({_short_url(base_url)})" + suffix
|
| 101 |
+
if provider == "auto":
|
| 102 |
+
return "auto" + suffix
|
| 103 |
+
if model:
|
| 104 |
+
return f"{provider}{suffix}"
|
| 105 |
+
return provider + suffix
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _aux_effort_word(task_cfg: dict) -> str:
|
| 109 |
+
"""The stored ``reasoning_effort`` as a display word ("" = provider default; YAML False = "none")."""
|
| 110 |
+
raw = task_cfg.get("reasoning_effort") if isinstance(task_cfg, dict) else None
|
| 111 |
+
if raw is False:
|
| 112 |
+
return "none"
|
| 113 |
+
return str(raw or "").strip().lower()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _delegation_cfg_as_task(cfg: dict) -> dict:
|
| 117 |
+
"""Project the top-level ``delegation`` section into aux-task shape (provider/model/
|
| 118 |
+
base_url/api_key); an empty provider means "inherit parent" and renders as "auto"."""
|
| 119 |
+
d = cfg.get("delegation")
|
| 120 |
+
if not isinstance(d, dict):
|
| 121 |
+
d = {}
|
| 122 |
+
out = {k: str(d.get(k) or "").strip() for k in ("provider", "model", "base_url", "api_key")}
|
| 123 |
+
out["reasoning_effort"] = d.get("reasoning_effort", "")
|
| 124 |
+
return out
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _aux_task_cfg(cfg: dict, task: str) -> dict:
|
| 128 |
+
"""The stored routing dict for *task* (delegation reads its top-level section)."""
|
| 129 |
+
if task == _DELEGATION_TASK_KEY:
|
| 130 |
+
return _delegation_cfg_as_task(cfg)
|
| 131 |
+
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
| 132 |
+
return aux.get(task, {}) if isinstance(aux.get(task), dict) else {}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _aux_task_display_name(task: str) -> str:
|
| 136 |
+
"""Display name for a task key, covering the special delegation entry."""
|
| 137 |
+
if task == _DELEGATION_TASK_KEY:
|
| 138 |
+
return _DELEGATION_TASK_NAME
|
| 139 |
+
return next((name for key, name, _ in _all_aux_tasks() if key == task), task)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _save_aux_choice(task: str, *, provider: str, model: str = "", base_url: str = "",
|
| 143 |
+
api_key: str = "", reasoning_effort: Optional[str] = None) -> None:
|
| 144 |
+
"""Persist an aux task's four routing fields (timeout etc. untouched; main model config never
|
| 145 |
+
modified). ``delegation`` writes the top-level section, with "auto" stored as an empty provider.
|
| 146 |
+
``reasoning_effort``: a level word or "" (provider default) to write; None leaves the key alone."""
|
| 147 |
+
from hermes_cli.config import load_config, save_config
|
| 148 |
+
cfg = load_config()
|
| 149 |
+
if task == _DELEGATION_TASK_KEY:
|
| 150 |
+
entry = _ensure_dict_section(cfg, "delegation")
|
| 151 |
+
provider = "" if provider == "auto" else provider
|
| 152 |
+
else:
|
| 153 |
+
entry = _ensure_dict_section(_ensure_dict_section(cfg, "auxiliary"), task)
|
| 154 |
+
entry["provider"] = provider
|
| 155 |
+
entry["model"] = model or ""
|
| 156 |
+
entry["base_url"] = base_url or ""
|
| 157 |
+
entry["api_key"] = api_key or ""
|
| 158 |
+
if reasoning_effort is not None and _aux_task_takes_reasoning(task):
|
| 159 |
+
entry["reasoning_effort"] = reasoning_effort
|
| 160 |
+
save_config(cfg)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _aux_task_takes_reasoning(task: str) -> bool:
|
| 164 |
+
"""Whether the task's config block honours ``reasoning_effort``. MoA slots and
|
| 165 |
+
``memory_query_rewrite`` omit the key by design (``config_defaults._aux``); ``review``
|
| 166 |
+
routes through delegation which reads ``delegation.reasoning_effort``, not its own block."""
|
| 167 |
+
if task == _DELEGATION_TASK_KEY:
|
| 168 |
+
return True
|
| 169 |
+
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
| 170 |
+
block = (DEFAULT_CONFIG.get("auxiliary") or {}).get(task)
|
| 171 |
+
if isinstance(block, dict):
|
| 172 |
+
return "reasoning_effort" in block
|
| 173 |
+
return True # plugin-registered task: the runtime folds the key in via _get_task_extra_body
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _prompt_aux_reasoning_effort(task: str, current: str) -> Optional[str]:
|
| 177 |
+
"""Effort step for an aux task: a level, "none", "" (provider default / inherit parent), or None to
|
| 178 |
+
keep current. The empty-value row is "Inherit parent" for delegation (a child inherits the parent's
|
| 179 |
+
effort; wording from #105431 by @fangliquanflq) and "Provider default" for aux tasks."""
|
| 180 |
+
from hermes_constants import VALID_REASONING_EFFORTS
|
| 181 |
+
label = "Inherit parent" if task == _DELEGATION_TASK_KEY else "Provider default"
|
| 182 |
+
return _prompt_reasoning_effort_selection(
|
| 183 |
+
list(VALID_REASONING_EFFORTS), current_effort=current, default_label=label)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _reset_aux_to_auto() -> int:
|
| 187 |
+
"""Reset every known aux task (built-in + plugin) back to auto/empty. Returns number reset."""
|
| 188 |
+
from hermes_cli.config import load_config, save_config
|
| 189 |
+
def _clear(entry: dict, auto: str) -> bool:
|
| 190 |
+
# Only the routing fields; timeout/download_timeout (aux) and max_concurrent_children
|
| 191 |
+
# etc. (delegation) are user-tuned and preserved. *auto* is the reset provider value
|
| 192 |
+
# ("auto" for aux tasks, "" for delegation); anything else counts as a change.
|
| 193 |
+
changed = False
|
| 194 |
+
if entry.get("provider") not in {None, "", auto}:
|
| 195 |
+
entry["provider"] = auto
|
| 196 |
+
changed = True
|
| 197 |
+
for field in ("model", "base_url", "api_key", "reasoning_effort"):
|
| 198 |
+
if entry.get(field) or entry.get(field) is False:
|
| 199 |
+
entry[field] = ""
|
| 200 |
+
changed = True
|
| 201 |
+
return changed
|
| 202 |
+
|
| 203 |
+
cfg = load_config()
|
| 204 |
+
aux = _ensure_dict_section(cfg, "auxiliary")
|
| 205 |
+
count = sum(_clear(_ensure_dict_section(aux, task), "auto") for task, _name, _desc in _all_aux_tasks())
|
| 206 |
+
dele = cfg.get("delegation")
|
| 207 |
+
if isinstance(dele, dict):
|
| 208 |
+
count += _clear(dele, "")
|
| 209 |
+
save_config(cfg)
|
| 210 |
+
return count
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _aux_config_menu() -> None:
|
| 214 |
+
"""Top-level auxiliary-model picker; loops until the user picks "Back"."""
|
| 215 |
+
from hermes_cli.config import load_config
|
| 216 |
+
while True:
|
| 217 |
+
cfg = load_config()
|
| 218 |
+
_say("", " Auxiliary models — side-task routing", "",
|
| 219 |
+
" Side tasks (vision, compression, web extraction, etc.) default",
|
| 220 |
+
' to your main chat model. "auto" means "use my main model" —',
|
| 221 |
+
" Hermes only falls back to a lightweight backend (OpenRouter,",
|
| 222 |
+
" Nous Portal) if the main model is unavailable. Override a",
|
| 223 |
+
" task below if you want it pinned to a specific provider/model.", "")
|
| 224 |
+
|
| 225 |
+
menu_tasks = _all_aux_tasks() + [(_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC)]
|
| 226 |
+
name_col = max(len(name) for _, name, _ in menu_tasks) + 2
|
| 227 |
+
desc_col = max(len(desc) for _, _, desc in menu_tasks) + 4
|
| 228 |
+
entries = [
|
| 229 |
+
(task_key, f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}"
|
| 230 |
+
f"{_format_aux_current(_aux_task_cfg(cfg, task_key))}")
|
| 231 |
+
for task_key, name, desc in menu_tasks]
|
| 232 |
+
entries.append(("__reset__", "Reset all to auto"))
|
| 233 |
+
entries.append(("__back__", "Back"))
|
| 234 |
+
|
| 235 |
+
idx = _prompt_provider_choice([label for _, label in entries], default=0)
|
| 236 |
+
if idx is None:
|
| 237 |
+
return
|
| 238 |
+
key = entries[idx][0]
|
| 239 |
+
if key == "__back__":
|
| 240 |
+
return
|
| 241 |
+
if key == "__reset__":
|
| 242 |
+
n = _reset_aux_to_auto()
|
| 243 |
+
_say(f"Reset {n} auxiliary task(s) to auto." if n else "All auxiliary tasks were already set to auto.",
|
| 244 |
+
"")
|
| 245 |
+
continue
|
| 246 |
+
_aux_select_for_task(key)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _aux_select_for_task(task: str) -> None:
|
| 250 |
+
"""Pick a provider + model for one aux task and persist it. Rows come from
|
| 251 |
+
``build_aux_picker_rows()`` (shared substrate): only already-configured providers appear."""
|
| 252 |
+
from hermes_cli.config import load_config
|
| 253 |
+
from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries
|
| 254 |
+
task_cfg = _aux_task_cfg(load_config(), task)
|
| 255 |
+
current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto"
|
| 256 |
+
current_model = str(task_cfg.get("model") or "").strip()
|
| 257 |
+
current_base_url = str(task_cfg.get("base_url") or "").strip()
|
| 258 |
+
display_name = _aux_task_display_name(task)
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
providers = build_aux_picker_rows(current_provider=current_provider, current_model=current_model,
|
| 262 |
+
current_base_url=current_base_url)
|
| 263 |
+
except Exception as exc:
|
| 264 |
+
print(f"Could not detect authenticated providers: {exc}")
|
| 265 |
+
providers = []
|
| 266 |
+
|
| 267 |
+
# (slug, label, models); "auto" always first
|
| 268 |
+
auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
|
| 269 |
+
auto_label = "auto (inherit main agent)" if task == _DELEGATION_TASK_KEY else "auto (recommended)"
|
| 270 |
+
entries: list[tuple[str, str, list[str]]] = [("__auto__", f"{auto_label}{auto_marker}", [])]
|
| 271 |
+
entries.extend(format_aux_picker_entries(providers, current_provider=current_provider,
|
| 272 |
+
current_base_url=current_base_url))
|
| 273 |
+
custom_marker = " ← current" if current_base_url else ""
|
| 274 |
+
entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", []))
|
| 275 |
+
entries.append(("__back__", "Back", []))
|
| 276 |
+
|
| 277 |
+
_say("", f" Configure {display_name} — current: {_format_aux_current(task_cfg)}", "")
|
| 278 |
+
idx = _prompt_provider_choice([label for _, label, _ in entries], default=0)
|
| 279 |
+
if idx is None:
|
| 280 |
+
return
|
| 281 |
+
slug, _label, models = entries[idx]
|
| 282 |
+
if slug == "__back__":
|
| 283 |
+
return
|
| 284 |
+
if slug == "__auto__":
|
| 285 |
+
_save_aux_choice(task, provider="auto", model="", base_url="", api_key="", reasoning_effort="")
|
| 286 |
+
print(f"{display_name}: reset to auto.")
|
| 287 |
+
elif slug == "__custom__":
|
| 288 |
+
_aux_flow_custom_endpoint(task, task_cfg)
|
| 289 |
+
else:
|
| 290 |
+
_aux_flow_provider_model(task, slug, models, current_model, current_effort=_aux_effort_word(task_cfg))
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _aux_flow_provider_model(task: str, provider_slug: str, curated_models: list,
|
| 294 |
+
current_model: str = "", current_effort: str = "") -> None:
|
| 295 |
+
"""Prompt for a model under an already-authenticated provider (then its reasoning effort),
|
| 296 |
+
save to aux."""
|
| 297 |
+
from hermes_cli.auth import _prompt_model_selection
|
| 298 |
+
from hermes_cli.models_pricing import get_pricing_for_provider
|
| 299 |
+
display_name = _aux_task_display_name(task)
|
| 300 |
+
try:
|
| 301 |
+
pricing = get_pricing_for_provider(provider_slug) or {}
|
| 302 |
+
except Exception:
|
| 303 |
+
pricing = {}
|
| 304 |
+
|
| 305 |
+
model_list = list(curated_models)
|
| 306 |
+
# _prompt_model_selection supports "Enter custom model name" and cancel; with no curated
|
| 307 |
+
# list (rare) fall back to a raw input prompt.
|
| 308 |
+
if not model_list:
|
| 309 |
+
_say(f"No curated model list for {provider_slug}.", "Enter a model slug manually (blank = use provider default):")
|
| 310 |
+
selected = _ask("Model: ", cancel_msg="")
|
| 311 |
+
if selected is None:
|
| 312 |
+
return
|
| 313 |
+
else:
|
| 314 |
+
selected = _prompt_model_selection(model_list, current_model=current_model, pricing=pricing,
|
| 315 |
+
confirm_provider=provider_slug)
|
| 316 |
+
if selected is None:
|
| 317 |
+
print("No change.")
|
| 318 |
+
return
|
| 319 |
+
|
| 320 |
+
effort = _prompt_aux_reasoning_effort(task, current_effort) if _aux_task_takes_reasoning(task) else None
|
| 321 |
+
_save_aux_choice(task, provider=provider_slug, model=selected or "", base_url="", api_key="",
|
| 322 |
+
reasoning_effort=effort)
|
| 323 |
+
effort_note = f" · reasoning {effort}" if effort else ""
|
| 324 |
+
if selected:
|
| 325 |
+
print(f"{display_name}: {provider_slug} · {selected}{effort_note}")
|
| 326 |
+
else:
|
| 327 |
+
print(f"{display_name}: {provider_slug} (provider default model){effort_note}")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
| 331 |
+
"""Prompt for a direct OpenAI-compatible base_url + optional api_key/model."""
|
| 332 |
+
display_name = _aux_task_display_name(task)
|
| 333 |
+
current_base_url = str(task_cfg.get("base_url") or "").strip()
|
| 334 |
+
current_model = str(task_cfg.get("model") or "").strip()
|
| 335 |
+
|
| 336 |
+
_say("", f" Custom endpoint for {display_name}",
|
| 337 |
+
" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)", "")
|
| 338 |
+
url = _ask(f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: ", cancel_msg="")
|
| 339 |
+
if url is None:
|
| 340 |
+
return
|
| 341 |
+
url = url or current_base_url
|
| 342 |
+
if not url:
|
| 343 |
+
print("No URL provided. No change.")
|
| 344 |
+
return
|
| 345 |
+
model = _ask(f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): ",
|
| 346 |
+
cancel_msg="")
|
| 347 |
+
if model is None:
|
| 348 |
+
return
|
| 349 |
+
model = model or current_model
|
| 350 |
+
api_key = _ask("API key (optional, blank = use OPENAI_API_KEY): ", secret=True, cancel_msg="")
|
| 351 |
+
if api_key is None:
|
| 352 |
+
return
|
| 353 |
+
effort = (_prompt_aux_reasoning_effort(task, _aux_effort_word(task_cfg))
|
| 354 |
+
if _aux_task_takes_reasoning(task) else None)
|
| 355 |
+
|
| 356 |
+
_save_aux_choice(task, provider="custom", model=model, base_url=url, api_key=api_key, reasoning_effort=effort)
|
| 357 |
+
print(f"{display_name}: custom ({_short_url(url)})" + (f" · {model}" if model else "")
|
| 358 |
+
+ (f" · reasoning {effort}" if effort else ""))
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
_CANCELLED = object()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _ask_index(prompt: str, count: int, *, echo_cancel: bool):
|
| 365 |
+
"""Numbered-menu input loop: 0-based index in ``range(count)``, ``None`` on blank,
|
| 366 |
+
``_CANCELLED`` on Ctrl-C/EOF (*echo_cancel* prints a blank line first)."""
|
| 367 |
+
while True:
|
| 368 |
+
try:
|
| 369 |
+
val = input(prompt).strip()
|
| 370 |
+
if not val:
|
| 371 |
+
return None
|
| 372 |
+
idx = int(val) - 1
|
| 373 |
+
if 0 <= idx < count:
|
| 374 |
+
return idx
|
| 375 |
+
print(f"Please enter 1-{count}")
|
| 376 |
+
except ValueError:
|
| 377 |
+
print("Please enter a number")
|
| 378 |
+
except (KeyboardInterrupt, EOFError):
|
| 379 |
+
if echo_cancel:
|
| 380 |
+
print()
|
| 381 |
+
return _CANCELLED
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
|
| 385 |
+
"""Provider menu with curses arrow keys; numbered-list fallback when curses is unavailable
|
| 386 |
+
(piped stdin, non-TTY). Returns the selected index, or None if the user cancels."""
|
| 387 |
+
with contextlib.suppress(Exception):
|
| 388 |
+
from hermes_cli.setup import _curses_prompt_choice
|
| 389 |
+
idx = _curses_prompt_choice(title, choices, default)
|
| 390 |
+
if idx >= 0:
|
| 391 |
+
print()
|
| 392 |
+
return idx
|
| 393 |
+
|
| 394 |
+
_print_numbered(title, choices, default)
|
| 395 |
+
print()
|
| 396 |
+
idx = _ask_index(f"Choice [1-{len(choices)}] ({default + 1}): ", len(choices), echo_cancel=True)
|
| 397 |
+
if idx is None:
|
| 398 |
+
return default
|
| 399 |
+
return None if idx is _CANCELLED else idx
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
_DEFAULT_QWEN_PORTAL_MODELS = [
|
| 403 |
+
"qwen3-coder-plus", "qwen3-coder"]
|
| 404 |
+
|
| 405 |
+
# (mode value, label, description, accepted answers); "" = auto-detect
|
| 406 |
+
_CUSTOM_API_MODES = (
|
| 407 |
+
("", "Auto-detect", "Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
|
| 408 |
+
("1", "auto", "detect", "auto-detect")),
|
| 409 |
+
("chat_completions", "Chat Completions", "Use /chat/completions for standard OpenAI-compatible servers.",
|
| 410 |
+
("2", "chat", "chat_completions", "completions")),
|
| 411 |
+
("codex_responses", "Responses / Codex", "Use /responses for Codex-compatible tool-calling backends.",
|
| 412 |
+
("3", "responses", "codex", "codex_responses")),
|
| 413 |
+
("anthropic_messages", "Anthropic Messages", "Use /v1/messages for Anthropic-compatible endpoints.",
|
| 414 |
+
("4", "anthropic", "anthropic_messages", "messages")))
|
| 415 |
+
_CUSTOM_API_MODE_ANSWERS = {answer: value for value, _, _, answers in _CUSTOM_API_MODES for answer in answers}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
|
| 419 |
+
"""Prompt for a custom provider API mode: an explicit mode string, or None for auto-detect."""
|
| 420 |
+
from hermes_cli.runtime_provider import _detect_api_mode_for_url
|
| 421 |
+
detected_mode = _detect_api_mode_for_url(base_url)
|
| 422 |
+
default_mode = str(current_api_mode or "").strip().lower() or detected_mode or ""
|
| 423 |
+
|
| 424 |
+
_say("", "Select API compatibility mode:")
|
| 425 |
+
for idx, (value, label, description, _answers) in enumerate(_CUSTOM_API_MODES, 1):
|
| 426 |
+
markers = [m for m, hit in (("detected", value == detected_mode), ("current", value == default_mode)) if hit]
|
| 427 |
+
suffix = f" [{' / '.join(markers)}]" if markers else ""
|
| 428 |
+
_say(f" {idx}. {label}{suffix}", f" {description}")
|
| 429 |
+
|
| 430 |
+
try:
|
| 431 |
+
raw = input("Choice [1-4, Enter to keep current/detected]: ").strip().lower()
|
| 432 |
+
except (KeyboardInterrupt, EOFError):
|
| 433 |
+
print("\nCancelled.")
|
| 434 |
+
raise
|
| 435 |
+
|
| 436 |
+
if not raw:
|
| 437 |
+
return default_mode or None
|
| 438 |
+
if raw in _CUSTOM_API_MODE_ANSWERS:
|
| 439 |
+
return _CUSTOM_API_MODE_ANSWERS[raw] or None
|
| 440 |
+
print(f"Invalid API mode choice: {raw}. Falling back to auto-detect.")
|
| 441 |
+
return None
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _auto_provider_name(base_url: str) -> str:
|
| 445 |
+
"""Display name from a custom endpoint URL, e.g. "Local (localhost:11434)" or
|
| 446 |
+
"RunPod (xyz.runpod.io)" — the default offered during custom endpoint setup."""
|
| 447 |
+
import re
|
| 448 |
+
name = re.sub(r"/v1/?$", "", _short_url(base_url)).split("/")[0]
|
| 449 |
+
if "localhost" in name or "127.0.0.1" in name:
|
| 450 |
+
return f"Local ({name})"
|
| 451 |
+
if "runpod" in name.lower():
|
| 452 |
+
return f"RunPod ({name})"
|
| 453 |
+
return name.capitalize()
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _custom_provider_api_key_config_value(provider_info, resolved_api_key=""):
|
| 457 |
+
"""Return the value that should be persisted for a custom provider key."""
|
| 458 |
+
api_key_ref = str(provider_info.get("api_key_ref", "") or "").strip()
|
| 459 |
+
if api_key_ref:
|
| 460 |
+
return api_key_ref
|
| 461 |
+
key_env = str(provider_info.get("key_env", "") or "").strip()
|
| 462 |
+
if key_env and not str(provider_info.get("api_key", "") or "").strip():
|
| 463 |
+
return f"${{{key_env}}}"
|
| 464 |
+
return str(resolved_api_key or "").strip()
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""):
|
| 468 |
+
"""Return the value that should be persisted for a custom provider URL."""
|
| 469 |
+
return str(provider_info.get("base_url_ref", "") or "").strip() or str(resolved_base_url or "").strip()
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _save_custom_provider(base_url, api_key="", model="", context_length=None, name=None, api_mode=None,
|
| 473 |
+
key_env=""):
|
| 474 |
+
"""Save a custom endpoint to ``custom_providers`` in config.yaml, deduplicated by base_url (an
|
| 475 |
+
existing entry gets model / context_length / api_mode updated). *key_env* set means the caller
|
| 476 |
+
already wrote the key to ``.env``; the entry references it instead of inlining the secret.
|
| 477 |
+
|
| 478 |
+
See #69449.
|
| 479 |
+
"""
|
| 480 |
+
from hermes_cli.config import load_config, save_config
|
| 481 |
+
cfg = load_config()
|
| 482 |
+
providers = cfg.get("custom_providers") or []
|
| 483 |
+
if not isinstance(providers, list):
|
| 484 |
+
providers = []
|
| 485 |
+
for entry in providers:
|
| 486 |
+
if not (isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/")):
|
| 487 |
+
continue
|
| 488 |
+
changed = False
|
| 489 |
+
if model and entry.get("model") != model:
|
| 490 |
+
entry["model"] = model
|
| 491 |
+
changed = True
|
| 492 |
+
if model and context_length:
|
| 493 |
+
_ensure_dict_section(entry, "models")[model] = {"context_length": context_length}
|
| 494 |
+
changed = True
|
| 495 |
+
if api_mode:
|
| 496 |
+
if entry.get("api_mode") != api_mode:
|
| 497 |
+
entry["api_mode"] = api_mode
|
| 498 |
+
changed = True
|
| 499 |
+
elif "api_mode" in entry:
|
| 500 |
+
entry.pop("api_mode", None)
|
| 501 |
+
changed = True
|
| 502 |
+
if key_env and (entry.get("key_env") != key_env or entry.get("api_key")):
|
| 503 |
+
entry["key_env"] = key_env
|
| 504 |
+
entry.pop("api_key", None)
|
| 505 |
+
changed = True
|
| 506 |
+
if changed:
|
| 507 |
+
cfg["custom_providers"] = providers
|
| 508 |
+
save_config(cfg)
|
| 509 |
+
return # already saved, updated if needed
|
| 510 |
+
|
| 511 |
+
name = name or _auto_provider_name(base_url)
|
| 512 |
+
entry = {"name": name, "base_url": base_url}
|
| 513 |
+
if key_env:
|
| 514 |
+
entry["key_env"] = key_env
|
| 515 |
+
elif api_key:
|
| 516 |
+
entry["api_key"] = api_key
|
| 517 |
+
if model:
|
| 518 |
+
entry["model"] = model
|
| 519 |
+
if api_mode:
|
| 520 |
+
entry["api_mode"] = api_mode
|
| 521 |
+
if model and context_length:
|
| 522 |
+
entry["models"] = {model: {"context_length": context_length}}
|
| 523 |
+
|
| 524 |
+
providers.append(entry)
|
| 525 |
+
cfg["custom_providers"] = providers
|
| 526 |
+
save_config(cfg)
|
| 527 |
+
print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)')
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def _remove_custom_provider(config):
|
| 531 |
+
"""Let the user remove a saved custom provider from config.yaml."""
|
| 532 |
+
from hermes_cli.config import load_config, save_config
|
| 533 |
+
cfg = load_config()
|
| 534 |
+
providers = cfg.get("custom_providers") or []
|
| 535 |
+
if not isinstance(providers, list) or not providers:
|
| 536 |
+
print("No custom providers configured.")
|
| 537 |
+
return
|
| 538 |
+
|
| 539 |
+
print("Remove a custom provider:\n")
|
| 540 |
+
choices = [
|
| 541 |
+
f"{entry.get('name', 'unnamed')} ({_short_url(entry.get('base_url', ''))})" if isinstance(entry, dict) else str(entry)
|
| 542 |
+
for entry in providers]
|
| 543 |
+
choices.append("Cancel")
|
| 544 |
+
|
| 545 |
+
idx = _radiolist("Select provider to remove:", list(choices))
|
| 546 |
+
if idx is not None:
|
| 547 |
+
print()
|
| 548 |
+
if idx < 0:
|
| 549 |
+
idx = None
|
| 550 |
+
else:
|
| 551 |
+
for i, c in enumerate(choices, 1):
|
| 552 |
+
print(f" {i}. {c}")
|
| 553 |
+
print()
|
| 554 |
+
try:
|
| 555 |
+
val = input(f"Choice [1-{len(choices)}]: ").strip()
|
| 556 |
+
idx = int(val) - 1 if val else None
|
| 557 |
+
except (ValueError, KeyboardInterrupt, EOFError):
|
| 558 |
+
idx = None
|
| 559 |
+
|
| 560 |
+
if idx is None or idx >= len(providers):
|
| 561 |
+
print("No change.")
|
| 562 |
+
return
|
| 563 |
+
|
| 564 |
+
removed = providers.pop(idx)
|
| 565 |
+
cfg["custom_providers"] = providers
|
| 566 |
+
save_config(cfg)
|
| 567 |
+
removed_name = removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed)
|
| 568 |
+
print(f'✅ Removed "{removed_name}" from custom providers.')
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def _prompt_reasoning_effort_selection(efforts, current_effort="", *, default_label=""):
|
| 572 |
+
"""Prompt for a reasoning effort. Returns a level, 'none', "" (only with *default_label*: the
|
| 573 |
+
"use the provider default" row), or None to keep current."""
|
| 574 |
+
deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
|
| 575 |
+
canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
| 576 |
+
ordered = [effort for effort in canonical_order if effort in deduped]
|
| 577 |
+
ordered.extend(effort for effort in deduped if effort not in canonical_order)
|
| 578 |
+
if not ordered:
|
| 579 |
+
return None
|
| 580 |
+
|
| 581 |
+
def _label(effort):
|
| 582 |
+
return f"{effort} ← currently in use" if effort == current_effort else effort
|
| 583 |
+
|
| 584 |
+
disable_label = "Disable reasoning"
|
| 585 |
+
skip_label = "Skip (keep current)"
|
| 586 |
+
# (return value, label) for the rows after the ladder; "" = provider default (aux tasks only).
|
| 587 |
+
tail: list[tuple[Optional[str], str]] = [("none", disable_label)]
|
| 588 |
+
if default_label:
|
| 589 |
+
tail.append(("", default_label + (" ← currently in use" if current_effort == "" else "")))
|
| 590 |
+
tail.append((None, skip_label))
|
| 591 |
+
tail_values = [v for v, _ in tail]
|
| 592 |
+
if current_effort == "none":
|
| 593 |
+
default_idx = len(ordered) + tail_values.index("none")
|
| 594 |
+
elif current_effort in ordered:
|
| 595 |
+
default_idx = ordered.index(current_effort)
|
| 596 |
+
elif default_label and current_effort == "":
|
| 597 |
+
default_idx = len(ordered) + tail_values.index("")
|
| 598 |
+
elif "medium" in ordered:
|
| 599 |
+
default_idx = ordered.index("medium")
|
| 600 |
+
else:
|
| 601 |
+
default_idx = 0
|
| 602 |
+
|
| 603 |
+
n = len(ordered)
|
| 604 |
+
rows = [_label(effort) for effort in ordered] + [label for _, label in tail]
|
| 605 |
+
idx = _radiolist("Select reasoning effort:", rows, default_idx)
|
| 606 |
+
if idx is not None:
|
| 607 |
+
if idx < 0:
|
| 608 |
+
return None
|
| 609 |
+
print()
|
| 610 |
+
else:
|
| 611 |
+
print("Select reasoning effort:")
|
| 612 |
+
for i, row in enumerate(rows, 1):
|
| 613 |
+
print(f" {i}. {row}")
|
| 614 |
+
print()
|
| 615 |
+
idx = _ask_index(f"Choice [1-{len(rows)}] (default: keep current): ", len(rows), echo_cancel=False)
|
| 616 |
+
if idx is None or idx is _CANCELLED:
|
| 617 |
+
return None
|
| 618 |
+
if idx < n:
|
| 619 |
+
return ordered[idx]
|
| 620 |
+
return tail_values[idx - n]
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def _offer_reasoning_after_pick(model_before: str) -> None:
|
| 624 |
+
"""Post-flow effort step for ``select_provider_and_model``: when a flow saved a different
|
| 625 |
+
``model.default`` (every flow persists through ``_save_model_choice``), offer the effort for
|
| 626 |
+
the new model + provider. A flow that made no change (cancel, "No change.") never prompts."""
|
| 627 |
+
from hermes_cli.config import load_config
|
| 628 |
+
model_cfg = load_config().get("model")
|
| 629 |
+
if not isinstance(model_cfg, dict):
|
| 630 |
+
return
|
| 631 |
+
model = str(model_cfg.get("default") or "").strip()
|
| 632 |
+
if not model or model == model_before:
|
| 633 |
+
return # same model re-picked or nothing saved: the "Reasoning effort" row covers that
|
| 634 |
+
_prompt_main_reasoning_effort(model, str(model_cfg.get("provider") or ""))
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def _prompt_main_reasoning_effort(model: str, provider: str) -> None:
|
| 638 |
+
"""The effort step every ``hermes model`` flow shares: after a main-model pick, offer the
|
| 639 |
+
model's supported levels (Copilot publishes a per-model set; everything else gets the full
|
| 640 |
+
ladder) and persist ``agent.reasoning_effort``. Skipped when the catalog says the route has
|
| 641 |
+
no reasoning control; "Skip" leaves the current value alone."""
|
| 642 |
+
from hermes_cli.config import load_config, save_config
|
| 643 |
+
from hermes_cli.setup import _current_reasoning_effort, _set_reasoning_effort
|
| 644 |
+
efforts = _main_model_reasoning_efforts(model, provider)
|
| 645 |
+
if efforts is None:
|
| 646 |
+
return
|
| 647 |
+
selected = _prompt_reasoning_effort_selection(efforts, current_effort=_current_reasoning_effort(load_config()))
|
| 648 |
+
if selected is None:
|
| 649 |
+
return
|
| 650 |
+
cfg = load_config()
|
| 651 |
+
_set_reasoning_effort(cfg, selected)
|
| 652 |
+
save_config(cfg)
|
| 653 |
+
print("Reasoning disabled for this model." if selected == "none" else f"Reasoning effort set to: {selected}")
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
def _main_model_reasoning_efforts(model: str, provider: str) -> Optional[list[str]]:
|
| 657 |
+
"""Levels to offer for *model* on *provider*: None when the route has no reasoning control."""
|
| 658 |
+
from hermes_constants import VALID_REASONING_EFFORTS
|
| 659 |
+
slug = (provider or "").strip().lower()
|
| 660 |
+
if slug == "copilot":
|
| 661 |
+
from hermes_cli.models import github_model_reasoning_efforts
|
| 662 |
+
return github_model_reasoning_efforts(model) or None
|
| 663 |
+
try:
|
| 664 |
+
from agent.models_dev import get_model_capabilities
|
| 665 |
+
meta = get_model_capabilities(slug, model)
|
| 666 |
+
except Exception:
|
| 667 |
+
meta = None
|
| 668 |
+
if meta is not None and not meta.supports_reasoning:
|
| 669 |
+
return None
|
| 670 |
+
return list(VALID_REASONING_EFFORTS)
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "", existing_source: str = "") -> tuple:
|
| 674 |
+
"""API-key entry for ``hermes setup`` / ``hermes model``: first-time entry, or [K]eep / [R]eplace /
|
| 675 |
+
[C]lear when a key exists (a malformed paste is recoverable without editing ``.env``).
|
| 676 |
+
Returns ``(resolved_key, abort)``; ``abort=True`` means the caller must ``return`` at once."""
|
| 677 |
+
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
|
| 678 |
+
from hermes_cli.config import save_env_value
|
| 679 |
+
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
| 680 |
+
|
| 681 |
+
def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
|
| 682 |
+
lmstudio_default = provider_id == "lmstudio" and allow_lmstudio_default
|
| 683 |
+
if lmstudio_default:
|
| 684 |
+
prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
|
| 685 |
+
else:
|
| 686 |
+
prompt = f"{key_env} (or Enter to cancel): "
|
| 687 |
+
entered = _ask(prompt, secret=True, cancel_msg="")
|
| 688 |
+
if entered is None:
|
| 689 |
+
return ""
|
| 690 |
+
if not entered and lmstudio_default:
|
| 691 |
+
return LMSTUDIO_NOAUTH_PLACEHOLDER
|
| 692 |
+
return entered
|
| 693 |
+
|
| 694 |
+
if not existing_key:
|
| 695 |
+
print(f"No {pconfig.name} API key configured.")
|
| 696 |
+
if not key_env:
|
| 697 |
+
return "", True
|
| 698 |
+
new_key = _prompt_new_key(allow_lmstudio_default=True)
|
| 699 |
+
if not new_key:
|
| 700 |
+
print("Cancelled.")
|
| 701 |
+
return "", True
|
| 702 |
+
save_env_value(key_env, new_key)
|
| 703 |
+
_say("API key saved.", "")
|
| 704 |
+
return new_key, False
|
| 705 |
+
|
| 706 |
+
# Already configured — offer K / R / C
|
| 707 |
+
from hermes_cli.env_loader import format_secret_source_suffix
|
| 708 |
+
source_suffix = format_secret_source_suffix(key_env) if key_env else ""
|
| 709 |
+
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓{source_suffix}")
|
| 710 |
+
if not key_env:
|
| 711 |
+
# Nothing we can rewrite; just acknowledge and move on.
|
| 712 |
+
print()
|
| 713 |
+
return existing_key, False
|
| 714 |
+
pool_backed = existing_source.startswith("credential_pool:")
|
| 715 |
+
menu = " [K]eep / [R]eplace (default K): " if pool_backed else " [K]eep / [R]eplace / [C]lear (default K): "
|
| 716 |
+
choice = _ask(menu, raw=True, cancel_msg="", on_cancel="k").lower()
|
| 717 |
+
|
| 718 |
+
if choice.startswith("r"):
|
| 719 |
+
new_key = _prompt_new_key(allow_lmstudio_default=False)
|
| 720 |
+
if not new_key:
|
| 721 |
+
_say(" No change.", "")
|
| 722 |
+
return existing_key, False
|
| 723 |
+
save_env_value(key_env, new_key)
|
| 724 |
+
_say(" API key updated.", "")
|
| 725 |
+
return new_key, False
|
| 726 |
+
if choice.startswith("c") and not pool_backed:
|
| 727 |
+
save_env_value(key_env, "")
|
| 728 |
+
print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
|
| 729 |
+
return "", True
|
| 730 |
+
# Keep (default, or any other input)
|
| 731 |
+
print()
|
| 732 |
+
return existing_key, False
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def _infer_stepfun_region(base_url: str) -> str:
|
| 736 |
+
"""Infer the current StepFun region from the configured endpoint."""
|
| 737 |
+
return "china" if "api.stepfun.com" in (base_url or "").strip().lower() else "international"
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def _stepfun_base_url_for_region(region: str) -> str:
|
| 741 |
+
from hermes_cli.auth import STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL
|
| 742 |
+
return STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def _run_anthropic_oauth_flow(save_env_value):
|
| 746 |
+
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
|
| 747 |
+
from agent.anthropic_credentials import run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid
|
| 748 |
+
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
|
| 749 |
+
|
| 750 |
+
def _activate_claude_code_credentials_if_available() -> bool:
|
| 751 |
+
try:
|
| 752 |
+
creds = read_claude_code_credentials()
|
| 753 |
+
except Exception:
|
| 754 |
+
creds = None
|
| 755 |
+
if creds and (is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))):
|
| 756 |
+
use_anthropic_claude_code_credentials(save_fn=save_env_value)
|
| 757 |
+
print(" ✓ Claude Code credentials linked.")
|
| 758 |
+
from hermes_constants import display_hermes_home as _dhh_fn
|
| 759 |
+
print(f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env.")
|
| 760 |
+
return True
|
| 761 |
+
return False
|
| 762 |
+
|
| 763 |
+
def _paste_token(prompt: str):
|
| 764 |
+
"""Manual setup-token entry: True saved, False empty, None cancelled."""
|
| 765 |
+
token = _ask(prompt, secret=True, cancel_msg="")
|
| 766 |
+
if not token:
|
| 767 |
+
return token
|
| 768 |
+
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
| 769 |
+
print(" ✓ Setup-token saved.")
|
| 770 |
+
return True
|
| 771 |
+
|
| 772 |
+
try:
|
| 773 |
+
_say("", " Running 'claude setup-token' — follow the prompts below.",
|
| 774 |
+
" A browser window will open for you to authorize access.", "")
|
| 775 |
+
token = run_oauth_setup_token()
|
| 776 |
+
if token:
|
| 777 |
+
if _activate_claude_code_credentials_if_available():
|
| 778 |
+
return True
|
| 779 |
+
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
| 780 |
+
print(" ✓ OAuth credentials saved.")
|
| 781 |
+
return True
|
| 782 |
+
|
| 783 |
+
# Subprocess completed but no token auto-detected — ask user to paste
|
| 784 |
+
_say("", " If the setup-token was displayed above, paste it here:", "")
|
| 785 |
+
saved = _paste_token(" Paste setup-token (or Enter to cancel): ")
|
| 786 |
+
if saved is None:
|
| 787 |
+
return False
|
| 788 |
+
if saved:
|
| 789 |
+
return True
|
| 790 |
+
print(" ⚠ Could not detect saved credentials.")
|
| 791 |
+
return False
|
| 792 |
+
|
| 793 |
+
except FileNotFoundError:
|
| 794 |
+
# Claude CLI not installed — guide user through manual setup
|
| 795 |
+
_say("", " The 'claude' CLI is required for OAuth login.", "", " To install and authenticate:", "",
|
| 796 |
+
" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code",
|
| 797 |
+
" 2. Run: claude setup-token",
|
| 798 |
+
" 3. Follow the browser prompts to authorize",
|
| 799 |
+
" 4. Re-run: hermes model", "",
|
| 800 |
+
" Or paste an existing setup-token now (sk-ant-oat-...):", "")
|
| 801 |
+
saved = _paste_token(" Setup-token (or Enter to cancel): ")
|
| 802 |
+
if saved is None:
|
| 803 |
+
return False
|
| 804 |
+
if saved:
|
| 805 |
+
return True
|
| 806 |
+
print(" Cancelled — install Claude Code and try again.")
|
| 807 |
+
return False
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
|
| 811 |
+
"""Saved custom providers keyed by slug, with raw ``${ENV}`` refs preserved."""
|
| 812 |
+
from hermes_cli.config import get_compatible_custom_providers, read_raw_config
|
| 813 |
+
from hermes_cli.providers import custom_provider_slug
|
| 814 |
+
|
| 815 |
+
# Raw (un-expanded) templates keyed by identity. ``get_compatible_custom_providers(
|
| 816 |
+
# read_raw_config())`` is deliberately bypassed: its normalize step ``urlparse()``s
|
| 817 |
+
# ``base_url`` and drops entries whose base_url is itself an env-ref template.
|
| 818 |
+
raw_api_key_refs: dict[tuple, str] = {}
|
| 819 |
+
raw_base_url_refs: dict[tuple, str] = {}
|
| 820 |
+
raw_cfg = read_raw_config()
|
| 821 |
+
|
| 822 |
+
raw_entries: list[tuple[str, str, dict]] = []
|
| 823 |
+
raw_list = raw_cfg.get("custom_providers")
|
| 824 |
+
if isinstance(raw_list, list):
|
| 825 |
+
raw_entries.extend((e.get("name", ""), "", e) for e in raw_list if isinstance(e, dict))
|
| 826 |
+
raw_providers = raw_cfg.get("providers")
|
| 827 |
+
if isinstance(raw_providers, dict):
|
| 828 |
+
raw_entries.extend((e.get("name", "") or k, k, e) for k, e in raw_providers.items() if isinstance(e, dict))
|
| 829 |
+
for name, provider_key, raw_entry in raw_entries:
|
| 830 |
+
template = str(raw_entry.get("api_key", "") or "").strip()
|
| 831 |
+
base_template = str(raw_entry.get("base_url", "") or raw_entry.get("url", "") or raw_entry.get("api", "") or "").strip()
|
| 832 |
+
name = str(name or "").strip()
|
| 833 |
+
provider_key = str(provider_key or "").strip()
|
| 834 |
+
model = str(raw_entry.get("model", "") or raw_entry.get("default_model", "") or "").strip()
|
| 835 |
+
# Index by every identity the loaded (expanded) config might present: (name),
|
| 836 |
+
# (name, model), (provider_key), (provider_key, model); case-insensitive names.
|
| 837 |
+
keys = [k.lower() for k in (name, provider_key) if k]
|
| 838 |
+
identities = [(k,) for k in keys] + [(k, model) for k in keys]
|
| 839 |
+
for refs, tmpl in ((raw_api_key_refs, template), (raw_base_url_refs, base_template)):
|
| 840 |
+
if "${" in tmpl:
|
| 841 |
+
for identity in identities:
|
| 842 |
+
refs.setdefault(identity, tmpl)
|
| 843 |
+
|
| 844 |
+
def _lookup_ref(refs: dict[tuple, str], name: str, provider_key: str, model: str) -> str:
|
| 845 |
+
name_lc = str(name or "").strip().lower()
|
| 846 |
+
pkey_lc = str(provider_key or "").strip().lower()
|
| 847 |
+
model = str(model or "").strip()
|
| 848 |
+
return next((refs[i] for i in ((pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)) if i[0] and i in refs), "")
|
| 849 |
+
|
| 850 |
+
custom_provider_map = {}
|
| 851 |
+
for entry in get_compatible_custom_providers(cfg):
|
| 852 |
+
if not isinstance(entry, dict):
|
| 853 |
+
continue
|
| 854 |
+
name = (entry.get("name") or "").strip()
|
| 855 |
+
base_url = (entry.get("base_url") or "").strip()
|
| 856 |
+
if not name or not base_url:
|
| 857 |
+
continue
|
| 858 |
+
provider_key = (entry.get("provider_key") or "").strip()
|
| 859 |
+
model = entry.get("model", "")
|
| 860 |
+
custom_provider_map[custom_provider_slug(name, provider_key)] = {
|
| 861 |
+
"name": name,
|
| 862 |
+
"base_url": base_url,
|
| 863 |
+
"api_key": entry.get("api_key", ""),
|
| 864 |
+
"key_env": entry.get("key_env") or entry.get("api_key_env", ""),
|
| 865 |
+
"key_cmd": entry.get("key_cmd", ""),
|
| 866 |
+
"model": model,
|
| 867 |
+
"models": entry.get("models", {}),
|
| 868 |
+
"models_discovered": entry.get("models_discovered", False),
|
| 869 |
+
"extra_headers": entry.get("extra_headers", {}),
|
| 870 |
+
"discover_models": entry.get("discover_models", True),
|
| 871 |
+
"api_mode": entry.get("api_mode", ""),
|
| 872 |
+
"provider_key": provider_key,
|
| 873 |
+
"api_key_ref": _lookup_ref(raw_api_key_refs, name, provider_key, model),
|
| 874 |
+
"base_url_ref": _lookup_ref(raw_base_url_refs, name, provider_key, model)}
|
| 875 |
+
return custom_provider_map
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _build_provider_picker_rows(config: dict, active: str, provider_labels: dict[str, str],
|
| 879 |
+
custom_provider_map: dict[str, dict[str, str]]) -> tuple[list[tuple[str, str, list[str]]], int]:
|
| 880 |
+
"""Rows for the ``hermes model`` provider picker plus the pre-selected index. Canonical providers
|
| 881 |
+
fold into display groups (PROVIDER_GROUPS): a group row's ``members`` drive a sub-picker, leaf
|
| 882 |
+
rows have ``members == []``; saved custom providers and trailing actions stay flat. Honors
|
| 883 |
+
``model_catalog.excluded_providers`` (slug or alias, case-insensitive) like the gateway/TUI."""
|
| 884 |
+
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_ALIASES
|
| 885 |
+
from hermes_cli.models_catalog_static import group_providers, provider_group_for_slug
|
| 886 |
+
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
|
| 887 |
+
_cli_excluded = {
|
| 888 |
+
str(p).strip().lower()
|
| 889 |
+
for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or []
|
| 890 |
+
if p}
|
| 891 |
+
if _cli_excluded:
|
| 892 |
+
# A canonical provider is hidden if its slug OR any alias is excluded.
|
| 893 |
+
_names_for: dict[str, set[str]] = {_p.slug: {_p.slug.lower()} for _p in CANONICAL_PROVIDERS}
|
| 894 |
+
for _alias, _canon in _PROVIDER_ALIASES.items():
|
| 895 |
+
_names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower())
|
| 896 |
+
_visible_slugs = [p.slug for p in CANONICAL_PROVIDERS if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded]
|
| 897 |
+
else:
|
| 898 |
+
_visible_slugs = [p.slug for p in CANONICAL_PROVIDERS]
|
| 899 |
+
|
| 900 |
+
# The active provider's group when grouped, otherwise the active slug itself.
|
| 901 |
+
active_group = provider_group_for_slug(active) if active else ""
|
| 902 |
+
|
| 903 |
+
# (key, label, members): members == [] → leaf row (provider slug / action);
|
| 904 |
+
# members != [] → group row, key is "group:<gid>"
|
| 905 |
+
ordered: list[tuple[str, str, list[str]]] = []
|
| 906 |
+
default_idx = 0
|
| 907 |
+
|
| 908 |
+
def _add(key, label, members, is_active):
|
| 909 |
+
nonlocal default_idx
|
| 910 |
+
if is_active:
|
| 911 |
+
label = f"{label} ← currently active"
|
| 912 |
+
default_idx = len(ordered)
|
| 913 |
+
ordered.append((key, label, members))
|
| 914 |
+
|
| 915 |
+
for row in group_providers(_visible_slugs):
|
| 916 |
+
if row["kind"] == "group":
|
| 917 |
+
gid = row["group_id"]
|
| 918 |
+
group_desc = row.get("description", "")
|
| 919 |
+
label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸"
|
| 920 |
+
_add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group)
|
| 921 |
+
else:
|
| 922 |
+
slug = row["slug"]
|
| 923 |
+
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
|
| 924 |
+
if slug == "nous":
|
| 925 |
+
# Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide
|
| 926 |
+
# when nous.guest is off, untouched for a real account.
|
| 927 |
+
from hermes_cli.model_switch_providers import _free_tier_nous_row
|
| 928 |
+
tier_row = _free_tier_nous_row({"name": label, "models": []})
|
| 929 |
+
if tier_row is None:
|
| 930 |
+
continue
|
| 931 |
+
label = tier_row["name"]
|
| 932 |
+
_add(slug, label, [], bool(active) and slug == active)
|
| 933 |
+
|
| 934 |
+
for key, provider_info in custom_provider_map.items():
|
| 935 |
+
saved_model = provider_info.get("model", "")
|
| 936 |
+
model_hint = f" — {saved_model}" if saved_model else ""
|
| 937 |
+
_add(key, f"{provider_info['name']} ({_short_url(provider_info['base_url'])}){model_hint}", [],
|
| 938 |
+
bool(active) and key == active)
|
| 939 |
+
|
| 940 |
+
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
|
| 941 |
+
if isinstance(config.get("custom_providers"), list) and config.get("custom_providers"):
|
| 942 |
+
ordered.append(("remove-custom", "Remove a saved custom provider", []))
|
| 943 |
+
ordered.append(("reasoning", "Reasoning effort for the current model...", []))
|
| 944 |
+
ordered.append(("aux-config", "Configure auxiliary models...", []))
|
| 945 |
+
ordered.append(("cancel", "Leave unchanged", []))
|
| 946 |
+
return ordered, default_idx
|
hermes_cli/main_tui_launch.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly.
|
| 2 |
+
|
| 3 |
+
Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...)
|
| 4 |
+
are imported lazily inside the functions that use them (avoids an import cycle).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import contextlib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import shutil
|
| 12 |
+
import subprocess
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
# Log-record parity with the origin module.
|
| 19 |
+
logger = logging.getLogger("hermes_cli.main")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]:
|
| 23 |
+
if not path:
|
| 24 |
+
return None
|
| 25 |
+
try:
|
| 26 |
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 27 |
+
return str(data.get("session_id") or "").strip() or None
|
| 28 |
+
except Exception:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None:
|
| 33 |
+
"""Print a shell-visible epilogue after TUI exits."""
|
| 34 |
+
from hermes_cli.main import _resolve_last_session
|
| 35 |
+
target = (
|
| 36 |
+
_read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui")
|
| 37 |
+
)
|
| 38 |
+
if not target:
|
| 39 |
+
return
|
| 40 |
+
|
| 41 |
+
db = None
|
| 42 |
+
try:
|
| 43 |
+
from hermes_state import SessionDB
|
| 44 |
+
db = SessionDB(read_only=True) # exit epilogue only reads
|
| 45 |
+
session = db.get_session(target)
|
| 46 |
+
if not session:
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
title = db.get_session_title(target)
|
| 50 |
+
message_count = int(session.get("message_count") or 0)
|
| 51 |
+
if message_count == 0:
|
| 52 |
+
return # No real conversation — don't show resume info
|
| 53 |
+
tokens = {
|
| 54 |
+
k: int(session.get(f"{k}_tokens") or 0)
|
| 55 |
+
for k in ("input", "output", "cache_read", "cache_write", "reasoning")}
|
| 56 |
+
except Exception:
|
| 57 |
+
return
|
| 58 |
+
finally:
|
| 59 |
+
if db is not None:
|
| 60 |
+
db.close()
|
| 61 |
+
|
| 62 |
+
print(f"\nResume this session with:\n hermes --tui --resume {target}")
|
| 63 |
+
if title:
|
| 64 |
+
print(f' hermes --tui -c "{title}"')
|
| 65 |
+
print(f"\nSession: {target}")
|
| 66 |
+
if title:
|
| 67 |
+
print(f"Title: {title}")
|
| 68 |
+
print(f"Messages: {message_count}")
|
| 69 |
+
print(
|
| 70 |
+
"Tokens: "
|
| 71 |
+
f"{sum(tokens.values())} (in {tokens['input']}, out {tokens['output']}, "
|
| 72 |
+
f"cache {tokens['cache_read'] + tokens['cache_write']}, reasoning {tokens['reasoning']})"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer", "dev", "extraneous", "hasInstallScript", "optional"})
|
| 77 |
+
"""Lockfile fields npm writes non-deterministically at install time.
|
| 78 |
+
|
| 79 |
+
``ideallyInert`` marks packages npm skipped (per-platform opt-outs); ``peer`` is
|
| 80 |
+
dropped from the hidden ``.package-lock.json`` on dev-deps that are also peers.
|
| 81 |
+
``dev`` / ``optional`` / ``extraneous`` / ``hasInstallScript`` are boolean
|
| 82 |
+
annotations npm populates differently in the hidden lock (npm >= 10/11), and
|
| 83 |
+
may differ even when present in both. None indicate a real declared-vs-installed
|
| 84 |
+
skew — the authoritative check is the ``resolved``/``integrity`` pair, which the
|
| 85 |
+
intersection comparison in :func:`_tui_need_npm_install` always catches.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _workspace_root(dir: Path) -> Path:
|
| 90 |
+
"""The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the
|
| 91 |
+
lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt).
|
| 92 |
+
Shared by the install check, TUI launcher and web build so their cwd can't diverge."""
|
| 93 |
+
if (
|
| 94 |
+
(dir / "package.json").is_file()
|
| 95 |
+
and not (dir / "package-lock.json").is_file()
|
| 96 |
+
and (dir.parent / "package-lock.json").is_file()):
|
| 97 |
+
return dir.parent
|
| 98 |
+
return dir
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _child_workspace_dirs(dir: Path):
|
| 102 |
+
"""Sorted ``dir/packages/*`` subdirs that carry a ``package.json``."""
|
| 103 |
+
packages_dir = dir / "packages"
|
| 104 |
+
if not packages_dir.is_dir():
|
| 105 |
+
return
|
| 106 |
+
for child in sorted(packages_dir.iterdir()):
|
| 107 |
+
if child.is_dir() and (child / "package.json").is_file():
|
| 108 |
+
yield child
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _termux_workspace_install_context(
|
| 112 |
+
dir: Path, *, include_child_workspaces: bool = False) -> tuple[Path, tuple[str, ...]]:
|
| 113 |
+
"""Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only."""
|
| 114 |
+
ws_root = _workspace_root(dir)
|
| 115 |
+
if ws_root == dir:
|
| 116 |
+
return dir, ()
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
workspace = dir.relative_to(ws_root).as_posix()
|
| 120 |
+
except ValueError:
|
| 121 |
+
return ws_root, ()
|
| 122 |
+
|
| 123 |
+
workspace_args: list[str] = ["--workspace", workspace]
|
| 124 |
+
if include_child_workspaces:
|
| 125 |
+
for child in _child_workspace_dirs(dir):
|
| 126 |
+
workspace_args.extend(["--workspace", child.relative_to(ws_root).as_posix()])
|
| 127 |
+
workspace_args.append("--include-workspace-root=false")
|
| 128 |
+
return ws_root, tuple(workspace_args)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
|
| 132 |
+
"""Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution.
|
| 133 |
+
|
| 134 |
+
``devDependencies`` are followed for each start (npm installs every selected
|
| 135 |
+
workspace's dev toolchain) but not for transitive deps. None when no start is
|
| 136 |
+
in *packages* so callers fall back to the full comparison — which would report
|
| 137 |
+
every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and
|
| 138 |
+
reinstall on every launch. Names resolve by walking up ``node_modules``
|
| 139 |
+
ancestors; ``link: true`` entries are followed to their real package.
|
| 140 |
+
|
| 141 |
+
The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only
|
| 142 |
+
the ui-tui workspace's dependency closure is written to the hidden ``.package-lock.json``. On Termux it
|
| 143 |
+
additionally selects ui-tui's child ``packages/*`` workspaces, so their devDependencies join the closure
|
| 144 |
+
too. See #66978.
|
| 145 |
+
"""
|
| 146 |
+
start_set = {starts} if isinstance(starts, str) else {s for s in starts if s}
|
| 147 |
+
present = [s for s in start_set if s in packages]
|
| 148 |
+
if not present:
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
def resolve(from_key: str, dep: str) -> Optional[str]:
|
| 152 |
+
base = from_key
|
| 153 |
+
while True:
|
| 154 |
+
candidate = f"{base}/node_modules/{dep}" if base else f"node_modules/{dep}"
|
| 155 |
+
if candidate in packages:
|
| 156 |
+
return candidate
|
| 157 |
+
if not base:
|
| 158 |
+
return None
|
| 159 |
+
base = base.rsplit("/", 1)[0] if "/" in base else ""
|
| 160 |
+
|
| 161 |
+
seen: set = set()
|
| 162 |
+
stack = list(present)
|
| 163 |
+
while stack:
|
| 164 |
+
key = stack.pop()
|
| 165 |
+
if key in seen:
|
| 166 |
+
continue
|
| 167 |
+
seen.add(key)
|
| 168 |
+
entry = packages.get(key)
|
| 169 |
+
if not isinstance(entry, dict):
|
| 170 |
+
continue
|
| 171 |
+
resolved = entry.get("resolved")
|
| 172 |
+
if entry.get("link") and isinstance(resolved, str) and resolved in packages:
|
| 173 |
+
stack.append(resolved)
|
| 174 |
+
fields = ["dependencies", "optionalDependencies", "peerDependencies"]
|
| 175 |
+
if key in start_set:
|
| 176 |
+
fields.append("devDependencies")
|
| 177 |
+
for field in fields:
|
| 178 |
+
deps = entry.get(field)
|
| 179 |
+
if not isinstance(deps, dict):
|
| 180 |
+
continue
|
| 181 |
+
for dep in deps:
|
| 182 |
+
target = resolve(key, dep)
|
| 183 |
+
if target is not None:
|
| 184 |
+
stack.append(target)
|
| 185 |
+
return seen
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
|
| 189 |
+
"""Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux
|
| 190 |
+
(each a dev-included closure root). Empty when ui-tui isn't under *ws_root*."""
|
| 191 |
+
from hermes_cli.main import _is_termux_startup_environment
|
| 192 |
+
try:
|
| 193 |
+
keys = {tui_dir.relative_to(ws_root).as_posix()}
|
| 194 |
+
except ValueError:
|
| 195 |
+
return set()
|
| 196 |
+
if _is_termux_startup_environment():
|
| 197 |
+
for child in _child_workspace_dirs(tui_dir):
|
| 198 |
+
try:
|
| 199 |
+
keys.add(child.relative_to(ws_root).as_posix())
|
| 200 |
+
except ValueError:
|
| 201 |
+
continue
|
| 202 |
+
return keys
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _tui_need_npm_install(root: Path) -> bool:
|
| 206 |
+
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
| 207 |
+
|
| 208 |
+
Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root
|
| 209 |
+
lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT
|
| 210 |
+
(git bumps mtimes without changing deps): missing from hidden → reinstall
|
| 211 |
+
unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in
|
| 212 |
+
both → compare the intersection of non-null fields minus
|
| 213 |
+
``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both).
|
| 214 |
+
Hidden-only entries are ignored; unparseable lockfiles fall back to mtime.
|
| 215 |
+
"""
|
| 216 |
+
entry = root / "dist" / "entry.js"
|
| 217 |
+
ws_root = _workspace_root(root)
|
| 218 |
+
lock = ws_root / "package-lock.json"
|
| 219 |
+
if entry.is_file() and not lock.is_file():
|
| 220 |
+
return False
|
| 221 |
+
|
| 222 |
+
if not (ws_root / "node_modules" / "@hermes" / "ink" / "package.json").is_file():
|
| 223 |
+
return True
|
| 224 |
+
if not lock.is_file():
|
| 225 |
+
return False
|
| 226 |
+
marker = ws_root / "node_modules" / ".package-lock.json"
|
| 227 |
+
if not marker.is_file():
|
| 228 |
+
return True
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
|
| 232 |
+
installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
|
| 233 |
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
| 234 |
+
return lock.stat().st_mtime > marker.stat().st_mtime
|
| 235 |
+
|
| 236 |
+
def entries_differ(pkg: dict, installed_pkg: dict) -> bool:
|
| 237 |
+
a = {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
|
| 238 |
+
b = {k: v for k, v in installed_pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
|
| 239 |
+
return any(a[k] is not None and b[k] is not None and a[k] != b[k] for k in a.keys() & b.keys())
|
| 240 |
+
|
| 241 |
+
# Shared workspace checkout: the launch install is scoped to ui-tui (+ child
|
| 242 |
+
# packages on Termux), so limit the comparison to that closure. Standalone /
|
| 243 |
+
# own-lockfile layouts do a full install and keep the full comparison.
|
| 244 |
+
# Limit the comparison to the same selected-workspace closure so unrelated workspace deps (apps/desktop,
|
| 245 |
+
# web, …) don't force a reinstall every launch (#66978).
|
| 246 |
+
closure: Optional[set] = None
|
| 247 |
+
if ws_root != root:
|
| 248 |
+
selected = _tui_selected_workspace_keys(root, ws_root)
|
| 249 |
+
if selected:
|
| 250 |
+
closure = _npm_lock_workspace_closure(wanted, selected)
|
| 251 |
+
|
| 252 |
+
for name, pkg in wanted.items():
|
| 253 |
+
if not name or (closure is not None and name not in closure) or not isinstance(pkg, dict):
|
| 254 |
+
continue
|
| 255 |
+
if name not in installed:
|
| 256 |
+
# Workspace link entries are never materialized by a partial
|
| 257 |
+
# `npm install --workspace ui-tui`; don't force a reinstall for them.
|
| 258 |
+
# Workspace link entries (`"link": true`, paths outside node_modules/ like `apps/desktop`,
|
| 259 |
+
# `node_modules/web`) are never materialized by a partial `npm install --workspace ui-tui` —
|
| 260 |
+
# they're deliberately skipped (see #38772) and would otherwise force a reinstall on every
|
| 261 |
+
# launch.
|
| 262 |
+
if pkg.get("optional") or pkg.get("peer") or pkg.get("link"):
|
| 263 |
+
continue
|
| 264 |
+
if not name.startswith("node_modules/"):
|
| 265 |
+
continue
|
| 266 |
+
return True
|
| 267 |
+
if isinstance(installed[name], dict) and entries_differ(pkg, installed[name]):
|
| 268 |
+
return True
|
| 269 |
+
|
| 270 |
+
return False
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
_TUI_BUILD_INPUT_DIRS = ("src", "packages/hermes-ink/src")
|
| 274 |
+
|
| 275 |
+
_TUI_BUILD_INPUT_FILES = (
|
| 276 |
+
"package.json",
|
| 277 |
+
"package-lock.json",
|
| 278 |
+
"tsconfig.json",
|
| 279 |
+
"tsconfig.build.json",
|
| 280 |
+
"babel.compiler.config.cjs",
|
| 281 |
+
"scripts/build.mjs",
|
| 282 |
+
"packages/hermes-ink/package.json",
|
| 283 |
+
"packages/hermes-ink/index.js",
|
| 284 |
+
"packages/hermes-ink/text-input.js",
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
_TUI_BUILD_INPUT_SUFFIXES = frozenset({".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"})
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _iter_tui_build_inputs(root: Path):
|
| 291 |
+
"""Yield source/config files that affect ``ui-tui/dist/entry.js``."""
|
| 292 |
+
for rel in _TUI_BUILD_INPUT_FILES:
|
| 293 |
+
path = root / rel
|
| 294 |
+
if path.is_file():
|
| 295 |
+
yield path
|
| 296 |
+
|
| 297 |
+
for rel in _TUI_BUILD_INPUT_DIRS:
|
| 298 |
+
base = root / rel
|
| 299 |
+
if not base.is_dir():
|
| 300 |
+
continue
|
| 301 |
+
for path in base.rglob("*"):
|
| 302 |
+
if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES:
|
| 303 |
+
yield path
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _tui_need_rebuild(root: Path) -> bool:
|
| 307 |
+
"""True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver);
|
| 308 |
+
``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild."""
|
| 309 |
+
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
|
| 310 |
+
if force in {"1", "true", "yes", "on"}:
|
| 311 |
+
return True
|
| 312 |
+
|
| 313 |
+
try:
|
| 314 |
+
output_mtime = (root / "dist" / "entry.js").stat().st_mtime
|
| 315 |
+
except OSError:
|
| 316 |
+
return True
|
| 317 |
+
|
| 318 |
+
for path in _iter_tui_build_inputs(root):
|
| 319 |
+
try:
|
| 320 |
+
if path.stat().st_mtime > output_mtime:
|
| 321 |
+
return True
|
| 322 |
+
except OSError:
|
| 323 |
+
return True
|
| 324 |
+
return False
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _ensure_tui_node() -> None:
|
| 328 |
+
"""Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend
|
| 329 |
+
the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install."""
|
| 330 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 331 |
+
if shutil.which("node") and shutil.which("npm"):
|
| 332 |
+
return
|
| 333 |
+
if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"):
|
| 334 |
+
return
|
| 335 |
+
|
| 336 |
+
helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
|
| 337 |
+
if not helper.is_file():
|
| 338 |
+
return
|
| 339 |
+
|
| 340 |
+
from hermes_constants import get_hermes_home
|
| 341 |
+
hermes_home = str(get_hermes_home())
|
| 342 |
+
try:
|
| 343 |
+
# Helper logs to stderr; stdout carries `command -v node` — subshell PATH
|
| 344 |
+
# edits don't leak back into Python, so the capture is the bridge.
|
| 345 |
+
result = subprocess.run(
|
| 346 |
+
["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'],
|
| 347 |
+
env={**os.environ, "HERMES_HOME": hermes_home},
|
| 348 |
+
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False)
|
| 349 |
+
except (OSError, subprocess.SubprocessError):
|
| 350 |
+
return
|
| 351 |
+
|
| 352 |
+
parts = os.environ.get("PATH", "").split(os.pathsep)
|
| 353 |
+
resolved = (result.stdout or "").strip()
|
| 354 |
+
extras = [Path(resolved).resolve().parent] if resolved else []
|
| 355 |
+
extras += [Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]
|
| 356 |
+
for extra in extras:
|
| 357 |
+
s = str(extra)
|
| 358 |
+
if extra.is_dir() and s not in parts:
|
| 359 |
+
parts.insert(0, s)
|
| 360 |
+
os.environ["PATH"] = os.pathsep.join(parts)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
|
| 364 |
+
"""Find a pre-built TUI entry.js bundled in the wheel."""
|
| 365 |
+
if hermes_cli_dir is None:
|
| 366 |
+
hermes_cli_dir = Path(__file__).parent
|
| 367 |
+
bundled = hermes_cli_dir / "tui_dist" / "entry.js"
|
| 368 |
+
return bundled if bundled.is_file() else None
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _restore_tui_workspace(tui_dir: Path) -> bool:
|
| 372 |
+
"""Best-effort ``git restore`` of a missing ``ui-tui/`` (Windows AV/NTFS filters can delete
|
| 373 |
+
tracked files after ``hermes update``); True when the directory exists afterwards.
|
| 374 |
+
|
| 375 |
+
On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` files deleted in the working
|
| 376 |
+
tree after ``hermes update`` (HEAD stays intact; the files just vanish — see issue #49145). Those files
|
| 377 |
+
are tracked, so ``git restore`` puts them back deterministically. Best-effort: returns False (rather
|
| 378 |
+
than raising) when git is unavailable, this isn't a checkout, or the restore leaves the directory still
|
| 379 |
+
missing — the caller then prints the manual-recovery message.
|
| 380 |
+
"""
|
| 381 |
+
git = shutil.which("git")
|
| 382 |
+
if not git or not (tui_dir.parent / ".git").exists():
|
| 383 |
+
return False
|
| 384 |
+
try:
|
| 385 |
+
subprocess.run(
|
| 386 |
+
[git, "restore", "--", tui_dir.name], cwd=str(tui_dir.parent), capture_output=True,
|
| 387 |
+
text=True, encoding="utf-8", errors="replace", check=False)
|
| 388 |
+
except OSError:
|
| 389 |
+
return False
|
| 390 |
+
return tui_dir.is_dir()
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _ensure_tui_workspace(tui_dir: Path) -> None:
|
| 394 |
+
"""Ensure ``ui-tui/`` exists before it is used as a subprocess cwd (else ``NotADirectoryError``
|
| 395 |
+
/ ``WinError 267`` with no usable message): git-restore first, then abort with recovery steps.
|
| 396 |
+
|
| 397 |
+
Without this, a missing workspace falls through to ``subprocess.run(..., cwd=<missing ui-tui>)``, which
|
| 398 |
+
crashes with ``NotADirectoryError`` (``WinError 267`` on Windows) instead of a usable message (#49145).
|
| 399 |
+
We first try to self-heal via ``git restore``; only if that can't recover the directory do we abort with
|
| 400 |
+
concrete manual-recovery steps.
|
| 401 |
+
"""
|
| 402 |
+
if tui_dir.is_dir():
|
| 403 |
+
return
|
| 404 |
+
|
| 405 |
+
if _restore_tui_workspace(tui_dir):
|
| 406 |
+
if not os.environ.get("HERMES_QUIET"):
|
| 407 |
+
print(f"Restored missing TUI workspace: {tui_dir}")
|
| 408 |
+
return
|
| 409 |
+
|
| 410 |
+
print(
|
| 411 |
+
"Error: the TUI workspace is missing from this Hermes checkout.\n"
|
| 412 |
+
f"Expected directory: {tui_dir}\n"
|
| 413 |
+
"This usually means `hermes update` left tracked ui-tui files deleted.\n"
|
| 414 |
+
"Recovery:\n"
|
| 415 |
+
" 1. From the Hermes checkout, run `git restore -- ui-tui`\n"
|
| 416 |
+
" 2. Run `npm install --silent --no-fund --no-audit --progress=false`\n"
|
| 417 |
+
" 3. Retry `hermes --tui`\n"
|
| 418 |
+
"If the checkout is still inconsistent, run `hermes update --force`.",
|
| 419 |
+
file=sys.stderr)
|
| 420 |
+
sys.exit(1)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
|
| 424 |
+
"""Build a clean environment for the pinned UI toolchain lifecycle."""
|
| 425 |
+
run_env = {**os.environ, **(env or {}), "CI": "1"}
|
| 426 |
+
# esbuild treats this as an executable override. If a shell points it at a
|
| 427 |
+
# different release, the pinned package's postinstall rejects that binary.
|
| 428 |
+
run_env.pop("ESBUILD_BINARY_PATH", None)
|
| 429 |
+
# The repo-root ``.npmrc`` is git-tracked, so the updater's autostash parks
|
| 430 |
+
# any mirror/proxy line added there and every update reinstalls without it
|
| 431 |
+
# (restricted networks then prune optional native deps like get-windows and
|
| 432 |
+
# the rebuild fails). ``$HERMES_HOME`` lives outside the git tree and the
|
| 433 |
+
# update hand-off already carries ``HERMES_HOME`` down to every npm child.
|
| 434 |
+
# An explicit ``NPM_CONFIG_USERCONFIG`` wins (#106373).
|
| 435 |
+
from hermes_constants import get_hermes_home
|
| 436 |
+
npmrc = get_hermes_home() / "npmrc"
|
| 437 |
+
if npmrc.is_file():
|
| 438 |
+
run_env.setdefault("NPM_CONFIG_USERCONFIG", os.fspath(npmrc))
|
| 439 |
+
return run_env
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _tui_node_bin(bin: str) -> str:
|
| 443 |
+
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node;
|
| 444 |
+
``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses."""
|
| 445 |
+
if bin == "node":
|
| 446 |
+
env_node = os.environ.get("HERMES_NODE")
|
| 447 |
+
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
|
| 448 |
+
return env_node
|
| 449 |
+
from hermes_constants import find_node_executable
|
| 450 |
+
path = find_node_executable(bin)
|
| 451 |
+
if not path and bin == "node":
|
| 452 |
+
with contextlib.suppress(Exception):
|
| 453 |
+
from hermes_cli.dep_ensure import ensure_dependency
|
| 454 |
+
if ensure_dependency("node"):
|
| 455 |
+
path = find_node_executable("node")
|
| 456 |
+
if not path:
|
| 457 |
+
print(f"{bin} not found — install Node.js to use the TUI.")
|
| 458 |
+
sys.exit(1)
|
| 459 |
+
return path
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _exit_on_npm_failure(result: subprocess.CompletedProcess, message: str, *, sep: str) -> None:
|
| 463 |
+
"""Print *message* plus the last 30 lines of npm output and exit 1 on a non-zero rc."""
|
| 464 |
+
if result.returncode == 0:
|
| 465 |
+
return
|
| 466 |
+
combined = f"{result.stdout or ''}{sep}{result.stderr or ''}".strip()
|
| 467 |
+
preview = "\n".join(combined.splitlines()[-30:])
|
| 468 |
+
print(message)
|
| 469 |
+
if preview:
|
| 470 |
+
print(preview)
|
| 471 |
+
sys.exit(1)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None:
|
| 475 |
+
"""``npm run build`` in *cwd*; exit with *failure_message* + output tail on failure."""
|
| 476 |
+
result = subprocess.run(
|
| 477 |
+
[npm, "run", "build"], cwd=str(cwd), capture_output=True, text=True, encoding="utf-8",
|
| 478 |
+
errors="replace", env=_npm_lifecycle_env())
|
| 479 |
+
_exit_on_npm_failure(result, failure_message, sep="")
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
|
| 483 |
+
"""``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure.
|
| 484 |
+
|
| 485 |
+
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and
|
| 486 |
+
is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build
|
| 487 |
+
toolchain is in devDependencies and an inherited ``NODE_ENV=production`` /
|
| 488 |
+
``omit=dev`` would silently skip it.
|
| 489 |
+
"""
|
| 490 |
+
npm = _tui_node_bin("npm")
|
| 491 |
+
if not os.environ.get("HERMES_QUIET"):
|
| 492 |
+
print("Installing TUI dependencies…")
|
| 493 |
+
npm_cwd = _workspace_root(tui_dir)
|
| 494 |
+
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). See #38772. When ui-tui/ has
|
| 495 |
+
# its own package-lock.json (e.g. curl install), _workspace_root() returns tui_dir itself. Passing
|
| 496 |
+
# --workspace in that case fails because npm cannot find a workspace named "ui-tui" inside ui-tui/. See
|
| 497 |
+
# #42973.
|
| 498 |
+
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
|
| 499 |
+
if termux_startup:
|
| 500 |
+
npm_cwd, npm_workspace_args = _termux_workspace_install_context(tui_dir, include_child_workspaces=True)
|
| 501 |
+
npm_install_cmd = [
|
| 502 |
+
npm, "install", *npm_workspace_args,
|
| 503 |
+
"--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false",
|
| 504 |
+
]
|
| 505 |
+
|
| 506 |
+
def _run_tui_install() -> subprocess.CompletedProcess:
|
| 507 |
+
from hermes_constants import with_hermes_node_path
|
| 508 |
+
# Managed tree first on PATH: if the EBADENGINE repair provisioned a
|
| 509 |
+
# managed Node, npm's shebang/lifecycle scripts must resolve that node.
|
| 510 |
+
return subprocess.run(
|
| 511 |
+
npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 512 |
+
text=True, encoding="utf-8", errors="replace",
|
| 513 |
+
env=_npm_lifecycle_env(with_hermes_node_path()))
|
| 514 |
+
|
| 515 |
+
result = _run_tui_install()
|
| 516 |
+
if result.returncode != 0:
|
| 517 |
+
# An npm outside the root `engines.npm` range fails before doing any work;
|
| 518 |
+
# repair once (upgrade a managed npm in place, or provision a managed
|
| 519 |
+
# runtime) and retry rather than dumping EBADENGINE at the user.
|
| 520 |
+
from hermes_cli.npm_engine import maybe_repair_npm_engine
|
| 521 |
+
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
|
| 522 |
+
if repaired_npm:
|
| 523 |
+
npm_install_cmd[0] = repaired_npm
|
| 524 |
+
result = _run_tui_install()
|
| 525 |
+
_exit_on_npm_failure(result, "npm install failed.", sep="\n")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
| 529 |
+
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
|
| 530 |
+
from hermes_cli.main import _is_termux_startup_environment
|
| 531 |
+
_ensure_tui_node()
|
| 532 |
+
|
| 533 |
+
# Footgun: --dev against a prebuilt bundle that has no source/node_modules.
|
| 534 |
+
ext_dir = os.environ.get("HERMES_TUI_DIR")
|
| 535 |
+
if tui_dev and ext_dir:
|
| 536 |
+
print(
|
| 537 |
+
f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n"
|
| 538 |
+
f"The prebuilt TUI has no source code to hot-reload.\n"
|
| 539 |
+
f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.",
|
| 540 |
+
file=sys.stderr)
|
| 541 |
+
sys.exit(1)
|
| 542 |
+
|
| 543 |
+
# 1. Prebuilt bundle (nix / packaged release / Docker image): just run it.
|
| 544 |
+
# Must run BEFORE _ensure_tui_workspace(): a prebuilt install ships
|
| 545 |
+
# hermes_cli/tui_dist/entry.js but never ui-tui/ (git checkouts only).
|
| 546 |
+
# 1. A prebuilt install (Docker image, Nix build, or prior `npm run build`) ships
|
| 547 |
+
# hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that directory only exists in a git
|
| 548 |
+
# checkout) — so requiring the workspace to exist first made every prebuilt dashboard Chat tab
|
| 549 |
+
# connection hard-exit before it ever got a chance to try the bundled entry.js it already has. See
|
| 550 |
+
# #56665.
|
| 551 |
+
if not tui_dev:
|
| 552 |
+
if ext_dir:
|
| 553 |
+
p = Path(ext_dir)
|
| 554 |
+
if (p / "dist" / "entry.js").is_file():
|
| 555 |
+
return [_tui_node_bin("node"), "--expose-gc", str(p / "dist" / "entry.js")], p
|
| 556 |
+
|
| 557 |
+
bundled = _find_bundled_tui()
|
| 558 |
+
if bundled is not None:
|
| 559 |
+
return [_tui_node_bin("node"), "--expose-gc", str(bundled)], bundled.parent
|
| 560 |
+
|
| 561 |
+
# About to npm install/build from source, so the workspace must exist.
|
| 562 |
+
if not ext_dir:
|
| 563 |
+
_ensure_tui_workspace(tui_dir)
|
| 564 |
+
|
| 565 |
+
# 2. Normal flow: npm install if needed, esbuild, then node dist/entry.js.
|
| 566 |
+
# --dev: npm install if needed, then tsx src/entry.tsx.
|
| 567 |
+
termux_startup = _is_termux_startup_environment()
|
| 568 |
+
termux_need_rebuild = termux_startup and not tui_dev and _tui_need_rebuild(tui_dir)
|
| 569 |
+
skip_install_for_fresh_termux_bundle = termux_startup and not tui_dev and not termux_need_rebuild
|
| 570 |
+
did_install = False
|
| 571 |
+
if not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir):
|
| 572 |
+
_install_tui_dependencies(tui_dir, termux_startup=termux_startup)
|
| 573 |
+
did_install = True
|
| 574 |
+
|
| 575 |
+
if tui_dev:
|
| 576 |
+
# --dev runs src/entry.tsx directly, but @hermes/ink resolves through
|
| 577 |
+
# packages/hermes-ink/dist/entry-exports.js; a stale dist after a pull
|
| 578 |
+
# leaves newer hooks/components missing at runtime. Prebuild it here.
|
| 579 |
+
npm = _tui_node_bin("npm")
|
| 580 |
+
_run_tui_npm_build(npm, tui_dir / "packages" / "hermes-ink", "TUI dev prebuild failed.")
|
| 581 |
+
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
|
| 582 |
+
if tsx.exists():
|
| 583 |
+
return [str(tsx), "src/entry.tsx"], tui_dir
|
| 584 |
+
return [npm, "start"], tui_dir
|
| 585 |
+
|
| 586 |
+
# Desktop/dev launches always rebuild; Termux cold starts use the freshness
|
| 587 |
+
# check because esbuild startup is expensive on old mobile CPUs.
|
| 588 |
+
if not termux_startup or did_install or termux_need_rebuild:
|
| 589 |
+
_run_tui_npm_build(_tui_node_bin("npm"), tui_dir, "TUI build failed.")
|
| 590 |
+
|
| 591 |
+
return [_tui_node_bin("node"), "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _split_comma_items(items, *, split_non_str: bool = True) -> list[str]:
|
| 595 |
+
"""Flatten str / list (comma-separated) input into stripped non-empty parts."""
|
| 596 |
+
raw_items = [items] if isinstance(items, str) else items
|
| 597 |
+
if not isinstance(raw_items, (list, tuple)):
|
| 598 |
+
raw_items = [raw_items]
|
| 599 |
+
normalized: list[str] = []
|
| 600 |
+
for item in raw_items:
|
| 601 |
+
if split_non_str or isinstance(item, str):
|
| 602 |
+
normalized.extend(part.strip() for part in str(item).split(","))
|
| 603 |
+
else:
|
| 604 |
+
normalized.append(str(item).strip())
|
| 605 |
+
return [item for item in normalized if item]
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
| 609 |
+
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
|
| 610 |
+
try:
|
| 611 |
+
from hermes_cli.oneshot import _normalize_toolsets
|
| 612 |
+
return _normalize_toolsets(toolsets) or []
|
| 613 |
+
except (AttributeError, ImportError):
|
| 614 |
+
return _split_comma_items(toolsets, split_non_str=False) if toolsets else []
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _read_cgroup_memory_limit() -> Optional[int]:
|
| 618 |
+
"""Container memory limit in bytes, or None if unconstrained (v2 ``memory.max``, then v1).
|
| 619 |
+
|
| 620 |
+
V8 is NOT cgroup-aware: a flat 8GB heap grows past a smaller container limit
|
| 621 |
+
and the OOM-killer SIGKILLs Node with no breadcrumb (bare ``stdin EOF``).
|
| 622 |
+
"""
|
| 623 |
+
candidates = (
|
| 624 |
+
"/sys/fs/cgroup/memory.max", # cgroup v2
|
| 625 |
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
|
| 626 |
+
)
|
| 627 |
+
for path in candidates:
|
| 628 |
+
try:
|
| 629 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 630 |
+
raw = f.read().strip()
|
| 631 |
+
except (OSError, ValueError):
|
| 632 |
+
continue
|
| 633 |
+
if raw == "max":
|
| 634 |
+
return None
|
| 635 |
+
if not raw:
|
| 636 |
+
continue # don't mistake an empty v2 file for "unlimited"
|
| 637 |
+
try:
|
| 638 |
+
limit = int(raw)
|
| 639 |
+
except ValueError:
|
| 640 |
+
continue
|
| 641 |
+
if limit <= 0:
|
| 642 |
+
continue
|
| 643 |
+
if limit >= (1 << 50): # >= ~1 PB is the v1 "unlimited" sentinel
|
| 644 |
+
return None
|
| 645 |
+
return limit
|
| 646 |
+
return None
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
| 650 |
+
"""V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained,
|
| 651 |
+
else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at
|
| 652 |
+
1536MB when the container is > 2GB (below that V8 GC-thrashes)."""
|
| 653 |
+
limit = _read_cgroup_memory_limit()
|
| 654 |
+
if not limit:
|
| 655 |
+
return default_mb
|
| 656 |
+
limit_mb = limit // (1024 * 1024)
|
| 657 |
+
sized = int(limit_mb * 0.75)
|
| 658 |
+
if sized >= default_mb:
|
| 659 |
+
return default_mb
|
| 660 |
+
# Below the floor, honor the limit-derived value anyway: a graceful V8 exit
|
| 661 |
+
# beats a silent cgroup kill.
|
| 662 |
+
return max(1536, sized) if limit_mb > 2048 else sized
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def _safe_tui_cwd(env: Optional[dict] = None) -> str:
|
| 666 |
+
"""Return a stable cwd value for the Node TUI child environment."""
|
| 667 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 668 |
+
try:
|
| 669 |
+
return os.getcwd()
|
| 670 |
+
except FileNotFoundError:
|
| 671 |
+
candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip()
|
| 672 |
+
if candidate and Path(candidate).is_dir():
|
| 673 |
+
return candidate
|
| 674 |
+
return str(PROJECT_ROOT)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def _apply_tui_python_env(env: dict) -> None:
|
| 678 |
+
"""Seed/repair Python-related env vars shared by CLI and dashboard TUI launches."""
|
| 679 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 680 |
+
src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip()
|
| 681 |
+
if not src_root or not Path(src_root).is_dir():
|
| 682 |
+
env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT)
|
| 683 |
+
|
| 684 |
+
cwd = str(env.get("HERMES_CWD") or "").strip()
|
| 685 |
+
if not cwd or not Path(cwd).is_dir():
|
| 686 |
+
env["HERMES_CWD"] = _safe_tui_cwd(env)
|
| 687 |
+
|
| 688 |
+
python = str(env.get("HERMES_PYTHON") or "").strip()
|
| 689 |
+
if os.path.dirname(python):
|
| 690 |
+
python_path = Path(python)
|
| 691 |
+
if not python_path.is_absolute():
|
| 692 |
+
python_path = Path(env["HERMES_CWD"]) / python_path
|
| 693 |
+
python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK)
|
| 694 |
+
else:
|
| 695 |
+
python_is_executable = bool(shutil.which(python, path=env.get("PATH")))
|
| 696 |
+
if not python_is_executable:
|
| 697 |
+
env["HERMES_PYTHON"] = sys.executable
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def _setup_tui_worktree() -> dict:
|
| 701 |
+
"""Create the ``--worktree`` checkout for a TUI launch (prune + async pack maintenance); exits on failure."""
|
| 702 |
+
wt_info = None
|
| 703 |
+
try:
|
| 704 |
+
from cli import _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree
|
| 705 |
+
repo = _git_repo_root()
|
| 706 |
+
if repo:
|
| 707 |
+
_prune_stale_worktrees(repo)
|
| 708 |
+
# Repack on pack sprawl so `worktree add` never crawls on a
|
| 709 |
+
# multi-agent box; on a thread so it can't block launch.
|
| 710 |
+
import threading as _threading
|
| 711 |
+
|
| 712 |
+
_threading.Thread(
|
| 713 |
+
target=_maintain_pack_health, args=(repo,), name="pack-maintenance", daemon=True).start()
|
| 714 |
+
wt_info = _setup_worktree()
|
| 715 |
+
except Exception as exc:
|
| 716 |
+
print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr)
|
| 717 |
+
if not wt_info:
|
| 718 |
+
sys.exit(1)
|
| 719 |
+
return wt_info
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def _launch_tui(
|
| 723 |
+
resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None,
|
| 724 |
+
provider: Optional[str] = None, toolsets: object = None, skills: object = None,
|
| 725 |
+
verbose: Optional[bool] = None, quiet: bool = False, query: Optional[str] = None,
|
| 726 |
+
image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False,
|
| 727 |
+
pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False):
|
| 728 |
+
"""Replace current process with the TUI."""
|
| 729 |
+
from hermes_cli.main import PROJECT_ROOT
|
| 730 |
+
tui_dir = PROJECT_ROOT / "ui-tui"
|
| 731 |
+
|
| 732 |
+
import tempfile
|
| 733 |
+
# TUI child is a hermes process: propagate the profile-home contract via
|
| 734 |
+
# the single factory; keep secrets (the TUI/agent needs provider creds).
|
| 735 |
+
from tools.environments.local import build_subprocess_env
|
| 736 |
+
env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True)
|
| 737 |
+
from hermes_cli.shared_session_attach import configure_tui_attachment
|
| 738 |
+
try:
|
| 739 |
+
configure_tui_attachment(env, resume_session_id)
|
| 740 |
+
except (ValueError, RuntimeError) as exc:
|
| 741 |
+
print(f"Error: {exc}", file=sys.stderr)
|
| 742 |
+
raise SystemExit(1) from None
|
| 743 |
+
try:
|
| 744 |
+
from hermes_cli.config import apply_terminal_config_to_env
|
| 745 |
+
apply_terminal_config_to_env(env=env)
|
| 746 |
+
except Exception:
|
| 747 |
+
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
|
| 748 |
+
active_session_fd, active_session_file = tempfile.mkstemp(
|
| 749 |
+
prefix="hermes-tui-active-session-", suffix=".json")
|
| 750 |
+
os.close(active_session_fd)
|
| 751 |
+
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
|
| 752 |
+
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
| 753 |
+
|
| 754 |
+
wt_info = None
|
| 755 |
+
if worktree:
|
| 756 |
+
wt_info = _setup_tui_worktree()
|
| 757 |
+
env["HERMES_CWD"] = wt_info["path"]
|
| 758 |
+
env["TERMINAL_CWD"] = wt_info["path"]
|
| 759 |
+
|
| 760 |
+
_apply_tui_python_env(env)
|
| 761 |
+
|
| 762 |
+
skills_value = ""
|
| 763 |
+
if skills:
|
| 764 |
+
skills_value = (
|
| 765 |
+
",".join(_split_comma_items(skills)) if isinstance(skills, (list, tuple)) else str(skills).strip())
|
| 766 |
+
for key, value in (
|
| 767 |
+
("HERMES_MODEL", model), ("HERMES_INFERENCE_MODEL", model),
|
| 768 |
+
("HERMES_TUI_PROVIDER", provider), ("HERMES_INFERENCE_PROVIDER", provider),
|
| 769 |
+
("HERMES_TUI_TOOLSETS", ",".join(_normalize_tui_toolsets(toolsets))),
|
| 770 |
+
("HERMES_TUI_SKILLS", skills_value),
|
| 771 |
+
("HERMES_TUI_QUERY", query), ("HERMES_TUI_IMAGE", image),
|
| 772 |
+
("HERMES_TUI_CHECKPOINTS", "1" if checkpoints else None),
|
| 773 |
+
("HERMES_TUI_PASS_SESSION_ID", "1" if pass_session_id else None),
|
| 774 |
+
("HERMES_TUI_MAX_TURNS", str(max_turns) if max_turns is not None else None),
|
| 775 |
+
("HERMES_TUI_TOOL_PROGRESS", "verbose" if verbose else "off" if quiet else None),
|
| 776 |
+
("HERMES_ACCEPT_HOOKS", "1" if accept_hooks else None)):
|
| 777 |
+
if value:
|
| 778 |
+
env[key] = value
|
| 779 |
+
# Generous V8 heap (8GB target; default cap can fatal-OOM on long sessions),
|
| 780 |
+
# sized below the cgroup limit by _resolve_tui_heap_mb() so V8 exits
|
| 781 |
+
# gracefully instead of being reaped silently. Token-level merge respects a
|
| 782 |
+
# user-supplied --max-old-space-size. --expose-gc is NOT added here: Node
|
| 783 |
+
# rejects it in NODE_OPTIONS; _make_tui_argv() passes it as a direct flag.
|
| 784 |
+
_tokens = env.get("NODE_OPTIONS", "").split()
|
| 785 |
+
if not any(t.startswith("--max-old-space-size=") for t in _tokens):
|
| 786 |
+
_tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
|
| 787 |
+
env["NODE_OPTIONS"] = " ".join(_tokens)
|
| 788 |
+
# HERMES_TUI_RESUME is an internal hand-off to the Ink app. We start from a
|
| 789 |
+
# full os.environ snapshot, so a stale exported value would make a plain
|
| 790 |
+
# `hermes --tui` try to resume a non-existent session; only forward the id
|
| 791 |
+
# argparse resolved for this invocation.
|
| 792 |
+
env.pop("HERMES_TUI_RESUME", None)
|
| 793 |
+
if resume_session_id:
|
| 794 |
+
env["HERMES_TUI_RESUME"] = resume_session_id
|
| 795 |
+
|
| 796 |
+
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
|
| 797 |
+
code: Optional[int] = None
|
| 798 |
+
try:
|
| 799 |
+
try:
|
| 800 |
+
code = subprocess.call(argv, cwd=str(cwd), env=env)
|
| 801 |
+
except KeyboardInterrupt:
|
| 802 |
+
code = 130
|
| 803 |
+
|
| 804 |
+
if code in {0, 130}:
|
| 805 |
+
_print_tui_exit_summary(resume_session_id, active_session_file)
|
| 806 |
+
finally:
|
| 807 |
+
with contextlib.suppress(OSError):
|
| 808 |
+
os.unlink(active_session_file)
|
| 809 |
+
if wt_info:
|
| 810 |
+
with contextlib.suppress(Exception):
|
| 811 |
+
from cli import _cleanup_worktree
|
| 812 |
+
_cleanup_worktree(wt_info)
|
| 813 |
+
|
| 814 |
+
# Exit code 42 = TUI requested an update. Relaunch as `hermes update`;
|
| 815 |
+
# preserve_inherited=False keeps --tui and other flags out of the subcommand.
|
| 816 |
+
if code == 42:
|
| 817 |
+
from hermes_cli.relaunch import relaunch
|
| 818 |
+
print("\n☤ Launching update...\n")
|
| 819 |
+
relaunch(["update"], preserve_inherited=False)
|
| 820 |
+
|
| 821 |
+
sys.exit(code)
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _pin_kanban_board_env() -> None:
|
| 825 |
+
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` so in-process tools and shelled-out
|
| 826 |
+
``hermes kanban`` calls agree even if a concurrent ``boards switch`` flips the file mid-turn.
|
| 827 |
+
|
| 828 |
+
Without this, in-process tools (``kanban_*``) and shelled-out CLI calls (``hermes kanban …``) resolve
|
| 829 |
+
the board on different paths: the env-pin if set, otherwise the global ``<root>/kanban/current`` file. A
|
| 830 |
+
concurrent ``hermes kanban boards switch`` from another session can flip the file mid-turn, so the same
|
| 831 |
+
chat sees its tool calls hit board A while its shell calls hit board B (#20074). Pinning at chat boot
|
| 832 |
+
mirrors what the dispatcher already does for spawned workers.
|
| 833 |
+
"""
|
| 834 |
+
if os.environ.get("HERMES_KANBAN_BOARD"):
|
| 835 |
+
return
|
| 836 |
+
with contextlib.suppress(Exception):
|
| 837 |
+
from hermes_cli.kanban_db import get_current_board
|
| 838 |
+
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def _sync_bundled_skills_quietly() -> None:
|
| 842 |
+
"""Seed ``~/.hermes/skills/`` with the bundled library (idempotent, milliseconds when synced).
|
| 843 |
+
Failures are swallowed: skills are an enhancement, not a hard dependency."""
|
| 844 |
+
with contextlib.suppress(Exception):
|
| 845 |
+
from tools.skills_sync import sync_skills
|
| 846 |
+
sync_skills(quiet=True)
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
def _resolve_use_tui(args) -> bool:
|
| 850 |
+
"""Decide whether to launch the TUI: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
|
| 851 |
+
``HERMES_TUI=1`` → TUI; ``display.interface`` config; default classic.
|
| 852 |
+
|
| 853 |
+
The TTY gate is load-bearing: ambient preferences must never hijack a piped
|
| 854 |
+
``hermes chat -q`` (kanban workers, cron) — the Ink no-TTY bail-out exits 0 and
|
| 855 |
+
the worker dies with a protocol violation. Explicit ``--tui`` still bails out.
|
| 856 |
+
"""
|
| 857 |
+
if getattr(args, "cli", False):
|
| 858 |
+
return False
|
| 859 |
+
if getattr(args, "tui", False):
|
| 860 |
+
return True
|
| 861 |
+
try:
|
| 862 |
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
| 863 |
+
return False
|
| 864 |
+
except Exception:
|
| 865 |
+
return False
|
| 866 |
+
if os.environ.get("HERMES_TUI") == "1":
|
| 867 |
+
return True
|
| 868 |
+
try:
|
| 869 |
+
from hermes_cli.config import load_config
|
| 870 |
+
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
|
| 871 |
+
return isinstance(iface, str) and iface.strip().lower() == "tui"
|
| 872 |
+
except Exception:
|
| 873 |
+
return False
|
hermes_cli/mcp_picker.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
from hermes_cli.colors import Colors
|
| 10 |
+
from hermes_cli.cli_output import prompt_yes_no
|
| 11 |
+
from hermes_cli.curses_ui import curses_single_select
|
| 12 |
+
from hermes_cli.mcp_catalog import (
|
| 13 |
+
CatalogEntry, CatalogError, catalog_diagnostics, install_entry, is_enabled, is_installed,
|
| 14 |
+
list_catalog, installed_servers, remove_server, server_enabled, uninstall_entry, _say,
|
| 15 |
+
)
|
| 16 |
+
from hermes_cli.config import load_config, save_config
|
| 17 |
+
|
| 18 |
+
_STATUS_NOT_INSTALLED = "available"
|
| 19 |
+
_STATUS_DISABLED = "installed (disabled)"
|
| 20 |
+
_STATUS_ENABLED = "enabled"
|
| 21 |
+
_STATUS_CUSTOM_ENABLED = "custom — enabled"
|
| 22 |
+
_STATUS_CUSTOM_DISABLED = "custom — disabled"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class _Row:
|
| 27 |
+
"""A picker row. ``entry`` is set for catalog rows; custom MCPs carry only name/description/status."""
|
| 28 |
+
|
| 29 |
+
name: str
|
| 30 |
+
description: str
|
| 31 |
+
status: str
|
| 32 |
+
entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def is_custom(self) -> bool:
|
| 36 |
+
return self.entry is None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _build_rows() -> List[_Row]:
|
| 40 |
+
"""Return catalog rows + any custom (non-catalog) MCPs found in config."""
|
| 41 |
+
catalog_entries = list_catalog()
|
| 42 |
+
catalog_names = {e.name for e in catalog_entries}
|
| 43 |
+
servers = installed_servers()
|
| 44 |
+
|
| 45 |
+
rows: List[_Row] = []
|
| 46 |
+
for entry in catalog_entries:
|
| 47 |
+
cfg = servers.get(entry.name)
|
| 48 |
+
if entry.name not in servers:
|
| 49 |
+
status = _STATUS_NOT_INSTALLED
|
| 50 |
+
elif cfg and server_enabled(cfg):
|
| 51 |
+
status = _STATUS_ENABLED
|
| 52 |
+
else:
|
| 53 |
+
status = _STATUS_DISABLED
|
| 54 |
+
rows.append(_Row(entry.name, entry.description, status, entry))
|
| 55 |
+
# Custom (non-catalog) MCPs: the transport URL/command doubles as the description.
|
| 56 |
+
for name, cfg in sorted(servers.items()):
|
| 57 |
+
if name in catalog_names:
|
| 58 |
+
continue
|
| 59 |
+
status = _STATUS_CUSTOM_ENABLED if server_enabled(cfg) else _STATUS_CUSTOM_DISABLED
|
| 60 |
+
rows.append(_Row(name, str(cfg.get("url") or cfg.get("command") or "(no transport)"), status))
|
| 61 |
+
return rows
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _format_row(row: _Row) -> str:
|
| 65 |
+
return f"{row.name:<18} {row.status:<24} {row.description}"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _enable_disable(name: str, *, enable: bool) -> None:
|
| 70 |
+
cfg = load_config()
|
| 71 |
+
servers = cfg.get("mcp_servers") or {}
|
| 72 |
+
server = servers.get(name)
|
| 73 |
+
if not server:
|
| 74 |
+
_say(f" '{name}' is not installed.", Colors.RED)
|
| 75 |
+
return
|
| 76 |
+
server["enabled"] = enable
|
| 77 |
+
cfg["mcp_servers"] = servers
|
| 78 |
+
save_config(cfg)
|
| 79 |
+
_say(
|
| 80 |
+
f" ✓ '{name}' {'enabled' if enable else 'disabled'}. "
|
| 81 |
+
"Start a new Hermes session for changes to take effect."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _configure_tools(name: str) -> None:
|
| 86 |
+
"""Open the tool selection checklist for an already-installed MCP."""
|
| 87 |
+
from argparse import Namespace
|
| 88 |
+
from hermes_cli.mcp_config import cmd_mcp_configure
|
| 89 |
+
|
| 90 |
+
cmd_mcp_configure(Namespace(name=name))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _remove_custom(name: str) -> None:
|
| 94 |
+
"""Remove a non-catalog MCP entry from config.yaml."""
|
| 95 |
+
if not is_installed(name):
|
| 96 |
+
_say(f" '{name}' is not configured.", Colors.RED)
|
| 97 |
+
return
|
| 98 |
+
if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False):
|
| 99 |
+
return
|
| 100 |
+
remove_server(name)
|
| 101 |
+
_say(f" ✓ Removed '{name}'")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _install(entry: CatalogEntry, verb: str) -> bool:
|
| 105 |
+
"""Install *entry*, printing (not raising) a CatalogError. True on success."""
|
| 106 |
+
try:
|
| 107 |
+
install_entry(entry, enable=True)
|
| 108 |
+
except CatalogError as exc:
|
| 109 |
+
_say(f" ✗ {verb} failed: {exc}", Colors.RED)
|
| 110 |
+
return False
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _uninstall(name: str) -> None:
|
| 115 |
+
if not prompt_yes_no(f"Uninstall '{name}'?", default=False):
|
| 116 |
+
return
|
| 117 |
+
if uninstall_entry(name):
|
| 118 |
+
_say(
|
| 119 |
+
f" ✓ Uninstalled '{name}'. "
|
| 120 |
+
"Credentials in .env preserved — delete manually if no longer needed."
|
| 121 |
+
)
|
| 122 |
+
else:
|
| 123 |
+
_say(f" '{name}' was not installed", Colors.DIM)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _run_submenu(title: str, actions: list) -> None:
|
| 127 |
+
"""Show a single-select of ``(label, callback)`` pairs and run the picked callback."""
|
| 128 |
+
choice = curses_single_select(title, [label for label, _ in actions])
|
| 129 |
+
if choice is not None:
|
| 130 |
+
actions[choice][1]()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _handle_row(row: _Row) -> None:
|
| 134 |
+
"""Act on the picked row based on its current status."""
|
| 135 |
+
if row.entry and not is_installed(row.name):
|
| 136 |
+
_install(row.entry, "install")
|
| 137 |
+
return
|
| 138 |
+
if row.entry and not is_enabled(row.name):
|
| 139 |
+
_enable_disable(row.name, enable=True)
|
| 140 |
+
return
|
| 141 |
+
if row.is_custom:
|
| 142 |
+
enabled = is_enabled(row.name)
|
| 143 |
+
_run_submenu(f"Action for '{row.name}' (custom)", [
|
| 144 |
+
("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
|
| 145 |
+
("Enable" if not enabled else "Disable",
|
| 146 |
+
lambda: _enable_disable(row.name, enable=not is_enabled(row.name))),
|
| 147 |
+
("Remove from config", lambda: _remove_custom(row.name))])
|
| 148 |
+
return
|
| 149 |
+
# Catalog row, installed + enabled
|
| 150 |
+
print()
|
| 151 |
+
_say(f" '{row.name}' is already enabled.", Colors.DIM)
|
| 152 |
+
_run_submenu(f"Action for '{row.name}'", [
|
| 153 |
+
("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
|
| 154 |
+
("Disable (keep config, stop loading on next session)",
|
| 155 |
+
lambda: _enable_disable(row.name, enable=False)),
|
| 156 |
+
("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)),
|
| 157 |
+
("Reinstall (re-clone, re-prompt for credentials)",
|
| 158 |
+
lambda: _install(row.entry, "reinstall"))])
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _print_rows_text(rows: List[_Row]) -> None:
|
| 162 |
+
"""Plain-text catalog dump: `hermes mcp catalog` output and the non-curses fallback."""
|
| 163 |
+
print()
|
| 164 |
+
if not rows:
|
| 165 |
+
_say(" No MCPs in the catalog or configured.", Colors.DIM)
|
| 166 |
+
print()
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
_say(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD)
|
| 170 |
+
print()
|
| 171 |
+
print(f" {'Name':<18} {'Status':<24} Description")
|
| 172 |
+
print(f" {'-' * 18} {'-' * 24} {'-' * 11}")
|
| 173 |
+
for row in rows:
|
| 174 |
+
print(f" {_format_row(row)}")
|
| 175 |
+
print()
|
| 176 |
+
_say(" Install: hermes mcp install <name> Picker: hermes mcp", Colors.DIM)
|
| 177 |
+
# Manifest-version warnings: the user's Hermes is too old to install everything listed.
|
| 178 |
+
future = [d for d in catalog_diagnostics() if d[1] == "future_manifest"]
|
| 179 |
+
if future:
|
| 180 |
+
print()
|
| 181 |
+
for name, _, _msg in future:
|
| 182 |
+
_say(f" ⚠ '{name}' requires a newer Hermes — run `hermes update` to install this entry.", Colors.YELLOW)
|
| 183 |
+
print()
|
| 184 |
+
print()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def show_catalog() -> None:
|
| 188 |
+
"""`hermes mcp catalog` — print the curated list + custom servers, no interaction."""
|
| 189 |
+
_print_rows_text(_build_rows())
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def run_picker() -> None:
|
| 193 |
+
"""`hermes mcp picker` (and default `hermes mcp`) — interactive selector; re-renders after each
|
| 194 |
+
action until ESC/q."""
|
| 195 |
+
while True:
|
| 196 |
+
rows = _build_rows()
|
| 197 |
+
if not rows or not sys.stdin.isatty():
|
| 198 |
+
_print_rows_text(rows) # non-interactive: degrade to the text dump
|
| 199 |
+
return
|
| 200 |
+
idx = curses_single_select(
|
| 201 |
+
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit",
|
| 202 |
+
[_format_row(r) for r in rows],
|
| 203 |
+
)
|
| 204 |
+
if idx is None:
|
| 205 |
+
return
|
| 206 |
+
_handle_row(rows[idx])
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def install_by_name(identifier: str) -> int:
|
| 210 |
+
"""`hermes mcp install <name>` — non-interactive entry-point."""
|
| 211 |
+
from hermes_cli.mcp_catalog import get_entry
|
| 212 |
+
|
| 213 |
+
entry = get_entry(identifier)
|
| 214 |
+
if entry is None:
|
| 215 |
+
_say(
|
| 216 |
+
f" ✗ '{identifier}' is not in the catalog. Run `hermes mcp catalog` to see available entries.",
|
| 217 |
+
Colors.RED,
|
| 218 |
+
)
|
| 219 |
+
return 1
|
| 220 |
+
return 0 if _install(entry, "install") else 1
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 224 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 225 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 226 |
+
# The whole block is removed by reverting the commit that added it.
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
_PLUGIN_COMPAT_LAZY = {
|
| 230 |
+
'color': ('hermes_cli.colors', 'color'),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
| 235 |
+
target = _PLUGIN_COMPAT_LAZY.get(name)
|
| 236 |
+
if target is None:
|
| 237 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
| 238 |
+
import importlib
|
| 239 |
+
from hermes_cli.plugin_compat import warn_once
|
| 240 |
+
warn_once(__name__, name, *target)
|
| 241 |
+
return getattr(importlib.import_module(target[0]), target[1])
|
| 242 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_cli/memory_setup.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""hermes memory setup|status — configure memory provider plugins."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
import shlex
|
| 9 |
+
|
| 10 |
+
from hermes_constants import get_hermes_home
|
| 11 |
+
from hermes_cli.secret_prompt import masked_secret_prompt
|
| 12 |
+
|
| 13 |
+
_CANCELLED = -1
|
| 14 |
+
|
| 15 |
+
# pip name → import name mapping for packages where they differ
|
| 16 |
+
_IMPORT_NAMES = {
|
| 17 |
+
"honcho-ai": "honcho",
|
| 18 |
+
"mem0ai": "mem0",
|
| 19 |
+
"hindsight-client": "hindsight_client",
|
| 20 |
+
"hindsight-all": "hindsight"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
|
| 24 |
+
"""Return the pip deps a provider actually needs on THIS install.
|
| 25 |
+
|
| 26 |
+
``plugin.yaml`` declares the baseline bridge packages; some providers add mode-dependent extras
|
| 27 |
+
at setup time that the manifest can't express.
|
| 28 |
+
|
| 29 |
+
Hindsight's ``local_embedded`` mode installs ``hindsight-all`` (daemon + embedder + client) during
|
| 30 |
+
``hermes memory setup`` — if the update-time refresh only reinstalled the declared ``hindsight-client``,
|
| 31 |
+
the embedded daemon would stay broken after a venv rebuild stripped ``hindsight-embed`` (#70636).
|
| 32 |
+
"""
|
| 33 |
+
deps = list(declared or [])
|
| 34 |
+
if provider_name == "hindsight":
|
| 35 |
+
try:
|
| 36 |
+
import json
|
| 37 |
+
cfg_path = get_hermes_home() / "hindsight" / "config.json"
|
| 38 |
+
cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {}
|
| 39 |
+
# "local" is a legacy alias for "local_embedded"
|
| 40 |
+
if cfg.get("mode", "") in {"local", "local_embedded"}:
|
| 41 |
+
deps.append("hindsight-all")
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
return deps
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _curses_select(
|
| 48 |
+
title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None
|
| 49 |
+
) -> int:
|
| 50 |
+
"""Interactive single-select with arrow keys."""
|
| 51 |
+
from hermes_cli.curses_ui import curses_radiolist
|
| 52 |
+
|
| 53 |
+
if cancel_returns is None:
|
| 54 |
+
cancel_returns = default
|
| 55 |
+
display_items = [f"{label} - {desc}" if desc else label for label, desc in items]
|
| 56 |
+
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
|
| 57 |
+
_clear_interactive_transition()
|
| 58 |
+
return result
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _print_cancelled_setup() -> None:
|
| 62 |
+
print("\n Cancelled. No changes saved.\n")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _clear_interactive_transition() -> None:
|
| 66 |
+
"""Clear stale curses content before entering a follow-up setup screen."""
|
| 67 |
+
if not sys.stdout.isatty():
|
| 68 |
+
return
|
| 69 |
+
sys.stdout.write("\033[2J\033[H")
|
| 70 |
+
sys.stdout.flush()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
|
| 74 |
+
"""Prompt for a value with optional default and secret masking."""
|
| 75 |
+
suffix = f" [{default}]" if default else ""
|
| 76 |
+
if secret:
|
| 77 |
+
val = masked_secret_prompt(f" {label}{suffix}: ")
|
| 78 |
+
else:
|
| 79 |
+
sys.stdout.write(f" {label}{suffix}: ")
|
| 80 |
+
sys.stdout.flush()
|
| 81 |
+
val = sys.stdin.readline().strip()
|
| 82 |
+
return val or (default or "")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _install_dependencies(provider_name: str, *, force: bool = False) -> None:
|
| 86 |
+
"""Install pip dependencies declared in ``plugin.yaml``.
|
| 87 |
+
|
| 88 |
+
With ``force`` every declared dependency goes to the installer even if it imports (the resolver
|
| 89 |
+
no-ops when nothing drifted) — how ``hermes update`` heals a provider after a venv rebuild.
|
| 90 |
+
|
| 91 |
+
When ``force`` is true, every declared dependency is handed to the installer even if its import
|
| 92 |
+
currently succeeds — the resolver then reinstalls anything missing or version-drifted and no-ops on
|
| 93 |
+
satisfied ranges. This is how ``hermes update`` heals the active memory provider after a venv
|
| 94 |
+
rebuild/sync removed or downgraded its bridge packages (#53272, #70636).
|
| 95 |
+
"""
|
| 96 |
+
import subprocess
|
| 97 |
+
from plugins.memory import find_provider_dir
|
| 98 |
+
|
| 99 |
+
plugin_dir = find_provider_dir(provider_name)
|
| 100 |
+
if not plugin_dir:
|
| 101 |
+
return
|
| 102 |
+
yaml_path = plugin_dir / "plugin.yaml"
|
| 103 |
+
if not yaml_path.exists():
|
| 104 |
+
return
|
| 105 |
+
try:
|
| 106 |
+
import yaml
|
| 107 |
+
with open(yaml_path, encoding="utf-8") as f:
|
| 108 |
+
meta = yaml.safe_load(f) or {}
|
| 109 |
+
except Exception:
|
| 110 |
+
return
|
| 111 |
+
|
| 112 |
+
pip_deps = _provider_pip_dependencies(provider_name, meta.get("pip_dependencies", []))
|
| 113 |
+
if not pip_deps:
|
| 114 |
+
return
|
| 115 |
+
|
| 116 |
+
missing = []
|
| 117 |
+
for dep in pip_deps:
|
| 118 |
+
if force:
|
| 119 |
+
missing.append(dep)
|
| 120 |
+
continue
|
| 121 |
+
dep_name = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*", dep)
|
| 122 |
+
base = dep_name.group(0) if dep_name else dep
|
| 123 |
+
import_name = _IMPORT_NAMES.get(base, base.replace("-", "_").split("[")[0])
|
| 124 |
+
try:
|
| 125 |
+
__import__(import_name)
|
| 126 |
+
except ImportError:
|
| 127 |
+
missing.append(dep)
|
| 128 |
+
if not missing:
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
print(f"\n Installing dependencies: {', '.join(missing)}")
|
| 132 |
+
|
| 133 |
+
# install_specs routes to the durable target on sealed hosted images (HERMES_LAZY_INSTALL_TARGET)
|
| 134 |
+
# and is venv-scoped on normal installs.
|
| 135 |
+
from tools.lazy_deps import install_specs
|
| 136 |
+
|
| 137 |
+
manual_cmd = f"uv pip install {' '.join(missing)}"
|
| 138 |
+
try:
|
| 139 |
+
outcome = install_specs(missing, timeout=120)
|
| 140 |
+
if outcome.ok:
|
| 141 |
+
print(f" ✓ Installed {', '.join(missing)}")
|
| 142 |
+
elif outcome.blocked:
|
| 143 |
+
print(f" ⚠ Cannot install {', '.join(missing)}: {outcome.reason}")
|
| 144 |
+
else:
|
| 145 |
+
print(f" ⚠ Failed to install {', '.join(missing)}")
|
| 146 |
+
stderr = (outcome.stderr or "")[:200]
|
| 147 |
+
if stderr:
|
| 148 |
+
print(f" {stderr}")
|
| 149 |
+
print(f" Run manually: {manual_cmd}")
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f" ⚠ Install failed: {e}")
|
| 152 |
+
print(f" Run manually: {manual_cmd}")
|
| 153 |
+
|
| 154 |
+
# Also show external (non-pip) dependencies that are missing.
|
| 155 |
+
for dep in meta.get("external_dependencies", []):
|
| 156 |
+
check_cmd = dep.get("check", "")
|
| 157 |
+
install_cmd = dep.get("install", "")
|
| 158 |
+
if check_cmd:
|
| 159 |
+
try:
|
| 160 |
+
subprocess.run(shlex.split(check_cmd), check=True, capture_output=True, timeout=5)
|
| 161 |
+
except Exception:
|
| 162 |
+
if install_cmd:
|
| 163 |
+
print(f"\n ⚠ '{dep.get('name', '')}' not found. Install with:")
|
| 164 |
+
print(f" {install_cmd}")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _schema_of(provider) -> list:
|
| 168 |
+
return provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _get_available_providers() -> list:
|
| 172 |
+
"""Discover memory providers from plugins/memory/ as ``(name, setup_hint, provider)`` tuples."""
|
| 173 |
+
try:
|
| 174 |
+
from plugins.memory import discover_memory_providers, load_memory_provider
|
| 175 |
+
raw = discover_memory_providers()
|
| 176 |
+
except Exception:
|
| 177 |
+
raw = []
|
| 178 |
+
|
| 179 |
+
results = []
|
| 180 |
+
for name, desc, available in raw:
|
| 181 |
+
try:
|
| 182 |
+
provider = load_memory_provider(name)
|
| 183 |
+
if not provider:
|
| 184 |
+
continue
|
| 185 |
+
except Exception:
|
| 186 |
+
continue
|
| 187 |
+
schema = _schema_of(provider)
|
| 188 |
+
has_secrets = any(f.get("secret") for f in schema)
|
| 189 |
+
has_non_secrets = any(not f.get("secret") for f in schema)
|
| 190 |
+
if has_secrets and has_non_secrets:
|
| 191 |
+
setup_hint = "API key / local"
|
| 192 |
+
elif has_secrets:
|
| 193 |
+
setup_hint = "requires API key"
|
| 194 |
+
elif not schema:
|
| 195 |
+
setup_hint = "no setup needed"
|
| 196 |
+
else:
|
| 197 |
+
setup_hint = "local"
|
| 198 |
+
results.append((name, setup_hint, provider))
|
| 199 |
+
return results
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _find_provider(providers: list, provider_name: str):
|
| 203 |
+
return next((p for p in providers if p[0] == provider_name), None)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _post_setup_hook(provider, config: dict) -> bool:
|
| 207 |
+
"""Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns
|
| 208 |
+
config, connection test and activation), so the caller must stop."""
|
| 209 |
+
if not isinstance(config.get("memory"), dict):
|
| 210 |
+
config["memory"] = {}
|
| 211 |
+
if hasattr(provider, "post_setup"):
|
| 212 |
+
provider.post_setup(str(get_hermes_home()), config)
|
| 213 |
+
return True
|
| 214 |
+
return False
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def cmd_setup_provider(provider_name: str) -> None:
|
| 218 |
+
"""Run memory setup for a specific provider, skipping the picker."""
|
| 219 |
+
from hermes_cli.config import load_config, save_config
|
| 220 |
+
|
| 221 |
+
match = _find_provider(_get_available_providers(), provider_name)
|
| 222 |
+
if not match:
|
| 223 |
+
print(f"\n Memory provider '{provider_name}' not found.")
|
| 224 |
+
print(" Run 'hermes memory setup' to see available providers.\n")
|
| 225 |
+
return
|
| 226 |
+
name, _, provider = match
|
| 227 |
+
|
| 228 |
+
_clear_interactive_transition()
|
| 229 |
+
_install_dependencies(name)
|
| 230 |
+
config = load_config()
|
| 231 |
+
if _post_setup_hook(provider, config):
|
| 232 |
+
return
|
| 233 |
+
# Fallback: generic schema-based setup (same as cmd_setup)
|
| 234 |
+
config["memory"]["provider"] = name
|
| 235 |
+
save_config(config)
|
| 236 |
+
print(f"\n Memory provider: {name}")
|
| 237 |
+
print(" Activation saved to config.yaml\n")
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_writes: dict) -> bool:
|
| 241 |
+
"""Walk a provider's config schema, prompting per field. False when the user cancelled."""
|
| 242 |
+
print(f"\n Configuring {name}:\n")
|
| 243 |
+
for field in schema:
|
| 244 |
+
key = field["key"]
|
| 245 |
+
desc = field.get("description", key)
|
| 246 |
+
default = field.get("default")
|
| 247 |
+
# Dynamic default: look up default from another field's value
|
| 248 |
+
default_from = field.get("default_from")
|
| 249 |
+
if default_from and isinstance(default_from, dict):
|
| 250 |
+
ref_value = provider_config.get(default_from.get("field", ""), "")
|
| 251 |
+
ref_map = default_from.get("map", {})
|
| 252 |
+
if ref_value and ref_value in ref_map:
|
| 253 |
+
default = ref_map[ref_value]
|
| 254 |
+
is_secret = field.get("secret", False)
|
| 255 |
+
choices = field.get("choices")
|
| 256 |
+
env_var = field.get("env_var")
|
| 257 |
+
url = field.get("url")
|
| 258 |
+
|
| 259 |
+
when = field.get("when")
|
| 260 |
+
if when and isinstance(when, dict) and not all(provider_config.get(k) == v for k, v in when.items()):
|
| 261 |
+
continue
|
| 262 |
+
|
| 263 |
+
if choices and not is_secret:
|
| 264 |
+
current = provider_config.get(key, default)
|
| 265 |
+
current_idx = choices.index(current) if current and current in choices else 0
|
| 266 |
+
sel = _curses_select(
|
| 267 |
+
f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED
|
| 268 |
+
)
|
| 269 |
+
if sel == _CANCELLED:
|
| 270 |
+
_print_cancelled_setup()
|
| 271 |
+
return False
|
| 272 |
+
provider_config[key] = choices[sel]
|
| 273 |
+
elif is_secret:
|
| 274 |
+
existing = os.environ.get(env_var, "") if env_var else ""
|
| 275 |
+
if existing:
|
| 276 |
+
masked = f"...{existing[-4:]}" if len(existing) > 4 else "set"
|
| 277 |
+
val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True)
|
| 278 |
+
else:
|
| 279 |
+
if url:
|
| 280 |
+
print(f" Get yours at {url}")
|
| 281 |
+
val = _prompt(desc, secret=True)
|
| 282 |
+
if val and env_var:
|
| 283 |
+
env_writes[env_var] = val
|
| 284 |
+
else:
|
| 285 |
+
effective_default = provider_config.get(key) or default
|
| 286 |
+
val = _prompt(desc, default=str(effective_default) if effective_default else None)
|
| 287 |
+
if val:
|
| 288 |
+
provider_config[key] = val
|
| 289 |
+
if env_var and env_var not in env_writes:
|
| 290 |
+
env_writes[env_var] = val
|
| 291 |
+
return True
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def cmd_setup(args) -> None:
|
| 295 |
+
"""Interactive memory provider setup wizard."""
|
| 296 |
+
from hermes_cli.config import load_config, save_config
|
| 297 |
+
|
| 298 |
+
providers = _get_available_providers()
|
| 299 |
+
if not providers:
|
| 300 |
+
print("\n No memory provider plugins detected.")
|
| 301 |
+
print(" Install a plugin to ~/.hermes/plugins/ and try again.\n")
|
| 302 |
+
return
|
| 303 |
+
|
| 304 |
+
items = [(name, f"— {desc}") for name, desc, _ in providers]
|
| 305 |
+
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
|
| 306 |
+
builtin_idx = len(items) - 1
|
| 307 |
+
selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
|
| 308 |
+
if selected == _CANCELLED:
|
| 309 |
+
_print_cancelled_setup()
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
config = load_config()
|
| 313 |
+
if not isinstance(config.get("memory"), dict):
|
| 314 |
+
config["memory"] = {}
|
| 315 |
+
if selected >= len(providers):
|
| 316 |
+
config["memory"]["provider"] = ""
|
| 317 |
+
save_config(config)
|
| 318 |
+
print("\n ✓ Memory provider: built-in only")
|
| 319 |
+
print(" Saved to config.yaml\n")
|
| 320 |
+
return
|
| 321 |
+
|
| 322 |
+
name, _, provider = providers[selected]
|
| 323 |
+
_clear_interactive_transition()
|
| 324 |
+
_install_dependencies(name)
|
| 325 |
+
if _post_setup_hook(provider, config):
|
| 326 |
+
return
|
| 327 |
+
|
| 328 |
+
provider_config = config["memory"].get(name, {})
|
| 329 |
+
if not isinstance(provider_config, dict):
|
| 330 |
+
provider_config = {}
|
| 331 |
+
env_writes: dict = {}
|
| 332 |
+
schema = _schema_of(provider)
|
| 333 |
+
if schema and not _prompt_schema_fields(name, schema, provider_config, env_writes):
|
| 334 |
+
return
|
| 335 |
+
|
| 336 |
+
config["memory"]["provider"] = name
|
| 337 |
+
save_config(config)
|
| 338 |
+
|
| 339 |
+
if provider_config and hasattr(provider, "save_config"):
|
| 340 |
+
try:
|
| 341 |
+
provider.save_config(provider_config, str(get_hermes_home()))
|
| 342 |
+
except Exception as e:
|
| 343 |
+
print(f" Failed to write provider config: {e}")
|
| 344 |
+
if env_writes:
|
| 345 |
+
_write_env_vars(env_writes)
|
| 346 |
+
|
| 347 |
+
print(f"\n Memory provider: {name}")
|
| 348 |
+
print(" Activation saved to config.yaml")
|
| 349 |
+
if provider_config:
|
| 350 |
+
print(" Provider config saved")
|
| 351 |
+
if env_writes:
|
| 352 |
+
print(" API keys saved to .env")
|
| 353 |
+
print("\n Start a new session to activate.\n")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _write_env_vars(
|
| 357 |
+
env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None:
|
| 358 |
+
"""Persist memory-provider env vars through the canonical ``.env`` writer.
|
| 359 |
+
|
| 360 |
+
``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME``
|
| 361 |
+
denylist, CR/LF stripping, atomic 0o600 writes). ``ValueError`` is reported and skipped so one
|
| 362 |
+
bad key doesn't sink the batch; filesystem errors propagate. ``hermes_home`` is applied via the
|
| 363 |
+
context-local override, not ``os.environ``.
|
| 364 |
+
"""
|
| 365 |
+
from hermes_cli.config import save_env_value
|
| 366 |
+
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
| 367 |
+
|
| 368 |
+
token = set_hermes_home_override(hermes_home) if hermes_home is not None else None
|
| 369 |
+
try:
|
| 370 |
+
for key, val in env_writes.items():
|
| 371 |
+
try:
|
| 372 |
+
save_env_value(key, val)
|
| 373 |
+
except ValueError as exc:
|
| 374 |
+
print(f" Skipping {key}: {exc}")
|
| 375 |
+
finally:
|
| 376 |
+
if token is not None:
|
| 377 |
+
reset_hermes_home_override(token)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def _mark(enabled) -> str:
|
| 381 |
+
return "enabled ✓" if enabled else "disabled ✗"
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def cmd_status(args) -> None:
|
| 385 |
+
"""Show current memory provider config."""
|
| 386 |
+
from hermes_cli.config import load_config
|
| 387 |
+
|
| 388 |
+
config = load_config()
|
| 389 |
+
mem_config = config.get("memory", {})
|
| 390 |
+
provider_name = mem_config.get("provider", "")
|
| 391 |
+
|
| 392 |
+
# Memory tool enablement for the CLI platform via the canonical resolver, respecting the
|
| 393 |
+
# check_fn gate when both stores are disabled.
|
| 394 |
+
from hermes_cli.tools_config import _get_platform_tools
|
| 395 |
+
from tools.memory_tool import check_memory_requirements
|
| 396 |
+
cli_tools = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
| 397 |
+
memory_tool_enabled = ("memory" in cli_tools) and check_memory_requirements()
|
| 398 |
+
|
| 399 |
+
print("\nMemory status\n" + "─" * 40)
|
| 400 |
+
print(" Built-in (MEMORY.md / USER.md):")
|
| 401 |
+
print(f" Memory injection: {_mark(mem_config.get('memory_enabled', True))}")
|
| 402 |
+
print(f" User profile: {_mark(mem_config.get('user_profile_enabled', True))}")
|
| 403 |
+
print(f" Memory tool: {_mark(memory_tool_enabled)}")
|
| 404 |
+
print(f" Provider: {provider_name or '(none — built-in only)'}")
|
| 405 |
+
|
| 406 |
+
providers = _get_available_providers()
|
| 407 |
+
match = _find_provider(providers, provider_name)
|
| 408 |
+
provider = match[2] if match else None
|
| 409 |
+
|
| 410 |
+
if provider_name:
|
| 411 |
+
provider_config = mem_config.get(provider_name, {})
|
| 412 |
+
display_config = provider_config
|
| 413 |
+
if provider and hasattr(provider, "get_status_config"):
|
| 414 |
+
try:
|
| 415 |
+
display_config = provider.get_status_config(provider_config)
|
| 416 |
+
except Exception as e:
|
| 417 |
+
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
|
| 418 |
+
if isinstance(display_config, dict):
|
| 419 |
+
display_config["status_config_error"] = str(e)
|
| 420 |
+
if display_config:
|
| 421 |
+
print(f"\n {provider_name} config:")
|
| 422 |
+
for key, val in display_config.items():
|
| 423 |
+
print(f" {key}: {val}")
|
| 424 |
+
|
| 425 |
+
if provider:
|
| 426 |
+
print("\n Plugin: installed ✓")
|
| 427 |
+
if provider.is_available():
|
| 428 |
+
print(" Status: available ✓")
|
| 429 |
+
else:
|
| 430 |
+
print(" Status: not available ✗")
|
| 431 |
+
# All fields with env_var (secret and non-secret)
|
| 432 |
+
required_fields = [f for f in _schema_of(provider) if f.get("env_var")]
|
| 433 |
+
if required_fields:
|
| 434 |
+
print(" Missing:")
|
| 435 |
+
for f in required_fields:
|
| 436 |
+
env_var = f.get("env_var", "")
|
| 437 |
+
url = f.get("url", "")
|
| 438 |
+
is_set = bool(os.environ.get(env_var))
|
| 439 |
+
line = f" {'✓' if is_set else '✗'} {env_var}"
|
| 440 |
+
if url and not is_set:
|
| 441 |
+
line += f" → {url}"
|
| 442 |
+
print(line)
|
| 443 |
+
print(" Note: systemd/gateway services do not inherit ~/.hermes/.env —")
|
| 444 |
+
print(" set any variables above in the service environment.")
|
| 445 |
+
else:
|
| 446 |
+
print("\n Plugin: NOT installed ✗")
|
| 447 |
+
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
|
| 448 |
+
|
| 449 |
+
if providers:
|
| 450 |
+
print("\n Installed plugins:")
|
| 451 |
+
for pname, desc, _ in providers:
|
| 452 |
+
active = " ← active" if pname == provider_name else ""
|
| 453 |
+
print(f" • {pname} ({desc}){active}")
|
| 454 |
+
print()
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def memory_command(args) -> None:
|
| 458 |
+
"""Route memory subcommands."""
|
| 459 |
+
if getattr(args, "memory_command", None) == "setup":
|
| 460 |
+
provider = getattr(args, "provider", None)
|
| 461 |
+
if provider:
|
| 462 |
+
cmd_setup_provider(provider)
|
| 463 |
+
else:
|
| 464 |
+
cmd_setup(args)
|
| 465 |
+
else:
|
| 466 |
+
cmd_status(args)
|
hermes_cli/middleware.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermes middleware contract helpers.
|
| 2 |
+
|
| 3 |
+
Observer hooks report what happened. Middleware can change what happens by rewriting a request or
|
| 4 |
+
wrapping the actual execution callback. Agent-loop call sites and plugins share this vocabulary.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from copy import deepcopy
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Any, Callable, Dict, List
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
OBSERVER_SCHEMA_VERSION = "hermes.observer.v1"
|
| 17 |
+
MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1"
|
| 18 |
+
|
| 19 |
+
TOOL_REQUEST_MIDDLEWARE = "tool_request"
|
| 20 |
+
TOOL_EXECUTION_MIDDLEWARE = "tool_execution"
|
| 21 |
+
LLM_REQUEST_MIDDLEWARE = "llm_request"
|
| 22 |
+
LLM_EXECUTION_MIDDLEWARE = "llm_execution"
|
| 23 |
+
|
| 24 |
+
VALID_MIDDLEWARE: set[str] = {
|
| 25 |
+
TOOL_REQUEST_MIDDLEWARE, TOOL_EXECUTION_MIDDLEWARE, LLM_REQUEST_MIDDLEWARE, LLM_EXECUTION_MIDDLEWARE,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class RequestMiddlewareResult:
|
| 31 |
+
"""Result of applying request middleware to a mutable payload."""
|
| 32 |
+
|
| 33 |
+
payload: Any
|
| 34 |
+
original_payload: Any
|
| 35 |
+
changed: bool = False
|
| 36 |
+
trace: List[Dict[str, Any]] = field(default_factory=list)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def observer_payload(**kwargs: Any) -> Dict[str, Any]:
|
| 40 |
+
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
| 41 |
+
return kwargs
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def middleware_payload(**kwargs: Any) -> Dict[str, Any]:
|
| 45 |
+
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
|
| 46 |
+
kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION)
|
| 47 |
+
return kwargs
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _safe_copy(payload: Any) -> Any:
|
| 51 |
+
"""Deep-copy a request payload, tolerating non-deepcopyable members.
|
| 52 |
+
|
| 53 |
+
An LLM request can carry clients/callbacks/file handles; a hard ``deepcopy`` failure would
|
| 54 |
+
otherwise abort the whole request-middleware pass.
|
| 55 |
+
"""
|
| 56 |
+
try:
|
| 57 |
+
return deepcopy(payload)
|
| 58 |
+
except Exception as exc: # pragma: no cover - exercised via fallback test
|
| 59 |
+
logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc)
|
| 60 |
+
return dict(payload) if isinstance(payload, dict) else payload
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _apply_request_chain(
|
| 64 |
+
kind: str, payload_key: str, trace: List[Dict[str, Any]], original: Any, **kwargs: Any
|
| 65 |
+
) -> RequestMiddlewareResult:
|
| 66 |
+
"""Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``."""
|
| 67 |
+
from hermes_cli.plugins import invoke_middleware
|
| 68 |
+
|
| 69 |
+
current = kwargs[payload_key]
|
| 70 |
+
for result in invoke_middleware(kind, **middleware_payload(**kwargs)):
|
| 71 |
+
if not isinstance(result, dict):
|
| 72 |
+
continue
|
| 73 |
+
next_payload = result.get(payload_key)
|
| 74 |
+
if not isinstance(next_payload, dict):
|
| 75 |
+
continue
|
| 76 |
+
current = _safe_copy(next_payload)
|
| 77 |
+
entry = {
|
| 78 |
+
key: value
|
| 79 |
+
for key in ("source", "reason", "name")
|
| 80 |
+
if isinstance(value := result.get(key), str) and value
|
| 81 |
+
}
|
| 82 |
+
trace.append(entry or {"source": "plugin"})
|
| 83 |
+
return RequestMiddlewareResult(
|
| 84 |
+
payload=current, original_payload=original, changed=bool(trace), trace=trace,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> RequestMiddlewareResult:
|
| 89 |
+
"""Apply registered LLM request middleware; ``{"request": {...}}`` replaces the provider kwargs."""
|
| 90 |
+
from hermes_cli.plugins import has_middleware
|
| 91 |
+
|
| 92 |
+
if not has_middleware(LLM_REQUEST_MIDDLEWARE):
|
| 93 |
+
return RequestMiddlewareResult(payload=request, original_payload=request)
|
| 94 |
+
|
| 95 |
+
original_request = _safe_copy(request)
|
| 96 |
+
return _apply_request_chain(
|
| 97 |
+
LLM_REQUEST_MIDDLEWARE, "request", [], original_request,
|
| 98 |
+
request=_safe_copy(original_request), original_request=original_request, **context,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def apply_tool_request_middleware(
|
| 103 |
+
tool_name: str, args: Dict[str, Any], **context: Any
|
| 104 |
+
) -> RequestMiddlewareResult:
|
| 105 |
+
"""Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool
|
| 106 |
+
arguments before hooks, guardrails, approvals, and execution see them."""
|
| 107 |
+
original_args = _safe_copy(args)
|
| 108 |
+
current_args = _safe_copy(original_args)
|
| 109 |
+
trace: List[Dict[str, Any]] = []
|
| 110 |
+
|
| 111 |
+
session_id = str(context.get("session_id") or "")
|
| 112 |
+
skip_relay = bool(context.pop("skip_relay", False))
|
| 113 |
+
if session_id and not skip_relay:
|
| 114 |
+
from agent import relay_runtime
|
| 115 |
+
|
| 116 |
+
relay_args = relay_runtime.apply_tool_request_intercepts(
|
| 117 |
+
session_id=session_id, tool_name=tool_name, args=current_args)
|
| 118 |
+
if relay_args != current_args:
|
| 119 |
+
current_args = _safe_copy(relay_args)
|
| 120 |
+
trace.append({"source": "nemo_relay"})
|
| 121 |
+
|
| 122 |
+
from hermes_cli.plugins import has_middleware
|
| 123 |
+
|
| 124 |
+
if not has_middleware(TOOL_REQUEST_MIDDLEWARE):
|
| 125 |
+
return RequestMiddlewareResult(
|
| 126 |
+
payload=args if not trace else current_args, original_payload=args,
|
| 127 |
+
changed=bool(trace), trace=trace,
|
| 128 |
+
)
|
| 129 |
+
return _apply_request_chain(
|
| 130 |
+
TOOL_REQUEST_MIDDLEWARE, "args", trace, original_args,
|
| 131 |
+
tool_name=tool_name, args=current_args, original_args=original_args, **context,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def run_llm_execution_middleware(
|
| 136 |
+
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any:
|
| 137 |
+
"""Run provider execution through registered LLM execution middleware."""
|
| 138 |
+
return _run_execution_chain(
|
| 139 |
+
LLM_EXECUTION_MIDDLEWARE, next_call,
|
| 140 |
+
request=request, original_request=context.pop("original_request", request), **context)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def run_tool_execution_middleware(
|
| 144 |
+
tool_name: str, args: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any,
|
| 145 |
+
) -> Any:
|
| 146 |
+
"""Run tool execution through registered tool execution middleware."""
|
| 147 |
+
return _run_execution_chain(
|
| 148 |
+
TOOL_EXECUTION_MIDDLEWARE, next_call,
|
| 149 |
+
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class _DownstreamExecutionError(Exception):
|
| 153 |
+
"""Marks an exception raised BELOW a middleware frame so the frame's own failure handling
|
| 154 |
+
(skip-and-continue) doesn't swallow it."""
|
| 155 |
+
|
| 156 |
+
def __init__(self, original: BaseException) -> None:
|
| 157 |
+
super().__init__(str(original))
|
| 158 |
+
self.original = original
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwargs: Any) -> Any:
|
| 162 |
+
from hermes_cli.plugins import get_plugin_manager
|
| 163 |
+
|
| 164 |
+
payload_key = "request" if "request" in kwargs else "args"
|
| 165 |
+
callbacks = list(get_plugin_manager()._middleware.get(kind, []))
|
| 166 |
+
if not callbacks:
|
| 167 |
+
return terminal_call(kwargs[payload_key])
|
| 168 |
+
|
| 169 |
+
def call_at(index: int, payload: Any) -> Any:
|
| 170 |
+
if index >= len(callbacks):
|
| 171 |
+
return terminal_call(payload)
|
| 172 |
+
|
| 173 |
+
callback = callbacks[index]
|
| 174 |
+
next_called = False
|
| 175 |
+
next_succeeded = False
|
| 176 |
+
next_result: Any = None
|
| 177 |
+
|
| 178 |
+
def next_call(next_payload: Any = None) -> Any:
|
| 179 |
+
nonlocal next_called, next_succeeded, next_result
|
| 180 |
+
# Single-use per frame: a second call would re-run the downstream provider/tool, so it
|
| 181 |
+
# is a contract violation, not a retry.
|
| 182 |
+
if next_called:
|
| 183 |
+
raise RuntimeError(
|
| 184 |
+
f"Middleware '{kind}' callback "
|
| 185 |
+
f"{getattr(callback, '__name__', repr(callback))} called "
|
| 186 |
+
"next_call() more than once; downstream execution is single-use"
|
| 187 |
+
)
|
| 188 |
+
next_called = True
|
| 189 |
+
try:
|
| 190 |
+
next_result = call_at(index + 1, payload if next_payload is None else next_payload)
|
| 191 |
+
next_succeeded = True
|
| 192 |
+
return next_result
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
raise _DownstreamExecutionError(exc) from exc
|
| 195 |
+
|
| 196 |
+
call_kwargs = middleware_payload(**kwargs)
|
| 197 |
+
call_kwargs[payload_key] = payload
|
| 198 |
+
call_kwargs["next_call"] = next_call
|
| 199 |
+
try:
|
| 200 |
+
return callback(**call_kwargs)
|
| 201 |
+
except _DownstreamExecutionError as exc:
|
| 202 |
+
raise exc.original
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
logger.warning(
|
| 205 |
+
"Middleware '%s' callback %s raised: %s",
|
| 206 |
+
kind, getattr(callback, "__name__", repr(callback)), exc)
|
| 207 |
+
if next_succeeded:
|
| 208 |
+
return next_result
|
| 209 |
+
if next_called:
|
| 210 |
+
raise
|
| 211 |
+
return call_at(index + 1, payload)
|
| 212 |
+
|
| 213 |
+
return call_at(0, kwargs[payload_key])
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 217 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 218 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 219 |
+
# The whole block is removed by reverting the commit that added it.
|
| 220 |
+
|
| 221 |
+
API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE
|
| 222 |
+
|
| 223 |
+
API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE
|
| 224 |
+
|
| 225 |
+
def apply_api_request_middleware(
|
| 226 |
+
request: Dict[str, Any],
|
| 227 |
+
**context: Any,
|
| 228 |
+
) -> RequestMiddlewareResult:
|
| 229 |
+
"""Compatibility wrapper for older ``api_request`` naming."""
|
| 230 |
+
return apply_llm_request_middleware(request, **context)
|
| 231 |
+
|
| 232 |
+
def run_api_execution_middleware(
|
| 233 |
+
request: Dict[str, Any],
|
| 234 |
+
next_call: Callable[[Dict[str, Any]], Any],
|
| 235 |
+
**context: Any,
|
| 236 |
+
) -> Any:
|
| 237 |
+
"""Compatibility wrapper for older ``api_execution`` naming."""
|
| 238 |
+
return run_llm_execution_middleware(request, next_call, **context)
|
| 239 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_cli/models_pricing.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live model pricing.
|
| 2 |
+
|
| 3 |
+
OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache,
|
| 4 |
+
Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks /
|
| 5 |
+
DeepInfra pricing adapters. Split out of ``hermes_cli.models``; helpers still defined there are
|
| 6 |
+
looked up on ``hermes_cli.models`` at call time so ``patch("hermes_cli.models.<name>")`` mocks keep
|
| 7 |
+
intercepting.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import time
|
| 15 |
+
import urllib.request
|
| 16 |
+
from typing import Any, Optional
|
| 17 |
+
from hermes_cli.models_reasoning_caps import _seed_reasoning_caps
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
|
| 21 |
+
_pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
|
| 22 |
+
# (profile key, provider) → endpoint cache key last fetched, so cached_only reads find the right entry.
|
| 23 |
+
_pricing_provider_cache_keys: dict[tuple[str, str], str] = {}
|
| 24 |
+
|
| 25 |
+
# A failed fetch caches its empty result too, so an unreachable endpoint isn't re-dialed on every
|
| 26 |
+
# call — but only until this deadline. Cached forever, one blip at startup would mean no live model
|
| 27 |
+
# discovery for the life of a process that runs for weeks (gateway, desktop backend), silently:
|
| 28 |
+
# every caller falls back to a curated list meanwhile.
|
| 29 |
+
_FAILED_CATALOG_TTL_SECONDS = 120.0
|
| 30 |
+
|
| 31 |
+
_pricing_cache_retry_after: dict[str, float] = {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _cached_catalog(cache_key: str) -> Optional[dict[str, dict[str, Any]]]:
|
| 35 |
+
"""The cached catalog for *cache_key*, or None to go fetch it."""
|
| 36 |
+
cached = _pricing_cache.get(cache_key)
|
| 37 |
+
if cached is None:
|
| 38 |
+
return None
|
| 39 |
+
retry_after = _pricing_cache_retry_after.get(cache_key)
|
| 40 |
+
if retry_after is not None and time.monotonic() >= retry_after:
|
| 41 |
+
_pricing_cache.pop(cache_key, None)
|
| 42 |
+
_pricing_cache_retry_after.pop(cache_key, None)
|
| 43 |
+
return None
|
| 44 |
+
return cached
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _cache_catalog(
|
| 48 |
+
cache_key: str,
|
| 49 |
+
result: dict[str, dict[str, Any]],
|
| 50 |
+
ttl_seconds: Optional[float] = None,
|
| 51 |
+
) -> dict[str, dict[str, Any]]:
|
| 52 |
+
"""Cache a catalog result, giving an empty one an expiry. *ttl_seconds* expires a non-empty
|
| 53 |
+
result too — only for a catalog whose contents depend on server-side state the client cannot
|
| 54 |
+
observe (an org's model policy can change while a long-lived process holds the entry)."""
|
| 55 |
+
_pricing_cache[cache_key] = result
|
| 56 |
+
if not result:
|
| 57 |
+
_pricing_cache_retry_after[cache_key] = time.monotonic() + _FAILED_CATALOG_TTL_SECONDS
|
| 58 |
+
elif ttl_seconds:
|
| 59 |
+
_pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds
|
| 60 |
+
else:
|
| 61 |
+
_pricing_cache_retry_after.pop(cache_key, None)
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# NUL cannot appear in a URL, so this cannot collide with a real base URL.
|
| 66 |
+
_PRICING_AUTH_KEY_PREFIX = "\x00auth:"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _pricing_auth_fingerprint(api_key: str | None) -> str:
|
| 70 |
+
"""Cache-key suffix identifying the credential a catalog was read with: a governed endpoint
|
| 71 |
+
answers each token with the catalog its org may reach, so two credentials cannot share an
|
| 72 |
+
entry. blake2b for fingerprinting only (same rationale as ``_custom_endpoint_fingerprint``)."""
|
| 73 |
+
if not api_key:
|
| 74 |
+
return ""
|
| 75 |
+
import hashlib
|
| 76 |
+
|
| 77 |
+
digest = hashlib.blake2b(api_key.encode("utf-8", errors="replace"), digest_size=8)
|
| 78 |
+
return _PRICING_AUTH_KEY_PREFIX + digest.hexdigest()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]:
|
| 82 |
+
"""Pricing already cached for *base_url* (with or without ``/v1``), or ``{}``; never fetches.
|
| 83 |
+
Prefers an authenticated catalog, scanning newest first (callers hold no credential) and
|
| 84 |
+
skipping expired entries so a rotated credential does not answer from its predecessor's."""
|
| 85 |
+
root = _strip_v1((base_url or "").rstrip("/"))
|
| 86 |
+
authed_prefix = root + _PRICING_AUTH_KEY_PREFIX
|
| 87 |
+
for key in reversed(list(_pricing_cache)):
|
| 88 |
+
if key.startswith(authed_prefix):
|
| 89 |
+
cached = _cached_catalog(key)
|
| 90 |
+
if cached:
|
| 91 |
+
return cached
|
| 92 |
+
return _cached_catalog(root) or {}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _strip_v1(url: str) -> str:
|
| 96 |
+
return url[:-3].rstrip("/") if url.endswith("/v1") else url
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _format_price_per_mtok(per_token_str: str) -> str:
|
| 100 |
+
"""Per-token price string → $/Mtok string. Always 2 decimals so right-justified prices align;
|
| 101 |
+
sub-cent prices (deep-discount cache-hit promos) widen precision until the value shows, keep
|
| 102 |
+
one extra digit and trim trailing zeros instead of collapsing to "$0.00"."""
|
| 103 |
+
try:
|
| 104 |
+
val = float(per_token_str)
|
| 105 |
+
except (TypeError, ValueError):
|
| 106 |
+
return "?"
|
| 107 |
+
if val == 0:
|
| 108 |
+
return "free"
|
| 109 |
+
per_m = val * 1_000_000
|
| 110 |
+
text = f"{per_m:.2f}"
|
| 111 |
+
if per_m < 0.01:
|
| 112 |
+
prec = 3
|
| 113 |
+
while prec < 12 and round(per_m, prec) == 0:
|
| 114 |
+
prec += 1
|
| 115 |
+
text = f"{per_m:.{min(prec + 1, 12)}f}".rstrip("0").rstrip(".")
|
| 116 |
+
return f"${text}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _price_float(raw: Any, *, positive: bool) -> float | None:
|
| 120 |
+
"""*raw* as a finite float (> 0, or >= 0 when not *positive*); None when unset/invalid/NaN."""
|
| 121 |
+
if raw in (None, ""):
|
| 122 |
+
return None
|
| 123 |
+
try:
|
| 124 |
+
n = float(raw)
|
| 125 |
+
except (TypeError, ValueError):
|
| 126 |
+
return None
|
| 127 |
+
if n != n or (n <= 0 if positive else n < 0):
|
| 128 |
+
return None
|
| 129 |
+
return n
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _sale_pct(current: Any, original: Any) -> int | None:
|
| 133 |
+
"""Percent discount when *current* is strictly below *original* (both positive finite)."""
|
| 134 |
+
cur, orig = _price_float(current, positive=True), _price_float(original, positive=True)
|
| 135 |
+
if cur is None or orig is None or cur >= orig:
|
| 136 |
+
return None
|
| 137 |
+
return int(round((1.0 - (cur / orig)) * 100))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def compute_sale_discount(prompt: str, completion: str, original: Any) -> tuple[int, str, str] | None:
|
| 141 |
+
"""Sale chrome from gateway ``pricing.original`` (Nous Portal only; callers gate on the provider
|
| 142 |
+
and opted in via ``include_sale_original=True``): ``(discount_percent, was_prompt_raw,
|
| 143 |
+
was_completion_raw)`` when ``original`` is a dict and the current prompt (fallback: completion)
|
| 144 |
+
rate is strictly below the original. Free / $0 models get a flat 100% off, with "was" prices
|
| 145 |
+
only when the gateway served an original (a natively-free stealth model gets bare "-100%")."""
|
| 146 |
+
orig_dict = original if isinstance(original, dict) else {}
|
| 147 |
+
was_prompt = orig_dict.get("prompt")
|
| 148 |
+
was_completion = orig_dict.get("completion")
|
| 149 |
+
was_prompt_str = str(was_prompt) if was_prompt not in (None, "") else ""
|
| 150 |
+
was_completion_str = str(was_completion) if was_completion not in (None, "") else ""
|
| 151 |
+
|
| 152 |
+
if _price_float(prompt, positive=False) == 0 and _price_float(completion, positive=False) in (0, None):
|
| 153 |
+
return (100, was_prompt_str, was_completion_str)
|
| 154 |
+
|
| 155 |
+
if not isinstance(original, dict) or (not was_prompt_str and not was_completion_str):
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
pct = _sale_pct(prompt, was_prompt)
|
| 159 |
+
if pct is not None:
|
| 160 |
+
return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None
|
| 161 |
+
pct = _sale_pct(completion, was_completion)
|
| 162 |
+
if pct is not None:
|
| 163 |
+
return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _get_json(url: str, headers: dict[str, str], timeout: float, opener=None) -> Optional[dict]:
|
| 168 |
+
"""GET *url* as JSON via the origin's catalog opener (or *opener*); None on any failure."""
|
| 169 |
+
from hermes_cli.models import _urlopen_model_catalog_request
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
req = urllib.request.Request(url, headers=headers)
|
| 173 |
+
with (opener or _urlopen_model_catalog_request)(req, timeout=timeout) as resp:
|
| 174 |
+
return json.loads(resp.read().decode())
|
| 175 |
+
except Exception:
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _pricing_entry(pricing: dict, prompt_key: str = "prompt", completion_key: str = "completion") -> dict[str, Any]:
|
| 180 |
+
"""Picker-shape ``{prompt, completion[, input_cache_read, input_cache_write]}`` from a catalog
|
| 181 |
+
``pricing`` block whose cache fields already use the hermes names."""
|
| 182 |
+
entry: dict[str, Any] = {
|
| 183 |
+
"prompt": str(pricing.get(prompt_key, "")),
|
| 184 |
+
"completion": str(pricing.get(completion_key, "")),
|
| 185 |
+
}
|
| 186 |
+
for key in ("input_cache_read", "input_cache_write"):
|
| 187 |
+
if pricing.get(key):
|
| 188 |
+
entry[key] = str(pricing[key])
|
| 189 |
+
return entry
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _per_token(per_mtok: Any) -> str:
|
| 193 |
+
"""$/MTok → the per-token price string the picker expects."""
|
| 194 |
+
return str(float(per_mtok) / 1_000_000)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _catalog_items(payload: dict) -> list[dict]:
|
| 198 |
+
return [item for item in payload.get("data", []) if isinstance(item, dict)]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def fetch_models_with_pricing(
|
| 202 |
+
api_key: str | None = None,
|
| 203 |
+
base_url: str = "https://openrouter.ai/api",
|
| 204 |
+
timeout: float = 8.0,
|
| 205 |
+
*,
|
| 206 |
+
force_refresh: bool = False,
|
| 207 |
+
include_sale_original: bool = False,
|
| 208 |
+
cache_ttl_seconds: Optional[float] = None,
|
| 209 |
+
) -> dict[str, dict[str, Any]]:
|
| 210 |
+
"""Fetch ``/v1/models`` (any OpenRouter-compatible endpoint) → ``{model_id: {prompt, completion,
|
| 211 |
+
...}}``, cached per *base_url* and per credential so one caller's catalog never answers
|
| 212 |
+
another's read. *include_sale_original* (Nous Portal only) copies the gateway's pre-discount
|
| 213 |
+
``pricing.original`` rates through as a nested ``original`` dict for sale chrome."""
|
| 214 |
+
from hermes_cli.models import _HERMES_USER_AGENT
|
| 215 |
+
url_root = (base_url or "").rstrip("/")
|
| 216 |
+
cache_key = url_root + _pricing_auth_fingerprint(api_key)
|
| 217 |
+
if not force_refresh:
|
| 218 |
+
cached = _cached_catalog(cache_key)
|
| 219 |
+
if cached is not None:
|
| 220 |
+
return cached
|
| 221 |
+
|
| 222 |
+
url = url_root + "/v1/models"
|
| 223 |
+
headers = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
|
| 224 |
+
if api_key:
|
| 225 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 226 |
+
payload = _get_json(url, headers, timeout)
|
| 227 |
+
if payload is None:
|
| 228 |
+
return _cache_catalog(cache_key, {})
|
| 229 |
+
|
| 230 |
+
# Same document the reasoning-capability fetch would pull — mirror it so a later hot-path
|
| 231 |
+
# lookup (and the next process) has an answer without its own round-trip.
|
| 232 |
+
_seed_reasoning_caps(url, payload.get("data"))
|
| 233 |
+
|
| 234 |
+
result: dict[str, dict[str, Any]] = {}
|
| 235 |
+
for item in payload.get("data", []):
|
| 236 |
+
mid, pricing = item.get("id"), item.get("pricing")
|
| 237 |
+
if mid and isinstance(pricing, dict):
|
| 238 |
+
entry = _pricing_entry(pricing)
|
| 239 |
+
# Sale chrome is Nous Portal-only; never copy pricing.original for other catalogs.
|
| 240 |
+
original = pricing.get("original") if include_sale_original else None
|
| 241 |
+
if isinstance(original, dict):
|
| 242 |
+
orig_entry = {key: str(original[key]) for key in ("prompt", "completion", "input_cache_read", "input_cache_write")
|
| 243 |
+
if original.get(key) not in (None, "")}
|
| 244 |
+
if orig_entry.get("prompt") or orig_entry.get("completion"):
|
| 245 |
+
entry["original"] = orig_entry
|
| 246 |
+
result[mid] = entry
|
| 247 |
+
|
| 248 |
+
return _cache_catalog(cache_key, result, cache_ttl_seconds)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def fetch_ai_gateway_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
| 252 |
+
"""Vercel AI Gateway /v1/models pricing, translating its ``input`` / ``output`` field names to
|
| 253 |
+
the picker's ``prompt`` / ``completion`` (cache read/write names already match)."""
|
| 254 |
+
from hermes_constants import AI_GATEWAY_BASE_URL
|
| 255 |
+
|
| 256 |
+
cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
|
| 257 |
+
if not force_refresh:
|
| 258 |
+
cached = _cached_catalog(cache_key)
|
| 259 |
+
if cached is not None:
|
| 260 |
+
return cached
|
| 261 |
+
|
| 262 |
+
payload = _get_json(f"{cache_key}/models", {"Accept": "application/json"}, timeout, opener=urllib.request.urlopen)
|
| 263 |
+
if payload is None:
|
| 264 |
+
return _cache_catalog(cache_key, {})
|
| 265 |
+
|
| 266 |
+
result: dict[str, dict[str, str]] = {}
|
| 267 |
+
for item in _catalog_items(payload):
|
| 268 |
+
mid, pricing = item.get("id"), item.get("pricing")
|
| 269 |
+
if mid and isinstance(pricing, dict):
|
| 270 |
+
result[mid] = _pricing_entry(pricing, "input", "output")
|
| 271 |
+
return _cache_catalog(cache_key, result)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _resolve_openrouter_api_key() -> str:
|
| 275 |
+
"""Best-effort OpenRouter API key for pricing fetch."""
|
| 276 |
+
return os.getenv("OPENROUTER_API_KEY", "").strip()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
| 283 |
+
"""``(api_key, base_url)`` for Nous Portal pricing; base_url is the bare origin (no ``/v1``).
|
| 284 |
+
Precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` (staging /
|
| 285 |
+
preview) → resolved credential ``base_url`` → production default. Without the override a
|
| 286 |
+
staging profile's sale ``pricing.original`` would never reach the pickers."""
|
| 287 |
+
try:
|
| 288 |
+
from hermes_cli.auth import _nous_inference_env_override
|
| 289 |
+
|
| 290 |
+
env_base = _nous_inference_env_override()
|
| 291 |
+
except Exception:
|
| 292 |
+
env_base = None
|
| 293 |
+
api_key = creds_base = ""
|
| 294 |
+
try:
|
| 295 |
+
from hermes_cli.auth import resolve_nous_runtime_credentials
|
| 296 |
+
|
| 297 |
+
creds = resolve_nous_runtime_credentials()
|
| 298 |
+
if creds:
|
| 299 |
+
api_key = creds.get("api_key", "") or ""
|
| 300 |
+
creds_base = (creds.get("base_url", "") or "").strip()
|
| 301 |
+
except Exception:
|
| 302 |
+
pass
|
| 303 |
+
base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/")
|
| 304 |
+
if base_url.endswith("/v1"):
|
| 305 |
+
base_url = base_url[:-3]
|
| 306 |
+
return (api_key, base_url)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# How long a Nous catalog stays trusted. Its contents depend on the org's policy, which an admin
|
| 310 |
+
# can change at any time and the client cannot observe, so a long-lived process must re-ask.
|
| 311 |
+
# Other providers' catalogs carry no such state and keep the default no-expiry caching.
|
| 312 |
+
_NOUS_CATALOG_TTL_SECONDS = 300.0
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _fetch_nous_pricing(api_key: str, base_url: str, *, force_refresh: bool) -> dict[str, dict[str, Any]]:
|
| 316 |
+
"""Shared by pricing and policy lookups so both read one cache entry."""
|
| 317 |
+
return fetch_models_with_pricing(
|
| 318 |
+
api_key=api_key,
|
| 319 |
+
base_url=base_url,
|
| 320 |
+
force_refresh=force_refresh,
|
| 321 |
+
include_sale_original=True, # Sale chrome (pricing.original) is Nous Portal-only.
|
| 322 |
+
cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def nous_policy_allowed_ids(*, force_refresh: bool = False) -> Optional[set[str]]:
|
| 327 |
+
"""The Nous model ids the caller's org may reach (keys of an authenticated ``GET /v1/models``,
|
| 328 |
+
which omits policy-blocked rows), or ``None`` to not filter: no policy (or a token too old to
|
| 329 |
+
say), an anonymous read (unfiltered catalog), or an empty read (a fetch failure, not an org
|
| 330 |
+
that may reach nothing)."""
|
| 331 |
+
try:
|
| 332 |
+
from hermes_cli.nous_account import nous_policy_present
|
| 333 |
+
|
| 334 |
+
if nous_policy_present() is not True:
|
| 335 |
+
return None
|
| 336 |
+
except Exception:
|
| 337 |
+
return None
|
| 338 |
+
|
| 339 |
+
api_key, base_url = _resolve_nous_pricing_credentials()
|
| 340 |
+
if not api_key or not base_url:
|
| 341 |
+
return None
|
| 342 |
+
return set(_fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)) or None
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# Past this size an allowed set reads as a whole catalog rather than an allowlist, and is not
|
| 346 |
+
# worth showing in place of an empty picker.
|
| 347 |
+
_NOUS_POLICY_APPEND_MAX = 64
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def restrict_to_nous_policy(
|
| 351 |
+
model_ids: list[str],
|
| 352 |
+
allowed: Optional[set[str]],
|
| 353 |
+
*,
|
| 354 |
+
rescue_empty: bool = False,
|
| 355 |
+
) -> list[str]:
|
| 356 |
+
"""*model_ids* narrowed to *allowed*, preserving order. A ``:free`` sibling is kept when its
|
| 357 |
+
base model is reachable (the gateway admits a row when any requestable id passes); over-listing
|
| 358 |
+
costs a 403 from the authoritative gate, hiding a servable row is unrecoverable client-side.
|
| 359 |
+
*rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an empty
|
| 360 |
+
picker — worse than no filter — so return the allowlist itself. Opt-in per list: an already-
|
| 361 |
+
empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived"."""
|
| 362 |
+
if not allowed:
|
| 363 |
+
return list(model_ids)
|
| 364 |
+
kept = [mid for mid in model_ids if mid in allowed or mid.split(":", 1)[0] in allowed]
|
| 365 |
+
if rescue_empty and not kept and len(allowed) <= _NOUS_POLICY_APPEND_MAX:
|
| 366 |
+
return sorted(allowed)
|
| 367 |
+
return kept
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _remember_provider_cache_key(provider: str, cache_key: str) -> None:
|
| 371 |
+
from hermes_cli.models import _pricing_profile_key
|
| 372 |
+
_pricing_provider_cache_keys[(_pricing_profile_key(), provider)] = cache_key
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _fetch_openrouter_pricing(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
| 376 |
+
_remember_provider_cache_key("openrouter", _OPENROUTER_PRICING_BASE)
|
| 377 |
+
return fetch_models_with_pricing(
|
| 378 |
+
api_key=_resolve_openrouter_api_key(),
|
| 379 |
+
base_url=_OPENROUTER_PRICING_BASE,
|
| 380 |
+
force_refresh=force_refresh,
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _fetch_ai_gateway_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
| 385 |
+
_remember_provider_cache_key("ai-gateway", _ai_gateway_pricing_scope())
|
| 386 |
+
return fetch_ai_gateway_pricing(force_refresh=force_refresh)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _fetch_novita_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
| 390 |
+
_remember_provider_cache_key("novita", _novita_pricing_scope())
|
| 391 |
+
return _fetch_novita_pricing(force_refresh=force_refresh)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _fetch_fireworks_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
| 395 |
+
_remember_provider_cache_key("fireworks", _FIREWORKS_PRICING_KEY)
|
| 396 |
+
return _fireworks_pricing_from_models_dev(force_refresh=force_refresh)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _fetch_nous_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
| 400 |
+
api_key, base_url = _resolve_nous_pricing_credentials()
|
| 401 |
+
if not base_url:
|
| 402 |
+
return {}
|
| 403 |
+
_remember_provider_cache_key("nous", base_url.rstrip("/"))
|
| 404 |
+
return _fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
_OPENROUTER_PRICING_BASE = "https://openrouter.ai/api"
|
| 408 |
+
_FIREWORKS_PRICING_KEY = "models.dev/fireworks"
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _ai_gateway_pricing_scope() -> str:
|
| 412 |
+
from hermes_constants import AI_GATEWAY_BASE_URL
|
| 413 |
+
return AI_GATEWAY_BASE_URL.rstrip("/")
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _novita_pricing_scope() -> str:
|
| 417 |
+
return (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/")
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def get_cached_nous_inference_base_url() -> str:
|
| 421 |
+
"""The profile's persisted Nous endpoint (bare origin, no ``/v1``) without refreshing auth."""
|
| 422 |
+
try:
|
| 423 |
+
from hermes_cli.auth import (
|
| 424 |
+
_load_auth_store, _load_provider_state, _optional_base_url, _validate_nous_inference_url_from_network,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
state = _load_provider_state(_load_auth_store(), "nous") or {}
|
| 428 |
+
url = _validate_nous_inference_url_from_network(_optional_base_url(state.get("inference_base_url"))) or ""
|
| 429 |
+
return url.rstrip("/").removesuffix("/v1")
|
| 430 |
+
except Exception:
|
| 431 |
+
return ""
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
# Static endpoint identity per provider; dynamic ones (deepinfra, nous) are resolved in pricing_cache_scope.
|
| 435 |
+
_STATIC_PRICING_SCOPES = {
|
| 436 |
+
"openrouter": lambda: _OPENROUTER_PRICING_BASE,
|
| 437 |
+
"ai-gateway": _ai_gateway_pricing_scope,
|
| 438 |
+
"novita": _novita_pricing_scope,
|
| 439 |
+
"fireworks": lambda: _FIREWORKS_PRICING_KEY,
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def pricing_cache_scope(provider: str, *, current_provider: str = "", current_base_url: str = "") -> str:
|
| 444 |
+
"""The current endpoint identity a provider's pricing cache is keyed on. Resolves local configuration
|
| 445 |
+
only, never fetches: picker prewarm single-flight uses it so an endpoint rotation can start a new
|
| 446 |
+
worker while the previous endpoint is still slow or unreachable."""
|
| 447 |
+
from hermes_cli.models import _deepinfra_catalog_url, _pricing_profile_key, normalize_provider
|
| 448 |
+
normalized = normalize_provider(provider)
|
| 449 |
+
static = _STATIC_PRICING_SCOPES.get(normalized)
|
| 450 |
+
if static:
|
| 451 |
+
return static()
|
| 452 |
+
if normalized == "deepinfra":
|
| 453 |
+
return _deepinfra_catalog_url()[0]
|
| 454 |
+
if normalized == "nous":
|
| 455 |
+
try:
|
| 456 |
+
from hermes_cli.auth import _nous_inference_env_override
|
| 457 |
+
|
| 458 |
+
env_base = _nous_inference_env_override()
|
| 459 |
+
except Exception:
|
| 460 |
+
env_base = None
|
| 461 |
+
if env_base:
|
| 462 |
+
return env_base.rstrip("/").removesuffix("/v1")
|
| 463 |
+
if normalize_provider(current_provider) == "nous" and current_base_url:
|
| 464 |
+
return current_base_url.rstrip("/").removesuffix("/v1")
|
| 465 |
+
persisted_base = get_cached_nous_inference_base_url()
|
| 466 |
+
if persisted_base:
|
| 467 |
+
return persisted_base
|
| 468 |
+
return _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized), _DEFAULT_NOUS_INFERENCE_BASE)
|
| 469 |
+
return ""
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _cached_only_pricing(normalized: str) -> dict[str, dict[str, str]]:
|
| 473 |
+
"""Process-resident pricing for *normalized* without any provider I/O."""
|
| 474 |
+
from hermes_cli.models import _deepinfra_catalog_cache, _deepinfra_catalog_url, _pricing_profile_key
|
| 475 |
+
if normalized == "deepinfra":
|
| 476 |
+
cache_key, _url = _deepinfra_catalog_url()
|
| 477 |
+
return _fetch_deepinfra_pricing() if cache_key in _deepinfra_catalog_cache else {}
|
| 478 |
+
cache_key = _pricing_provider_cache_keys.get((_pricing_profile_key(), normalized))
|
| 479 |
+
if cache_key is None and normalized in ("openrouter", "ai-gateway", "fireworks"):
|
| 480 |
+
cache_key = _STATIC_PRICING_SCOPES[normalized]()
|
| 481 |
+
return (_cached_catalog(cache_key) or {}) if cache_key else {}
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def get_pricing_for_provider(
|
| 485 |
+
provider: str, *, force_refresh: bool = False, cached_only: bool = False
|
| 486 |
+
) -> dict[str, dict[str, str]]:
|
| 487 |
+
"""Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita,
|
| 488 |
+
deepinfra, fireworks); ``{}`` for everything else. ``cached_only`` never starts provider I/O:
|
| 489 |
+
normal picker opens use it so cold endpoints cannot hold the response path, while a background
|
| 490 |
+
prewarm fills the same caches for later opens."""
|
| 491 |
+
from hermes_cli.models import normalize_provider
|
| 492 |
+
normalized = normalize_provider(provider)
|
| 493 |
+
if cached_only:
|
| 494 |
+
return _cached_only_pricing(normalized)
|
| 495 |
+
fetcher = _PRICING_FETCHERS.get(normalized)
|
| 496 |
+
return fetcher(force_refresh=force_refresh) if fetcher else {}
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _fireworks_pricing_from_models_dev(*, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
| 500 |
+
"""Fireworks picker pricing from the models.dev registry cache (``fetch_models_dev()`` keeps a
|
| 501 |
+
shared in-memory + disk cache, 1h TTL) — a pure dict transform, no per-render network call."""
|
| 502 |
+
cache_key = "models.dev/fireworks"
|
| 503 |
+
if not force_refresh:
|
| 504 |
+
cached = _cached_catalog(cache_key)
|
| 505 |
+
if cached is not None:
|
| 506 |
+
return cached
|
| 507 |
+
|
| 508 |
+
result: dict[str, dict[str, str]] = {}
|
| 509 |
+
try:
|
| 510 |
+
from agent.models_dev import _get_provider_models
|
| 511 |
+
|
| 512 |
+
for mid, entry in (_get_provider_models("fireworks") or {}).items():
|
| 513 |
+
cost = entry.get("cost") if isinstance(entry, dict) else None
|
| 514 |
+
if not isinstance(cost, dict):
|
| 515 |
+
continue
|
| 516 |
+
inp, out = cost.get("input"), cost.get("output")
|
| 517 |
+
if inp is None and out is None:
|
| 518 |
+
continue
|
| 519 |
+
row = {"prompt": _per_token(inp or 0), "completion": _per_token(out or 0)}
|
| 520 |
+
if cost.get("cache_read"):
|
| 521 |
+
row["input_cache_read"] = _per_token(cost["cache_read"])
|
| 522 |
+
result[str(mid)] = row
|
| 523 |
+
except Exception:
|
| 524 |
+
result = {}
|
| 525 |
+
|
| 526 |
+
return _cache_catalog(cache_key, result)
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
| 530 |
+
"""NovitaAI /v1/models pricing (per-million prices in units of 0.0001 USD → per-token strings),
|
| 531 |
+
cached on the resolved base URL so menu renders don't re-hit the network."""
|
| 532 |
+
from hermes_cli.models import _HERMES_USER_AGENT
|
| 533 |
+
api_key = os.getenv("NOVITA_API_KEY", "").strip()
|
| 534 |
+
if not api_key:
|
| 535 |
+
return {}
|
| 536 |
+
|
| 537 |
+
cache_key = (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/")
|
| 538 |
+
if not force_refresh:
|
| 539 |
+
cached = _cached_catalog(cache_key)
|
| 540 |
+
if cached is not None:
|
| 541 |
+
return cached
|
| 542 |
+
|
| 543 |
+
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
|
| 544 |
+
payload = _get_json(cache_key + "/models", headers, timeout)
|
| 545 |
+
if payload is None:
|
| 546 |
+
return _cache_catalog(cache_key, {})
|
| 547 |
+
|
| 548 |
+
result: dict[str, dict[str, str]] = {}
|
| 549 |
+
for item in _catalog_items(payload):
|
| 550 |
+
mid = item.get("id")
|
| 551 |
+
inp, out = item.get("input_token_price_per_m"), item.get("output_token_price_per_m")
|
| 552 |
+
if not mid or (inp is None and out is None):
|
| 553 |
+
continue
|
| 554 |
+
result[str(mid)] = {
|
| 555 |
+
"prompt": str(float(inp or 0) / 10_000 / 1_000_000),
|
| 556 |
+
"completion": str(float(out or 0) / 10_000 / 1_000_000),
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
return _cache_catalog(cache_key, result)
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _fetch_deepinfra_pricing(timeout: float = 5.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
| 563 |
+
"""DeepInfra chat-model pricing: ``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` in
|
| 564 |
+
$/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read`` (cached by the by-tag
|
| 565 |
+
helper)."""
|
| 566 |
+
from hermes_cli.models import _fetch_deepinfra_models_by_tag
|
| 567 |
+
items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh)
|
| 568 |
+
result: dict[str, dict[str, str]] = {}
|
| 569 |
+
for item in items or []:
|
| 570 |
+
metadata = item.get("metadata") or {}
|
| 571 |
+
pricing = metadata.get("pricing") if isinstance(metadata, dict) else None
|
| 572 |
+
if not isinstance(pricing, dict):
|
| 573 |
+
continue
|
| 574 |
+
entry = {
|
| 575 |
+
ours: _per_token(pricing[theirs])
|
| 576 |
+
for theirs, ours in (("input_tokens", "prompt"), ("output_tokens", "completion"), ("cache_read_tokens", "input_cache_read"))
|
| 577 |
+
if pricing.get(theirs) is not None
|
| 578 |
+
}
|
| 579 |
+
if entry:
|
| 580 |
+
result[item["id"]] = entry
|
| 581 |
+
return result
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
_PRICING_FETCHERS = {
|
| 585 |
+
"openrouter": _fetch_openrouter_pricing,
|
| 586 |
+
"ai-gateway": _fetch_ai_gateway_pricing_for_provider,
|
| 587 |
+
"novita": _fetch_novita_pricing_for_provider,
|
| 588 |
+
"deepinfra": _fetch_deepinfra_pricing,
|
| 589 |
+
"fireworks": _fetch_fireworks_pricing_for_provider,
|
| 590 |
+
"nous": _fetch_nous_pricing_for_provider,
|
| 591 |
+
}
|
hermes_cli/provider_catalog.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified provider catalog — one source of truth for the provider universe.
|
| 2 |
+
|
| 3 |
+
The provider list shown by ``hermes model`` (CLI/TUI) and the desktop Settings → Providers tabs
|
| 4 |
+
(Accounts + API keys) **must be the same set**; providers added after those lists were written
|
| 5 |
+
silently went missing from the GUI. ``auth_type`` / ``api_key_env_vars`` / ``base_url_env_var``
|
| 6 |
+
come from :data:`hermes_cli.auth.PROVIDER_REGISTRY` (credential truth); ``display_name`` /
|
| 7 |
+
``description`` / ``signup_url`` from the provider's :class:`providers.base.ProviderProfile`, falling
|
| 8 |
+
back to the ``CANONICAL_PROVIDERS`` entry's ``label`` / ``tui_desc`` and the ``OPTIONAL_ENV_VARS``
|
| 9 |
+
signup URL (many profiles leave these blank, and lmstudio, openai-api, tencent-tokenhub, xai-oauth
|
| 10 |
+
have no profile at all — the fallbacks are load-bearing).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
# Auth types that authenticate via an account / sign-in flow rather than a pasted API key; these
|
| 18 |
+
# route to the desktop "Accounts" tab, everything else (api_key, and aws_sdk configured via
|
| 19 |
+
# AWS_REGION/AWS_PROFILE) to "API keys". Mirrors the auth_type strings in PROVIDER_REGISTRY and
|
| 20 |
+
# ProviderProfile: external_process = copilot-acp (spawns `copilot --acp --stdio`), copilot = GitHub
|
| 21 |
+
# Copilot token / gh auth.
|
| 22 |
+
_ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
|
| 23 |
+
{"oauth_device_code", "oauth_external", "oauth_minimax", "external_process", "copilot"}
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class ProviderDescriptor:
|
| 29 |
+
"""One provider, as seen by every surface (CLI picker + both GUI tabs)."""
|
| 30 |
+
|
| 31 |
+
slug: str # canonical id, e.g. "openai-codex"
|
| 32 |
+
label: str # human display name
|
| 33 |
+
description: str # one-line description
|
| 34 |
+
auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk
|
| 35 |
+
tab: str # "keys" | "accounts"
|
| 36 |
+
api_key_env_vars: tuple[str, ...] # credential env vars (may be empty)
|
| 37 |
+
base_url_env_var: str # base-URL override env var (may be "")
|
| 38 |
+
signup_url: str # signup / console URL (may be "")
|
| 39 |
+
order: int # CANONICAL_PROVIDERS index — mirrors `hermes model`
|
| 40 |
+
keyless: bool = False # served anonymously — no credential exists to configure
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def tab_for_auth_type(auth_type: str) -> str:
|
| 44 |
+
"""Return the desktop tab ("keys"|"accounts") a provider's auth maps to."""
|
| 45 |
+
return "accounts" if auth_type in _ACCOUNTS_AUTH_TYPES else "keys"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _is_url_var(name: str) -> bool:
|
| 49 |
+
return name.endswith("_BASE_URL") or name.endswith("_URL")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _split_env_vars(env_vars: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
|
| 53 |
+
"""Split a profile's ``env_vars`` into (api_key_vars, base_url_var)."""
|
| 54 |
+
return tuple(v for v in env_vars if not _is_url_var(v)), next((v for v in env_vars if _is_url_var(v)), "")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _safe_import(module: str, attr: str, default):
|
| 58 |
+
"""Import ``attr`` from ``module``; ``default`` on ANY failure — this module is on the import
|
| 59 |
+
path of the web server and the CLI, and a provider-plugin import error must never blank the
|
| 60 |
+
whole catalog."""
|
| 61 |
+
try:
|
| 62 |
+
return getattr(__import__(module, fromlist=[attr]), attr)
|
| 63 |
+
except Exception:
|
| 64 |
+
return default
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def provider_catalog() -> list[ProviderDescriptor]:
|
| 68 |
+
"""One descriptor per provider in the ``hermes model`` universe (:data:`CANONICAL_PROVIDERS`,
|
| 69 |
+
auto-extended by provider plugins). Auth/env from ``PROVIDER_REGISTRY``; display metadata from
|
| 70 |
+
``ProviderProfile`` with canonical/env fallbacks so profile-less providers still resolve."""
|
| 71 |
+
from hermes_cli.models import CANONICAL_PROVIDERS
|
| 72 |
+
PROVIDER_REGISTRY = _safe_import("hermes_cli.auth", "PROVIDER_REGISTRY", {})
|
| 73 |
+
OPTIONAL_ENV_VARS = _safe_import("hermes_cli.config", "OPTIONAL_ENV_VARS", {})
|
| 74 |
+
# Overlays carry auth_type for providers with no registry/profile entry — notably the ``moa``
|
| 75 |
+
# virtual provider (auth_type "virtual"), which has no credential and no network endpoint.
|
| 76 |
+
HERMES_OVERLAYS = _safe_import("hermes_cli.providers", "HERMES_OVERLAYS", {})
|
| 77 |
+
try:
|
| 78 |
+
from providers import list_providers
|
| 79 |
+
profiles = {p.name: p for p in list_providers()}
|
| 80 |
+
except Exception:
|
| 81 |
+
profiles = {}
|
| 82 |
+
out: list[ProviderDescriptor] = []
|
| 83 |
+
for order, entry in enumerate(CANONICAL_PROVIDERS):
|
| 84 |
+
slug = entry.slug
|
| 85 |
+
cfg = PROVIDER_REGISTRY.get(slug)
|
| 86 |
+
prof = profiles.get(slug)
|
| 87 |
+
overlay = HERMES_OVERLAYS.get(slug)
|
| 88 |
+
# auth_type: registry is authoritative; then profile, then overlay (moa → "virtual"), then api_key.
|
| 89 |
+
auth_type = ((cfg.auth_type if cfg else "") or (prof.auth_type if prof else "")
|
| 90 |
+
or (overlay.auth_type if overlay else "") or "api_key")
|
| 91 |
+
# Credential env vars: registry first (already normalized), else derived from the profile.
|
| 92 |
+
if cfg and cfg.api_key_env_vars:
|
| 93 |
+
api_key_vars, base_url_var = tuple(cfg.api_key_env_vars), cfg.base_url_env_var or ""
|
| 94 |
+
elif prof and prof.env_vars:
|
| 95 |
+
api_key_vars, base_url_var = _split_env_vars(tuple(prof.env_vars))
|
| 96 |
+
else:
|
| 97 |
+
api_key_vars, base_url_var = (), ""
|
| 98 |
+
label = (prof.display_name if prof else "") or entry.label or slug
|
| 99 |
+
signup_url = (prof.signup_url if prof else "") or ""
|
| 100 |
+
if not signup_url and api_key_vars:
|
| 101 |
+
signup_url = (OPTIONAL_ENV_VARS.get(api_key_vars[0]) or {}).get("url") or ""
|
| 102 |
+
out.append(
|
| 103 |
+
ProviderDescriptor(
|
| 104 |
+
slug=slug, label=label, description=(prof.description if prof else "") or entry.tui_desc or label,
|
| 105 |
+
auth_type=auth_type, tab=tab_for_auth_type(auth_type), api_key_env_vars=api_key_vars,
|
| 106 |
+
base_url_env_var=base_url_var, signup_url=signup_url, order=order,
|
| 107 |
+
# Keyless providers (opencode-free) are served anonymously: no key card in the GUI,
|
| 108 |
+
# and contract tests exempt them.
|
| 109 |
+
keyless=bool(overlay.keyless) if overlay else False,
|
| 110 |
+
)
|
| 111 |
+
)
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def provider_catalog_by_slug() -> dict[str, ProviderDescriptor]:
|
| 116 |
+
"""Convenience: the catalog keyed by slug."""
|
| 117 |
+
return {d.slug: d for d in provider_catalog()}
|
hermes_cli/session_export_md.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Markdown/QMD export helpers for Hermes sessions.
|
| 2 |
+
|
| 3 |
+
Filesystem-only: formats already-exported SessionDB dicts and writes them to user-selected export
|
| 4 |
+
directories. Must not mutate state.db or call delete/prune/archive APIs.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from hermes_cli.timefmt import coerce_epoch
|
| 18 |
+
|
| 19 |
+
EXPORTER_VERSION = "hermes sessions export (md/qmd) v1"
|
| 20 |
+
_SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`")
|
| 21 |
+
_SHA_PLACEHOLDER = "__SHA256_PLACEHOLDER__"
|
| 22 |
+
_VERIFICATION_HEADING = "## Export verification"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _iso_timestamp(value: Any) -> str:
|
| 26 |
+
if value is None or value == "":
|
| 27 |
+
return ""
|
| 28 |
+
if (ts := coerce_epoch(value)) is None:
|
| 29 |
+
return str(value) # corrupt cell: odd-looking date, not an aborted export
|
| 30 |
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _frontmatter_line(key: str, value: Any) -> str:
|
| 34 |
+
if value is None or isinstance(value, bool):
|
| 35 |
+
shown = {None: "null", True: "true", False: "false"}[value]
|
| 36 |
+
else:
|
| 37 |
+
shown = json.dumps(value if isinstance(value, (int, float, list)) else str(value), ensure_ascii=False)
|
| 38 |
+
return f"{key}: {shown}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _json_block(value: Any) -> str:
|
| 42 |
+
return "```json\n" + json.dumps(value, ensure_ascii=False, indent=2) + "\n```"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _message_heading(message: dict[str, Any]) -> str:
|
| 46 |
+
role = str(message.get("role") or "message")
|
| 47 |
+
name = message.get("name") or message.get("tool_name")
|
| 48 |
+
label = f"Tool — {name}" if role == "tool" and name else role.capitalize()
|
| 49 |
+
timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp"))
|
| 50 |
+
return f"### {label}{' — ' + timestamp if timestamp else ''}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _session_id(session: dict[str, Any]) -> str:
|
| 54 |
+
return str(session.get("id") or session.get("session_id") or "unknown-session")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _segments(session: dict[str, Any]) -> list[dict[str, Any]]:
|
| 58 |
+
segments = session.get("segments")
|
| 59 |
+
return [s for s in segments if isinstance(s, dict)] if isinstance(segments, list) and segments else [session]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _message_count(session: dict[str, Any]) -> int:
|
| 63 |
+
return sum(len(seg.get("messages") or []) for seg in _segments(session))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _render_messages(session: dict[str, Any]) -> str:
|
| 67 |
+
segments = _segments(session)
|
| 68 |
+
if _message_count(session) == 0:
|
| 69 |
+
return "## Messages\n\n_No messages in this session._\n"
|
| 70 |
+
parts: list[str] = ["## Messages\n"]
|
| 71 |
+
for segment in segments:
|
| 72 |
+
if len(segments) > 1:
|
| 73 |
+
parts.append(f"## Compression segment: {_session_id(segment)}\n")
|
| 74 |
+
for message in list(segment.get("messages") or []):
|
| 75 |
+
parts.append(_message_heading(message) + "\n")
|
| 76 |
+
content = message.get("content")
|
| 77 |
+
rendered = "" if content is None else content.rstrip() if isinstance(content, str) else _json_block(content)
|
| 78 |
+
if rendered:
|
| 79 |
+
parts.append(rendered + "\n")
|
| 80 |
+
if tool_calls := message.get("tool_calls"):
|
| 81 |
+
parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n")
|
| 82 |
+
parts.append("")
|
| 83 |
+
return "\n".join(parts).rstrip() + "\n"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str:
|
| 87 |
+
session_id = _session_id(session)
|
| 88 |
+
exported_iso = _iso_timestamp(exported_at)
|
| 89 |
+
message_count = _message_count(session)
|
| 90 |
+
fields = [
|
| 91 |
+
("session_id", session_id),
|
| 92 |
+
("title", session.get("title")),
|
| 93 |
+
("source", session.get("source")),
|
| 94 |
+
("created_at", _iso_timestamp(session.get("started_at") or session.get("created_at"))),
|
| 95 |
+
("updated_at", _iso_timestamp(session.get("last_active") or session.get("updated_at"))),
|
| 96 |
+
("ended_at", _iso_timestamp(session.get("ended_at"))),
|
| 97 |
+
("model", session.get("model")),
|
| 98 |
+
("provider", session.get("billing_provider") or session.get("provider")),
|
| 99 |
+
("cwd", session.get("cwd")),
|
| 100 |
+
("archived", bool(session.get("archived"))),
|
| 101 |
+
("message_count", message_count),
|
| 102 |
+
("tool_call_count", session.get("tool_call_count") or 0),
|
| 103 |
+
*([("lineage_session_ids", session["lineage_session_ids"])] if session.get("lineage_session_ids") else []),
|
| 104 |
+
("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION),
|
| 105 |
+
]
|
| 106 |
+
parts = [
|
| 107 |
+
"\n".join(["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""]),
|
| 108 |
+
f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n",
|
| 109 |
+
*([f"Source: `{session.get('source')}`\n"] if session.get("source") else []),
|
| 110 |
+
*([f"Working directory: `{session.get('cwd')}`\n"] if session.get("cwd") else []),
|
| 111 |
+
_render_messages(session),
|
| 112 |
+
f"{_VERIFICATION_HEADING}\n",
|
| 113 |
+
f"- Session id: `{session_id}`",
|
| 114 |
+
f"- Exported messages: `{message_count}`",
|
| 115 |
+
f"- Source DB message count at export: `{session.get('message_count', message_count)}`",
|
| 116 |
+
f"- Exported at: `{exported_iso}`",
|
| 117 |
+
f"- SHA256 of exported body: `{_SHA_PLACEHOLDER}`",
|
| 118 |
+
]
|
| 119 |
+
return "\n".join(parts).rstrip() + "\n"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _check_fmt(fmt: str) -> None:
|
| 123 |
+
if fmt not in {"md", "qmd"}:
|
| 124 |
+
raise ValueError("fmt must be 'md' or 'qmd'")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def render_session_markdown(
|
| 128 |
+
session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True
|
| 129 |
+
) -> str:
|
| 130 |
+
"""Render a SessionDB export dictionary as Markdown/QMD text."""
|
| 131 |
+
_check_fmt(fmt)
|
| 132 |
+
body = _export_body_without_hash(session, fmt=fmt, exported_at=time.time())
|
| 133 |
+
if not include_verification:
|
| 134 |
+
return body.split(f"\n{_VERIFICATION_HEADING}\n", 1)[0].rstrip() + "\n"
|
| 135 |
+
# The digest covers the body with the SHA line set to `pending`, which is what verify recomputes.
|
| 136 |
+
digest_body = body.replace(f"`{_SHA_PLACEHOLDER}`", "`pending`")
|
| 137 |
+
return body.replace(_SHA_PLACEHOLDER, hashlib.sha256(digest_body.encode("utf-8")).hexdigest())
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str:
|
| 141 |
+
"""Return a deterministic, path-safe filename for a session export."""
|
| 142 |
+
_check_fmt(fmt)
|
| 143 |
+
title = str(session.get("title") or "session")
|
| 144 |
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() or "session"
|
| 145 |
+
return f"{_session_id(session)}-{slug[:60]}.{fmt}"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def file_sha256(path: Path | str) -> str:
|
| 149 |
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]:
|
| 153 |
+
if not Path(path).exists():
|
| 154 |
+
return False, "file missing"
|
| 155 |
+
text = Path(path).read_text(encoding="utf-8")
|
| 156 |
+
match = _SHA_LINE_RE.search(text)
|
| 157 |
+
if not match:
|
| 158 |
+
return False, "sha256 marker missing"
|
| 159 |
+
digest_body = _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text)
|
| 160 |
+
if hashlib.sha256(digest_body.encode("utf-8")).hexdigest() != match.group(1):
|
| 161 |
+
return False, "sha256 mismatch"
|
| 162 |
+
if f"- Exported messages: `{_message_count(session)}`" not in text:
|
| 163 |
+
return False, "message count mismatch"
|
| 164 |
+
if f"- Session id: `{_session_id(session)}`" not in text:
|
| 165 |
+
return False, "session id mismatch"
|
| 166 |
+
return True, "ok"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def redact_session_data(session: dict[str, Any]) -> dict[str, Any]:
|
| 170 |
+
"""Return a copy of a session export dict with secrets redacted.
|
| 171 |
+
|
| 172 |
+
Every message's content and tool-call arguments go through the force-mode redaction pass
|
| 173 |
+
(``agent.redact.redact_sensitive_text``) so credentials in tool output never land in exports.
|
| 174 |
+
"""
|
| 175 |
+
from agent.redact import redact_sensitive_text
|
| 176 |
+
|
| 177 |
+
def _clean(value: Any) -> Any:
|
| 178 |
+
if isinstance(value, str):
|
| 179 |
+
return redact_sensitive_text(value, force=True)
|
| 180 |
+
if isinstance(value, list):
|
| 181 |
+
return [_clean(v) for v in value]
|
| 182 |
+
if isinstance(value, dict):
|
| 183 |
+
return {k: _clean(v) for k, v in value.items()}
|
| 184 |
+
return value
|
| 185 |
+
|
| 186 |
+
return {**session, **{k: _clean(session[k]) for k in ("messages", "segments") if session.get(k) is not None}}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _export_dir(output_dir: Path | str) -> Path:
|
| 190 |
+
out_dir = Path(output_dir).expanduser()
|
| 191 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 192 |
+
return out_dir
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def write_session_markdown(
|
| 196 |
+
session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
|
| 197 |
+
) -> Path:
|
| 198 |
+
"""Write a Markdown/QMD export file and return its path."""
|
| 199 |
+
path = _export_dir(output_dir) / safe_session_filename(session, fmt=fmt)
|
| 200 |
+
if path.exists() and not force:
|
| 201 |
+
raise FileExistsError(str(path))
|
| 202 |
+
path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8")
|
| 203 |
+
return path
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path:
|
| 207 |
+
entry = {
|
| 208 |
+
"session_id": _session_id(session),
|
| 209 |
+
"lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)],
|
| 210 |
+
"path": str(Path(path)),
|
| 211 |
+
"format": fmt,
|
| 212 |
+
"message_count": _message_count(session),
|
| 213 |
+
"sha256": file_sha256(path),
|
| 214 |
+
"exported_at": time.time(),
|
| 215 |
+
}
|
| 216 |
+
manifest = _export_dir(output_dir) / "manifest.jsonl"
|
| 217 |
+
with manifest.open("a", encoding="utf-8") as fh:
|
| 218 |
+
fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
|
| 219 |
+
return manifest
|
hermes_cli/shared_session_attach.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Discover a cooperative local runtime without taking its session lease.
|
| 2 |
+
|
| 3 |
+
The owner's handshake supplies the existing authenticated WebSocket URL. A
|
| 4 |
+
registry entry is discovery information, not authority to mint a credential.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import ipaddress
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from urllib.parse import urlencode, urlsplit
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
from hermes_constants import get_hermes_home
|
| 16 |
+
from hermes_cli.active_sessions import active_session_registry_snapshot, session_already_owned_message
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _local_origin(url: str, scheme: str) -> tuple[str, int]:
|
| 20 |
+
parts = urlsplit(url)
|
| 21 |
+
host = parts.hostname or ""
|
| 22 |
+
try:
|
| 23 |
+
local = ipaddress.ip_address(host).is_loopback
|
| 24 |
+
except ValueError:
|
| 25 |
+
local = False
|
| 26 |
+
if (parts.scheme != scheme or not local or parts.username is not None
|
| 27 |
+
or parts.password is not None or parts.fragment or not parts.port):
|
| 28 |
+
raise ValueError("Shared runtime endpoint must be an explicit loopback address and port.")
|
| 29 |
+
return host, parts.port
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def discover_attach_url(session_id: str, *, registry_home: str | Path | None = None) -> str | None:
|
| 33 |
+
"""Return a fenced authenticated URL, None for no owner, or refuse safely.
|
| 34 |
+
|
| 35 |
+
This deliberately does not scan ports or read another profile. The runtime
|
| 36 |
+
must advertise ``metadata.shared_runtime_url`` and implement the local
|
| 37 |
+
``/api/session-attach`` handshake. Unsupported owners keep their lease.
|
| 38 |
+
"""
|
| 39 |
+
home = Path(registry_home if registry_home is not None else get_hermes_home()).resolve()
|
| 40 |
+
owners = [entry for entry in active_session_registry_snapshot(home, strict=True)
|
| 41 |
+
if entry.get("session_id") == session_id]
|
| 42 |
+
if not owners:
|
| 43 |
+
return None
|
| 44 |
+
if len(owners) != 1:
|
| 45 |
+
raise ValueError("Session owner identity is ambiguous; no attachment was attempted.")
|
| 46 |
+
owner = owners[0]
|
| 47 |
+
endpoint = (owner.get("metadata") or {}).get("shared_runtime_url")
|
| 48 |
+
if not isinstance(endpoint, str) or not endpoint:
|
| 49 |
+
raise ValueError("The live owner does not advertise cooperative attachment. "
|
| 50 |
+
+ session_already_owned_message(session_id, owner))
|
| 51 |
+
origin = _local_origin(endpoint, "http")
|
| 52 |
+
parts = urlsplit(endpoint)
|
| 53 |
+
if parts.path not in ("", "/") or parts.query:
|
| 54 |
+
raise ValueError("Shared runtime endpoint must be an origin without a path or query.")
|
| 55 |
+
query = urlencode({"session_id": session_id, "lease_id": owner["lease_id"],
|
| 56 |
+
"profile_home": str(home)})
|
| 57 |
+
try:
|
| 58 |
+
# Ignore proxy env and redirects: local discovery must stay on the
|
| 59 |
+
# advertised endpoint, including on machines with corporate proxies.
|
| 60 |
+
with httpx.Client(trust_env=False, follow_redirects=False, timeout=3.0) as client:
|
| 61 |
+
with client.stream("GET", endpoint.rstrip("/") + "/api/session-attach?" + query) as response:
|
| 62 |
+
response.raise_for_status()
|
| 63 |
+
body = bytearray()
|
| 64 |
+
for chunk in response.iter_bytes():
|
| 65 |
+
body.extend(chunk)
|
| 66 |
+
if len(body) > 65536:
|
| 67 |
+
raise ValueError("Shared runtime handshake response is too large.")
|
| 68 |
+
reply = json.loads(body)
|
| 69 |
+
except (httpx.HTTPError, json.JSONDecodeError) as exc:
|
| 70 |
+
# Never include a remote body or authenticated URL in diagnostics.
|
| 71 |
+
raise ValueError("The live owner could not authorize cooperative attachment; "
|
| 72 |
+
"its lease was left intact.") from exc
|
| 73 |
+
if not isinstance(reply, dict) or any(reply.get(key) != value for key, value in {
|
| 74 |
+
"session_id": session_id, "lease_id": owner["lease_id"], "profile_home": str(home),
|
| 75 |
+
}.items()):
|
| 76 |
+
raise ValueError("Shared runtime handshake identity does not match the requested owner.")
|
| 77 |
+
websocket_url = reply.get("websocket_url")
|
| 78 |
+
if (not isinstance(websocket_url, str) or _local_origin(websocket_url, "ws") != origin
|
| 79 |
+
or urlsplit(websocket_url).path != "/api/ws"):
|
| 80 |
+
raise ValueError("Shared runtime handshake returned a different endpoint.")
|
| 81 |
+
return websocket_url
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def configure_tui_attachment(env: dict[str, str], session_id: str | None, *,
|
| 85 |
+
registry_home: str | Path | None = None) -> None:
|
| 86 |
+
"""Retain an explicit transport, otherwise attach a resumed owner's runtime."""
|
| 87 |
+
if not session_id or env.get("HERMES_TUI_GATEWAY_URL", "").strip():
|
| 88 |
+
return
|
| 89 |
+
url = discover_attach_url(session_id, registry_home=registry_home)
|
| 90 |
+
if url is not None:
|
| 91 |
+
env["HERMES_TUI_GATEWAY_URL"] = url
|
hermes_cli/sizefmt.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small shared size-formatting helpers for CLI/agent output (sibling of ``hermes_cli.timefmt``:
|
| 2 |
+
a tiny purpose-named module lightweight consumers can import without the CLI surface)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def format_bytes(n) -> str:
|
| 8 |
+
"""1234567 -> '1.2 MB' (B/KB/MB/GB/TB; integer bytes, one decimal above). Returns ``"?"`` for
|
| 9 |
+
None or unparseable input so display call sites never raise."""
|
| 10 |
+
try:
|
| 11 |
+
size = float(n)
|
| 12 |
+
except (TypeError, ValueError):
|
| 13 |
+
return "?"
|
| 14 |
+
if size < 1024:
|
| 15 |
+
return f"{int(size)} B"
|
| 16 |
+
for unit in ("KB", "MB", "GB"):
|
| 17 |
+
size /= 1024.0
|
| 18 |
+
if size < 1024:
|
| 19 |
+
return f"{size:.1f} {unit}"
|
| 20 |
+
return f"{size / 1024.0:.1f} TB"
|
hermes_cli/skin_engine.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermes skin/theme engine — the theme SDK for every surface."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
from hermes_constants import get_hermes_home
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class SkinConfig:
|
| 15 |
+
"""Complete skin configuration."""
|
| 16 |
+
name: str
|
| 17 |
+
description: str = ""
|
| 18 |
+
colors: Dict[str, str] = field(default_factory=dict)
|
| 19 |
+
# Paired palettes for the opposite background polarity (mirrors the desktop app's
|
| 20 |
+
# colors/darkColors pairing): a light terminal prefers `light_colors` (falling back to
|
| 21 |
+
# `colors`), and vice versa for `dark_colors`.
|
| 22 |
+
light_colors: Dict[str, str] = field(default_factory=dict)
|
| 23 |
+
dark_colors: Dict[str, str] = field(default_factory=dict)
|
| 24 |
+
spinner: Dict[str, Any] = field(default_factory=dict)
|
| 25 |
+
branding: Dict[str, str] = field(default_factory=dict)
|
| 26 |
+
tool_prefix: str = "┊"
|
| 27 |
+
tool_emojis: Dict[str, str] = field(default_factory=dict) # per-tool emoji overrides
|
| 28 |
+
banner_logo: str = "" # Rich-markup ASCII art logo (replaces HERMES_AGENT_LOGO)
|
| 29 |
+
banner_hero: str = "" # Rich-markup hero art (replaces HERMES_CADUCEUS)
|
| 30 |
+
|
| 31 |
+
def get_color(self, key: str, fallback: str = "") -> str:
|
| 32 |
+
return self.colors.get(key, fallback)
|
| 33 |
+
|
| 34 |
+
def get_branding(self, key: str, fallback: str = "") -> str:
|
| 35 |
+
return self.branding.get(key, fallback)
|
| 36 |
+
|
| 37 |
+
def get_spinner_wings(self) -> List[Tuple[str, str]]:
|
| 38 |
+
"""Spinner wing pairs, or empty list if none."""
|
| 39 |
+
return [(str(pair[0]), str(pair[1])) for pair in self.spinner.get("wings", [])
|
| 40 |
+
if isinstance(pair, (list, tuple)) and len(pair) == 2]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _branding(who: str, symbol: str, goodbye: str, prompt: str = "", help_header: str = "") -> Dict[str, str]:
|
| 44 |
+
"""Branding block for a "<who> Agent" persona keyed by its glyph."""
|
| 45 |
+
return {
|
| 46 |
+
"agent_name": f"{who} Agent",
|
| 47 |
+
"welcome": f"Welcome to {who} Agent! Type your message or /help for commands.",
|
| 48 |
+
"goodbye": goodbye, "response_label": f" {symbol} {who} ", "prompt_symbol": prompt or symbol,
|
| 49 |
+
"help_header": help_header or f"({symbol}) Available Commands"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _wings(*glyphs) -> List[List[str]]:
|
| 53 |
+
"""Spinner wing pairs `⟪g` / `g⟫`; a (left, right) tuple gives asymmetric glyphs."""
|
| 54 |
+
return [[f"⟪{g[0] if isinstance(g, tuple) else g}", f"{g[1] if isinstance(g, tuple) else g}⟫"]
|
| 55 |
+
for g in glyphs]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# Branding shared by every Hermes-named built-in (mono/daylight override help_header).
|
| 59 |
+
_HERMES_BRANDING: Dict[str, str] = _branding(
|
| 60 |
+
"Hermes", "☤", "Goodbye! ☤", prompt="❯", help_header="(^_^)? Available Commands")
|
| 61 |
+
|
| 62 |
+
_BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
| 63 |
+
"default": {
|
| 64 |
+
"name": "default", "description": "Classic Hermes — gold and kawaii",
|
| 65 |
+
# Dark-authored; values match the TUI's DARK_THEME so both render the same gold.
|
| 66 |
+
"colors": {
|
| 67 |
+
"banner_border": "#CD7F32", "banner_title": "#FFD700", "banner_accent": "#FFBF00",
|
| 68 |
+
"banner_dim": "#B8860B", "banner_text": "#FFF8DC", "ui_accent": "#FFBF00",
|
| 69 |
+
"ui_label": "#DAA520", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
|
| 70 |
+
"prompt": "#FFF8DC", "input_rule": "#CD7F32", "response_border": "#FFD700",
|
| 71 |
+
"status_bar_bg": "#1a1a2e", "status_bar_text": "#C0C0C0",
|
| 72 |
+
"status_bar_strong": "#FFD700", "status_bar_dim": "#8A7A4A",
|
| 73 |
+
"status_bar_good": "#8FBC8F", "status_bar_warn": "#FFD700", "status_bar_bad": "#FF8C00",
|
| 74 |
+
"status_bar_critical": "#FF6B6B", "session_label": "#DAA520",
|
| 75 |
+
"session_border": "#8B8682", "completion_menu_bg": "#1a1a2e",
|
| 76 |
+
"completion_menu_current_bg": "#333355", "selection_bg": "#3a3a55",
|
| 77 |
+
"shell_dollar": "#4dabf7", "voice_status_bg": "#1a1a2e"},
|
| 78 |
+
# Light overlay (merged onto `colors`). Goldenrod ladder: on white the vivid
|
| 79 |
+
# #FFD700/#FFBF00 read as glare and WCAG-darkened mustard (#867000) as mud; the
|
| 80 |
+
# statusbar's goldenrod family (#B8860B/#DAA520) keeps the hue, tames saturation.
|
| 81 |
+
# Hierarchy on white: ink body 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 >
|
| 82 |
+
# headers 2.4. Fills (*_bg) flip the dark navy surfaces to light polarity.
|
| 83 |
+
"light_colors": {
|
| 84 |
+
"banner_title": "#C8961E", "banner_accent": "#D89B04", "banner_dim": "#B8860B",
|
| 85 |
+
"banner_text": "#5C4718", "ui_accent": "#D89B04", "ui_label": "#A97E10",
|
| 86 |
+
"ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#D97706", "prompt": "#5C4718",
|
| 87 |
+
"response_border": "#C8961E", "session_label": "#A97E10", "status_bar_text": "#6F6F6F",
|
| 88 |
+
"status_bar_strong": "#C8961E", "status_bar_dim": "#9A8A5A",
|
| 89 |
+
"status_bar_good": "#2E7D32", "status_bar_warn": "#C8961E", "status_bar_bad": "#C2410C",
|
| 90 |
+
"status_bar_critical": "#B91C1C", "shell_dollar": "#1E6FC0",
|
| 91 |
+
"completion_menu_bg": "#F5F5F5", "completion_menu_current_bg": "#E0D1BF",
|
| 92 |
+
"selection_bg": "#D4E4F7", "status_bar_bg": "#F5F5F5", "voice_status_bg": "#F5F5F5"},
|
| 93 |
+
"spinner": {}, # empty = hardcoded defaults in display.py
|
| 94 |
+
"branding": _HERMES_BRANDING,
|
| 95 |
+
"tool_prefix": "┊"},
|
| 96 |
+
"ares": {
|
| 97 |
+
"name": "ares", "description": "War-god theme — crimson and bronze",
|
| 98 |
+
"colors": {
|
| 99 |
+
"banner_border": "#A93333", "banner_title": "#C7A96B", "banner_accent": "#DD4A3A",
|
| 100 |
+
"banner_dim": "#905151", "banner_text": "#F1E6CF", "ui_accent": "#DD4A3A",
|
| 101 |
+
"ui_label": "#C7A96B", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
|
| 102 |
+
"prompt": "#F1E6CF", "input_rule": "#A93333", "response_border": "#C7A96B",
|
| 103 |
+
"status_bar_bg": "#2A1212", "status_bar_text": "#F1E6CF",
|
| 104 |
+
"status_bar_strong": "#C7A96B", "status_bar_dim": "#756054",
|
| 105 |
+
"status_bar_good": "#7BC96F", "status_bar_warn": "#C7A96B", "status_bar_bad": "#DD4A3A",
|
| 106 |
+
"status_bar_critical": "#EF5350", "session_label": "#C7A96B",
|
| 107 |
+
"session_border": "#6E584B", "completion_menu_bg": "#2A1212",
|
| 108 |
+
"completion_menu_current_bg": "#5C221D", "selection_bg": "#692620",
|
| 109 |
+
"shell_dollar": "#DD4A3A", "voice_status_bg": "#2A1212"},
|
| 110 |
+
"spinner": {
|
| 111 |
+
"waiting_faces": ["(⚔)", "(⛨)", "(▲)", "(<>)", "(/)"],
|
| 112 |
+
"thinking_faces": ["(⚔)", "(⛨)", "(▲)", "(⌁)", "(<>)"],
|
| 113 |
+
"thinking_verbs": [
|
| 114 |
+
"forging", "marching", "sizing the field", "holding the line",
|
| 115 |
+
"hammering plans", "tempering steel", "plotting impact", "raising the shield"],
|
| 116 |
+
"wings": _wings("⚔", "▲", ("╸", "╺"), "⛨")},
|
| 117 |
+
"branding": _branding("Ares", "⚔", "Farewell, warrior! ⚔"),
|
| 118 |
+
"tool_prefix": "╎",
|
| 119 |
+
"banner_logo": """[bold #A3261F] █████╗ ██████╗ ███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
| 120 |
+
[bold #B73122]██╔══██╗██╔══██╗██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
| 121 |
+
[#C93C24]███████║██████╔╝█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
| 122 |
+
[#D84A28]██╔══██║██╔══██╗██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
| 123 |
+
[#E15A2D]██║ ██║██║ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
| 124 |
+
[#EB6C32]╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
| 125 |
+
"banner_hero": """[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 126 |
+
[#9F1C1C]⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠻⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 127 |
+
[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠋⠀⠀⠀⠙⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀[/]
|
| 128 |
+
[#C7A96B]⠀⠀⠀⠀⠀⢀⣾⡿⠋⠀⠀⢠⡄⠀⠀⠙⢿⣷⡀⠀⠀⠀⠀⠀[/]
|
| 129 |
+
[#DD4A3A]⠀⠀⠀⠀⣰⣿⠟⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠻⣿⣆⠀⠀⠀⠀[/]
|
| 130 |
+
[#DD4A3A]⠀⠀⠀⢰⣿⠏⠀⠀⢀⣾⡿⠉⢿⣷⡀⠀⠀⠹⣿⡆⠀⠀⠀[/]
|
| 131 |
+
[#9F1C1C]⠀⠀⠀⣿⡟⠀⠀⣠⣿⠟⠀⠀⠀⠻⣿⣄⠀⠀⢻⣿⠀⠀⠀[/]
|
| 132 |
+
[#9F1C1C]⠀⠀⠀⣿⡇⠀⠀⠙⠋⠀⠀⚔⠀⠀⠙⠋⠀⠀⢸⣿⠀⠀⠀[/]
|
| 133 |
+
[#6B1717]⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀[/]
|
| 134 |
+
[#6B1717]⠀⠀⠀⠘⢿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⡿⠃⠀⠀⠀[/]
|
| 135 |
+
[#C7A96B]⠀⠀⠀⠀⠈⠻⣿⣷⣦⣤⣀⣀⣤⣤⣶⣿⠿⠋⠀⠀⠀⠀[/]
|
| 136 |
+
[#C7A96B]⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠀[/]
|
| 137 |
+
[#DD4A3A]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⚔⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 138 |
+
[dim #6B1717]⠀⠀⠀⠀⠀⠀⠀⠀war god online⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
| 139 |
+
},
|
| 140 |
+
"mono": {
|
| 141 |
+
"name": "mono", "description": "Monochrome — clean grayscale",
|
| 142 |
+
"colors": {
|
| 143 |
+
"banner_border": "#5E5E5E", "banner_title": "#e6edf3", "banner_accent": "#aaaaaa",
|
| 144 |
+
"banner_dim": "#606060", "banner_text": "#c9d1d9", "ui_accent": "#aaaaaa",
|
| 145 |
+
"ui_label": "#888888", "ui_ok": "#888888", "ui_error": "#cccccc", "ui_warn": "#999999",
|
| 146 |
+
"prompt": "#c9d1d9", "input_rule": "#606060", "response_border": "#aaaaaa",
|
| 147 |
+
"status_bar_bg": "#1F1F1F", "status_bar_text": "#C9D1D9",
|
| 148 |
+
"status_bar_strong": "#E6EDF3", "status_bar_dim": "#777777",
|
| 149 |
+
"status_bar_good": "#B5B5B5", "status_bar_warn": "#AAAAAA", "status_bar_bad": "#D0D0D0",
|
| 150 |
+
"status_bar_critical": "#F0F0F0", "session_label": "#888888",
|
| 151 |
+
"session_border": "#5E5E5E", "completion_menu_bg": "#1F1F1F",
|
| 152 |
+
"completion_menu_current_bg": "#464646", "selection_bg": "#505050",
|
| 153 |
+
"shell_dollar": "#aaaaaa", "voice_status_bg": "#1F1F1F"},
|
| 154 |
+
"spinner": {},
|
| 155 |
+
"branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"},
|
| 156 |
+
"tool_prefix": "┊"},
|
| 157 |
+
"slate": {
|
| 158 |
+
"name": "slate", "description": "Cool blue — developer-focused",
|
| 159 |
+
"colors": {
|
| 160 |
+
"banner_border": "#4169e1", "banner_title": "#7eb8f6", "banner_accent": "#8EA8FF",
|
| 161 |
+
"banner_dim": "#545E6B", "banner_text": "#c9d1d9", "ui_accent": "#7eb8f6",
|
| 162 |
+
"ui_label": "#8EA8FF", "ui_ok": "#63D0A6", "ui_error": "#F7A072", "ui_warn": "#e6a855",
|
| 163 |
+
"prompt": "#c9d1d9", "input_rule": "#4169e1", "response_border": "#7eb8f6",
|
| 164 |
+
"status_bar_bg": "#151C2F", "status_bar_text": "#C9D1D9",
|
| 165 |
+
"status_bar_strong": "#7EB8F6", "status_bar_dim": "#5D6672",
|
| 166 |
+
"status_bar_good": "#63D0A6", "status_bar_warn": "#E6A855", "status_bar_bad": "#F7A072",
|
| 167 |
+
"status_bar_critical": "#FF7A7A", "session_label": "#7eb8f6",
|
| 168 |
+
"session_border": "#545E6B", "completion_menu_bg": "#151C2F",
|
| 169 |
+
"completion_menu_current_bg": "#324867", "selection_bg": "#3A5375",
|
| 170 |
+
"shell_dollar": "#7eb8f6", "voice_status_bg": "#151C2F"},
|
| 171 |
+
"spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"},
|
| 172 |
+
"daylight": {
|
| 173 |
+
"name": "daylight",
|
| 174 |
+
"description": "Light theme for bright terminals with dark text and cool blue accents",
|
| 175 |
+
"colors": {
|
| 176 |
+
"banner_border": "#2563EB", "banner_title": "#0F172A", "banner_accent": "#1D4ED8",
|
| 177 |
+
"banner_dim": "#475569", "banner_text": "#111827", "ui_accent": "#2563EB",
|
| 178 |
+
"ui_label": "#0F766E", "ui_ok": "#15803D", "ui_error": "#B91C1C", "ui_warn": "#B45309",
|
| 179 |
+
"prompt": "#111827", "input_rule": "#6E94BE", "response_border": "#2563EB",
|
| 180 |
+
"status_bar_bg": "#E5EDF8", "status_bar_text": "#111827",
|
| 181 |
+
"status_bar_strong": "#2563EB", "status_bar_dim": "#838890",
|
| 182 |
+
"status_bar_good": "#15803D", "status_bar_warn": "#B45309", "status_bar_bad": "#B45309",
|
| 183 |
+
"status_bar_critical": "#B91C1C", "session_label": "#1D4ED8",
|
| 184 |
+
"session_border": "#64748B", "completion_menu_bg": "#F8FAFC",
|
| 185 |
+
"completion_menu_current_bg": "#DBEAFE", "completion_menu_meta_bg": "#EEF2FF",
|
| 186 |
+
"completion_menu_meta_current_bg": "#BFDBFE", "selection_bg": "#D3E0FB",
|
| 187 |
+
"shell_dollar": "#2563EB", "voice_status_bg": "#E5EDF8"},
|
| 188 |
+
"spinner": {},
|
| 189 |
+
"branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"},
|
| 190 |
+
"tool_prefix": "│"},
|
| 191 |
+
"warm-lightmode": {
|
| 192 |
+
"name": "warm-lightmode",
|
| 193 |
+
"description": "Warm light mode — dark brown/gold text for light terminal backgrounds",
|
| 194 |
+
"colors": {
|
| 195 |
+
"banner_border": "#8B6914", "banner_title": "#5C3D11", "banner_accent": "#8B4513",
|
| 196 |
+
"banner_dim": "#8B7355", "banner_text": "#2C1810", "ui_accent": "#8B4513",
|
| 197 |
+
"ui_label": "#5C3D11", "ui_ok": "#2E7D32", "ui_error": "#C62828", "ui_warn": "#E65100",
|
| 198 |
+
"prompt": "#2C1810", "input_rule": "#8B6914", "response_border": "#8B6914",
|
| 199 |
+
"status_bar_bg": "#F5F0E8", "status_bar_text": "#2C1810",
|
| 200 |
+
"status_bar_strong": "#8B4513", "status_bar_dim": "#8A8F98",
|
| 201 |
+
"status_bar_good": "#2E7D32", "status_bar_warn": "#E65100", "status_bar_bad": "#DA4D00",
|
| 202 |
+
"status_bar_critical": "#C62828", "session_label": "#5C3D11",
|
| 203 |
+
"session_border": "#A0845C", "completion_menu_bg": "#F5EFE0",
|
| 204 |
+
"completion_menu_current_bg": "#E8DCC8", "completion_menu_meta_bg": "#F0E8D8",
|
| 205 |
+
"completion_menu_meta_current_bg": "#DFCFB0", "selection_bg": "#E8DAD0",
|
| 206 |
+
"shell_dollar": "#8B4513", "voice_status_bg": "#F5F0E8"},
|
| 207 |
+
"spinner": {}, "branding": _HERMES_BRANDING, "tool_prefix": "┊"},
|
| 208 |
+
"poseidon": {
|
| 209 |
+
"name": "poseidon", "description": "Ocean-god theme — deep blue and seafoam",
|
| 210 |
+
"colors": {
|
| 211 |
+
"banner_border": "#2A6FB9", "banner_title": "#A9DFFF", "banner_accent": "#5DB8F5",
|
| 212 |
+
"banner_dim": "#44638F", "banner_text": "#EAF7FF", "ui_accent": "#5DB8F5",
|
| 213 |
+
"ui_label": "#A9DFFF", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
|
| 214 |
+
"prompt": "#EAF7FF", "input_rule": "#2A6FB9", "response_border": "#5DB8F5",
|
| 215 |
+
"status_bar_bg": "#0F2440", "status_bar_text": "#EAF7FF",
|
| 216 |
+
"status_bar_strong": "#A9DFFF", "status_bar_dim": "#52708A",
|
| 217 |
+
"status_bar_good": "#6ED7B0", "status_bar_warn": "#5DB8F5", "status_bar_bad": "#3576BC",
|
| 218 |
+
"status_bar_critical": "#D94F4F", "session_label": "#A9DFFF",
|
| 219 |
+
"session_border": "#496884", "completion_menu_bg": "#0F2440",
|
| 220 |
+
"completion_menu_current_bg": "#254D73", "selection_bg": "#2A587F",
|
| 221 |
+
"shell_dollar": "#5DB8F5", "voice_status_bg": "#0F2440"},
|
| 222 |
+
"spinner": {
|
| 223 |
+
"waiting_faces": ["(≈)", "(Ψ)", "(∿)", "(◌)", "(◠)"],
|
| 224 |
+
"thinking_faces": ["(Ψ)", "(∿)", "(≈)", "(⌁)", "(◌)"],
|
| 225 |
+
"thinking_verbs": [
|
| 226 |
+
"charting currents", "sounding the depth", "reading foam lines",
|
| 227 |
+
"steering the trident", "tracking undertow", "plotting sea lanes",
|
| 228 |
+
"calling the swell", "measuring pressure"],
|
| 229 |
+
"wings": _wings("≈", "Ψ", "∿", "◌")},
|
| 230 |
+
"branding": _branding("Poseidon", "Ψ", "Fair winds! Ψ"),
|
| 231 |
+
"tool_prefix": "│",
|
| 232 |
+
"banner_logo": """[bold #B8E8FF]██████╗ ██████╗ ███████╗███████╗██╗██████╗ ██████╗ ███╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
| 233 |
+
[bold #97D6FF]██╔══██╗██╔═══██╗██╔════╝██╔════╝██║██╔══██╗██╔═══██╗████╗ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
| 234 |
+
[#75C1F6]██████╔╝██║ ██║███████╗█████╗ ██║██║ ██║██║ ██║██╔██╗ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
| 235 |
+
[#4FA2E0]██╔═══╝ ██║ ██║╚════██║██╔══╝ ██║██║ ██║██║ ██║██║╚██╗██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
| 236 |
+
[#2E7CC7]██║ ╚██████╔╝███████║███████╗██║██████╔╝╚██████╔╝██║ ╚████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
| 237 |
+
[#1B4F95]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
| 238 |
+
"banner_hero": """[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 239 |
+
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 240 |
+
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⢠⣿⠏⠀Ψ⠀⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀[/]
|
| 241 |
+
[#A9DFFF]⠀⠀⠀⠀⠀⠀⠀⣿⡟⠀⠀⠀⠀⠀⢻⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
| 242 |
+
[#A9DFFF]⠀⠀⠀≈≈≈≈≈⣿⡇⠀⠀⠀⠀⠀⢸⣿≈≈≈≈≈⠀⠀⠀[/]
|
| 243 |
+
[#5DB8F5]⠀⠀⠀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
| 244 |
+
[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⢿⣧⠀⠀⠀⠀⠀⣼⡿⠀⠀⠀⠀⠀⠀⠀[/]
|
| 245 |
+
[#2A6FB9]⠀⠀⠀⠀⠀⠀⠀⠘⢿⣷⣄⣀⣠⣾⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
|
| 246 |
+
[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 247 |
+
[#153C73]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 248 |
+
[#5DB8F5]⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀[/]
|
| 249 |
+
[#A9DFFF]⠀⠀⠀⠀⠀⠀≈≈≈≈≈≈≈≈≈≈≈≈≈⠀⠀⠀⠀⠀⠀[/]
|
| 250 |
+
[dim #153C73]⠀⠀⠀⠀⠀⠀⠀deep waters hold⠀⠀⠀⠀⠀⠀⠀[/]""",
|
| 251 |
+
},
|
| 252 |
+
"sisyphus": {
|
| 253 |
+
"name": "sisyphus", "description": "Sisyphean theme — austere grayscale with persistence",
|
| 254 |
+
"colors": {
|
| 255 |
+
"banner_border": "#B7B7B7", "banner_title": "#F5F5F5", "banner_accent": "#E7E7E7",
|
| 256 |
+
"banner_dim": "#5C5C5C", "banner_text": "#D3D3D3", "ui_accent": "#E7E7E7",
|
| 257 |
+
"ui_label": "#D3D3D3", "ui_ok": "#919191", "ui_error": "#E7E7E7", "ui_warn": "#B7B7B7",
|
| 258 |
+
"prompt": "#F5F5F5", "input_rule": "#656565", "response_border": "#B7B7B7",
|
| 259 |
+
"status_bar_bg": "#202020", "status_bar_text": "#D3D3D3",
|
| 260 |
+
"status_bar_strong": "#F5F5F5", "status_bar_dim": "#6D6D6D",
|
| 261 |
+
"status_bar_good": "#B7B7B7", "status_bar_warn": "#D3D3D3", "status_bar_bad": "#E7E7E7",
|
| 262 |
+
"status_bar_critical": "#F5F5F5", "session_label": "#919191",
|
| 263 |
+
"session_border": "#656565", "completion_menu_bg": "#202020",
|
| 264 |
+
"completion_menu_current_bg": "#585858", "selection_bg": "#666666",
|
| 265 |
+
"shell_dollar": "#E7E7E7", "voice_status_bg": "#202020"},
|
| 266 |
+
"spinner": {
|
| 267 |
+
"waiting_faces": ["(◉)", "(◌)", "(◬)", "(⬤)", "(::)"],
|
| 268 |
+
"thinking_faces": ["(◉)", "(◬)", "(◌)", "(○)", "(●)"],
|
| 269 |
+
"thinking_verbs": [
|
| 270 |
+
"finding traction", "measuring the grade", "resetting the boulder",
|
| 271 |
+
"counting the ascent", "testing leverage", "setting the shoulder",
|
| 272 |
+
"pushing uphill", "enduring the loop"],
|
| 273 |
+
"wings": _wings("◉", "◬", "◌", "⬤")},
|
| 274 |
+
"branding": _branding("Sisyphus", "◉", "The boulder waits. ◉"),
|
| 275 |
+
"tool_prefix": "│",
|
| 276 |
+
"banner_logo": """[bold #F5F5F5]███████╗██╗███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ██╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
| 277 |
+
[bold #E7E7E7]██╔════╝██║██╔════╝╚██╗ ██╔╝██╔══██╗██║ ██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
| 278 |
+
[#D7D7D7]███████╗██║███████╗ ╚████╔╝ ██████╔╝███████║██║ ██║███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
| 279 |
+
[#BFBFBF]╚════██║██║╚════██║ ╚██╔╝ ██╔═══╝ ██╔══██║██║ ██║╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
| 280 |
+
[#8F8F8F]███████║██║███████║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
| 281 |
+
[#626262]╚══════╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
| 282 |
+
"banner_hero": """[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 283 |
+
[#D3D3D3]⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 284 |
+
[#E7E7E7]⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀[/]
|
| 285 |
+
[#F5F5F5]⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀[/]
|
| 286 |
+
[#E7E7E7]⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀[/]
|
| 287 |
+
[#D3D3D3]⠀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀[/]
|
| 288 |
+
[#B7B7B7]⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 289 |
+
[#919191]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 290 |
+
[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 291 |
+
[#656565]⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 292 |
+
[#4A4A4A]⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 293 |
+
[#4A4A4A]⠀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀[/]
|
| 294 |
+
[#656565]⠀⠀⠀━━━━━━━━━━━━━━━━━━━━━━━⠀⠀⠀[/]
|
| 295 |
+
[dim #4A4A4A]⠀⠀⠀⠀⠀⠀⠀⠀⠀the boulder⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
| 296 |
+
},
|
| 297 |
+
"charizard": {
|
| 298 |
+
"name": "charizard", "description": "Volcanic theme — burnt orange and ember",
|
| 299 |
+
"colors": {
|
| 300 |
+
"banner_border": "#C75B1D", "banner_title": "#FFD39A", "banner_accent": "#F29C38",
|
| 301 |
+
"banner_dim": "#C58A45", "banner_text": "#FFF0D4", "ui_accent": "#F29C38",
|
| 302 |
+
"ui_label": "#FFD39A", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726",
|
| 303 |
+
"prompt": "#FFF0D4", "input_rule": "#C75B1D", "response_border": "#F29C38",
|
| 304 |
+
"status_bar_bg": "#2B160E", "status_bar_text": "#FFF0D4",
|
| 305 |
+
"status_bar_strong": "#FFD39A", "status_bar_dim": "#826144",
|
| 306 |
+
"status_bar_good": "#6BCB77", "status_bar_warn": "#F29C38", "status_bar_bad": "#E2832B",
|
| 307 |
+
"status_bar_critical": "#EF5350", "session_label": "#FFD39A",
|
| 308 |
+
"session_border": "#7B593A", "completion_menu_bg": "#0B0503",
|
| 309 |
+
"completion_menu_current_bg": "#4A1B07", "completion_menu_meta_bg": "#120806",
|
| 310 |
+
"completion_menu_meta_current_bg": "#5A260D", "selection_bg": "#5A260D",
|
| 311 |
+
"shell_dollar": "#F29C38", "voice_status_bg": "#2B160E"},
|
| 312 |
+
"spinner": {
|
| 313 |
+
"waiting_faces": ["(✦)", "(▲)", "(◇)", "(<>)", "(🔥)"],
|
| 314 |
+
"thinking_faces": ["(✦)", "(▲)", "(◇)", "(⌁)", "(🔥)"],
|
| 315 |
+
"thinking_verbs": [
|
| 316 |
+
"banking into the draft", "measuring burn", "reading the updraft",
|
| 317 |
+
"tracking ember fall", "setting wing angle", "holding the flame core",
|
| 318 |
+
"plotting a hot landing", "coiling for lift"],
|
| 319 |
+
"wings": _wings("✦", "▲", "◌", "◇")},
|
| 320 |
+
"branding": _branding("Charizard", "✦", "Flame out! ✦"),
|
| 321 |
+
"tool_prefix": "│",
|
| 322 |
+
"banner_logo": """[bold #FFF0D4] ██████╗██╗ ██╗ █████╗ ██████╗ ██╗███████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
| 323 |
+
[bold #FFD39A]██╔════╝██║ ██║██╔══██╗██╔══██╗██║╚══███╔╝██╔══██╗██╔══██╗██╔══██╗ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
|
| 324 |
+
[#F29C38]██║ ███████║███████║██████╔╝██║ ███╔╝ ███████║██████╔╝██║ ██║█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
|
| 325 |
+
[#E2832B]██║ ██╔══██║██╔══██║██╔══██╗██║ ███╔╝ ██╔══██║██╔══██╗██║ ██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
|
| 326 |
+
[#C75B1D]╚██████╗██║ ██║██║ ██║██║ ██║██║███████╗██║ ██║██║ ██║██████╔╝ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
|
| 327 |
+
[#7A3511] ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""",
|
| 328 |
+
"banner_hero": """[#FFD39A]⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⠶⠶⠶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 329 |
+
[#F29C38]⠀⠀⠀⠀⠀⠀⣴⠟⠁⠀⠀⠀⠀⠈⠻⣦⠀⠀⠀⠀⠀⠀[/]
|
| 330 |
+
[#F29C38]⠀⠀⠀⠀⠀⣼⠏⠀⠀⠀✦⠀⠀⠀⠀⠹⣧⠀⠀⠀⠀⠀[/]
|
| 331 |
+
[#E2832B]⠀⠀⠀⠀⢰⡟⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢻⡆⠀⠀⠀⠀[/]
|
| 332 |
+
[#E2832B]⠀⠀⣠⡾⠛⠁⣠⣾⠟⠉⠀⠉⠻⣷⣄⠀⠈⠛⢷⣄⠀⠀[/]
|
| 333 |
+
[#C75B1D]⠀⣼⠟⠀⢀⣾⠟⠁⠀⠀⠀⠀⠀⠈⠻⣷⡀⠀⠻⣧⠀[/]
|
| 334 |
+
[#C75B1D]⢸⡟⠀⠀⣿⡟⠀⠀⠀🔥⠀⠀⠀⠀⢻⣿⠀⠀⢻⡇[/]
|
| 335 |
+
[#7A3511]⠀⠻⣦⡀⠘⢿⣧⡀⠀⠀⠀⠀⠀⢀⣼⡿⠃⢀⣴⠟⠀[/]
|
| 336 |
+
[#7A3511]⠀⠀⠈⠻⣦⣀⠙⢿⣷⣤⣤⣤⣾⡿⠋⣀⣴⠟⠁⠀⠀[/]
|
| 337 |
+
[#C75B1D]⠀⠀⠀⠀⠈⠙⠛⠶⠤⠭⠭⠤⠶⠛⠋⠁⠀⠀⠀⠀[/]
|
| 338 |
+
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⠀⣰⡿⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 339 |
+
[#F29C38]⠀⠀⠀⠀⠀⠀⠀⣼⡟⠀⠀⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀[/]
|
| 340 |
+
[dim #7A3511]⠀⠀⠀⠀⠀⠀⠀tail flame lit⠀⠀⠀⠀⠀⠀⠀⠀[/]""",
|
| 341 |
+
}}
|
| 342 |
+
|
| 343 |
+
_active_skin: Optional[SkinConfig] = None
|
| 344 |
+
_active_skin_name: str = "default"
|
| 345 |
+
# Routed multiplex profiles: (name, skin) per home key. ``display.skin`` and ``<home>/skins/*.yaml``
|
| 346 |
+
# are per profile, and the relay display name / TUI skin payload are read under each profile's
|
| 347 |
+
# override — one module slot would be last-writer-wins across profiles. Unscoped keeps the module slot.
|
| 348 |
+
_active_skin_by_home: Dict[str, Tuple[str, SkinConfig]] = {}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _routed_home_key() -> Optional[str]:
|
| 352 |
+
from hermes_constants import get_hermes_home_override, hermes_home_key
|
| 353 |
+
return None if get_hermes_home_override() is None else hermes_home_key()
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _profile_config() -> dict:
|
| 357 |
+
try:
|
| 358 |
+
from hermes_cli.config import load_config_readonly
|
| 359 |
+
return load_config_readonly() or {}
|
| 360 |
+
except Exception:
|
| 361 |
+
return {}
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _skins_dir() -> Path:
|
| 365 |
+
return get_hermes_home() / "skins"
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
| 369 |
+
"""Load a skin definition from a YAML file; None on any failure."""
|
| 370 |
+
try:
|
| 371 |
+
import yaml
|
| 372 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 373 |
+
data = yaml.safe_load(f)
|
| 374 |
+
if isinstance(data, dict) and "name" in data:
|
| 375 |
+
return data
|
| 376 |
+
except Exception as e:
|
| 377 |
+
logger.debug("Failed to load skin from %s: %s", path, e)
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _build_skin_config(data: Dict[str, Any]) -> SkinConfig:
|
| 382 |
+
"""Build a SkinConfig from a raw dict (built-in or loaded from YAML)."""
|
| 383 |
+
default = _BUILTIN_SKINS["default"]
|
| 384 |
+
skin_name = str(data.get("name", "unknown"))
|
| 385 |
+
|
| 386 |
+
def section(key: str) -> Dict[str, Any]:
|
| 387 |
+
value = data.get(key)
|
| 388 |
+
if isinstance(value, dict):
|
| 389 |
+
return value
|
| 390 |
+
if value is not None:
|
| 391 |
+
logger.warning("Skin '%s' has invalid '%s' section type (%s); ignoring section",
|
| 392 |
+
skin_name, key, type(value).__name__)
|
| 393 |
+
return {}
|
| 394 |
+
|
| 395 |
+
def merged(key: str) -> Dict[str, Any]:
|
| 396 |
+
return {**default.get(key, {}), **section(key)}
|
| 397 |
+
# Paired palettes are NOT merged over the default skin's blocks: an empty block means
|
| 398 |
+
# "no hand-tuned variant for that polarity" and consumers (the TUI) fall back to `colors`
|
| 399 |
+
# + automatic adaptation, which beats the default's gold light palette under a crimson skin.
|
| 400 |
+
return SkinConfig(
|
| 401 |
+
name=skin_name, description=data.get("description", ""), colors=merged("colors"),
|
| 402 |
+
light_colors=section("light_colors"), dark_colors=section("dark_colors"),
|
| 403 |
+
spinner=merged("spinner"), branding=merged("branding"),
|
| 404 |
+
tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")),
|
| 405 |
+
tool_emojis=section("tool_emojis"), banner_logo=data.get("banner_logo", ""),
|
| 406 |
+
banner_hero=data.get("banner_hero", ""))
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def list_skins() -> List[Dict[str, str]]:
|
| 410 |
+
"""List all available skins (built-in + user-installed); user skins never shadow built-ins."""
|
| 411 |
+
result = [{"name": name, "description": data.get("description", ""), "source": "builtin"}
|
| 412 |
+
for name, data in _BUILTIN_SKINS.items()]
|
| 413 |
+
skins_path = _skins_dir()
|
| 414 |
+
for f in sorted(skins_path.glob("*.yaml")) if skins_path.is_dir() else ():
|
| 415 |
+
data = _load_skin_from_yaml(f)
|
| 416 |
+
if data and not any(s["name"] == data.get("name", f.stem) for s in result):
|
| 417 |
+
result.append({"name": data.get("name", f.stem), "description": data.get("description", ""),
|
| 418 |
+
"source": "user"})
|
| 419 |
+
return result
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def load_skin(name: str) -> SkinConfig:
|
| 423 |
+
"""Load a skin by name: user skins first, then built-in, then default."""
|
| 424 |
+
user_file = _skins_dir() / f"{name}.yaml"
|
| 425 |
+
data = _load_skin_from_yaml(user_file) if user_file.is_file() else None
|
| 426 |
+
if not data and name not in _BUILTIN_SKINS:
|
| 427 |
+
logger.warning("Skin '%s' not found, using default", name)
|
| 428 |
+
return _build_skin_config(data or _BUILTIN_SKINS.get(name) or _BUILTIN_SKINS["default"])
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def get_active_skin() -> SkinConfig:
|
| 432 |
+
"""Currently active skin config (cached)."""
|
| 433 |
+
global _active_skin
|
| 434 |
+
home_key = _routed_home_key()
|
| 435 |
+
if home_key is not None:
|
| 436 |
+
entry = _active_skin_by_home.get(home_key)
|
| 437 |
+
if entry is None:
|
| 438 |
+
# Cold routed profile: its own ``display.skin`` (nobody ran init_skin_from_config for it).
|
| 439 |
+
init_skin_from_config(_profile_config())
|
| 440 |
+
entry = _active_skin_by_home[home_key]
|
| 441 |
+
return entry[1]
|
| 442 |
+
if _active_skin is None:
|
| 443 |
+
_active_skin = load_skin(_active_skin_name)
|
| 444 |
+
return _active_skin
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def set_active_skin(name: str) -> SkinConfig:
|
| 448 |
+
"""Switch the active skin. Returns the new SkinConfig."""
|
| 449 |
+
global _active_skin, _active_skin_name
|
| 450 |
+
skin = load_skin(name)
|
| 451 |
+
home_key = _routed_home_key()
|
| 452 |
+
if home_key is not None:
|
| 453 |
+
_active_skin_by_home[home_key] = (name, skin)
|
| 454 |
+
return skin
|
| 455 |
+
_active_skin_name = name
|
| 456 |
+
_active_skin = skin
|
| 457 |
+
return _active_skin
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def get_active_skin_name() -> str:
|
| 461 |
+
home_key = _routed_home_key()
|
| 462 |
+
if home_key is not None:
|
| 463 |
+
entry = _active_skin_by_home.get(home_key)
|
| 464 |
+
return entry[0] if entry else "default"
|
| 465 |
+
return _active_skin_name
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def init_skin_from_config(config: dict) -> None:
|
| 469 |
+
"""Initialize the active skin from CLI config at startup."""
|
| 470 |
+
display = config.get("display") or {}
|
| 471 |
+
skin_name = display.get("skin", "default") if isinstance(display, dict) else "default"
|
| 472 |
+
set_active_skin(skin_name.strip() if isinstance(skin_name, str) and skin_name.strip() else "default")
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _active_branding(key: str, fallback: str) -> str:
|
| 476 |
+
try:
|
| 477 |
+
return get_active_skin().get_branding(key, fallback)
|
| 478 |
+
except Exception:
|
| 479 |
+
return fallback
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def get_active_prompt_symbol(fallback: str = "❯") -> str:
|
| 483 |
+
"""Interactive prompt symbol (skins store a bare token) plus a single trailing space."""
|
| 484 |
+
cleaned = (_active_branding("prompt_symbol", fallback) or fallback).strip()
|
| 485 |
+
return f"{cleaned or fallback.strip()} "
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
|
| 489 |
+
return _active_branding("help_header", fallback)
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def get_active_goodbye(fallback: str = "Goodbye! ☤") -> str:
|
| 493 |
+
return _active_branding("goodbye", fallback)
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
# Palette resolution order for prompt_toolkit styles: (name, skin color key, fallback). A
|
| 497 |
+
# fallback starting with "@" names an earlier entry (so a missing key inherits its remapped value).
|
| 498 |
+
_STYLE_PALETTE = (
|
| 499 |
+
("prompt", "prompt", ""), ("input_rule", "input_rule", "#CD7F32"),
|
| 500 |
+
("title", "banner_title", "#FFD700"), ("text", "banner_text", "#FFF8DC"),
|
| 501 |
+
("dim", "banner_dim", "#555555"), ("label", "ui_label", "@title"), ("warn", "ui_warn", "#FF8C00"),
|
| 502 |
+
("error", "ui_error", "#FF6B6B"), ("status_bg", "status_bar_bg", "#1a1a2e"),
|
| 503 |
+
("status_text", "status_bar_text", "@text"), ("status_strong", "status_bar_strong", "@title"),
|
| 504 |
+
("status_dim", "status_bar_dim", "@dim"), ("ok", "ui_ok", "#8FBC8F"),
|
| 505 |
+
("status_good", "status_bar_good", "@ok"), ("status_warn", "status_bar_warn", "@warn"),
|
| 506 |
+
("accent", "banner_accent", "@warn"), ("status_bad", "status_bar_bad", "@accent"),
|
| 507 |
+
("status_critical", "status_bar_critical", "@error"), ("voice_bg", "voice_status_bg", "@status_bg"),
|
| 508 |
+
("menu_bg", "completion_menu_bg", "#1a1a2e"), ("menu_current_bg", "completion_menu_current_bg", "#333355"),
|
| 509 |
+
("menu_meta_bg", "completion_menu_meta_bg", "@menu_bg"),
|
| 510 |
+
("menu_meta_current_bg", "completion_menu_meta_current_bg", "@menu_current_bg"))
|
| 511 |
+
|
| 512 |
+
# prompt_toolkit style class -> format template over the resolved palette names.
|
| 513 |
+
_STYLE_TEMPLATES = {
|
| 514 |
+
"input-area": "", # terminal default fg/bg — `prompt` styles the symbol, NOT typed text
|
| 515 |
+
"placeholder": "{dim} italic", "prompt": "{prompt}", "prompt-working": "{dim} italic",
|
| 516 |
+
"hint": "{dim} italic",
|
| 517 |
+
"status-bar": "bg:{status_bg} {status_text}", "status-bar-strong": "bg:{status_bg} {status_strong} bold",
|
| 518 |
+
"status-bar-session-title": "bg:{badge_bg} {badge_fg} bold",
|
| 519 |
+
"status-bar-dim": "bg:{status_bg} {status_dim}", "status-bar-good": "bg:{status_bg} {status_good} bold",
|
| 520 |
+
"status-bar-warn": "bg:{status_bg} {status_warn} bold", "status-bar-bad": "bg:{status_bg} {status_bad} bold",
|
| 521 |
+
"status-bar-critical": "bg:{status_bg} {status_critical} bold",
|
| 522 |
+
"subagent-dock": "bg:{status_bg} {status_text}",
|
| 523 |
+
"subagent-dock.heading": "bg:{status_bg} {status_strong} bold",
|
| 524 |
+
"subagent-dock.selected": "bg:{menu_current_bg} {text} bold",
|
| 525 |
+
"input-rule": "{input_rule}", "image-badge": "{label} bold",
|
| 526 |
+
"completion-menu": "bg:{menu_bg} {text}", "completion-menu.completion": "bg:{menu_bg} {text}",
|
| 527 |
+
"completion-menu.completion.current": "bg:{menu_current_bg} {title}",
|
| 528 |
+
"completion-menu.meta.completion": "bg:{menu_meta_bg} {dim}",
|
| 529 |
+
"completion-menu.meta.completion.current": "bg:{menu_meta_current_bg} {label}",
|
| 530 |
+
"clarify-border": "{input_rule}", "clarify-title": "{title} bold", "clarify-question": "{text} bold",
|
| 531 |
+
"clarify-choice": "{dim}", "clarify-selected": "{title} bold", "clarify-active-other": "{title} italic",
|
| 532 |
+
"clarify-countdown": "{input_rule}",
|
| 533 |
+
"sudo-prompt": "{error} bold", "sudo-border": "{input_rule}", "sudo-title": "{error} bold",
|
| 534 |
+
"sudo-text": "{text}",
|
| 535 |
+
"approval-border": "{input_rule}", "approval-title": "{warn} bold", "approval-desc": "{text} bold",
|
| 536 |
+
"approval-cmd": "{dim} italic", "approval-choice": "{dim}", "approval-selected": "{title} bold",
|
| 537 |
+
"voice-status": "bg:{voice_bg} {label}", "voice-status-recording": "bg:{voice_bg} {error} bold"}
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
|
| 541 |
+
"""Return prompt_toolkit style overrides derived from the active skin."""
|
| 542 |
+
try:
|
| 543 |
+
skin = get_active_skin()
|
| 544 |
+
except Exception:
|
| 545 |
+
return {}
|
| 546 |
+
# `prompt` is unset by default so typed text inherits the terminal's foreground (readable
|
| 547 |
+
# on light and dark schemes); skins opt into a colored prompt symbol via `prompt` in YAML.
|
| 548 |
+
# Every read goes through skin.get_color (cli.py wraps it for light-mode remapping).
|
| 549 |
+
palette: Dict[str, str] = {}
|
| 550 |
+
for name, key, fallback in _STYLE_PALETTE:
|
| 551 |
+
palette[name] = skin.get_color(key, palette[fallback[1:]] if fallback.startswith("@") else fallback)
|
| 552 |
+
# This badge paints both sides; foreground-only light remapping destroys its contrast.
|
| 553 |
+
palette["badge_bg"] = skin.colors.get(
|
| 554 |
+
"status_bar_strong", skin.colors.get("banner_title", "#FFD700"))
|
| 555 |
+
palette["badge_fg"] = skin.colors.get("status_bar_bg", "#1a1a2e")
|
| 556 |
+
return {cls: tpl.format(**palette) for cls, tpl in _STYLE_TEMPLATES.items()}
|
hermes_cli/slack_cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``hermes slack manifest`` — generate the Slack app manifest JSON that registers every gateway
|
| 2 |
+
command as a native Slack slash (``/btw``, ``/stop``, ``/model``, …)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
SLACK_LONG_DESCRIPTION_MIN_CHARACTERS = 175
|
| 10 |
+
SLACK_LONG_DESCRIPTION_MAX_CHARACTERS = 4000
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _build_full_manifest(
|
| 14 |
+
bot_name: str, bot_description: str, include_assistant: bool = True,
|
| 15 |
+
messaging_experience: str | None = None, long_description: str | None = None) -> dict:
|
| 16 |
+
"""Build a full Slack manifest: display info + slash list from ``COMMAND_REGISTRY``.
|
| 17 |
+
|
| 18 |
+
Other sections (OAuth scopes, socket mode) are sensible Hermes defaults, tweakable in the Slack
|
| 19 |
+
UI after pasting.
|
| 20 |
+
"""
|
| 21 |
+
from hermes_cli.commands_platforms import slack_app_manifest
|
| 22 |
+
if messaging_experience is None:
|
| 23 |
+
messaging_experience = "assistant" if include_assistant else "none"
|
| 24 |
+
messaging_experience = str(messaging_experience).strip().lower()
|
| 25 |
+
if messaging_experience not in {"assistant", "agent", "none"}:
|
| 26 |
+
raise ValueError("messaging_experience must be one of: assistant, agent, none")
|
| 27 |
+
|
| 28 |
+
features = {
|
| 29 |
+
"app_home": {
|
| 30 |
+
"home_tab_enabled": False,
|
| 31 |
+
"messages_tab_enabled": True,
|
| 32 |
+
"messages_tab_read_only_enabled": False},
|
| 33 |
+
"bot_user": {"display_name": bot_name[:80], "always_online": True},
|
| 34 |
+
"slash_commands": slack_app_manifest()["features"]["slash_commands"]}
|
| 35 |
+
|
| 36 |
+
bot_scopes = [
|
| 37 |
+
"app_mentions:read", "channels:history", "channels:read", "chat:write", "commands",
|
| 38 |
+
"files:read", "files:write", "groups:history", "groups:read", "im:history", "im:read",
|
| 39 |
+
"im:write", "mpim:history", "mpim:read", "reactions:read", "users:read"]
|
| 40 |
+
bot_events = [
|
| 41 |
+
"app_mention", "message.channels", "message.groups", "message.im", "message.mpim",
|
| 42 |
+
"reaction_added", "reaction_removed"]
|
| 43 |
+
|
| 44 |
+
if messaging_experience == "assistant":
|
| 45 |
+
features["assistant_view"] = {
|
| 46 |
+
"assistant_description": "Chat with Hermes in threads and DMs."}
|
| 47 |
+
bot_scopes.append("assistant:write")
|
| 48 |
+
bot_events.extend(["assistant_thread_context_changed", "assistant_thread_started"])
|
| 49 |
+
elif messaging_experience == "agent":
|
| 50 |
+
features["agent_view"] = {"agent_description": "Chat with Hermes in Slack Messages."}
|
| 51 |
+
bot_scopes.append("assistant:write")
|
| 52 |
+
# Slack includes current viewing context in Agent DM events only after this subscription
|
| 53 |
+
# is enabled; the adapter uses it to preserve the referred channel across the agent turn.
|
| 54 |
+
bot_events.extend(["app_context_changed", "app_home_opened"])
|
| 55 |
+
|
| 56 |
+
bot_scopes.sort()
|
| 57 |
+
bot_events.sort()
|
| 58 |
+
|
| 59 |
+
display_information = {
|
| 60 |
+
"name": bot_name[:35],
|
| 61 |
+
"description": (bot_description or "Your Hermes agent on Slack")[:140],
|
| 62 |
+
"background_color": "#1a1a2e"}
|
| 63 |
+
if long_description is not None:
|
| 64 |
+
display_information["long_description"] = long_description
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"_metadata": {"major_version": 1, "minor_version": 1},
|
| 68 |
+
"display_information": display_information,
|
| 69 |
+
"features": features,
|
| 70 |
+
"oauth_config": {"scopes": {"bot": bot_scopes}},
|
| 71 |
+
"settings": {
|
| 72 |
+
"event_subscriptions": {"bot_events": bot_events},
|
| 73 |
+
"interactivity": {"is_enabled": True},
|
| 74 |
+
"org_deploy_enabled": False,
|
| 75 |
+
"socket_mode_enabled": True,
|
| 76 |
+
"token_rotation_enabled": False}}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def slack_manifest_command(args) -> int:
|
| 80 |
+
"""Print or write a Slack app manifest JSON (flags documented in ``hermes_cli/main.py``)."""
|
| 81 |
+
name = getattr(args, "name", None) or "Hermes"
|
| 82 |
+
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
|
| 83 |
+
long_description = getattr(args, "long_description", None)
|
| 84 |
+
long_description_file = getattr(args, "long_description_file", None)
|
| 85 |
+
slashes_only = getattr(args, "slashes_only", False)
|
| 86 |
+
|
| 87 |
+
def fail(msg: str) -> int:
|
| 88 |
+
print(f"hermes slack manifest: {msg}", file=sys.stderr)
|
| 89 |
+
return 2
|
| 90 |
+
|
| 91 |
+
if slashes_only and (long_description is not None or long_description_file is not None):
|
| 92 |
+
return fail("long description options cannot be used with --slashes-only")
|
| 93 |
+
if long_description_file is not None:
|
| 94 |
+
source_arg = str(long_description_file)
|
| 95 |
+
try:
|
| 96 |
+
with Path(source_arg).expanduser().open("r", encoding="utf-8", newline="") as handle:
|
| 97 |
+
long_description = handle.read()
|
| 98 |
+
except (OSError, UnicodeError, RuntimeError) as exc:
|
| 99 |
+
return fail(f"cannot read long description from {source_arg}: {exc}")
|
| 100 |
+
if long_description is not None:
|
| 101 |
+
n = len(long_description)
|
| 102 |
+
if n < SLACK_LONG_DESCRIPTION_MIN_CHARACTERS:
|
| 103 |
+
return fail(f"long description must be at least "
|
| 104 |
+
f"{SLACK_LONG_DESCRIPTION_MIN_CHARACTERS} characters (got {n})")
|
| 105 |
+
if n > SLACK_LONG_DESCRIPTION_MAX_CHARACTERS:
|
| 106 |
+
return fail(f"long description must be at most "
|
| 107 |
+
f"{SLACK_LONG_DESCRIPTION_MAX_CHARACTERS} characters (got {n})")
|
| 108 |
+
if getattr(args, "agent_view", False):
|
| 109 |
+
messaging_experience = "agent"
|
| 110 |
+
elif getattr(args, "no_assistant", False):
|
| 111 |
+
messaging_experience = "none"
|
| 112 |
+
else:
|
| 113 |
+
messaging_experience = "assistant"
|
| 114 |
+
|
| 115 |
+
if slashes_only:
|
| 116 |
+
from hermes_cli.commands_platforms import slack_app_manifest
|
| 117 |
+
manifest = slack_app_manifest()["features"]["slash_commands"]
|
| 118 |
+
else:
|
| 119 |
+
manifest = _build_full_manifest(
|
| 120 |
+
name, description, messaging_experience=messaging_experience,
|
| 121 |
+
long_description=long_description)
|
| 122 |
+
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
| 123 |
+
|
| 124 |
+
write_target = getattr(args, "write", None)
|
| 125 |
+
if write_target is None:
|
| 126 |
+
sys.stdout.write(payload)
|
| 127 |
+
return 0
|
| 128 |
+
if isinstance(write_target, bool) and write_target: # bare --write → default location
|
| 129 |
+
from hermes_constants import get_hermes_home
|
| 130 |
+
target = Path(get_hermes_home()) / "slack-manifest.json"
|
| 131 |
+
else:
|
| 132 |
+
target = Path(write_target).expanduser()
|
| 133 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 134 |
+
target.write_text(payload, encoding="utf-8")
|
| 135 |
+
print(f"Slack manifest written to: {target}", file=sys.stderr)
|
| 136 |
+
print(
|
| 137 |
+
"\nNext steps:\n"
|
| 138 |
+
" 1. Open https://api.slack.com/apps and pick your Hermes app\n"
|
| 139 |
+
" (or create a new one: Create New App → From an app manifest).\n"
|
| 140 |
+
f" 2. Features → App Manifest → paste the contents of\n"
|
| 141 |
+
f" {target}\n"
|
| 142 |
+
" 3. Save; Slack will prompt to reinstall the app if scopes or\n"
|
| 143 |
+
" slash commands changed.\n"
|
| 144 |
+
" 4. Make sure Socket Mode is enabled and you have a bot token\n"
|
| 145 |
+
" (xoxb-...) and app token (xapp-...) configured via\n"
|
| 146 |
+
" `hermes setup`.\n", file=sys.stderr)
|
| 147 |
+
return 0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 151 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 152 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 153 |
+
# The whole block is removed by reverting the commit that added it.
|
| 154 |
+
import os # noqa: F401,E402
|
| 155 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_cli/status.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Status command for hermes CLI."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import time
|
| 7 |
+
import importlib.util
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from types import SimpleNamespace
|
| 10 |
+
|
| 11 |
+
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
| 12 |
+
|
| 13 |
+
from hermes_cli.auth import AuthError, resolve_provider
|
| 14 |
+
from hermes_cli.colors import Colors, color
|
| 15 |
+
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
| 16 |
+
from hermes_cli.models import provider_label
|
| 17 |
+
from hermes_cli.runtime_provider import resolve_requested_provider
|
| 18 |
+
from hermes_cli.vercel_auth import describe_vercel_auth
|
| 19 |
+
from hermes_cli.status_auth import ( # renderers wired into _SECTIONS below
|
| 20 |
+
_render_api_keys, _render_apikey_providers, _render_auth_providers, _render_nous_gateway)
|
| 21 |
+
from hermes_constants import OPENROUTER_MODELS_URL
|
| 22 |
+
from hermes_constants import is_termux as _is_termux
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def check_mark(ok: bool) -> str:
|
| 26 |
+
return color("✓", Colors.GREEN) if ok else color("✗", Colors.RED)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _section(title: str) -> None:
|
| 30 |
+
"""Print a blank line followed by a bold cyan ``◆`` section heading."""
|
| 31 |
+
print()
|
| 32 |
+
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _row(name: str, ok: bool, text: str, width: int = 12, sep: str = " ") -> None:
|
| 36 |
+
"""Print one ``name ✓/✗ text`` status row."""
|
| 37 |
+
print(f" {name:<{width}}{sep}{check_mark(ok)} {text}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _detail(label: str, value) -> None:
|
| 41 |
+
"""Print an indented ``label: value`` detail line under a status row."""
|
| 42 |
+
_kv(label, value, " ", 12)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _kv(label: str, value, indent: str = " ", width: int = 14) -> None:
|
| 46 |
+
"""Print a `` Label: value`` line (label padded to the 14-col status layout)."""
|
| 47 |
+
print(f"{indent}{label:<{width}}{value}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _kv_flag(label: str, ok, on: str, off: str) -> None:
|
| 51 |
+
"""``_kv`` with a ✓/✗ mark followed by ``on`` or ``off`` text."""
|
| 52 |
+
_kv(label, f"{check_mark(bool(ok))} {on if ok else off}")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _configured(ok) -> str:
|
| 56 |
+
return "configured" if ok else "not configured"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _first_env_value(names) -> str:
|
| 60 |
+
"""Return the first non-empty env value among ``names`` (a str or tuple of names)."""
|
| 61 |
+
return next((v for v in (get_env_value(n) or "" for n in ((names,) if isinstance(names, str) else names)) if v), "")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _configured_model_label(config: dict) -> str:
|
| 65 |
+
"""Return the configured default model from config.yaml."""
|
| 66 |
+
model_cfg = config.get("model")
|
| 67 |
+
if isinstance(model_cfg, dict):
|
| 68 |
+
model_cfg = model_cfg.get("default") or model_cfg.get("name") or ""
|
| 69 |
+
return (model_cfg.strip() if isinstance(model_cfg, str) else "") or "(not set)"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _effective_provider_label() -> str:
|
| 73 |
+
"""Return the provider label matching current CLI runtime resolution."""
|
| 74 |
+
requested = resolve_requested_provider()
|
| 75 |
+
try:
|
| 76 |
+
effective = resolve_provider(requested)
|
| 77 |
+
except AuthError:
|
| 78 |
+
effective = requested or "auto"
|
| 79 |
+
|
| 80 |
+
if effective == "openrouter":
|
| 81 |
+
# A custom endpoint may live in config.yaml (model.base_url, the canonical location) or
|
| 82 |
+
# the legacy OPENAI_BASE_URL env var; either way labeling it "OpenRouter" is misleading.
|
| 83 |
+
try:
|
| 84 |
+
model_cfg = load_config().get("model")
|
| 85 |
+
except Exception:
|
| 86 |
+
model_cfg = None
|
| 87 |
+
config_base_url = (model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else ""
|
| 88 |
+
if config_base_url or get_env_value("OPENAI_BASE_URL"):
|
| 89 |
+
effective = "custom"
|
| 90 |
+
return provider_label(effective)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _estop_status_line():
|
| 94 |
+
"""One-line pause banner for `hermes status`, or None when not paused."""
|
| 95 |
+
try:
|
| 96 |
+
from agent.estop import get_state
|
| 97 |
+
except ImportError:
|
| 98 |
+
return None
|
| 99 |
+
state = get_state()
|
| 100 |
+
if state is None:
|
| 101 |
+
return None
|
| 102 |
+
reason = state.get("reason")
|
| 103 |
+
return f"⏸️ PAUSED (global emergency stop{f' — reason: {reason}' if reason else ''}; `hermes resume` to lift)"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# --- Data tables driving the per-section renderers -------------------------
|
| 107 |
+
|
| 108 |
+
# Simple env-driven terminal backends: (label, env var, default, empty-counts-as-unset).
|
| 109 |
+
_TERMINAL_ENV_ROWS = {
|
| 110 |
+
"ssh": (("SSH Host:", "TERMINAL_SSH_HOST", "(not set)", True), ("SSH User:", "TERMINAL_SSH_USER", "(not set)", True)),
|
| 111 |
+
"docker": (("Docker Image:", "TERMINAL_DOCKER_IMAGE", "python:3.11-slim", False),),
|
| 112 |
+
"daytona": (("Daytona Image:", "TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20", False),),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
_PLATFORMS = { # name -> (token env var, home-channel env var or None)
|
| 116 |
+
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
| 117 |
+
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), "WhatsApp": ("WHATSAPP_ENABLED", None),
|
| 118 |
+
"Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"),
|
| 119 |
+
"Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
| 120 |
+
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"), "DingTalk": ("DINGTALK_CLIENT_ID", None),
|
| 121 |
+
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"), "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
|
| 122 |
+
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None), "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
|
| 123 |
+
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), "QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
| 124 |
+
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL")}
|
| 125 |
+
|
| 126 |
+
# Gateway manager label when the runtime snapshot is unavailable, keyed by platform.
|
| 127 |
+
_GATEWAY_FALLBACK = {"termux": ("unknown", "Termux / manual process"), "linux": ("unknown", "systemd/manual"),
|
| 128 |
+
"darwin": ("unknown", "launchd")}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _banner(lines, *styles) -> None:
|
| 132 |
+
"""Blank line, then each line in ``styles``."""
|
| 133 |
+
print()
|
| 134 |
+
for line in lines:
|
| 135 |
+
print(color(line, *styles))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _render_header(ctx):
|
| 139 |
+
_banner(("┌─────────────────────────────────────────────────────────┐",
|
| 140 |
+
"│ ☤ Hermes Agent Status │",
|
| 141 |
+
"└─────────────────────────────────────────────────────────┘"), Colors.CYAN)
|
| 142 |
+
paused = _estop_status_line()
|
| 143 |
+
if paused:
|
| 144 |
+
_banner((paused,), Colors.YELLOW, Colors.BOLD)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _render_environment(ctx):
|
| 148 |
+
_section("Environment")
|
| 149 |
+
_kv("Project:", PROJECT_ROOT)
|
| 150 |
+
_kv("Python:", sys.version.split()[0])
|
| 151 |
+
_kv_flag(".env file:", get_env_path().exists(), "exists", "not found")
|
| 152 |
+
try:
|
| 153 |
+
ctx.config = load_config()
|
| 154 |
+
except Exception:
|
| 155 |
+
ctx.config = {}
|
| 156 |
+
_kv("Model:", _configured_model_label(ctx.config))
|
| 157 |
+
_kv("Provider:", _effective_provider_label())
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _render_terminal(ctx):
|
| 161 |
+
_section("Terminal Backend")
|
| 162 |
+
terminal_cfg = ctx.config.get("terminal", {}) if isinstance(ctx.config.get("terminal"), dict) else {}
|
| 163 |
+
terminal_env = os.getenv("TERMINAL_ENV", "") or terminal_cfg.get("backend", "local")
|
| 164 |
+
_kv("Backend:", terminal_env)
|
| 165 |
+
if terminal_env in _TERMINAL_ENV_ROWS:
|
| 166 |
+
for label, var, default, empty_is_unset in _TERMINAL_ENV_ROWS[terminal_env]:
|
| 167 |
+
value = (os.getenv(var, "") or default) if empty_is_unset else os.getenv(var, default)
|
| 168 |
+
print(f" {label:<13} {value}")
|
| 169 |
+
elif terminal_env == "vercel_sandbox":
|
| 170 |
+
persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
|
| 171 |
+
persist_enabled = (bool(terminal_cfg.get("container_persistent", True)) if persist is None
|
| 172 |
+
else persist.lower() in {"1", "true", "yes", "on"})
|
| 173 |
+
auth_status = describe_vercel_auth()
|
| 174 |
+
_kv("Runtime:", os.getenv('TERMINAL_VERCEL_RUNTIME') or terminal_cfg.get('vercel_runtime') or 'node24')
|
| 175 |
+
_kv_flag("SDK:", importlib.util.find_spec("vercel") is not None, "installed",
|
| 176 |
+
"missing (install: pip install 'hermes-agent[vercel]')")
|
| 177 |
+
_kv("Auth:", f"{check_mark(auth_status.ok)} {auth_status.label}")
|
| 178 |
+
for line in auth_status.detail_lines:
|
| 179 |
+
_kv("Auth detail:", line)
|
| 180 |
+
_kv("Persistence:", 'snapshot filesystem' if persist_enabled else 'ephemeral filesystem')
|
| 181 |
+
_kv("Processes:", "live processes do not survive cleanup, snapshots, or sandbox recreation")
|
| 182 |
+
else:
|
| 183 |
+
# Plugin-registered terminal backends: show availability via the provider's doctor rows
|
| 184 |
+
# (fail-soft — never break `hermes status`).
|
| 185 |
+
try:
|
| 186 |
+
from hermes_cli.plugins import discover_plugins
|
| 187 |
+
discover_plugins()
|
| 188 |
+
from agent.terminal_env_registry import get_provider
|
| 189 |
+
provider = get_provider(terminal_env)
|
| 190 |
+
if provider is not None:
|
| 191 |
+
for ok, label, text in provider.doctor_checks():
|
| 192 |
+
print(f" {label}: {check_mark(bool(ok))} {text}")
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
_kv_flag("Sudo:", os.getenv("SUDO_PASSWORD", ""), "enabled", "disabled")
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _render_platforms(ctx):
|
| 199 |
+
_section("Messaging Platforms")
|
| 200 |
+
for name, (token_var, home_var) in _PLATFORMS.items():
|
| 201 |
+
has_token = bool(os.getenv(token_var, ""))
|
| 202 |
+
home_channel = os.getenv(home_var, "") if home_var else ""
|
| 203 |
+
_row(name, has_token, _configured(has_token) + (f" (home: {home_channel})" if home_channel else ""))
|
| 204 |
+
|
| 205 |
+
try: # Plugin-registered platforms
|
| 206 |
+
from gateway.platform_registry import platform_registry
|
| 207 |
+
for entry in platform_registry.plugin_entries():
|
| 208 |
+
# Per-entry guard: one raising probe must not abort the listing of every remaining
|
| 209 |
+
# plugin platform (matches the other check_fn sites).
|
| 210 |
+
try:
|
| 211 |
+
configured = bool(entry.check_fn())
|
| 212 |
+
except Exception:
|
| 213 |
+
configured = False
|
| 214 |
+
_row(entry.label, configured, f"{_configured(configured)} (plugin)")
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _render_gateway(ctx):
|
| 220 |
+
_section("Gateway Service")
|
| 221 |
+
try:
|
| 222 |
+
from hermes_cli.gateway import (
|
| 223 |
+
get_gateway_runtime_snapshot, _format_gateway_pids, named_profile_served_by_running_multiplexer)
|
| 224 |
+
from hermes_cli.gateway_multiplex_served import multiplexer_served_secondaries
|
| 225 |
+
snapshot = get_gateway_runtime_snapshot()
|
| 226 |
+
# A satellite profile has no gateway.pid of its own; the default multiplexer is its live process.
|
| 227 |
+
if not snapshot.running and named_profile_served_by_running_multiplexer():
|
| 228 |
+
_kv_flag("Status:", True, "running (via the default-profile multiplexer)", "stopped")
|
| 229 |
+
_kv("Manage with:", "hermes gateway status # from the default profile")
|
| 230 |
+
return
|
| 231 |
+
_kv_flag("Status:", snapshot.running, "running", "stopped")
|
| 232 |
+
_kv("Manager:", snapshot.manager)
|
| 233 |
+
if snapshot.gateway_pids:
|
| 234 |
+
_kv("PID(s):", _format_gateway_pids(snapshot.gateway_pids))
|
| 235 |
+
if snapshot.running and (served := multiplexer_served_secondaries()):
|
| 236 |
+
_kv("Serves:", ", ".join(served))
|
| 237 |
+
from hermes_cli.gateway_multiplex_served import served_profile_ingress_urls
|
| 238 |
+
for name, per_platform in sorted(served_profile_ingress_urls().items()):
|
| 239 |
+
for platform, url in sorted(per_platform.items()):
|
| 240 |
+
_kv(f" {name}/{platform}:", url)
|
| 241 |
+
if snapshot.has_process_service_mismatch:
|
| 242 |
+
_kv("Service:", "installed but not managing the current running gateway")
|
| 243 |
+
elif _is_termux() and not snapshot.gateway_pids:
|
| 244 |
+
_kv("Start with:", "hermes gateway")
|
| 245 |
+
_kv("Note:", "Android may stop background jobs when Termux is suspended")
|
| 246 |
+
elif snapshot.service_installed and not snapshot.service_running:
|
| 247 |
+
_kv("Service:", "installed but stopped")
|
| 248 |
+
except Exception:
|
| 249 |
+
platform = "termux" if _is_termux() else "linux" if sys.platform.startswith("linux") else sys.platform
|
| 250 |
+
status_text, manager = _GATEWAY_FALLBACK.get(platform, ("N/A", "(not supported on this platform)"))
|
| 251 |
+
_kv("Status:", color(status_text, Colors.DIM))
|
| 252 |
+
_kv("Manager:", manager)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _load_json(path: Path, encoding: str = "utf-8"):
|
| 256 |
+
with open(path, encoding=encoding) as f:
|
| 257 |
+
return json.load(f)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _render_cron(ctx):
|
| 261 |
+
_section("Scheduled Jobs")
|
| 262 |
+
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
| 263 |
+
if not jobs_file.exists():
|
| 264 |
+
_kv("Jobs:", 0)
|
| 265 |
+
return
|
| 266 |
+
try:
|
| 267 |
+
# utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors may leave a UTF-8 BOM
|
| 268 |
+
# that plain utf-8 json.load rejects.
|
| 269 |
+
jobs = _load_json(jobs_file, "utf-8-sig").get("jobs", [])
|
| 270 |
+
_kv("Jobs:", f"{sum(1 for j in jobs if j.get('enabled', True))} active, {len(jobs)} total")
|
| 271 |
+
except Exception:
|
| 272 |
+
_kv("Jobs:", "(error reading jobs file)")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _render_sessions(ctx):
|
| 276 |
+
_section("Sessions")
|
| 277 |
+
# Gateway session count: state.db is the source of truth; fall back to sessions.json for
|
| 278 |
+
# pre-migration installs.
|
| 279 |
+
try:
|
| 280 |
+
from hermes_state import SessionDB
|
| 281 |
+
db = SessionDB(read_only=True) # status only reads; never a writer beside a running gateway
|
| 282 |
+
try:
|
| 283 |
+
gateway_rows = db.list_gateway_sessions(active_only=True) or []
|
| 284 |
+
finally:
|
| 285 |
+
db.close()
|
| 286 |
+
except Exception:
|
| 287 |
+
gateway_rows = []
|
| 288 |
+
|
| 289 |
+
if gateway_rows:
|
| 290 |
+
_kv("Active:", f"{len(gateway_rows)} session(s)")
|
| 291 |
+
freshest = max((float(r.get("last_active") or 0) for r in gateway_rows), default=0.0)
|
| 292 |
+
if freshest > 0:
|
| 293 |
+
from hermes_cli.timefmt import relative_time
|
| 294 |
+
print(f" Last activity:{relative_time(freshest):>13}")
|
| 295 |
+
elif not (sessions_file := get_hermes_home() / "sessions" / "sessions.json").exists():
|
| 296 |
+
_kv("Active:", 0)
|
| 297 |
+
else:
|
| 298 |
+
try:
|
| 299 |
+
data = _load_json(sessions_file)
|
| 300 |
+
entries = [k for k in data if not str(k).startswith("_")] if isinstance(data, dict) else []
|
| 301 |
+
_kv("Active:", f"{len(entries)} session(s)")
|
| 302 |
+
except Exception:
|
| 303 |
+
_kv("Active:", "(error reading sessions file)")
|
| 304 |
+
|
| 305 |
+
# Slot usage, only when max_concurrent_sessions is set. The cap is shared across CLI,
|
| 306 |
+
# desktop/TUI and the messaging gateway, so the surface that gets rejected is rarely the one
|
| 307 |
+
# holding the slots — without this the only way to find out is reading
|
| 308 |
+
# runtime/active_sessions.json by hand.
|
| 309 |
+
try:
|
| 310 |
+
from hermes_cli.active_sessions import (
|
| 311 |
+
active_session_registry_snapshot, format_age, resolve_max_concurrent_sessions)
|
| 312 |
+
cap = resolve_max_concurrent_sessions(ctx.config)
|
| 313 |
+
except Exception:
|
| 314 |
+
cap = None
|
| 315 |
+
if cap:
|
| 316 |
+
try:
|
| 317 |
+
held = active_session_registry_snapshot()
|
| 318 |
+
except Exception:
|
| 319 |
+
held = []
|
| 320 |
+
_kv("Slots:", color(f"{len(held)}/{cap} in use", Colors.YELLOW if len(held) >= cap else Colors.GREEN))
|
| 321 |
+
now = time.time()
|
| 322 |
+
for entry in sorted(held, key=lambda e: e.get("started_at") or 0):
|
| 323 |
+
age = format_age(now - float(entry.get("started_at") or now))
|
| 324 |
+
print(f" {entry.get('surface') or 'unknown':<17} {entry.get('session_id') or '?':<24} {age}")
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _render_deep(ctx):
|
| 328 |
+
if not ctx.deep:
|
| 329 |
+
return
|
| 330 |
+
_section("Deep Checks")
|
| 331 |
+
openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
|
| 332 |
+
if openrouter_key:
|
| 333 |
+
try:
|
| 334 |
+
import httpx
|
| 335 |
+
response = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {openrouter_key}"}, timeout=10)
|
| 336 |
+
_kv_flag("OpenRouter:", response.status_code == 200, "reachable", f"error ({response.status_code})")
|
| 337 |
+
except Exception as e:
|
| 338 |
+
_kv("OpenRouter:", f"{check_mark(False)} error: {e}")
|
| 339 |
+
try: # gateway port, informational: in use == gateway likely running
|
| 340 |
+
import socket
|
| 341 |
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
| 342 |
+
sock.settimeout(1)
|
| 343 |
+
port_in_use = sock.connect_ex(('127.0.0.1', 18789)) == 0
|
| 344 |
+
sock.close()
|
| 345 |
+
_kv("Port 18789:", 'in use' if port_in_use else 'available')
|
| 346 |
+
except OSError:
|
| 347 |
+
pass
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _render_footer(ctx):
|
| 351 |
+
_banner(("─" * 60, " Run 'hermes doctor' for detailed diagnostics", " Run 'hermes setup' to configure"),
|
| 352 |
+
Colors.DIM)
|
| 353 |
+
print()
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# Print order of `hermes status`; each renderer takes the shared _StatusContext.
|
| 357 |
+
_SECTIONS = (
|
| 358 |
+
_render_header, _render_environment, _render_api_keys, _render_auth_providers, _render_nous_gateway,
|
| 359 |
+
_render_apikey_providers, _render_terminal, _render_platforms, _render_gateway, _render_cron,
|
| 360 |
+
_render_sessions, _render_deep, _render_footer)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def show_status(args):
|
| 364 |
+
"""Show status of all Hermes Agent components."""
|
| 365 |
+
# Shared by section renderers: config, --deep, and the Nous login facts Auth Providers derives
|
| 366 |
+
# for the later Nous Tool Gateway section.
|
| 367 |
+
ctx = SimpleNamespace(deep=getattr(args, 'deep', False), config={}, nous_logged_in=False,
|
| 368 |
+
nous_inference_present=False, nous_account_info=None)
|
| 369 |
+
for render in _SECTIONS:
|
| 370 |
+
render(ctx)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 374 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 375 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 376 |
+
# The whole block is removed by reverting the commit that added it.
|
| 377 |
+
import subprocess # noqa: F401,E402
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
_PLUGIN_COMPAT_LAZY = {
|
| 381 |
+
'format_nous_portal_entitlement_message': ('hermes_cli.nous_account', 'format_nous_portal_entitlement_message'),
|
| 382 |
+
'get_nous_portal_account_info': ('hermes_cli.nous_account', 'get_nous_portal_account_info'),
|
| 383 |
+
'get_nous_subscription_features': ('hermes_cli.nous_subscription', 'get_nous_subscription_features'),
|
| 384 |
+
'managed_nous_tools_enabled': ('tools.tool_backend_helpers', 'managed_nous_tools_enabled'),
|
| 385 |
+
'redact_key': ('hermes_cli.config', 'redact_key'),
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
| 390 |
+
target = _PLUGIN_COMPAT_LAZY.get(name)
|
| 391 |
+
if target is None:
|
| 392 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
| 393 |
+
import importlib
|
| 394 |
+
from hermes_cli.plugin_compat import warn_once
|
| 395 |
+
warn_once(__name__, name, *target)
|
| 396 |
+
return getattr(importlib.import_module(target[0]), target[1])
|
| 397 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_cli/update_cmd_git.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Git plumbing for ``hermes update``: fork/upstream sync, trampoline-git detection, lockfile/EOL churn cleanup, orphan rescue refs, parked-branch assessment, fetch-failure classification.
|
| 2 |
+
|
| 3 |
+
Split out of ``update_cmd.py``, which re-imports every name so ``hermes_cli.update_cmd.<name>``
|
| 4 |
+
still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle;
|
| 5 |
+
test patches on ``update_cmd`` stay effective).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from contextlib import suppress
|
| 10 |
+
import subprocess
|
| 11 |
+
import sys
|
| 12 |
+
from datetime import datetime, timedelta, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("hermes_cli.update_cmd") # log-record parity with the origin module
|
| 17 |
+
|
| 18 |
+
_ORPHAN_RESCUE_REFS_TO_KEEP = 10
|
| 19 |
+
_ORPHAN_RESCUE_REF_MAX_AGE_DAYS = 30
|
| 20 |
+
|
| 21 |
+
_GIT_TEXT_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace")
|
| 22 |
+
_BAR = "=" * 68
|
| 23 |
+
_UPSTREAM_ADD_CMD = "git remote add upstream https://github.com/NousResearch/hermes-agent.git"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _git_ok(git_cmd, args, cwd, **kw) -> bool:
|
| 27 |
+
"""True when ``_git_run`` exits 0; any exception counts as failure."""
|
| 28 |
+
return _git_stdout(git_cmd, args, cwd, **kw) is not None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]:
|
| 32 |
+
"""Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception."""
|
| 33 |
+
from hermes_cli.update_cmd import _git_run
|
| 34 |
+
with suppress(Exception):
|
| 35 |
+
result = _git_run(git_cmd, args, cwd, **kw)
|
| 36 |
+
if result.returncode == 0:
|
| 37 |
+
return result.stdout.strip()
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _prune_orphan_rescue_refs(
|
| 42 |
+
git_cmd, cwd, branch, keep=_ORPHAN_RESCUE_REFS_TO_KEEP, max_age_days=_ORPHAN_RESCUE_REF_MAX_AGE_DAYS
|
| 43 |
+
) -> None:
|
| 44 |
+
"""Expire old orphan rescue refs (``refs/hermes-update-backups/orphan-<branch>-<ts>-<sha>``).
|
| 45 |
+
|
| 46 |
+
Each ref pins a possibly multi-GB snapshot against ``git gc``, so a repeatedly corrupted install would
|
| 47 |
+
grow ``.git`` unbounded. Keep the ``keep`` newest AND drop any older than ``max_age_days`` by the
|
| 48 |
+
``YYYYMMDD-HHMMSS`` stamp (unparseable names left alone); names sort chronologically so
|
| 49 |
+
``for-each-ref`` order is creation order. Best-effort, never blocks.
|
| 50 |
+
|
| 51 |
+
A rescue ref pins every object reachable from that commit against ``git gc`` — and in the incident shape
|
| 52 |
+
those objects include a full working-tree snapshot (the autostash orphan commit), which can be multi-GB
|
| 53 |
+
when the tree holds large stray files. See #87694.
|
| 54 |
+
"""
|
| 55 |
+
from hermes_cli.update_cmd import _git_run
|
| 56 |
+
with suppress(OSError):
|
| 57 |
+
prefix = f"refs/hermes-update-backups/orphan-{branch}-"
|
| 58 |
+
list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd)
|
| 59 |
+
if list_result.returncode != 0:
|
| 60 |
+
return
|
| 61 |
+
refs = [line.strip() for line in list_result.stdout.splitlines() if line.strip()]
|
| 62 |
+
stale = set(refs[:-keep] if keep > 0 else refs)
|
| 63 |
+
if max_age_days > 0:
|
| 64 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
| 65 |
+
for ref in refs:
|
| 66 |
+
with suppress(ValueError):
|
| 67 |
+
if datetime.strptime(ref[len(prefix):][:15], "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc) < cutoff:
|
| 68 |
+
stale.add(ref)
|
| 69 |
+
for ref in sorted(stale):
|
| 70 |
+
_git_run(git_cmd, ["update-ref", "-d", ref], cwd)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _branch_head_label(git_cmd=None, cwd=None) -> str | None:
|
| 74 |
+
"""``"<branch> @ <short-sha>"`` for the checkout (``detached`` when not on a branch), or None. Never raises.
|
| 75 |
+
|
| 76 |
+
Appended to summary lines so a checkout parked on a stale branch is visible."""
|
| 77 |
+
from hermes_cli.update_cmd import _m
|
| 78 |
+
try:
|
| 79 |
+
cmd = list(git_cmd) if git_cmd else ["git"]
|
| 80 |
+
root = cwd if cwd is not None else _m().PROJECT_ROOT
|
| 81 |
+
|
| 82 |
+
def _rev_parse(*args):
|
| 83 |
+
return subprocess.run(cmd + ["rev-parse", *args], cwd=root, **_GIT_TEXT_KW)
|
| 84 |
+
|
| 85 |
+
branch, sha = _rev_parse("--abbrev-ref", "HEAD"), _rev_parse("--short", "HEAD")
|
| 86 |
+
branch_name, sha_text = branch.stdout.strip(), sha.stdout.strip()
|
| 87 |
+
if branch.returncode != 0 or sha.returncode != 0 or not sha_text or not branch_name:
|
| 88 |
+
return None
|
| 89 |
+
return f"{'detached' if branch_name == 'HEAD' else branch_name} @ {sha_text}"
|
| 90 |
+
except Exception:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _branch_head_suffix(git_cmd=None, cwd=None) -> str:
|
| 95 |
+
"""`` [<branch> @ <sha>]`` suffix for summary lines ("" when unknown)."""
|
| 96 |
+
label = _branch_head_label(git_cmd, cwd)
|
| 97 |
+
return f" [{label}]" if label else ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _assess_parked_branch_switch(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str) -> tuple[bool, str]:
|
| 101 |
+
"""Decide whether a parked feature branch may be auto-switched back to the update target.
|
| 102 |
+
|
| 103 |
+
- (True, "") — tree clean and every parked commit is in ``origin/<target>`` (no ``git cherry +``).
|
| 104 |
+
- (True, "unmerged:<n>") — tree clean but commits not in target; switching is safe (checkout keeps
|
| 105 |
+
committed work) but caller must print a LOUD notice. Non-interactive callers (desktop, gateway
|
| 106 |
+
/update, cron) can't resolve a skip, so a clean checkout must reach target.
|
| 107 |
+
- (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is the
|
| 108 |
+
genuinely unsafe case: uncommitted work riding an autostash across branches.
|
| 109 |
+
A config read failure must not disable the safety checks: fall through with the default."""
|
| 110 |
+
from hermes_cli.update_cmd import _git_run
|
| 111 |
+
try:
|
| 112 |
+
from hermes_cli.config import load_config
|
| 113 |
+
_update_cfg = (load_config() or {}).get("updates", {})
|
| 114 |
+
if isinstance(_update_cfg, dict) and not bool(_update_cfg.get("auto_switch_parked_branch", True)):
|
| 115 |
+
return False, "disabled"
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.debug("Could not read updates.auto_switch_parked_branch: %s", exc)
|
| 118 |
+
status = _git_run(git_cmd, ["status", "--porcelain"], cwd)
|
| 119 |
+
if status.returncode != 0:
|
| 120 |
+
return False, "unverifiable"
|
| 121 |
+
if status.stdout.strip():
|
| 122 |
+
return False, "dirty"
|
| 123 |
+
cherry = _git_run(git_cmd, ["cherry", f"origin/{target_branch}"], cwd)
|
| 124 |
+
if cherry.returncode != 0:
|
| 125 |
+
return False, "unverifiable"
|
| 126 |
+
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
|
| 127 |
+
return True, f"unmerged:{len(unmerged)}" if unmerged else ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
_PARKED_SKIP_WHY = {
|
| 131 |
+
"dirty": "the working tree has uncommitted changes",
|
| 132 |
+
"disabled": "updates.auto_switch_parked_branch is set to false in config.yaml",
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _print_parked_branch_skip_warning(git_cmd: list[str], cwd: Path, current_branch: str, target_branch: str, reason: str) -> None:
|
| 137 |
+
"""LOUD block: why the update was skipped on a parked branch, behind-count, fix commands."""
|
| 138 |
+
behind = None
|
| 139 |
+
with suppress(Exception):
|
| 140 |
+
behind_text = _git_stdout(git_cmd, ["rev-list", f"HEAD..origin/{target_branch}", "--count"], cwd)
|
| 141 |
+
if behind_text:
|
| 142 |
+
behind = int(behind_text)
|
| 143 |
+
why = _PARKED_SKIP_WHY.get(reason, f"the branch state could not be verified against origin/{target_branch}")
|
| 144 |
+
print(f"\n{_BAR}\n⚠ CODE UPDATE SKIPPED — checkout is parked on '{current_branch}'")
|
| 145 |
+
print(f" Not auto-switching to {target_branch}: {why}.")
|
| 146 |
+
if behind is not None and behind > 0:
|
| 147 |
+
print(f" This checkout is {behind} commit(s) BEHIND origin/{target_branch} — the code you are running is stale.")
|
| 148 |
+
print(
|
| 149 |
+
f"\n To resolve, inspect the branch and switch back yourself:\n"
|
| 150 |
+
f" git -C {cwd} status\n"
|
| 151 |
+
f" git -C {cwd} checkout {target_branch} && hermes update\n"
|
| 152 |
+
f" (commit or stash your work on the branch first if you want to keep it)\n{_BAR}"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _print_parked_branch_kept_notice(current_branch: str, target_branch: str, unmerged_count: str) -> None:
|
| 157 |
+
"""LOUD notice when a clean parked branch with unmerged commits is auto-switched.
|
| 158 |
+
|
| 159 |
+
Non-interactive callers can't resolve a skip, so we proceed — but the unmerged work
|
| 160 |
+
(still safe on its branch) must be impossible to miss."""
|
| 161 |
+
print(
|
| 162 |
+
f"\n{_BAR}\n"
|
| 163 |
+
f"⚠ Checkout was parked on '{current_branch}' with "
|
| 164 |
+
f"{unmerged_count} commit(s) not merged into origin/{target_branch}.\n"
|
| 165 |
+
f" Switching to {target_branch} so the update can proceed — your "
|
| 166 |
+
f"commit(s) are safe on '{current_branch}'.\n\n"
|
| 167 |
+
f" To pick the work back up later:\n git checkout {current_branch}\n{_BAR}"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
OFFICIAL_REPO_URLS = {
|
| 172 |
+
"https://github.com/NousResearch/hermes-agent.git",
|
| 173 |
+
"git@github.com:NousResearch/hermes-agent.git",
|
| 174 |
+
"https://github.com/NousResearch/hermes-agent",
|
| 175 |
+
"git@github.com:NousResearch/hermes-agent",
|
| 176 |
+
}
|
| 177 |
+
OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
| 178 |
+
SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt"
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
| 182 |
+
"""Get the URL of the origin remote, or None if not set."""
|
| 183 |
+
return _git_stdout(git_cmd, ["remote", "get-url", "origin"], cwd)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _is_fork(origin_url: Optional[str]) -> bool:
|
| 187 |
+
"""Check if the origin remote points to a fork (not the official repo)."""
|
| 188 |
+
if not origin_url:
|
| 189 |
+
return False
|
| 190 |
+
|
| 191 |
+
def _norm(url: str) -> str:
|
| 192 |
+
url = url.rstrip("/")
|
| 193 |
+
return url[:-4] if url.endswith(".git") else url
|
| 194 |
+
|
| 195 |
+
return _norm(origin_url) not in {_norm(official) for official in OFFICIAL_REPO_URLS}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
|
| 199 |
+
"""Check if an 'upstream' remote already exists."""
|
| 200 |
+
return _git_ok(git_cmd, ["remote", "get-url", "upstream"], cwd)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool:
|
| 204 |
+
"""Add the official repo as the 'upstream' remote. Returns True on success."""
|
| 205 |
+
return _git_ok(git_cmd, ["remote", "add", "upstream", OFFICIAL_REPO_URL], cwd)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int:
|
| 209 |
+
"""Count commits on `head` that are not on `base`. Returns -1 on error."""
|
| 210 |
+
with suppress(Exception):
|
| 211 |
+
count = _git_stdout(git_cmd, ["rev-list", "--count", f"{base}..{head}"], cwd)
|
| 212 |
+
if count is not None:
|
| 213 |
+
return int(count)
|
| 214 |
+
return -1
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _should_skip_upstream_prompt() -> bool:
|
| 218 |
+
"""Check if user previously declined to add upstream."""
|
| 219 |
+
from hermes_constants import get_hermes_home
|
| 220 |
+
return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists()
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _mark_skip_upstream_prompt():
|
| 224 |
+
"""Create marker file to skip future upstream prompts."""
|
| 225 |
+
with suppress(Exception):
|
| 226 |
+
from hermes_constants import get_hermes_home
|
| 227 |
+
(get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool:
|
| 231 |
+
"""Push updated main to origin (sync fork); True on success."""
|
| 232 |
+
return _git_ok(git_cmd, ["push", "origin", "main", "--force-with-lease"], cwd, network=True)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _offer_upstream_remote(git_cmd: list[str], cwd: Path, *, assume_yes: bool, input_fn) -> bool:
|
| 236 |
+
"""Prompt to add ``upstream`` and add it; False when the user declined, the run is non-interactive, or add failed.
|
| 237 |
+
|
| 238 |
+
``--yes`` means "don't block", not "mutate my remotes", so a non-interactive skip is NOT persisted."""
|
| 239 |
+
from hermes_cli.update_cmd import _add_upstream_remote, _mark_skip_upstream_prompt
|
| 240 |
+
print(
|
| 241 |
+
"\nℹ Your fork is not tracking the official Hermes repository.\n"
|
| 242 |
+
" This means you may miss updates from NousResearch/hermes-agent.\n"
|
| 243 |
+
)
|
| 244 |
+
if assume_yes or (input_fn is None and not (sys.stdin.isatty() and sys.stdout.isatty())):
|
| 245 |
+
print(f" Skipping upstream setup (non-interactive run).\n Add it later with: {_UPSTREAM_ADD_CMD}")
|
| 246 |
+
return False
|
| 247 |
+
if input_fn is not None:
|
| 248 |
+
response = input_fn("Add official repo as 'upstream' remote? [y/N]", "n").strip().lower()
|
| 249 |
+
else:
|
| 250 |
+
try:
|
| 251 |
+
response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower()
|
| 252 |
+
except (EOFError, KeyboardInterrupt, UnicodeDecodeError):
|
| 253 |
+
print()
|
| 254 |
+
response = "n"
|
| 255 |
+
if response not in {"", "y", "yes"}:
|
| 256 |
+
print(f" Skipped. Run '{_UPSTREAM_ADD_CMD}' to add later.")
|
| 257 |
+
_mark_skip_upstream_prompt()
|
| 258 |
+
return False
|
| 259 |
+
print("→ Adding upstream remote...")
|
| 260 |
+
if not _add_upstream_remote(git_cmd, cwd):
|
| 261 |
+
print(" ✗ Failed to add upstream remote. Skipping upstream sync.")
|
| 262 |
+
return False
|
| 263 |
+
print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git")
|
| 264 |
+
return True
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path, *, assume_yes: bool = False, input_fn=None) -> bool:
|
| 268 |
+
"""Offer to add ``upstream``, compare origin/main vs upstream/main, ff-pull when strictly behind, then push origin.
|
| 269 |
+
|
| 270 |
+
Returns True only when origin/main was actually verified against upstream/main; False when the check never
|
| 271 |
+
happened, so the caller never reports "up to date" on an origin-only compare. Fetches only upstream/main:
|
| 272 |
+
a bare fetch drags in thousands of auto-generated branches.
|
| 273 |
+
|
| 274 |
+
See #97052.
|
| 275 |
+
"""
|
| 276 |
+
from hermes_cli.update_cmd import _count_commits_between, _has_upstream_remote, _no_prompt_git_kwargs, _should_skip_upstream_prompt
|
| 277 |
+
if not _has_upstream_remote(git_cmd, cwd) and (
|
| 278 |
+
_should_skip_upstream_prompt() or not _offer_upstream_remote(git_cmd, cwd, assume_yes=assume_yes, input_fn=input_fn)
|
| 279 |
+
):
|
| 280 |
+
return False
|
| 281 |
+
print("\n→ Fetching upstream...")
|
| 282 |
+
try:
|
| 283 |
+
subprocess.run(git_cmd + ["fetch", "upstream", "main", "--quiet"], cwd=cwd, capture_output=True, check=True, **_no_prompt_git_kwargs())
|
| 284 |
+
except subprocess.CalledProcessError:
|
| 285 |
+
print(" ✗ Failed to fetch upstream. Skipping upstream sync.")
|
| 286 |
+
return False
|
| 287 |
+
origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main")
|
| 288 |
+
upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main")
|
| 289 |
+
if origin_ahead < 0 or upstream_ahead < 0:
|
| 290 |
+
print(" ✗ Could not compare branches. Skipping upstream sync.")
|
| 291 |
+
return False
|
| 292 |
+
if origin_ahead > 0:
|
| 293 |
+
print(
|
| 294 |
+
f"\nℹ Your fork has {origin_ahead} commit(s) not on upstream.\n"
|
| 295 |
+
" Skipping upstream sync to preserve your changes.\n"
|
| 296 |
+
" If you want to merge upstream changes, run:\n git pull upstream main"
|
| 297 |
+
)
|
| 298 |
+
return True
|
| 299 |
+
if upstream_ahead == 0:
|
| 300 |
+
print(" ✓ Fork is up to date with upstream")
|
| 301 |
+
return True
|
| 302 |
+
print(f"\n→ Fork is {upstream_ahead} commit(s) behind upstream\n→ Pulling from upstream...")
|
| 303 |
+
try:
|
| 304 |
+
subprocess.run(git_cmd + ["pull", "--ff-only", "upstream", "main"], cwd=cwd, check=True, **_no_prompt_git_kwargs())
|
| 305 |
+
except subprocess.CalledProcessError:
|
| 306 |
+
print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.")
|
| 307 |
+
return False
|
| 308 |
+
print(" ✓ Updated from upstream\n→ Syncing fork...")
|
| 309 |
+
if _sync_fork_with_upstream(git_cmd, cwd):
|
| 310 |
+
print(" ✓ Fork synced with upstream")
|
| 311 |
+
else:
|
| 312 |
+
print(
|
| 313 |
+
" ℹ Got updates from upstream but couldn't push to fork (no write access?)\n"
|
| 314 |
+
" Your local repo is updated, but your fork on GitHub may be behind."
|
| 315 |
+
)
|
| 316 |
+
return True
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _has_http_code(stderr: str, *codes: str) -> bool:
|
| 320 |
+
return any(f"HTTP {code}" in stderr or f"returned error: {code}" in stderr for code in codes)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# Ordered (predicate, diagnosis): curl reports HTTP errors as ``unable to access '<url>': ... error: 429``,
|
| 324 |
+
# so rate-limit/outage checks must run BEFORE the generic "unable to access" check. An anonymous fetch
|
| 325 |
+
# answered with HTTP 401 ("could not read Username") is GitHub during an outage (or a renamed/private
|
| 326 |
+
# repo), not a user credentials problem.
|
| 327 |
+
_FETCH_FAILURE_RULES = (
|
| 328 |
+
(lambda s: _has_http_code(s, "429") or "rate limit" in s.lower(),
|
| 329 |
+
"✗ GitHub is rate limiting requests or having an outage (HTTP 429) — try again in 5 minutes."),
|
| 330 |
+
(lambda s: _has_http_code(s, "500", "502", "503", "504"),
|
| 331 |
+
"✗ GitHub appears to be having an outage — try again in a few minutes (https://www.githubstatus.com)."),
|
| 332 |
+
(lambda s: "Could not resolve host" in s or "unable to access" in s,
|
| 333 |
+
"✗ Network error — cannot reach the remote repository."),
|
| 334 |
+
(lambda s: "could not read Username" in s or "terminal prompts disabled" in s,
|
| 335 |
+
"✗ GitHub rejected the anonymous fetch (asked for a login) — this usually means a GitHub outage;"
|
| 336 |
+
" try again in a few minutes (https://www.githubstatus.com). If it persists, check"
|
| 337 |
+
" `git remote -v` points at a public repo."),
|
| 338 |
+
(lambda s: "Authentication failed" in s,
|
| 339 |
+
"✗ Authentication failed — check your git credentials or SSH key."),
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _classify_fetch_failure(stderr: str) -> str:
|
| 344 |
+
"""Map git-fetch stderr to a one-line diagnosis (caller also prints the raw first line)."""
|
| 345 |
+
return next((message for matches, message in _FETCH_FAILURE_RULES if matches(stderr)), "✗ Failed to fetch updates from origin.")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _print_fetch_failure(stderr: str) -> None:
|
| 349 |
+
"""Print the classified diagnosis plus the first raw stderr line."""
|
| 350 |
+
stderr = (stderr or "").strip()
|
| 351 |
+
print(_classify_fetch_failure(stderr))
|
| 352 |
+
if stderr:
|
| 353 |
+
print(f" {stderr.splitlines()[0]}")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _probe_fork_bomb(argv: list) -> Optional[bool]:
|
| 357 |
+
"""Run ``<argv> --version``; True/False = guard message seen/absent, None = probe itself failed."""
|
| 358 |
+
try:
|
| 359 |
+
result = subprocess.run(argv + ["--version"], timeout=15, **_GIT_TEXT_KW)
|
| 360 |
+
except Exception:
|
| 361 |
+
return None
|
| 362 |
+
return "fork bomb" in ((result.stdout or "") + (result.stderr or "")).lower()
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _git_is_trampoline(git_cmd: list) -> bool:
|
| 366 |
+
"""Whether *git_cmd* is a broken Git-for-Windows trampoline shim.
|
| 367 |
+
|
| 368 |
+
The ~46KB ``bin\\git.exe``/``cmd\\git.exe`` shims re-exec git-core; when they can't find it every call
|
| 369 |
+
dies with the launcher's guard message (a PATH problem, not network). Never raises; unknown states
|
| 370 |
+
report False so a probe failure can't block an update.
|
| 371 |
+
|
| 372 |
+
Git for Windows ships two ~46KB shims (``bin\\git.exe``, ``cmd\\git.exe``) that re-exec the real
|
| 373 |
+
``mingw64\\libexec\\git-core\\git.exe``. See #87876.
|
| 374 |
+
"""
|
| 375 |
+
return _probe_fork_bomb(git_cmd) is True
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _portable_git_candidates() -> list:
|
| 379 |
+
"""PortableGit candidates: shared root first (where the managed tree actually lives, not the
|
| 380 |
+
profile-scoped HERMES_HOME), then profile home as a fallback for custom layouts.
|
| 381 |
+
|
| 382 |
+
The Hermes-managed PortableGit tree lives under the SHARED root (``<root>/git/...``), not the
|
| 383 |
+
profile-scoped HERMES_HOME (``<root>/profiles/<name>``), so a profile-scoped ``hermes update`` must look
|
| 384 |
+
there (monerostar review, #87876).
|
| 385 |
+
"""
|
| 386 |
+
from hermes_cli.update_cmd import get_default_hermes_root, get_hermes_home
|
| 387 |
+
candidates = []
|
| 388 |
+
with suppress(Exception):
|
| 389 |
+
candidates += [root / "git" / "mingw64" / "libexec" / "git-core" / "git.exe" for root in (get_default_hermes_root(), Path(get_hermes_home()))]
|
| 390 |
+
return candidates
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _locate_real_git() -> Optional[Path]:
|
| 394 |
+
"""Find a real Git-for-Windows ``git-core/git.exe`` (standard locations + managed PortableGit) that runs
|
| 395 |
+
without the trampoline guard. None when nothing suits — callers keep the broken command and let the
|
| 396 |
+
fetch-failure ZIP fallback handle it. A failed probe (None) disqualifies a candidate like a guard hit.
|
| 397 |
+
|
| 398 |
+
The trampoline symptom is PATH-level: ``bin\\git.exe`` / ``cmd\\git.exe`` (both ~46KB shims) fail to
|
| 399 |
+
re-exec git-core, while the real binary at ``mingw64\\libexec\\git-core\\git.exe`` (≈4.4MB) works when
|
| 400 |
+
invoked directly (#87876).
|
| 401 |
+
"""
|
| 402 |
+
candidates = [
|
| 403 |
+
Path(r"C:\Program Files\Git\mingw64\libexec\git-core\git.exe"),
|
| 404 |
+
Path(r"C:\Program Files (x86)\Git\mingw64\libexec\git-core\git.exe"),
|
| 405 |
+
] + _portable_git_candidates()
|
| 406 |
+
return next((c for c in candidates if c.exists() and _probe_fork_bomb([str(c)]) is False), None)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _ensure_non_trampoline_git(git_cmd: list) -> list:
|
| 410 |
+
"""Swap a broken Git-for-Windows trampoline for a real git binary so fetch/pull/checkout keep working;
|
| 411 |
+
if none is found leave the command untouched (fetch-failure handler falls back to ZIP). No-op off
|
| 412 |
+
Windows and when git is healthy."""
|
| 413 |
+
from hermes_cli.update_cmd import _locate_real_git
|
| 414 |
+
if sys.platform != "win32" or not _git_is_trampoline(git_cmd):
|
| 415 |
+
return git_cmd
|
| 416 |
+
real_git = _locate_real_git()
|
| 417 |
+
if real_git is None:
|
| 418 |
+
print("⚠ Detected a broken git trampoline and could not locate a real git binary — the update will fall back to the ZIP path.")
|
| 419 |
+
return git_cmd
|
| 420 |
+
print(f"⚠ Detected a broken git trampoline; switching to real git at {real_git}")
|
| 421 |
+
return [str(real_git)] + list(git_cmd[1:])
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _discard_lockfile_churn(git_cmd, repo_root):
|
| 425 |
+
"""Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update sees a clean tree
|
| 426 |
+
instead of autostashing every run. Only touches lockfiles whose package.json is NOT also dirty. Best-effort."""
|
| 427 |
+
from hermes_cli.update_cmd import _git_run
|
| 428 |
+
with suppress(Exception):
|
| 429 |
+
diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root)
|
| 430 |
+
if diff.returncode != 0:
|
| 431 |
+
return
|
| 432 |
+
changed = [line.strip() for line in diff.stdout.splitlines()]
|
| 433 |
+
dirty_package_dirs = {Path(p).parent for p in changed if p.endswith("package.json")}
|
| 434 |
+
dirty = [p for p in changed if p.endswith("package-lock.json") and Path(p).parent not in dirty_package_dirs]
|
| 435 |
+
if not dirty:
|
| 436 |
+
return
|
| 437 |
+
_git_run(git_cmd, ["checkout", "--", *dirty], repo_root)
|
| 438 |
+
print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))")
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _normalize_managed_eol(git_cmd, repo_root):
|
| 442 |
+
"""Take a managed checkout off ``core.autocrlf=true`` without leaving it dirty.
|
| 443 |
+
|
| 444 |
+
Git for Windows sets ``autocrlf=true`` system-wide, turning LF files CRLF and breaking ``git checkout``
|
| 445 |
+
on update; install.ps1 pins ``false`` but older checkouts never got it and only ``hermes update`` can
|
| 446 |
+
fix them. Pin and cleanup are one operation: under ``autocrlf=true`` a CRLF tree reads clean, so pinning
|
| 447 |
+
alone would expose every file as modified (whole-tree autostash). Pin only after the tree verifies clean
|
| 448 |
+
under it; a checkout we can't fully normalize is left as-is. Only ``true`` rewrites LF->CRLF
|
| 449 |
+
(unset/false/input leave the tree alone). Best-effort.
|
| 450 |
+
|
| 451 |
+
Checkouts created before that landed never got the pin and cannot receive it — the bootstrap installer
|
| 452 |
+
reuses its build-pinned ``install.ps1`` forever — so ``hermes update``, which ships with the checkout
|
| 453 |
+
itself, is the only path left that can fix them. See #67730.
|
| 454 |
+
"""
|
| 455 |
+
from hermes_cli.update_cmd import _git_run
|
| 456 |
+
# -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing.
|
| 457 |
+
probe = git_cmd + ["-c", "core.autocrlf=false"]
|
| 458 |
+
|
| 459 |
+
def _probe_run(*args, **kw):
|
| 460 |
+
return subprocess.run(probe + list(args), cwd=repo_root, **_GIT_TEXT_KW, **kw)
|
| 461 |
+
|
| 462 |
+
def _eol_only():
|
| 463 |
+
"""Dirty paths whose ONLY change is CRLF; None when either probe fails."""
|
| 464 |
+
all_dirty = _probe_run("diff", "-z", "--name-only")
|
| 465 |
+
# Files with a *content* change ignoring CRLF. ``--name-only --ignore-cr-at-eol`` still LISTS
|
| 466 |
+
# CR-only files; ``--numstat`` honors the filter (no record for them). Records are
|
| 467 |
+
# "<added>\\t<deleted>\\t<path>"; rename detection is off, so exactly one path.
|
| 468 |
+
real_dirty = _probe_run("-c", "core.quotepath=false", "diff", "--numstat", "--ignore-cr-at-eol")
|
| 469 |
+
if all_dirty.returncode != 0 or real_dirty.returncode != 0:
|
| 470 |
+
return None
|
| 471 |
+
return {p for p in all_dirty.stdout.split("\0") if p} - {
|
| 472 |
+
parts[2]
|
| 473 |
+
for parts in (line.split("\t", 2) for line in real_dirty.stdout.splitlines() if line.strip())
|
| 474 |
+
if len(parts) == 3 and parts[2]
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
with suppress(Exception):
|
| 478 |
+
if _git_run(git_cmd, ["config", "--get", "core.autocrlf"], repo_root).stdout.strip().lower() != "true":
|
| 479 |
+
return
|
| 480 |
+
eol_only = _eol_only()
|
| 481 |
+
if eol_only is None:
|
| 482 |
+
return
|
| 483 |
+
if eol_only:
|
| 484 |
+
# Pathspec via stdin: thousands of paths exceed the Windows argv limit.
|
| 485 |
+
_probe_run("checkout", "--pathspec-from-file=-", "--pathspec-file-nul", "--",
|
| 486 |
+
input="\0".join(sorted(eol_only)), check=False)
|
| 487 |
+
if _eol_only(): # still dirty: pinning would only surface churn we failed to clear
|
| 488 |
+
return
|
| 489 |
+
print(f"→ Normalized line-ending churn ({len(eol_only)} file(s))")
|
| 490 |
+
subprocess.run(git_cmd + ["config", "core.autocrlf", "false"], cwd=repo_root, capture_output=True, check=False)
|
hermes_cli/update_cmd_stash.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autostash handling for ``hermes update``: stash before the pull, restore/park/discard afterwards, warn about orphans.
|
| 2 |
+
|
| 3 |
+
Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>`` still resolves/monkeypatches.
|
| 4 |
+
Origin helpers are imported lazily per function (no cycle; test patches on the origin stay effective).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import subprocess
|
| 9 |
+
from datetime import datetime, timedelta, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
# Log-record parity with the origin module.
|
| 14 |
+
logger = logging.getLogger("hermes_cli.update_cmd")
|
| 15 |
+
|
| 16 |
+
#: Autostash subject contract: this prefix + UTC YYYYMMDD-HHMMSS stamp
|
| 17 |
+
#: (producer _stash_local_changes_if_needed, consumer _warn_orphaned_update_autostashes).
|
| 18 |
+
_AUTOSTASH_NAME_PREFIX = "hermes-update-autostash-"
|
| 19 |
+
|
| 20 |
+
#: Age past which a leftover autostash is called out. Younger entries are normal
|
| 21 |
+
#: (recent --keep-stash park); older ones are almost always forgotten.
|
| 22 |
+
# Entries younger than this are normal (a parked stash from : the desktop updater's --keep-stash run minutes
|
| 23 |
+
# ago); older ones are almost : always forgotten (#63717 problem 6: an orphan persisted 9+ days unnoticed).
|
| 24 |
+
_AUTOSTASH_WARN_AGE_DAYS = 7
|
| 25 |
+
|
| 26 |
+
_STASH_LEFT_IN_PLACE = " The stash was left in place. You can remove it manually after checking the result."
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _git_quiet(git_cmd: list[str], args: list[str], cwd: Path, **kwargs):
|
| 30 |
+
"""``subprocess.run`` of a git command with captured output; None when git cannot run."""
|
| 31 |
+
try:
|
| 32 |
+
return subprocess.run(git_cmd + args, cwd=cwd, capture_output=True, **kwargs)
|
| 33 |
+
except (OSError, subprocess.SubprocessError):
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _git_paths_z(git_cmd: list[str], args: list[str], cwd: Path):
|
| 38 |
+
"""NUL-separated path listing as a set, or None when git failed (surrogateescape keeps odd filenames)."""
|
| 39 |
+
result = _git_quiet(git_cmd, args, cwd, text=True, encoding="utf-8", errors="surrogateescape")
|
| 40 |
+
if result is None or result.returncode != 0:
|
| 41 |
+
return None
|
| 42 |
+
return {path for path in result.stdout.split("\0") if path}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _reset_hard(git_cmd: list[str], cwd: Path) -> None:
|
| 46 |
+
subprocess.run(git_cmd + ["reset", "--hard", "HEAD"], cwd=cwd, capture_output=True)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _print_nonempty(text: str, prefix: str = "") -> None:
|
| 50 |
+
if text.strip():
|
| 51 |
+
print(f"{prefix}{text.strip()}")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _print_first_line(text: str) -> None:
|
| 55 |
+
if text.strip():
|
| 56 |
+
print(f" {text.strip().splitlines()[0]}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
| 60 |
+
from hermes_cli.update_cmd import _git_run
|
| 61 |
+
status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True)
|
| 62 |
+
if not status.stdout.strip():
|
| 63 |
+
return None
|
| 64 |
+
# Unmerged index entries (interrupted merge/rebase) make `git stash` fail with
|
| 65 |
+
# "needs merge"; `git reset` drops only the index conflict state, not the tree.
|
| 66 |
+
if _git_run(git_cmd, ["ls-files", "--unmerged"], cwd).stdout.strip():
|
| 67 |
+
print("→ Clearing unmerged index entries from a previous conflict...")
|
| 68 |
+
subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True)
|
| 69 |
+
|
| 70 |
+
stash_name = datetime.now(timezone.utc).strftime(f"{_AUTOSTASH_NAME_PREFIX}%Y%m%d-%H%M%S")
|
| 71 |
+
print("→ Local changes detected — stashing before update...")
|
| 72 |
+
prev_stash = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd).stdout.strip()
|
| 73 |
+
push = _git_run(git_cmd, ["stash", "push", "--include-untracked", "-m", stash_name], cwd)
|
| 74 |
+
_print_nonempty(push.stdout)
|
| 75 |
+
stash_probe = _git_run(git_cmd, ["rev-parse", "--verify", "refs/stash"], cwd)
|
| 76 |
+
stash_ref = stash_probe.stdout.strip()
|
| 77 |
+
stash_created = stash_probe.returncode == 0 and bool(stash_ref) and stash_ref != prev_stash
|
| 78 |
+
if push.returncode != 0:
|
| 79 |
+
if not stash_created:
|
| 80 |
+
# No entry created: changes NOT saved — bail before touching HEAD.
|
| 81 |
+
print("✗ Could not stash local changes — update aborted.")
|
| 82 |
+
_print_first_line(push.stderr)
|
| 83 |
+
print(" Commit, stash, or clean up your local changes manually, then re-run `hermes update`.")
|
| 84 |
+
raise subprocess.CalledProcessError(push.returncode, push.args, output=push.stdout, stderr=push.stderr)
|
| 85 |
+
# Non-zero but entry created: push saved everything yet couldn't delete some untracked files
|
| 86 |
+
# (e.g. root-owned dir). Not a failure — continue.
|
| 87 |
+
_print_nonempty(push.stderr)
|
| 88 |
+
print(" ⚠ Some untracked files could not be removed from the working tree (permission denied).")
|
| 89 |
+
print(" They were still saved to the stash and were left in place — the update will continue.")
|
| 90 |
+
# A partially-failed push also skips cleanup of TRACKED modifications; they'd break the following
|
| 91 |
+
# pull. Safe to reset: all is in the stash.
|
| 92 |
+
_reset_hard(git_cmd, cwd)
|
| 93 |
+
return stash_ref
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]:
|
| 97 |
+
from hermes_cli.update_cmd import _git_run
|
| 98 |
+
stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True)
|
| 99 |
+
for line in stash_list.stdout.splitlines():
|
| 100 |
+
selector, _, commit = line.partition(" ")
|
| 101 |
+
if commit.strip() == stash_ref:
|
| 102 |
+
return selector.strip()
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int:
|
| 107 |
+
"""Print a notice for update autostashes older than the warn threshold; return the count (0 on any git failure).
|
| 108 |
+
|
| 109 |
+
Autostashes legitimately outlive a run (--keep-stash, failed restore) but nothing re-surfaces them.
|
| 110 |
+
Deliberately NOT a GC: a stash may be the only copy of the user's work, so Hermes never drops one.
|
| 111 |
+
|
| 112 |
+
Autostash entries legitimately outlive an update run (``--keep-stash`` parks them; a conflicted or
|
| 113 |
+
failed restore preserves them for safety), but nothing ever re-surfaces them afterwards — they sit in
|
| 114 |
+
``git stash`` invisibly for weeks (#63717 problem 6). This prints a short notice naming the stale
|
| 115 |
+
entries with recovery/cleanup guidance.
|
| 116 |
+
"""
|
| 117 |
+
from hermes_cli.update_cmd import _git_run
|
| 118 |
+
try:
|
| 119 |
+
stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd)
|
| 120 |
+
if stash_list.returncode != 0:
|
| 121 |
+
return 0
|
| 122 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=_AUTOSTASH_WARN_AGE_DAYS)
|
| 123 |
+
stale: list[tuple[str, str]] = []
|
| 124 |
+
for line in stash_list.stdout.splitlines():
|
| 125 |
+
selector, _, subject = line.strip().partition(" ")
|
| 126 |
+
pos = subject.find(_AUTOSTASH_NAME_PREFIX)
|
| 127 |
+
if pos < 0:
|
| 128 |
+
continue
|
| 129 |
+
stamp = subject[pos + len(_AUTOSTASH_NAME_PREFIX):][:15] # "YYYYMMDD-HHMMSS"
|
| 130 |
+
try:
|
| 131 |
+
stash_time = datetime.strptime(stamp, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
|
| 132 |
+
except ValueError:
|
| 133 |
+
continue # age unknown — leave it alone rather than guess
|
| 134 |
+
if stash_time < cutoff:
|
| 135 |
+
stale.append((selector, stamp))
|
| 136 |
+
if not stale:
|
| 137 |
+
return 0
|
| 138 |
+
print()
|
| 139 |
+
print(
|
| 140 |
+
f"⚠ {len(stale)} leftover update autostash entr"
|
| 141 |
+
f"{'y is' if len(stale) == 1 else 'ies are'} more than "
|
| 142 |
+
f"{_AUTOSTASH_WARN_AGE_DAYS} days old:"
|
| 143 |
+
)
|
| 144 |
+
for selector, stamp in stale:
|
| 145 |
+
print(f" {selector} ({_AUTOSTASH_NAME_PREFIX}{stamp})")
|
| 146 |
+
print(" These hold local changes stashed by earlier updates and never")
|
| 147 |
+
print(" restored. Review with: git stash show -p <entry>")
|
| 148 |
+
print(" Restore with: git stash apply <entry> Discard with: git stash drop <entry>")
|
| 149 |
+
return len(stale)
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
logger.debug("Autostash age check failed: %s", exc)
|
| 152 |
+
return 0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None:
|
| 156 |
+
print(" Check `git status` first so you don't accidentally reapply the same change twice.")
|
| 157 |
+
print(" Find the saved entry with: git stash list --format='%gd %H %s'")
|
| 158 |
+
if stash_selector:
|
| 159 |
+
print(f" Remove it with: git stash drop {stash_selector}")
|
| 160 |
+
else:
|
| 161 |
+
print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _stash_apply_failed_only_on_existing_untracked(stderr: str) -> bool:
|
| 165 |
+
"""True when a ``git stash apply`` failure is ONLY about untracked files that already exist in the tree.
|
| 166 |
+
|
| 167 |
+
Tail of the permission-denied class: push swept undeletable files into the stash but couldn't remove them;
|
| 168 |
+
apply restores tracked changes, then refuses to overwrite those files and exits non-zero though nothing was lost.
|
| 169 |
+
Any other error line (e.g. ``would be overwritten by merge``) means the tracked apply failed -> False.
|
| 170 |
+
"""
|
| 171 |
+
saw_untracked_error = False
|
| 172 |
+
for ln in (ln.strip() for ln in (stderr or "").splitlines() if ln.strip()):
|
| 173 |
+
if "already exists, no checkout" in ln or "could not restore untracked files from stash" in ln:
|
| 174 |
+
saw_untracked_error = True
|
| 175 |
+
elif not ln.startswith(("warning:", "hint:")):
|
| 176 |
+
return False
|
| 177 |
+
return saw_untracked_error
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _park_stashed_changes(stash_ref: str) -> None:
|
| 181 |
+
"""Leave a pre-update autostash parked (``--keep-stash``, the desktop updater's mode): local source
|
| 182 |
+
edits must never be silently re-applied onto updated code; the entry stays in ``git stash``."""
|
| 183 |
+
print()
|
| 184 |
+
print("ℹ️ Local changes were stashed before updating and were NOT re-applied (--keep-stash).")
|
| 185 |
+
print(f" Stash ref: {stash_ref}")
|
| 186 |
+
print(f" Restore manually with: git stash apply {stash_ref}")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _git_untracked_paths(git_cmd: list[str], cwd: Path) -> set[str] | None:
|
| 190 |
+
"""Return untracked paths, or ``None`` when Git cannot enumerate them."""
|
| 191 |
+
paths = _git_paths_z(git_cmd, ["ls-files", "--others", "--exclude-standard", "-z"], cwd)
|
| 192 |
+
if paths is None:
|
| 193 |
+
print(" ⚠ Could not enumerate untracked files while validating the restored stash.")
|
| 194 |
+
return paths
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _restored_python_paths(git_cmd: list[str], cwd: Path) -> tuple[str, ...] | None:
|
| 198 |
+
"""Restored ``.py`` paths changed from ``HEAD``; deliberately Python-only (entry scripts stay outside the health check)."""
|
| 199 |
+
from hermes_cli.update_cmd import _git_untracked_paths
|
| 200 |
+
paths = _git_paths_z(git_cmd, ["diff", "--name-only", "-z", "HEAD", "--", "*.py"], cwd)
|
| 201 |
+
if paths is None:
|
| 202 |
+
print(" ⚠ Could not enumerate tracked Python files restored from the stash.")
|
| 203 |
+
return None
|
| 204 |
+
untracked = _git_untracked_paths(git_cmd, cwd)
|
| 205 |
+
if untracked is None:
|
| 206 |
+
return None
|
| 207 |
+
paths.update(path for path in untracked if path.endswith(".py"))
|
| 208 |
+
return tuple(sorted(paths))
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _reject_unsafe_stash_restore(
|
| 212 |
+
git_cmd: list[str], cwd: Path, stash_ref: str, preexisting_untracked: set[str], failing_target: str,
|
| 213 |
+
detail: str | None,
|
| 214 |
+
) -> None:
|
| 215 |
+
"""Restore the clean updated tree, preserve the stash, and abort the update."""
|
| 216 |
+
from hermes_cli.update_cmd import _git_untracked_paths
|
| 217 |
+
print()
|
| 218 |
+
print("✗ Restored local changes made the Hermes agent unexecutable.")
|
| 219 |
+
print(f" Health check failed: {failing_target}")
|
| 220 |
+
if detail:
|
| 221 |
+
for line in str(detail).splitlines()[:6]:
|
| 222 |
+
print(f" {line}")
|
| 223 |
+
|
| 224 |
+
def _ok(result) -> bool:
|
| 225 |
+
return result is not None and result.returncode == 0
|
| 226 |
+
|
| 227 |
+
current_untracked = _git_untracked_paths(git_cmd, cwd)
|
| 228 |
+
restored_untracked = current_untracked - preexisting_untracked if current_untracked is not None else set()
|
| 229 |
+
reset = _git_quiet(git_cmd, ["reset", "--hard", "HEAD"], cwd)
|
| 230 |
+
clean = _git_quiet(git_cmd, ["clean", "-fd", "--", *sorted(restored_untracked)], cwd) if restored_untracked else None
|
| 231 |
+
cleanup_ok = current_untracked is not None and _ok(reset) and (not restored_untracked or _ok(clean))
|
| 232 |
+
if cleanup_ok:
|
| 233 |
+
cleanup_ok = _ok(_git_quiet(git_cmd, ["diff", "--quiet", "HEAD", "--"], cwd))
|
| 234 |
+
if cleanup_ok:
|
| 235 |
+
print(" The clean updated tree has been restored; the gateway was not restarted.")
|
| 236 |
+
else:
|
| 237 |
+
print(" ⚠ The clean updated tree could not be fully restored automatically.")
|
| 238 |
+
print(" Inspect `git status` and run `git reset --hard HEAD` before retrying.")
|
| 239 |
+
print(" Platform connectivity alone does not mean the agent can execute turns.")
|
| 240 |
+
print(f" Your local changes remain preserved in stash: {stash_ref}")
|
| 241 |
+
print(f" Inspect them with: git stash show --stat {stash_ref}")
|
| 242 |
+
print(f" Restore manually after fixing them: git stash apply {stash_ref}")
|
| 243 |
+
raise SystemExit(1)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _confirm_restore(stash_ref: str, input_fn) -> bool:
|
| 247 |
+
"""Interactive gate; a remote ``input_fn`` defaults to No (``[y/N]``), the local prompt to Yes."""
|
| 248 |
+
remote_prompt = input_fn is not None
|
| 249 |
+
prompt_suffix = "[y/N]" if remote_prompt else "[Y/n]"
|
| 250 |
+
print()
|
| 251 |
+
print("⚠ Local changes were stashed before updating.")
|
| 252 |
+
print(" Restoring them may reapply local customizations onto the updated codebase.")
|
| 253 |
+
print(" Review the result afterward if Hermes behaves unexpectedly.")
|
| 254 |
+
print(f"Restore local changes now? {prompt_suffix}")
|
| 255 |
+
if remote_prompt:
|
| 256 |
+
response = input_fn(f"Restore local changes now? {prompt_suffix}", "n")
|
| 257 |
+
else:
|
| 258 |
+
try:
|
| 259 |
+
response = input().strip().lower()
|
| 260 |
+
except (EOFError, UnicodeDecodeError):
|
| 261 |
+
response = "n" # closed stdin/encoding error must not crash mid-restore
|
| 262 |
+
if response in {"y", "yes"} or (not remote_prompt and response == ""):
|
| 263 |
+
return True
|
| 264 |
+
print("Skipped restoring local changes.")
|
| 265 |
+
print("Your changes are still preserved in git stash.")
|
| 266 |
+
print(f"Restore manually with: git stash apply {stash_ref}")
|
| 267 |
+
return False
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
|
| 271 |
+
"""``git stash apply``; False (tree reset, stash kept) on conflicts or any failure other than the
|
| 272 |
+
undeletable-untracked class."""
|
| 273 |
+
from hermes_cli.update_cmd import _git_run
|
| 274 |
+
print("→ Restoring local changes...")
|
| 275 |
+
restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
|
| 276 |
+
unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) # conflicts can exist even on rc 0
|
| 277 |
+
conflicted_files = unmerged.stdout.strip()
|
| 278 |
+
if restore.returncode == 0 and not conflicted_files:
|
| 279 |
+
return True
|
| 280 |
+
if not conflicted_files and _stash_apply_failed_only_on_existing_untracked(restore.stderr):
|
| 281 |
+
# Tracked changes applied; only undeletable-at-stash-time untracked files were refused. Their
|
| 282 |
+
# content is untouched — treat as restored.
|
| 283 |
+
print(" ⚠ Some stashed untracked files already exist in the working tree and were kept as-is.")
|
| 284 |
+
return True
|
| 285 |
+
print("✗ Update pulled new code, but restoring local changes hit conflicts.")
|
| 286 |
+
_print_nonempty(restore.stdout)
|
| 287 |
+
_print_nonempty(restore.stderr)
|
| 288 |
+
if conflicted_files:
|
| 289 |
+
print("\nConflicted files:")
|
| 290 |
+
for f in conflicted_files.splitlines():
|
| 291 |
+
print(f" • {f}")
|
| 292 |
+
print("\nYour stashed changes are preserved — nothing is lost.")
|
| 293 |
+
print(f" Stash ref: {stash_ref}")
|
| 294 |
+
_reset_hard(git_cmd, cwd) # conflict markers make hermes unrunnable; changes stay in the stash
|
| 295 |
+
print("Working tree reset to clean state.")
|
| 296 |
+
print(f"Restore your changes later with: git stash apply {stash_ref}")
|
| 297 |
+
return False # code update succeeded; cmd_update continues (deps, skills, gateway)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
|
| 301 |
+
from hermes_cli.update_cmd import _git_run
|
| 302 |
+
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
| 303 |
+
if stash_selector is None:
|
| 304 |
+
print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
|
| 305 |
+
print(_STASH_LEFT_IN_PLACE)
|
| 306 |
+
_print_stash_cleanup_guidance(stash_ref)
|
| 307 |
+
return
|
| 308 |
+
drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
|
| 309 |
+
if drop.returncode != 0:
|
| 310 |
+
print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.")
|
| 311 |
+
_print_nonempty(drop.stdout)
|
| 312 |
+
_print_nonempty(drop.stderr)
|
| 313 |
+
print(_STASH_LEFT_IN_PLACE)
|
| 314 |
+
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _restore_stashed_changes(
|
| 318 |
+
git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None,
|
| 319 |
+
) -> bool:
|
| 320 |
+
from hermes_cli.update_cmd import (
|
| 321 |
+
_critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax,
|
| 322 |
+
)
|
| 323 |
+
if prompt_user and not _confirm_restore(stash_ref, input_fn):
|
| 324 |
+
return False
|
| 325 |
+
preexisting_untracked = _git_untracked_paths(git_cmd, cwd)
|
| 326 |
+
if preexisting_untracked is None:
|
| 327 |
+
print(" The stash was not restored because its cleanup baseline is unknown.")
|
| 328 |
+
print(f" Restore manually with: git stash apply {stash_ref}")
|
| 329 |
+
return False
|
| 330 |
+
clean_import_failures = _critical_module_import_failures(cwd, report_runtime_errors=True)
|
| 331 |
+
if not _apply_stash(git_cmd, cwd, stash_ref):
|
| 332 |
+
return False
|
| 333 |
+
|
| 334 |
+
def reject(failing_target: str, detail) -> None:
|
| 335 |
+
_reject_unsafe_stash_restore(git_cmd, cwd, stash_ref, preexisting_untracked, failing_target, detail)
|
| 336 |
+
|
| 337 |
+
restored_python = _restored_python_paths(git_cmd, cwd)
|
| 338 |
+
if restored_python is None:
|
| 339 |
+
reject("restored Python source discovery", "could not determine which restored Python files require validation")
|
| 340 |
+
syntax_ok, failing_path, syntax_error = _validate_python_files_syntax(cwd, restored_python)
|
| 341 |
+
if not syntax_ok:
|
| 342 |
+
reject(failing_path or "restored Python source", syntax_error)
|
| 343 |
+
for module, error in _critical_module_import_failures(cwd, report_runtime_errors=True).items():
|
| 344 |
+
if clean_import_failures.get(module) != error:
|
| 345 |
+
reject(f"agent import {module or 'unknown'}", error[1])
|
| 346 |
+
break
|
| 347 |
+
_drop_restored_stash(git_cmd, cwd, stash_ref)
|
| 348 |
+
print("⚠ Local changes were restored on top of the updated codebase.")
|
| 349 |
+
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
|
| 350 |
+
return True
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
|
| 354 |
+
"""Drop a pre-update stash without applying (non-interactive ``updates.non_interactive_local_changes: discard``).
|
| 355 |
+
|
| 356 |
+
Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected.
|
| 357 |
+
Returns True if dropped, False on git failure (stash left in place).
|
| 358 |
+
"""
|
| 359 |
+
from hermes_cli.update_cmd import _git_run
|
| 360 |
+
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
| 361 |
+
if stash_selector is None:
|
| 362 |
+
print(
|
| 363 |
+
"⚠ Configured to discard local changes on non-interactive update, "
|
| 364 |
+
"but Hermes couldn't find the stash entry to drop."
|
| 365 |
+
)
|
| 366 |
+
_print_stash_cleanup_guidance(stash_ref)
|
| 367 |
+
return False
|
| 368 |
+
drop = _git_run(git_cmd, ["stash", "drop", stash_selector], cwd)
|
| 369 |
+
if drop.returncode != 0:
|
| 370 |
+
print("⚠ Configured to discard local changes, but Hermes couldn't drop the saved stash entry.")
|
| 371 |
+
_print_first_line(drop.stderr)
|
| 372 |
+
_print_stash_cleanup_guidance(stash_ref, stash_selector)
|
| 373 |
+
return False
|
| 374 |
+
print("→ Discarded local source changes (updates.non_interactive_local_changes=discard).")
|
| 375 |
+
return True
|
hermes_cli/web_read_coalescing.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coalesce concurrent synchronous HTTP reads before threadpool admission."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import inspect
|
| 4 |
+
from functools import partial, wraps
|
| 5 |
+
from weakref import WeakKeyDictionary
|
| 6 |
+
from typing import get_type_hints
|
| 7 |
+
|
| 8 |
+
from anyio.to_thread import run_sync
|
| 9 |
+
from hermes_constants import get_hermes_home
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _freeze(value):
|
| 13 |
+
if isinstance(value, dict):
|
| 14 |
+
return (type(value), frozenset((_freeze(k), _freeze(v)) for k, v in value.items()))
|
| 15 |
+
if isinstance(value, (list, tuple)):
|
| 16 |
+
return (type(value), tuple(map(_freeze, value)))
|
| 17 |
+
if isinstance(value, (set, frozenset)):
|
| 18 |
+
return (type(value), frozenset(map(_freeze, value)))
|
| 19 |
+
return (type(value), value)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def coalesced_read(func, *, thread_runner=run_sync):
|
| 23 |
+
"""Return an async, in-flight-only wrapper around a synchronous read.
|
| 24 |
+
|
| 25 |
+
One worker per decorated function/event loop; identical home + bound
|
| 26 |
+
arguments share its result. Arguments must be hashable or built-in
|
| 27 |
+
containers of hashable values and must not be mutated during the read.
|
| 28 |
+
A custom async thread_runner must await the supplied zero-argument callable
|
| 29 |
+
through a context-preserving threadpool, not abandon running work.
|
| 30 |
+
"""
|
| 31 |
+
signature = inspect.signature(func)
|
| 32 |
+
hints = get_type_hints(func, include_extras=True)
|
| 33 |
+
signature = signature.replace(
|
| 34 |
+
parameters=[p.replace(annotation=hints.get(p.name, p.annotation))
|
| 35 |
+
for p in signature.parameters.values()],
|
| 36 |
+
return_annotation=hints.get("return", signature.return_annotation),
|
| 37 |
+
)
|
| 38 |
+
states = WeakKeyDictionary()
|
| 39 |
+
|
| 40 |
+
@wraps(func)
|
| 41 |
+
async def wrapped(*args, **kwargs):
|
| 42 |
+
loop = asyncio.get_running_loop()
|
| 43 |
+
bound = signature.bind(*args, **kwargs)
|
| 44 |
+
bound.apply_defaults()
|
| 45 |
+
key = (str(get_hermes_home()), _freeze(bound.arguments))
|
| 46 |
+
pending, admission = states.setdefault(loop, ({}, asyncio.Semaphore(1)))
|
| 47 |
+
task = pending.get(key)
|
| 48 |
+
if task is None:
|
| 49 |
+
async def execute():
|
| 50 |
+
try:
|
| 51 |
+
async with admission:
|
| 52 |
+
return await thread_runner(partial(func, *args, **kwargs))
|
| 53 |
+
finally:
|
| 54 |
+
pending.pop(key, None)
|
| 55 |
+
if not pending:
|
| 56 |
+
states.pop(loop, None)
|
| 57 |
+
task = loop.create_task(execute())
|
| 58 |
+
pending[key] = task
|
| 59 |
+
# Observe failures even if every HTTP caller has disconnected.
|
| 60 |
+
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
|
| 61 |
+
# A disconnected caller must not release admission for a live worker.
|
| 62 |
+
return await asyncio.shield(task)
|
| 63 |
+
|
| 64 |
+
wrapped.__signature__ = signature
|
| 65 |
+
wrapped.__annotations__ = hints
|
| 66 |
+
return wrapped
|
hermes_cli/web_server_dashboard.py
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge.
|
| 2 |
+
"""
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import importlib.util
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
import yaml
|
| 12 |
+
from fastapi import FastAPI, Request
|
| 13 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
+
from hermes_cli.config import cfg_get, get_process_hermes_home
|
| 18 |
+
from utils import env_var_enabled
|
| 19 |
+
|
| 20 |
+
# Same logger the code used before extraction (record parity).
|
| 21 |
+
_log = logging.getLogger("hermes_cli.web_server")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _normalise_prefix(raw: Optional[str]) -> str:
|
| 25 |
+
"""Normalise an X-Forwarded-Prefix header value (single source of truth lives in
|
| 26 |
+
``hermes_cli.dashboard_auth.prefix`` so gate, OAuth, cookies and SPA mount agree)."""
|
| 27 |
+
from hermes_cli.dashboard_auth.prefix import normalise_prefix
|
| 28 |
+
return normalise_prefix(raw)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _layer_hex(palette: Dict[str, Any], key: str, default: str) -> str:
|
| 32 |
+
layer = palette.get(key) or {}
|
| 33 |
+
return layer.get("hex", default) if isinstance(layer, dict) else default
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _render_active_theme_bootstrap_css() -> str:
|
| 37 |
+
"""Critical-CSS ``<style>`` shim for the active *user* theme, so the first paint uses the
|
| 38 |
+
target palette instead of flashing the bundle's default Hermes Teal until
|
| 39 |
+
``ThemeProvider.applyTheme()`` runs. Built-in themes return "" (the bundle owns them).
|
| 40 |
+
|
| 41 |
+
Variable names MUST match what the bundle consumes (``layerVars()`` /
|
| 42 |
+
``typographyVars()`` in ``web/src/themes/context.tsx``). The ``html,body`` rule
|
| 43 |
+
references the variables rather than literals so runtime theme switches stay live:
|
| 44 |
+
``applyTheme()`` writes inline styles on ``documentElement`` which outrank this block.
|
| 45 |
+
"""
|
| 46 |
+
from hermes_cli.config import load_config
|
| 47 |
+
try:
|
| 48 |
+
active = cfg_get(load_config(), "dashboard", "theme", default="default")
|
| 49 |
+
if not active or not isinstance(active, str):
|
| 50 |
+
return ""
|
| 51 |
+
if any(b["name"] == active for b in _BUILTIN_DASHBOARD_THEMES):
|
| 52 |
+
return ""
|
| 53 |
+
for theme in _discover_user_themes():
|
| 54 |
+
if theme.get("name") != active:
|
| 55 |
+
continue
|
| 56 |
+
palette = theme.get("palette") or {}
|
| 57 |
+
typo = theme.get("typography") or {}
|
| 58 |
+
font_sans = typo.get("fontSans") or _THEME_DEFAULT_TYPOGRAPHY["fontSans"]
|
| 59 |
+
base_size = typo.get("baseSize") or _THEME_DEFAULT_TYPOGRAPHY["baseSize"]
|
| 60 |
+
|
| 61 |
+
def _esc(s: str) -> str: # defensive ``</style>`` escape
|
| 62 |
+
return str(s).replace("</", "<\\/")
|
| 63 |
+
return (
|
| 64 |
+
'<style id="hermes-theme-bootstrap">'
|
| 65 |
+
":root{"
|
| 66 |
+
f"--background-base:{_esc(_layer_hex(palette, 'background', '#0a0a0a'))};"
|
| 67 |
+
f"--midground-base:{_esc(_layer_hex(palette, 'midground', '#e5e5e5'))};"
|
| 68 |
+
f"--theme-font-sans:{_esc(font_sans)};"
|
| 69 |
+
f"--theme-base-size:{_esc(base_size)};"
|
| 70 |
+
"}"
|
| 71 |
+
"html,body{background-color:var(--background-base);"
|
| 72 |
+
"color:var(--midground-base);"
|
| 73 |
+
"font-family:var(--theme-font-sans);"
|
| 74 |
+
"font-size:var(--theme-base-size);}"
|
| 75 |
+
"</style>"
|
| 76 |
+
)
|
| 77 |
+
return ""
|
| 78 |
+
except Exception:
|
| 79 |
+
_log.debug("theme bootstrap render failed", exc_info=True)
|
| 80 |
+
return ""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# Hashed bundle assets are immutable by construction (content hash in the filename; index.html
|
| 84 |
+
# is served ``no-store`` and always references the current hashes).
|
| 85 |
+
_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
| 86 |
+
_NO_STORE = {"Cache-Control": "no-store, no-cache, must-revalidate"}
|
| 87 |
+
_HEADLESS_MSG = (
|
| 88 |
+
"Headless backend (hermes serve): web UI disabled — use "
|
| 89 |
+
"`hermes dashboard` for the browser UI."
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def mount_spa(application: FastAPI):
|
| 94 |
+
"""Mount the built SPA; unmatched paths fall back to index.html for client-side routing.
|
| 95 |
+
|
| 96 |
+
The session token is injected into index.html via a ``<script>`` tag so the SPA can
|
| 97 |
+
authenticate without a separate token-dispensing endpoint. Behind a path-prefix reverse
|
| 98 |
+
proxy (``X-Forwarded-Prefix: /hermes``) the served index.html is rewritten so absolute
|
| 99 |
+
asset URLs and the runtime ``__HERMES_BASE_PATH__`` honour that prefix without a rebuild.
|
| 100 |
+
|
| 101 |
+
A missing WEB_DIST is deliberately NOT a mount-time terminal state: every route copes
|
| 102 |
+
with a missing dist per-request (404 JSON / ``check_dir=False``), so a long-lived
|
| 103 |
+
``--skip-build`` process recovers the moment a build appears on disk — no restart.
|
| 104 |
+
"""
|
| 105 |
+
from hermes_cli.web_server import WEB_DIST, _DASHBOARD_EMBEDDED_CHAT_ENABLED, app
|
| 106 |
+
from hermes_cli.web_deps import _server
|
| 107 |
+
|
| 108 |
+
# `hermes serve` is the headless backend: it must NEVER serve the browser SPA, even if a
|
| 109 |
+
# dist is lying around, so only the JSON-RPC/WS/API surface is reachable.
|
| 110 |
+
if os.environ.get("HERMES_SERVE_HEADLESS") == "1":
|
| 111 |
+
|
| 112 |
+
@application.get("/{full_path:path}")
|
| 113 |
+
async def no_frontend(full_path: str):
|
| 114 |
+
# Desktop token handshake: the Electron shell boots by fetching `/` and reading
|
| 115 |
+
# ``window.__HERMES_SESSION_TOKEN__`` for /api/ws auth. When headless 404'd every
|
| 116 |
+
# path, a renderer whose spawn token no longer matched (e.g. after `hermes update`)
|
| 117 |
+
# white-screened. Serve a token-only page at the exact root, but ONLY when the auth
|
| 118 |
+
# gate is off: on a gated serve the token must never be readable without auth.
|
| 119 |
+
# See #94227, #95575.
|
| 120 |
+
gated = bool(getattr(application.state, "auth_required", False))
|
| 121 |
+
if full_path == "" and not gated:
|
| 122 |
+
return HTMLResponse(
|
| 123 |
+
"<!doctype html><html><head><script>"
|
| 124 |
+
f"window.__HERMES_SESSION_TOKEN__={json.dumps(_server()._SESSION_TOKEN)};"
|
| 125 |
+
"window.__HERMES_AUTH_REQUIRED__=false;"
|
| 126 |
+
f"</script></head><body>{_HEADLESS_MSG}</body></html>",
|
| 127 |
+
headers=_NO_STORE,
|
| 128 |
+
)
|
| 129 |
+
return JSONResponse({"error": _HEADLESS_MSG}, status_code=404)
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
# A missing WEB_DIST is deliberately NOT a mount-time terminal state (#82614): a long-lived `hermes
|
| 133 |
+
# dashboard --skip-build` process that survives a `git pull` (or starts before the first build) used to
|
| 134 |
+
# install a permanent no_frontend catch-all here and could never recover — every route answered 404
|
| 135 |
+
# "Frontend not built" until the process was restarted, even after `npm run build` completed. The SPA
|
| 136 |
+
# routes below all cope with a missing dist per-request (`_serve_index` returns the same 404 JSON when
|
| 137 |
+
# index.html is unreadable; the asset mounts use check_dir=False and 404 on missing files), so mounting
|
| 138 |
+
# them unconditionally makes the dashboard recover the moment a build appears on disk — no restart
|
| 139 |
+
# needed.
|
| 140 |
+
def _serve_index(prefix: str = ""):
|
| 141 |
+
"""index.html with the session token + base-path injected.
|
| 142 |
+
|
| 143 |
+
When the OAuth auth gate is active (``app.state.auth_required``), the legacy
|
| 144 |
+
``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over
|
| 145 |
+
cookie auth; ``__HERMES_AUTH_REQUIRED__`` tells it which scheme to use for /api/pty
|
| 146 |
+
and /api/ws (ticket vs token).
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
html = (WEB_DIST / "index.html").read_text(encoding="utf-8")
|
| 150 |
+
except OSError:
|
| 151 |
+
# Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist.
|
| 152 |
+
return JSONResponse({"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404)
|
| 153 |
+
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
| 154 |
+
gated = bool(getattr(app.state, "auth_required", False))
|
| 155 |
+
token_js = "" if gated else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";'
|
| 156 |
+
bootstrap_script = (
|
| 157 |
+
f"<script>{token_js}"
|
| 158 |
+
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
| 159 |
+
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
| 160 |
+
f"window.__HERMES_AUTH_REQUIRED__={'true' if gated else 'false'};"
|
| 161 |
+
f"</script>"
|
| 162 |
+
)
|
| 163 |
+
if prefix:
|
| 164 |
+
# Rewrite absolute asset URLs baked into the Vite build to go through the proxy.
|
| 165 |
+
for attr in ('href="/assets/', 'src="/assets/', 'href="/favicon.ico"', 'href="/fonts/',
|
| 166 |
+
'href="/ds-assets/', 'src="/ds-assets/'):
|
| 167 |
+
html = html.replace(attr, attr.replace('"/', f'"{prefix}/', 1))
|
| 168 |
+
theme_bootstrap = _render_active_theme_bootstrap_css()
|
| 169 |
+
if theme_bootstrap:
|
| 170 |
+
html = html.replace("</head>", f"{theme_bootstrap}</head>", 1)
|
| 171 |
+
html = html.replace("</head>", f"{bootstrap_script}</head>", 1)
|
| 172 |
+
return HTMLResponse(html, headers=_NO_STORE)
|
| 173 |
+
|
| 174 |
+
# Built CSS contains absolute ``url(/fonts/...)`` / ``url(/ds-assets/...)`` references that
|
| 175 |
+
# browsers resolve against the document origin — wrong under a proxy prefix. Intercept CSS
|
| 176 |
+
# BEFORE the StaticFiles mount and rewrite when a prefix is in play.
|
| 177 |
+
@application.get("/assets/{filename}.css")
|
| 178 |
+
async def serve_css(filename: str, request: Request):
|
| 179 |
+
css_path = WEB_DIST / "assets" / f"{filename}.css"
|
| 180 |
+
if not css_path.is_file() or not css_path.resolve().is_relative_to(WEB_DIST.resolve()):
|
| 181 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 182 |
+
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
| 183 |
+
css = css_path.read_text(encoding="utf-8")
|
| 184 |
+
if prefix:
|
| 185 |
+
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
|
| 186 |
+
for quote in ("", '"', "'"):
|
| 187 |
+
css = css.replace(f"url({quote}{asset_dir}", f"url({quote}{prefix}{asset_dir}")
|
| 188 |
+
return Response(
|
| 189 |
+
content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
class _ImmutableAssetFiles(StaticFiles):
|
| 193 |
+
"""StaticFiles that marks hashed bundle assets immutable so reloads skip revalidation."""
|
| 194 |
+
|
| 195 |
+
async def get_response(self, path: str, scope):
|
| 196 |
+
response = await super().get_response(path, scope)
|
| 197 |
+
if response.status_code == 200:
|
| 198 |
+
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
|
| 199 |
+
return response
|
| 200 |
+
|
| 201 |
+
# check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does.
|
| 202 |
+
application.mount(
|
| 203 |
+
"/assets", _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
@application.get("/{full_path:path}")
|
| 207 |
+
async def serve_spa(full_path: str, request: Request):
|
| 208 |
+
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
| 209 |
+
# An unmatched /api/* path is a missing endpoint, not a client-side route: return a
|
| 210 |
+
# real 404 JSON instead of index.html (which breaks JSON clients with a SyntaxError).
|
| 211 |
+
if full_path == "api" or full_path.startswith("api/"):
|
| 212 |
+
return JSONResponse({"detail": f"No such API endpoint: /{full_path}"}, status_code=404)
|
| 213 |
+
file_path = WEB_DIST / full_path
|
| 214 |
+
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
| 215 |
+
if (
|
| 216 |
+
full_path
|
| 217 |
+
and file_path.resolve().is_relative_to(WEB_DIST.resolve())
|
| 218 |
+
and file_path.exists()
|
| 219 |
+
and file_path.is_file()
|
| 220 |
+
):
|
| 221 |
+
return FileResponse(file_path)
|
| 222 |
+
return _serve_index(prefix)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ---------------------------------------------------------------------------
|
| 226 |
+
# Dashboard themes
|
| 227 |
+
# ---------------------------------------------------------------------------
|
| 228 |
+
|
| 229 |
+
# Built-in themes — label + description only; colors live in web/src/themes/presets.ts.
|
| 230 |
+
_BUILTIN_DASHBOARD_THEMES = [
|
| 231 |
+
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
|
| 232 |
+
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
|
| 233 |
+
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
|
| 234 |
+
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
|
| 235 |
+
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
|
| 236 |
+
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
|
| 237 |
+
{"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"},
|
| 238 |
+
{"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"},
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]:
|
| 243 |
+
"""Normalise a theme layer spec (bare hex shorthand or ``{hex, alpha}`` dict); ``None`` on
|
| 244 |
+
garbage so the caller falls back to a built-in default."""
|
| 245 |
+
if value is None:
|
| 246 |
+
return {"hex": default_hex, "alpha": default_alpha}
|
| 247 |
+
if isinstance(value, str):
|
| 248 |
+
return {"hex": value, "alpha": default_alpha}
|
| 249 |
+
if not isinstance(value, dict):
|
| 250 |
+
return None
|
| 251 |
+
hex_val = value.get("hex", default_hex)
|
| 252 |
+
if not isinstance(hex_val, str):
|
| 253 |
+
return None
|
| 254 |
+
try:
|
| 255 |
+
alpha_f = float(value.get("alpha", default_alpha))
|
| 256 |
+
except (TypeError, ValueError):
|
| 257 |
+
alpha_f = default_alpha
|
| 258 |
+
return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
|
| 262 |
+
"fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
| 263 |
+
"fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace',
|
| 264 |
+
"baseSize": "15px",
|
| 265 |
+
"lineHeight": "1.55",
|
| 266 |
+
"letterSpacing": "0",
|
| 267 |
+
}
|
| 268 |
+
_THEME_DEFAULT_LAYOUT: Dict[str, str] = {
|
| 269 |
+
"radius": "0.5rem", "density": "comfortable"
|
| 270 |
+
}
|
| 271 |
+
_THEME_OVERRIDE_KEYS = {
|
| 272 |
+
"card", "cardForeground", "popover", "popoverForeground",
|
| 273 |
+
"primary", "primaryForeground", "secondary", "secondaryForeground",
|
| 274 |
+
"muted", "mutedForeground", "accent", "accentForeground",
|
| 275 |
+
"destructive", "destructiveForeground", "success", "warning",
|
| 276 |
+
"border", "input", "ring",
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
# Named asset slots; other keys under ``assets.custom`` become ``--theme-asset-custom-<key>``.
|
| 280 |
+
_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
|
| 281 |
+
|
| 282 |
+
# Component-style buckets: each camelCase property under a bucket emits
|
| 283 |
+
# ``--component-<bucket>-<kebab-property>`` on :root, consumed by shell components.
|
| 284 |
+
_THEME_COMPONENT_BUCKETS = {
|
| 285 |
+
"card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page"
|
| 286 |
+
}
|
| 287 |
+
_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
|
| 288 |
+
|
| 289 |
+
# customCSS cap so an oversized theme YAML can't blow up the payload or <style> tag.
|
| 290 |
+
_THEME_CUSTOM_CSS_MAX = 32 * 1024
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _dict_field(data: Dict[str, Any], key: str) -> Dict[str, Any]:
|
| 294 |
+
value = data.get(key)
|
| 295 |
+
return value if isinstance(value, dict) else {}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _nonempty_str(value: Any) -> bool:
|
| 299 |
+
return isinstance(value, str) and bool(value.strip())
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _css_ident(key: Any) -> bool:
|
| 303 |
+
return isinstance(key, str) and key.replace("-", "").replace("_", "").isalnum()
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 307 |
+
"""Normalise a user theme YAML into the wire format ``ThemeProvider`` expects; ``None`` if
|
| 308 |
+
unusable. Accepts the full schema and a loose form (top-level ``colors``, bare hex).
|
| 309 |
+
|
| 310 |
+
customCSS is clipped but intentionally NOT sanitised — themes are user-authored YAML in
|
| 311 |
+
~/.hermes/, the same trust level as config.yaml. Empty asset values are dropped so a
|
| 312 |
+
theme can explicitly clear a slot.
|
| 313 |
+
"""
|
| 314 |
+
if not isinstance(data, dict):
|
| 315 |
+
return None
|
| 316 |
+
name = data.get("name")
|
| 317 |
+
if not _nonempty_str(name):
|
| 318 |
+
return None
|
| 319 |
+
|
| 320 |
+
palette_src = _dict_field(data, "palette")
|
| 321 |
+
colors_src = _dict_field(data, "colors")
|
| 322 |
+
|
| 323 |
+
def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]:
|
| 324 |
+
parsed = _parse_theme_layer(palette_src.get(key, colors_src.get(key)), default_hex, default_alpha)
|
| 325 |
+
return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha}
|
| 326 |
+
|
| 327 |
+
raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity"))
|
| 328 |
+
try:
|
| 329 |
+
noise = float(raw_noise) if raw_noise is not None else 1.0
|
| 330 |
+
except (TypeError, ValueError):
|
| 331 |
+
noise = 1.0
|
| 332 |
+
palette = {
|
| 333 |
+
"background": _layer("background", "#041c1c", 1.0),
|
| 334 |
+
"midground": _layer("midground", "#ffe6cb", 1.0),
|
| 335 |
+
"foreground": _layer("foreground", "#ffffff", 0.0),
|
| 336 |
+
"warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)",
|
| 337 |
+
"noiseOpacity": noise,
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
typo_src = _dict_field(data, "typography")
|
| 341 |
+
typography = dict(_THEME_DEFAULT_TYPOGRAPHY)
|
| 342 |
+
for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"):
|
| 343 |
+
if _nonempty_str(typo_src.get(key)):
|
| 344 |
+
typography[key] = typo_src[key]
|
| 345 |
+
|
| 346 |
+
layout_src = _dict_field(data, "layout")
|
| 347 |
+
layout = dict(_THEME_DEFAULT_LAYOUT)
|
| 348 |
+
if _nonempty_str(layout_src.get("radius")):
|
| 349 |
+
layout["radius"] = layout_src["radius"]
|
| 350 |
+
density = layout_src.get("density")
|
| 351 |
+
if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}:
|
| 352 |
+
layout["density"] = density
|
| 353 |
+
|
| 354 |
+
color_overrides = {
|
| 355 |
+
k: v for k, v in _dict_field(data, "colorOverrides").items()
|
| 356 |
+
if k in _THEME_OVERRIDE_KEYS and _nonempty_str(v)
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
assets_src = _dict_field(data, "assets")
|
| 360 |
+
assets_out: Dict[str, Any] = {k: assets_src[k] for k in _THEME_NAMED_ASSET_KEYS if _nonempty_str(assets_src.get(k))}
|
| 361 |
+
custom_assets = {k: v for k, v in _dict_field(assets_src, "custom").items() if _css_ident(k) and _nonempty_str(v)}
|
| 362 |
+
if custom_assets:
|
| 363 |
+
assets_out["custom"] = custom_assets
|
| 364 |
+
|
| 365 |
+
custom_css_val = data.get("customCSS")
|
| 366 |
+
custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX] if _nonempty_str(custom_css_val) else None
|
| 367 |
+
|
| 368 |
+
component_styles: Dict[str, Dict[str, str]] = {}
|
| 369 |
+
for bucket, props in _dict_field(data, "componentStyles").items():
|
| 370 |
+
if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict):
|
| 371 |
+
continue
|
| 372 |
+
clean = {
|
| 373 |
+
prop: str(value) for prop, value in props.items()
|
| 374 |
+
if _css_ident(prop) and isinstance(value, (str, int, float)) and str(value).strip()
|
| 375 |
+
}
|
| 376 |
+
if clean:
|
| 377 |
+
component_styles[bucket] = clean
|
| 378 |
+
|
| 379 |
+
layout_variant = data.get("layoutVariant")
|
| 380 |
+
if not (isinstance(layout_variant, str) and layout_variant in _THEME_LAYOUT_VARIANTS):
|
| 381 |
+
layout_variant = "standard"
|
| 382 |
+
|
| 383 |
+
result: Dict[str, Any] = {
|
| 384 |
+
"name": name,
|
| 385 |
+
"label": data.get("label") or name,
|
| 386 |
+
"description": data.get("description", ""),
|
| 387 |
+
"palette": palette,
|
| 388 |
+
"typography": typography,
|
| 389 |
+
"layout": layout,
|
| 390 |
+
"layoutVariant": layout_variant,
|
| 391 |
+
}
|
| 392 |
+
if color_overrides:
|
| 393 |
+
result["colorOverrides"] = color_overrides
|
| 394 |
+
if assets_out:
|
| 395 |
+
result["assets"] = assets_out
|
| 396 |
+
if custom_css is not None:
|
| 397 |
+
result["customCSS"] = custom_css
|
| 398 |
+
if component_styles:
|
| 399 |
+
result["componentStyles"] = component_styles
|
| 400 |
+
return result
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def _discover_user_themes() -> list:
|
| 404 |
+
"""Fully-normalised user themes from ``<launch home>/dashboard-themes/*.yaml``.
|
| 405 |
+
|
| 406 |
+
Uses the process launch home, not ``get_hermes_home()``, so a transient profile override
|
| 407 |
+
from embedded chat does not hide themes under the server's own ``HERMES_HOME``.
|
| 408 |
+
"""
|
| 409 |
+
themes_dir = get_process_hermes_home() / "dashboard-themes"
|
| 410 |
+
if not themes_dir.is_dir():
|
| 411 |
+
return []
|
| 412 |
+
result = []
|
| 413 |
+
for f in sorted(themes_dir.glob("*.yaml")):
|
| 414 |
+
try:
|
| 415 |
+
data = yaml.safe_load(f.read_text(encoding="utf-8"))
|
| 416 |
+
except Exception:
|
| 417 |
+
continue
|
| 418 |
+
normalised = _normalise_theme_definition(data)
|
| 419 |
+
if normalised is not None:
|
| 420 |
+
result.append(normalised)
|
| 421 |
+
return result
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# ---------------------------------------------------------------------------
|
| 425 |
+
# Dashboard plugin system
|
| 426 |
+
# ---------------------------------------------------------------------------
|
| 427 |
+
|
| 428 |
+
def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional[str]:
|
| 429 |
+
"""Validate the manifest's ``api`` field (later imported as a Python module — arbitrary
|
| 430 |
+
code execution by design).
|
| 431 |
+
|
| 432 |
+
An absolute path would swallow the plugin dir (``Path('safe') / '/tmp/evil.py'`` ->
|
| 433 |
+
``/tmp/evil.py``) and ``../`` could climb out of it (GHSA-5qr3-c538-wm9j). Returns the
|
| 434 |
+
original string when the resolved path stays under ``dashboard_dir``, else ``None`` so
|
| 435 |
+
the plugin still loads its static JS/CSS but its backend ``api`` is rejected.
|
| 436 |
+
|
| 437 |
+
The web server later imports this file as a Python module via ``importlib.util.spec_from_file_location``
|
| 438 |
+
(arbitrary code execution by design — that's how plugins extend the backend). Pre-#29156 the field was
|
| 439 |
+
used as-is, which meant:
|
| 440 |
+
"""
|
| 441 |
+
if not isinstance(api_field, str) or not api_field.strip():
|
| 442 |
+
return None
|
| 443 |
+
candidate = Path(api_field)
|
| 444 |
+
if candidate.is_absolute():
|
| 445 |
+
return None
|
| 446 |
+
try:
|
| 447 |
+
(dashboard_dir / candidate).resolve().relative_to(dashboard_dir.resolve())
|
| 448 |
+
except (OSError, RuntimeError, ValueError):
|
| 449 |
+
return None
|
| 450 |
+
return api_field
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _dashboard_plugin_search_dirs() -> List[tuple]:
|
| 454 |
+
"""``(root, source)`` pairs to scan, in priority order (first name wins).
|
| 455 |
+
|
| 456 |
+
User dashboard plugins are a dashboard-owned asset (like theme YAML): resolved from the
|
| 457 |
+
process launch home so they don't vanish when a request is scoped to another profile.
|
| 458 |
+
When the process itself is profile-scoped (``HERMES_HOME=<root>/profiles/<name>``) the
|
| 459 |
+
launch home has no ``plugins/`` — user plugins live in the hermes root — so the default
|
| 460 |
+
root is scanned too; profile-local plugins stay authoritative over same-named root ones.
|
| 461 |
+
The project source is gated on shared truthy semantics (``1``/``true``/``yes``/``on``):
|
| 462 |
+
a bare non-empty check let ``=0``/``=false`` silently enable it (GHSA-5qr3-c538-wm9j).
|
| 463 |
+
"""
|
| 464 |
+
from hermes_cli.plugins import get_bundled_plugins_dir
|
| 465 |
+
from hermes_constants import get_default_hermes_root
|
| 466 |
+
|
| 467 |
+
bundled_root = get_bundled_plugins_dir()
|
| 468 |
+
# User dashboard plugins are a dashboard-owned asset (same category as theme YAML): resolve them from
|
| 469 |
+
# the process launch home so they don't vanish when a request is scoped to another profile via a
|
| 470 |
+
# context-local HERMES_HOME override (e.g. embedded /chat under --open-profile). #87197: when the
|
| 471 |
+
# process itself is profile-scoped (``--profile <name>`` sets ``HERMES_HOME=<root>/profiles/<name>``),
|
| 472 |
+
# the launch home is the profile directory, which has no ``plugins/`` — user plugins are installed in
|
| 473 |
+
# the hermes root (``~/.hermes/plugins``). Scan the default root as well (``get_default_hermes_root()``
|
| 474 |
+
# unwraps ``<root>/profiles/<name>`` → ``<root>`` and returns a custom ``HERMES_HOME`` unchanged when it
|
| 475 |
+
# *is* the root), mirroring how ``hermes_cli.plugins`` resolves plugin install locations. The
|
| 476 |
+
# ``seen_names`` dedupe below keeps profile-local plugins (if any) authoritative over same-named root
|
| 477 |
+
# plugins.
|
| 478 |
+
user_plugin_roots = [get_process_hermes_home() / "plugins"]
|
| 479 |
+
root_plugins = get_default_hermes_root() / "plugins"
|
| 480 |
+
if root_plugins.resolve(strict=False) != user_plugin_roots[0].resolve(strict=False):
|
| 481 |
+
user_plugin_roots.append(root_plugins)
|
| 482 |
+
search_dirs = [(d, "user") for d in user_plugin_roots]
|
| 483 |
+
search_dirs += [(bundled_root / "memory", "bundled"), (bundled_root, "bundled")]
|
| 484 |
+
# GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)`` check treated *any* non-empty
|
| 485 |
+
# string as truthy, so ``=0``, ``=false``, and ``=no`` — all of which the agent loader and operators
|
| 486 |
+
# correctly read as "disabled" — silently *enabled* the untrusted project source in the web server.
|
| 487 |
+
# Combined with the absolute-path RCE primitive on the manifest's ``api`` field (now patched below),
|
| 488 |
+
# this turned the opt-in into a sticky always-on switch. Use the shared truthy semantics (``1`` /
|
| 489 |
+
# ``true`` / ``yes`` / ``on``) so the gate matches ``hermes_cli/plugins.py`` and the documented user
|
| 490 |
+
# contract.
|
| 491 |
+
if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
|
| 492 |
+
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
| 493 |
+
return search_dirs
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _dashboard_plugin_entry(data: Dict[str, Any], name: str, dashboard_dir: Path, source: str) -> Dict[str, Any]:
|
| 497 |
+
# Tab options: ``path`` + ``position`` for a new tab, optional ``override`` to replace a
|
| 498 |
+
# built-in route, and ``hidden`` to register component/slots without adding a tab.
|
| 499 |
+
raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {}
|
| 500 |
+
tab_info = {"path": raw_tab.get("path", f"/{name}"), "position": raw_tab.get("position", "end")}
|
| 501 |
+
override_path = raw_tab.get("override")
|
| 502 |
+
if isinstance(override_path, str) and override_path.startswith("/"):
|
| 503 |
+
tab_info["override"] = override_path
|
| 504 |
+
if bool(raw_tab.get("hidden")):
|
| 505 |
+
tab_info["hidden"] = True
|
| 506 |
+
# Slots the plugin populates via ``window.registerSlot(pluginName, slotName, Component)``.
|
| 507 |
+
slots_src = data.get("slots")
|
| 508 |
+
slots = [s for s in slots_src if isinstance(s, str) and s] if isinstance(slots_src, list) else []
|
| 509 |
+
# Validate ``api`` at discovery time so the cached value is already safe for the importer.
|
| 510 |
+
raw_api = data.get("api")
|
| 511 |
+
safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
|
| 512 |
+
if raw_api and safe_api is None:
|
| 513 |
+
_log.warning(
|
| 514 |
+
"Plugin %s: refusing unsafe api path %r (must be a "
|
| 515 |
+
"relative file inside the plugin's dashboard/ "
|
| 516 |
+
"directory); backend routes from this plugin will "
|
| 517 |
+
"not be mounted",
|
| 518 |
+
name, raw_api,
|
| 519 |
+
)
|
| 520 |
+
return {
|
| 521 |
+
"name": name,
|
| 522 |
+
"label": data.get("label", name),
|
| 523 |
+
"description": data.get("description", ""),
|
| 524 |
+
"icon": data.get("icon", "Puzzle"),
|
| 525 |
+
"version": data.get("version", "0.0.0"),
|
| 526 |
+
"tab": tab_info,
|
| 527 |
+
"slots": slots,
|
| 528 |
+
"entry": data.get("entry", "dist/index.js"),
|
| 529 |
+
"css": data.get("css"),
|
| 530 |
+
"has_api": bool(safe_api),
|
| 531 |
+
"source": source,
|
| 532 |
+
"_dir": str(dashboard_dir),
|
| 533 |
+
"_api_file": safe_api,
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def _discover_dashboard_plugins() -> list:
|
| 538 |
+
"""Scan ``<plugins root>/*/dashboard/manifest.json`` across user, bundled and (opt-in)
|
| 539 |
+
project plugin sources — same three sources as ``hermes_cli.plugins``."""
|
| 540 |
+
plugins = []
|
| 541 |
+
seen_names: set = set()
|
| 542 |
+
for plugins_root, source in _dashboard_plugin_search_dirs():
|
| 543 |
+
if not plugins_root.is_dir():
|
| 544 |
+
continue
|
| 545 |
+
with os.scandir(plugins_root) as scan:
|
| 546 |
+
children = sorted((Path(e.path) for e in scan), key=lambda p: p.name)
|
| 547 |
+
for child in children:
|
| 548 |
+
manifest_file = child / "dashboard" / "manifest.json"
|
| 549 |
+
if not child.is_dir() or not manifest_file.exists():
|
| 550 |
+
continue
|
| 551 |
+
try:
|
| 552 |
+
data = json.loads(manifest_file.read_text(encoding="utf-8"))
|
| 553 |
+
name = data.get("name", child.name)
|
| 554 |
+
if name in seen_names:
|
| 555 |
+
continue
|
| 556 |
+
seen_names.add(name)
|
| 557 |
+
plugins.append(_dashboard_plugin_entry(data, name, child / "dashboard", source))
|
| 558 |
+
except Exception as exc:
|
| 559 |
+
_log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc)
|
| 560 |
+
continue
|
| 561 |
+
return plugins
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
|
| 565 |
+
return {k: v for k, v in p.items() if not k.startswith("_")}
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
_PLUGINS_HUB_CACHE_TTL_SECONDS = 5.0
|
| 569 |
+
_plugins_hub_cache: Optional[Dict[str, Any]] = None
|
| 570 |
+
_plugins_hub_cache_expires_at = 0.0
|
| 571 |
+
_plugins_hub_cache_lock = threading.Lock()
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def _invalidate_plugins_hub_cache() -> None:
|
| 575 |
+
global _plugins_hub_cache, _plugins_hub_cache_expires_at
|
| 576 |
+
with _plugins_hub_cache_lock:
|
| 577 |
+
_plugins_hub_cache = None
|
| 578 |
+
_plugins_hub_cache_expires_at = 0.0
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
_plugins_hub_probe_inflight: set = set()
|
| 582 |
+
_plugins_hub_probe_lock = threading.Lock()
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _schedule_check_fn_probe(fn) -> Optional[threading.Thread]:
|
| 586 |
+
"""Warm a cold ``check_fn`` verdict off the request path.
|
| 587 |
+
|
| 588 |
+
The hub read path only consumes cached availability; the only other warmer is the
|
| 589 |
+
tool-schema build, which a dashboard-only session never runs — so a cold cache would
|
| 590 |
+
report ``auth_required=False`` forever. Daemon-thread probe, deduplicated per function;
|
| 591 |
+
the short hub TTL surfaces the verdict on the next fetch. Returns ``None`` when a probe
|
| 592 |
+
for *fn* is already in flight.
|
| 593 |
+
"""
|
| 594 |
+
with _plugins_hub_probe_lock:
|
| 595 |
+
if fn in _plugins_hub_probe_inflight:
|
| 596 |
+
return None
|
| 597 |
+
_plugins_hub_probe_inflight.add(fn)
|
| 598 |
+
|
| 599 |
+
def _probe():
|
| 600 |
+
try:
|
| 601 |
+
from tools.registry import _check_fn_cached
|
| 602 |
+
|
| 603 |
+
_check_fn_cached(fn)
|
| 604 |
+
except Exception:
|
| 605 |
+
pass
|
| 606 |
+
finally:
|
| 607 |
+
with _plugins_hub_probe_lock:
|
| 608 |
+
_plugins_hub_probe_inflight.discard(fn)
|
| 609 |
+
|
| 610 |
+
thread = threading.Thread(target=_probe, name="plugins-hub-checkfn-probe", daemon=True)
|
| 611 |
+
thread.start()
|
| 612 |
+
return thread
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _plugin_auth_hint(name: str, provides_tools: list) -> tuple:
|
| 616 |
+
"""``(auth_required, auth_command)`` from last-known cached tool availability only.
|
| 617 |
+
|
| 618 |
+
A missing cache entry is "unknown": schedule a background probe rather than probing
|
| 619 |
+
inline (which would starve the root event loop), so the short hub TTL picks it up.
|
| 620 |
+
"""
|
| 621 |
+
try:
|
| 622 |
+
from tools.registry import get_cached_check_fn_result, registry
|
| 623 |
+
for tname in provides_tools:
|
| 624 |
+
entry = registry.get_entry(tname)
|
| 625 |
+
if not entry or not entry.check_fn:
|
| 626 |
+
continue
|
| 627 |
+
cached_result = get_cached_check_fn_result(entry.check_fn)
|
| 628 |
+
if cached_result is None:
|
| 629 |
+
_schedule_check_fn_probe(entry.check_fn)
|
| 630 |
+
elif cached_result is False:
|
| 631 |
+
return True, f"hermes auth {name}"
|
| 632 |
+
except Exception:
|
| 633 |
+
pass
|
| 634 |
+
return False, ""
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def _plugin_runtime_status(aliases: set, enabled_set: set, disabled_set: set) -> str:
|
| 638 |
+
"""enabled / disabled / inactive for a plugin's name+key alias set (disabled wins)."""
|
| 639 |
+
return "disabled" if aliases & disabled_set else "enabled" if aliases & enabled_set else "inactive"
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
| 643 |
+
"""Agent discovery + dashboard manifests + provider picker metadata.
|
| 644 |
+
|
| 645 |
+
IMPORTANT: powers a dashboard request path, so it must stay read-only and cheap — never
|
| 646 |
+
execute tool ``check_fn`` probes here (imports, auth/network checks would starve the root
|
| 647 |
+
event loop). Only cached availability is consumed and the payload is memoized briefly to
|
| 648 |
+
collapse the dashboard's bursty duplicate fetches.
|
| 649 |
+
"""
|
| 650 |
+
from hermes_cli.web_server_memory import _discover_memory_provider_statuses, _normalize_memory_provider_name
|
| 651 |
+
from hermes_cli.web_server import _get_dashboard_plugins
|
| 652 |
+
from hermes_cli.config import get_hermes_home, load_config
|
| 653 |
+
global _plugins_hub_cache, _plugins_hub_cache_expires_at
|
| 654 |
+
now = time.monotonic()
|
| 655 |
+
if not force_refresh:
|
| 656 |
+
with _plugins_hub_cache_lock:
|
| 657 |
+
if _plugins_hub_cache is not None and now < _plugins_hub_cache_expires_at:
|
| 658 |
+
return _plugins_hub_cache
|
| 659 |
+
|
| 660 |
+
started_at = time.monotonic()
|
| 661 |
+
from hermes_cli.plugins_cmd import (
|
| 662 |
+
_discover_all_plugins,
|
| 663 |
+
_get_current_context_engine,
|
| 664 |
+
_get_current_memory_provider,
|
| 665 |
+
_discover_context_engines,
|
| 666 |
+
_get_disabled_set,
|
| 667 |
+
_get_enabled_set,
|
| 668 |
+
_read_manifest as _read_plugin_manifest_at,
|
| 669 |
+
)
|
| 670 |
+
from hermes_cli.plugins_cmd_catalog import removed_annotation
|
| 671 |
+
|
| 672 |
+
dashboard_list = _get_dashboard_plugins()
|
| 673 |
+
dash_by_name = {str(p["name"]): p for p in dashboard_list}
|
| 674 |
+
disabled_set = _get_disabled_set()
|
| 675 |
+
enabled_set = _get_enabled_set()
|
| 676 |
+
hidden_plugins: list = cfg_get(load_config(), "dashboard", "hidden_plugins", default=[]) or []
|
| 677 |
+
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
|
| 678 |
+
rows: List[Dict[str, Any]] = []
|
| 679 |
+
|
| 680 |
+
for name, version, description, source, dir_str, key in _discover_all_plugins():
|
| 681 |
+
# Both the path-derived key (nested category plugins) and the bare manifest name
|
| 682 |
+
# count for enabled/disabled state, matching the runtime loader's back-compat lookup.
|
| 683 |
+
aliases = {name, key} if key else {name}
|
| 684 |
+
runtime_status = _plugin_runtime_status(aliases, enabled_set, disabled_set)
|
| 685 |
+
|
| 686 |
+
dir_path = Path(dir_str)
|
| 687 |
+
dm = dash_by_name.get(name)
|
| 688 |
+
try:
|
| 689 |
+
dir_path.resolve().relative_to(plugins_root_resolved)
|
| 690 |
+
under_user_tree = True
|
| 691 |
+
except ValueError:
|
| 692 |
+
under_user_tree = False
|
| 693 |
+
can_remove_update = source in {"user", "git"} and under_user_tree and dir_path.is_dir()
|
| 694 |
+
|
| 695 |
+
provides_tools = _read_plugin_manifest_at(dir_path).get("provides_tools") or []
|
| 696 |
+
auth_required, auth_command = _plugin_auth_hint(name, provides_tools) if provides_tools else (False, "")
|
| 697 |
+
|
| 698 |
+
rows.append({
|
| 699 |
+
"name": name,
|
| 700 |
+
"version": version or "",
|
| 701 |
+
"description": description or "",
|
| 702 |
+
"source": source,
|
| 703 |
+
"runtime_status": runtime_status,
|
| 704 |
+
"has_dashboard_manifest": dm is not None or (dir_path / "dashboard" / "manifest.json").exists(),
|
| 705 |
+
"dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
|
| 706 |
+
"path": dir_str,
|
| 707 |
+
"can_remove": can_remove_update,
|
| 708 |
+
"can_update_git": can_remove_update and (dir_path / ".git").exists(),
|
| 709 |
+
"auth_required": auth_required,
|
| 710 |
+
"auth_command": auth_command,
|
| 711 |
+
"user_hidden": name in hidden_plugins,
|
| 712 |
+
"removed_reason": removed_annotation(name, dir_str),
|
| 713 |
+
})
|
| 714 |
+
|
| 715 |
+
agent_names = {r["name"] for r in rows}
|
| 716 |
+
orphan_dashboard = [_strip_dashboard_manifest(p) for p in dashboard_list if str(p["name"]) not in agent_names]
|
| 717 |
+
memory_providers = _discover_memory_provider_statuses()
|
| 718 |
+
try:
|
| 719 |
+
context_engines = [{"name": n, "description": desc} for n, desc in _discover_context_engines()]
|
| 720 |
+
except Exception:
|
| 721 |
+
context_engines = []
|
| 722 |
+
|
| 723 |
+
payload = {
|
| 724 |
+
"plugins": rows,
|
| 725 |
+
"orphan_dashboard_plugins": orphan_dashboard,
|
| 726 |
+
"providers": {
|
| 727 |
+
"memory_provider": _normalize_memory_provider_name(_get_current_memory_provider()),
|
| 728 |
+
"memory_options": memory_providers,
|
| 729 |
+
"context_engine": _get_current_context_engine(),
|
| 730 |
+
"context_options": context_engines,
|
| 731 |
+
},
|
| 732 |
+
}
|
| 733 |
+
duration = time.monotonic() - started_at
|
| 734 |
+
if duration >= 0.25:
|
| 735 |
+
_log.info(
|
| 736 |
+
"plugins/hub rebuilt in %.3fs (plugins=%d memory_options=%d)", duration, len(rows), len(memory_providers)
|
| 737 |
+
)
|
| 738 |
+
with _plugins_hub_cache_lock:
|
| 739 |
+
_plugins_hub_cache = payload
|
| 740 |
+
_plugins_hub_cache_expires_at = time.monotonic() + _PLUGINS_HUB_CACHE_TTL_SECONDS
|
| 741 |
+
return payload
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def _plugin_api_mount_skip_reason(plugin: Dict[str, Any], enabled_set: set, disabled_set: set) -> Optional[str]:
|
| 745 |
+
"""Why a plugin's backend ``api`` must NOT be imported, or None when it may be.
|
| 746 |
+
|
| 747 |
+
User plugins must be in ``plugins.enabled`` and not ``plugins.disabled`` before their
|
| 748 |
+
Python runs (GHSA-mcfc-hp25-cjv7); bundled plugins are trusted but respect an explicit
|
| 749 |
+
disable; project plugins (``./.hermes/plugins/``) ship with the CWD and are
|
| 750 |
+
attacker-controlled when opening a malicious repo — never auto-imported (GHSA-5qr3-c538-wm9j).
|
| 751 |
+
"""
|
| 752 |
+
source, plugin_name = plugin.get("source"), plugin.get("name", "")
|
| 753 |
+
if source in ("user", "bundled") and plugin_name in disabled_set:
|
| 754 |
+
return "explicitly disabled"
|
| 755 |
+
if source == "user" and plugin_name not in enabled_set:
|
| 756 |
+
return "not in plugins.enabled"
|
| 757 |
+
return None
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def _mount_plugin_api_routes():
|
| 761 |
+
"""Import and mount backend API routes from plugins that declare them.
|
| 762 |
+
|
| 763 |
+
Each plugin's ``api`` file must expose a ``router`` (FastAPI APIRouter), mounted under
|
| 764 |
+
``/api/plugins/<name>/``. See ``_plugin_api_mount_skip_reason`` for the trust gates.
|
| 765 |
+
|
| 766 |
+
Backend import is restricted to ``bundled`` and ``user`` sources. Project plugins
|
| 767 |
+
(``./.hermes/plugins/``) ship with the CWD and are therefore attacker-controlled in any threat model
|
| 768 |
+
where the user opens a malicious repo; they can extend the dashboard UI via static JS/CSS but their
|
| 769 |
+
Python ``api`` file is never auto-imported by the web server. See GHSA-5qr3-c538-wm9j (#29156).
|
| 770 |
+
Additionally, user plugins must be explicitly enabled via the ``plugins.enabled`` allow-list in
|
| 771 |
+
config.yaml before their backend code is imported. Without this gate, an installed-but-not-enabled
|
| 772 |
+
plugin's Python code would execute at dashboard startup — a code execution vector that bypasses the
|
| 773 |
+
user's intent. (#46435, GHSA-mcfc-hp25-cjv7)
|
| 774 |
+
"""
|
| 775 |
+
from hermes_cli.web_server import _get_dashboard_plugins, app
|
| 776 |
+
try:
|
| 777 |
+
from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set
|
| 778 |
+
enabled_set = _get_enabled_set()
|
| 779 |
+
disabled_set = _get_disabled_set()
|
| 780 |
+
except Exception:
|
| 781 |
+
enabled_set = set()
|
| 782 |
+
disabled_set = set()
|
| 783 |
+
|
| 784 |
+
for plugin in _get_dashboard_plugins():
|
| 785 |
+
api_file_name = plugin.get("_api_file")
|
| 786 |
+
if not api_file_name:
|
| 787 |
+
continue
|
| 788 |
+
skip = _plugin_api_mount_skip_reason(plugin, enabled_set, disabled_set)
|
| 789 |
+
if skip:
|
| 790 |
+
_log.debug("Plugin %s: skipping API mount (%s)", plugin.get("name", ""), skip)
|
| 791 |
+
continue
|
| 792 |
+
if plugin.get("source") == "project":
|
| 793 |
+
_log.warning(
|
| 794 |
+
"Plugin %s: ignoring backend api=%s (project plugins may "
|
| 795 |
+
"not auto-import Python code; move the plugin to "
|
| 796 |
+
"~/.hermes/plugins/ if you trust it)",
|
| 797 |
+
plugin["name"], api_file_name,
|
| 798 |
+
)
|
| 799 |
+
continue
|
| 800 |
+
dashboard_dir = Path(plugin["_dir"])
|
| 801 |
+
api_path = dashboard_dir / api_file_name
|
| 802 |
+
try:
|
| 803 |
+
api_path.resolve().relative_to(dashboard_dir.resolve())
|
| 804 |
+
except (OSError, RuntimeError, ValueError):
|
| 805 |
+
# Discovery already filters this; defence in depth in case ``_dir`` was tampered
|
| 806 |
+
# with after caching or a future caller bypasses the validator.
|
| 807 |
+
_log.warning(
|
| 808 |
+
"Plugin %s: refusing to import api file outside its "
|
| 809 |
+
"dashboard directory (%s)", plugin["name"], api_path,
|
| 810 |
+
)
|
| 811 |
+
continue
|
| 812 |
+
if not api_path.exists():
|
| 813 |
+
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
| 814 |
+
continue
|
| 815 |
+
try:
|
| 816 |
+
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
|
| 817 |
+
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
| 818 |
+
if spec is None or spec.loader is None:
|
| 819 |
+
continue
|
| 820 |
+
mod = importlib.util.module_from_spec(spec)
|
| 821 |
+
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI can resolve
|
| 822 |
+
# string annotations (``from __future__ import annotations``) by module name.
|
| 823 |
+
sys.modules[module_name] = mod
|
| 824 |
+
try:
|
| 825 |
+
spec.loader.exec_module(mod)
|
| 826 |
+
except Exception:
|
| 827 |
+
sys.modules.pop(module_name, None)
|
| 828 |
+
raise
|
| 829 |
+
router = getattr(mod, "router", None)
|
| 830 |
+
if router is None:
|
| 831 |
+
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
|
| 832 |
+
continue
|
| 833 |
+
app.include_router(router, prefix=f"/api/plugins/{plugin['name']}")
|
| 834 |
+
_log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"])
|
| 835 |
+
except Exception as exc:
|
| 836 |
+
_log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc)
|
hermes_cli/web_server_sessions.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session-DB access for the dashboard: per-profile SessionDB opening with schema
|
| 2 |
+
heal, latest-descendant lookup and the auto-archive ticker.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import asyncio
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, Optional
|
| 11 |
+
|
| 12 |
+
# Same logger the code used before extraction (record parity).
|
| 13 |
+
_log = logging.getLogger("hermes_cli.web_server")
|
| 14 |
+
|
| 15 |
+
_DESCENDANTS_SQL = """
|
| 16 |
+
WITH RECURSIVE descendants(id, parent_session_id, started_at) AS (
|
| 17 |
+
SELECT id, parent_session_id, started_at FROM sessions WHERE id = ?
|
| 18 |
+
UNION
|
| 19 |
+
SELECT s.id, s.parent_session_id, s.started_at
|
| 20 |
+
FROM sessions s
|
| 21 |
+
JOIN descendants d ON s.parent_session_id = d.id
|
| 22 |
+
)
|
| 23 |
+
SELECT id, parent_session_id, started_at FROM descendants
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _session_latest_descendant(session_id: str, db):
|
| 28 |
+
"""Resolve a session id to the newest child leaf session.
|
| 29 |
+
|
| 30 |
+
/model may create child sessions; a dashboard refresh should continue the
|
| 31 |
+
newest child instead of reopening the old parent. Returns ``(leaf, path)``.
|
| 32 |
+
"""
|
| 33 |
+
sid = db.resolve_session_id(session_id)
|
| 34 |
+
if not sid or not db.get_session(sid):
|
| 35 |
+
return None, []
|
| 36 |
+
|
| 37 |
+
conn = getattr(db, "_conn", None)
|
| 38 |
+
if conn is not None:
|
| 39 |
+
keys = ("id", "parent_session_id", "started_at")
|
| 40 |
+
rows = [dict(zip(keys, row)) for row in conn.execute(_DESCENDANTS_SQL, (sid,)).fetchall()]
|
| 41 |
+
else:
|
| 42 |
+
rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True)
|
| 43 |
+
|
| 44 |
+
children = {}
|
| 45 |
+
for row in rows:
|
| 46 |
+
rid = row.get("id")
|
| 47 |
+
parent = row.get("parent_session_id")
|
| 48 |
+
if rid and parent:
|
| 49 |
+
children.setdefault(parent, []).append(row)
|
| 50 |
+
|
| 51 |
+
def started(row):
|
| 52 |
+
try:
|
| 53 |
+
return float(row.get("started_at") or 0)
|
| 54 |
+
except Exception:
|
| 55 |
+
return 0.0
|
| 56 |
+
|
| 57 |
+
current = sid
|
| 58 |
+
path = [sid]
|
| 59 |
+
seen = {sid}
|
| 60 |
+
while children.get(current):
|
| 61 |
+
candidates = [r for r in children[current] if r.get("id") not in seen]
|
| 62 |
+
if not candidates:
|
| 63 |
+
break
|
| 64 |
+
candidates.sort(key=started, reverse=True)
|
| 65 |
+
current = candidates[0]["id"]
|
| 66 |
+
path.append(current)
|
| 67 |
+
seen.add(current)
|
| 68 |
+
return current, path
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Serialises the one-time writable schema bootstrap for read-only opens, so
|
| 72 |
+
# concurrent first-load polls don't open mode=ro against a half-written schema
|
| 73 |
+
# ("no such table: sessions").
|
| 74 |
+
_session_db_bootstrap_lock = threading.Lock()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _session_db_read_probe_statements() -> tuple:
|
| 78 |
+
"""Stale-schema probes for read-only opens (which skip _reconcile_columns()).
|
| 79 |
+
Derived from SCHEMA_SQL so a new column is probed automatically — a
|
| 80 |
+
hand-written list once went stale and emptied the sidebar after update."""
|
| 81 |
+
from hermes_state_schema import schema_read_probe_statements
|
| 82 |
+
|
| 83 |
+
return schema_read_probe_statements()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Stores where a heal WRITABLE OPEN SUCCEEDED but the read probe still failed:
|
| 87 |
+
# one reconciliation cannot fix them (e.g. a NOT-NULL-without-default column),
|
| 88 |
+
# so they fall back to the raw read-only open until restart instead of paying
|
| 89 |
+
# a writable init per poll. A FAILED writable open (transient lock) is NOT
|
| 90 |
+
# recorded — the next poll retries the heal.
|
| 91 |
+
_session_db_heal_exhausted: set = set()
|
| 92 |
+
|
| 93 |
+
# Deduplicates the heal-failure warning per store per process.
|
| 94 |
+
_session_db_heal_warned: set = set()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _is_stale_schema_error(exc: BaseException) -> bool:
|
| 98 |
+
message = str(exc).lower()
|
| 99 |
+
return "no such table" in message or "no such column" in message
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _open_session_db_at_path(db_path: Path, *, read_only: bool):
|
| 103 |
+
"""Open a SessionDB at an explicit path with an explicit access mode.
|
| 104 |
+
|
| 105 |
+
Read-only opens bootstrap a missing/zero-byte store once and heal a stale or
|
| 106 |
+
malformed schema through ONE writable open before reopening read-only; the
|
| 107 |
+
healthy read path never takes a write lock. Tables outside SCHEMA_SQL
|
| 108 |
+
(telemetry ``tel_*``, FTS shadow tables) are outside both probe and heal.
|
| 109 |
+
"""
|
| 110 |
+
import sqlite3
|
| 111 |
+
|
| 112 |
+
from hermes_state import SessionDB, is_malformed_schema_error
|
| 113 |
+
from hermes_state_registry import acquire, release_or_close
|
| 114 |
+
|
| 115 |
+
# Read-only file/sidecar preflight (port of kilocode#12508): repair-or-refuse BEFORE the first
|
| 116 |
+
# connection so users get an actionable message instead of an opaque "attempt to write a readonly
|
| 117 |
+
# database" from deep inside _init_schema.
|
| 118 |
+
if not read_only:
|
| 119 |
+
return acquire(db_path)
|
| 120 |
+
|
| 121 |
+
def _needs_bootstrap() -> bool:
|
| 122 |
+
try:
|
| 123 |
+
return db_path.stat().st_size == 0
|
| 124 |
+
except FileNotFoundError:
|
| 125 |
+
return True
|
| 126 |
+
except OSError:
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
if _needs_bootstrap():
|
| 130 |
+
with _session_db_bootstrap_lock:
|
| 131 |
+
if _needs_bootstrap():
|
| 132 |
+
db = acquire(db_path)
|
| 133 |
+
release_or_close(db)
|
| 134 |
+
|
| 135 |
+
def _open_probed():
|
| 136 |
+
db = SessionDB(db_path=db_path, read_only=True)
|
| 137 |
+
# Unit-test fakes may replace SessionDB without exposing a raw
|
| 138 |
+
# connection. Probe only real connections.
|
| 139 |
+
conn = getattr(db, "_conn", None)
|
| 140 |
+
if conn is not None and str(db_path) not in _session_db_heal_exhausted:
|
| 141 |
+
try:
|
| 142 |
+
for statement in _session_db_read_probe_statements():
|
| 143 |
+
conn.execute(statement).fetchone()
|
| 144 |
+
except BaseException:
|
| 145 |
+
db.close()
|
| 146 |
+
raise
|
| 147 |
+
return db
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
return _open_probed()
|
| 151 |
+
except (sqlite3.DatabaseError, UnicodeDecodeError) as exc:
|
| 152 |
+
# UnicodeDecodeError = pysqlite could not decode SQLite's own error
|
| 153 |
+
# message because corrupt file bytes were embedded in it; the
|
| 154 |
+
# one-writable-open heal is the only repair path, so treat it as
|
| 155 |
+
# malformed schema.
|
| 156 |
+
if not (
|
| 157 |
+
_is_stale_schema_error(exc)
|
| 158 |
+
or is_malformed_schema_error(exc)
|
| 159 |
+
or isinstance(exc, UnicodeDecodeError)):
|
| 160 |
+
raise
|
| 161 |
+
db = acquire(db_path)
|
| 162 |
+
release_or_close(db)
|
| 163 |
+
try:
|
| 164 |
+
return _open_probed()
|
| 165 |
+
except (sqlite3.DatabaseError, UnicodeDecodeError) as still_stale:
|
| 166 |
+
if not _is_stale_schema_error(still_stale):
|
| 167 |
+
raise
|
| 168 |
+
# Writable open succeeded but the store is STILL behind the probe:
|
| 169 |
+
# serve reads without the probe (only queries touching the broken
|
| 170 |
+
# part fail) and stop paying the writable init per poll.
|
| 171 |
+
_session_db_heal_exhausted.add(str(db_path))
|
| 172 |
+
if str(db_path) not in _session_db_heal_warned:
|
| 173 |
+
_session_db_heal_warned.add(str(db_path))
|
| 174 |
+
_log.warning(
|
| 175 |
+
"state.db at %s is missing schema that a writable "
|
| 176 |
+
"reconcile could not add (%s); read paths may partially "
|
| 177 |
+
"fail until the store is repaired",
|
| 178 |
+
db_path,
|
| 179 |
+
still_stale)
|
| 180 |
+
return _open_probed()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _session_db_path_for_profile(profile: Optional[str]) -> Path:
|
| 184 |
+
"""state.db path for ``profile`` (None/empty = this process's own)."""
|
| 185 |
+
from hermes_cli.web_server_cron import _cron_profile_home
|
| 186 |
+
from hermes_state import _default_db_path
|
| 187 |
+
|
| 188 |
+
if profile:
|
| 189 |
+
_name, home = _cron_profile_home(profile)
|
| 190 |
+
return Path(home) / "state.db"
|
| 191 |
+
return Path(_default_db_path())
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool):
|
| 195 |
+
"""Open a SessionDB for ``profile`` (None/empty = this process's own state.db).
|
| 196 |
+
|
| 197 |
+
Access-mode semantics: see :func:`_open_session_db_at_path`.
|
| 198 |
+
"""
|
| 199 |
+
return _open_session_db_at_path(_session_db_path_for_profile(profile), read_only=read_only)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# In-process throttle for the opportunistic auto-archive trigger, keyed by
|
| 203 |
+
# profile: bounds the config.yaml read to once per window; the sweep itself is
|
| 204 |
+
# throttled far more coarsely by state_meta (sessions.min_interval_hours).
|
| 205 |
+
_AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0
|
| 206 |
+
_last_auto_archive_check: Dict[str, float] = {}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
|
| 210 |
+
"""Config-gated stale-session auto-archive for ``profile``; never raises.
|
| 211 |
+
``hermes serve`` runs neither CLI nor gateway startup hooks, so this
|
| 212 |
+
session-list trigger is what makes ``sessions.auto_archive`` work there."""
|
| 213 |
+
try:
|
| 214 |
+
key = profile or ""
|
| 215 |
+
now = time.monotonic()
|
| 216 |
+
last = _last_auto_archive_check.get(key)
|
| 217 |
+
if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S:
|
| 218 |
+
return
|
| 219 |
+
_last_auto_archive_check[key] = now
|
| 220 |
+
|
| 221 |
+
from hermes_cli.config import load_config as _load_full_config
|
| 222 |
+
cfg = (_load_full_config().get("sessions") or {})
|
| 223 |
+
if not cfg.get("auto_archive", False):
|
| 224 |
+
return
|
| 225 |
+
db = _open_session_db_for_profile(profile, read_only=False)
|
| 226 |
+
try:
|
| 227 |
+
db.maybe_auto_archive(
|
| 228 |
+
idle_days=float(cfg.get("auto_archive_days", 3)),
|
| 229 |
+
min_interval_hours=int(cfg.get("min_interval_hours", 24)))
|
| 230 |
+
finally:
|
| 231 |
+
db.close()
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
_log.debug("opportunistic auto-archive skipped: %s", exc)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _skill_maintenance_idle_for(started_at: float) -> Optional[float]:
|
| 237 |
+
"""Measure chat inactivity, not socket inactivity (Desktop stays connected)."""
|
| 238 |
+
import tui_gateway.server as gateway
|
| 239 |
+
|
| 240 |
+
from hermes_constants import get_hermes_home
|
| 241 |
+
|
| 242 |
+
home = get_hermes_home().resolve()
|
| 243 |
+
with gateway._sessions_lock:
|
| 244 |
+
sessions = [session for session in gateway._sessions.values()
|
| 245 |
+
if Path(session.get("profile_home") or home).resolve() == home]
|
| 246 |
+
if any(session.get("running") for session in sessions):
|
| 247 |
+
return None
|
| 248 |
+
last_active = max(
|
| 249 |
+
[started_at, gateway._closed_session_activity.get(str(home), 0)]
|
| 250 |
+
+ [float(session.get("last_active") or started_at) for session in sessions])
|
| 251 |
+
return max(0.0, time.time() - last_active)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _maybe_run_skill_maintenance(started_at: float) -> None:
|
| 255 |
+
from hermes_constants import get_hermes_home
|
| 256 |
+
from hermes_cli.profiles import _check_gateway_running
|
| 257 |
+
|
| 258 |
+
# A live messaging gateway already owns these chores for this profile.
|
| 259 |
+
if _check_gateway_running(get_hermes_home()):
|
| 260 |
+
return
|
| 261 |
+
|
| 262 |
+
from agent.curator import maybe_run_curator
|
| 263 |
+
from tools.skills_sync_client import maybe_pull_skills
|
| 264 |
+
from tools.skills_sync_client_org import maybe_pull_org_skills
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
idle_for = _skill_maintenance_idle_for(started_at)
|
| 268 |
+
if idle_for is not None:
|
| 269 |
+
maybe_run_curator(idle_for_seconds=idle_for)
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
_log.debug("serve curator tick skipped: %s", exc)
|
| 272 |
+
for pull in (maybe_pull_skills, maybe_pull_org_skills):
|
| 273 |
+
try:
|
| 274 |
+
pull()
|
| 275 |
+
except Exception as exc:
|
| 276 |
+
_log.debug("serve skill sync tick skipped: %s", exc)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
async def _auto_archive_ticker_loop(
|
| 280 |
+
interval_s: float = 3600.0, initial_delay_s: float = 90.0) -> None:
|
| 281 |
+
"""Poll maintenance for this serve profile, including Desktop-only installs.
|
| 282 |
+
|
| 283 |
+
Individual chores own their config/interval gates. Curator additionally uses
|
| 284 |
+
real chat inactivity; merely keeping a Desktop WebSocket open is not activity.
|
| 285 |
+
"""
|
| 286 |
+
started_at = time.time()
|
| 287 |
+
|
| 288 |
+
def _sweep() -> None:
|
| 289 |
+
_maybe_auto_archive_for_profile(None)
|
| 290 |
+
_maybe_run_skill_maintenance(started_at)
|
| 291 |
+
|
| 292 |
+
await asyncio.sleep(initial_delay_s)
|
| 293 |
+
while True:
|
| 294 |
+
try:
|
| 295 |
+
await asyncio.to_thread(_sweep)
|
| 296 |
+
except Exception as exc:
|
| 297 |
+
_log.debug("auto-archive tick skipped: %s", exc)
|
| 298 |
+
await asyncio.sleep(interval_s)
|
hermes_constants.py
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants for Hermes Agent.
|
| 2 |
+
|
| 3 |
+
Import-safe, stdlib-only — importable from anywhere without circular-import risk.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import contextlib
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import stat
|
| 11 |
+
import sys
|
| 12 |
+
from contextvars import ContextVar, Token
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
_profile_fallback_warned: bool = False
|
| 16 |
+
_UNSET = object()
|
| 17 |
+
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar("_HERMES_HOME_OVERRIDE", default=_UNSET)
|
| 18 |
+
|
| 19 |
+
# TUI busy-indicator styles (CLI /indicator, TUI gateway config, /help registry).
|
| 20 |
+
# Keep in sync with INDICATOR_STYLES / DEFAULT_INDICATOR_STYLE in ui-tui/src/app/interfaces.ts.
|
| 21 |
+
INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
|
| 22 |
+
DEFAULT_INDICATOR_STYLE: str = "kaomoji"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def set_hermes_home_override(path: str | Path | None) -> Token:
|
| 26 |
+
"""Set a context-local Hermes home override and return its reset token.
|
| 27 |
+
|
| 28 |
+
Deliberately does not mutate ``os.environ`` (shared by every thread in the process).
|
| 29 |
+
"""
|
| 30 |
+
value: str | object = _UNSET if path is None else str(path)
|
| 31 |
+
return _HERMES_HOME_OVERRIDE.set(value)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def reset_hermes_home_override(token: Token) -> None:
|
| 35 |
+
"""Restore the previous context-local Hermes home override."""
|
| 36 |
+
_HERMES_HOME_OVERRIDE.reset(token)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_hermes_home_override() -> str | None:
|
| 40 |
+
"""Return the active context-local Hermes home override, if any."""
|
| 41 |
+
override = _HERMES_HOME_OVERRIDE.get()
|
| 42 |
+
return str(override) if override is not _UNSET and override else None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _get_platform_default_hermes_home() -> Path:
|
| 46 |
+
"""Return the platform-native default Hermes home path."""
|
| 47 |
+
if sys.platform == "win32":
|
| 48 |
+
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
|
| 49 |
+
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
|
| 50 |
+
return base / "hermes"
|
| 51 |
+
return Path.home() / ".hermes"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def sudo_invoker_default_home() -> Path | None:
|
| 55 |
+
"""The invoking user's native ``~/.hermes`` when this process is root under ``sudo``, else None.
|
| 56 |
+
|
| 57 |
+
sudo strips HERMES_HOME and sets HOME=/root, so the process's own default is root's; the profile
|
| 58 |
+
store and the system service being operated on belong to SUDO_USER.
|
| 59 |
+
"""
|
| 60 |
+
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
| 61 |
+
return None
|
| 62 |
+
sudo_user = os.environ.get("SUDO_USER", "").strip()
|
| 63 |
+
if not sudo_user or sudo_user == "root":
|
| 64 |
+
return None
|
| 65 |
+
import pwd
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
return Path(pwd.getpwnam(sudo_user).pw_dir) / ".hermes"
|
| 69 |
+
except KeyError: # SUDO_USER not in passwd (chroot/container)
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _warn_profile_fallback_once() -> None:
|
| 74 |
+
"""Warn once when HERMES_HOME is unset but a non-default profile is sticky-active (wrong fallback)."""
|
| 75 |
+
global _profile_fallback_warned
|
| 76 |
+
if _profile_fallback_warned:
|
| 77 |
+
return
|
| 78 |
+
try:
|
| 79 |
+
fallback_home = _get_platform_default_hermes_home()
|
| 80 |
+
active_path = fallback_home / "active_profile"
|
| 81 |
+
active = active_path.read_text(encoding="utf-8").strip() if active_path.exists() else ""
|
| 82 |
+
except (UnicodeDecodeError, OSError):
|
| 83 |
+
active = ""
|
| 84 |
+
if active and active != "default":
|
| 85 |
+
_profile_fallback_warned = True
|
| 86 |
+
# Direct stderr, not logging: runs at import time (often before logging is
|
| 87 |
+
# configured) and root-logger propagation would double-emit.
|
| 88 |
+
msg = (
|
| 89 |
+
f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
|
| 90 |
+
f"profile is {active!r}. Falling back to {fallback_home}, which "
|
| 91 |
+
f"is the DEFAULT profile — not {active!r}. Any data this "
|
| 92 |
+
f"process writes will land in the wrong profile. The "
|
| 93 |
+
f"subprocess spawner should pass HERMES_HOME explicitly "
|
| 94 |
+
f"(see issue #18594)."
|
| 95 |
+
)
|
| 96 |
+
with contextlib.suppress(Exception):
|
| 97 |
+
sys.stderr.write(msg + "\n")
|
| 98 |
+
sys.stderr.flush()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_hermes_home() -> Path:
|
| 102 |
+
"""Hermes home: context-local override → ``HERMES_HOME`` env var → platform default."""
|
| 103 |
+
override = get_hermes_home_override()
|
| 104 |
+
if override:
|
| 105 |
+
return Path(override)
|
| 106 |
+
if not os.environ.get("HERMES_HOME", "").strip():
|
| 107 |
+
_warn_profile_fallback_once()
|
| 108 |
+
return get_process_hermes_home()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Resolved keys, keyed by the path string that was handed in. Path.resolve()
|
| 112 |
+
# is a filesystem call, and this function sits under every ToolRegistry
|
| 113 |
+
# lookup through current_scope_key(), so without this the registry pays a
|
| 114 |
+
# syscall per lookup. A process only ever sees a handful of home paths, so
|
| 115 |
+
# the dict stays tiny. Only paths that really exist are stored, see below.
|
| 116 |
+
_HOME_KEY_CACHE: dict[str, str] = {}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def hermes_home_key(path: str | Path | None = None) -> str:
|
| 120 |
+
"""Stable registry key for a Hermes home/profile dir.
|
| 121 |
+
|
| 122 |
+
``strict=False`` so profiles whose directories don't exist yet still get a key.
|
| 123 |
+
|
| 124 |
+
The resolved value is remembered per input path. A directory that does
|
| 125 |
+
not exist yet is resolved without touching the cache, because the answer
|
| 126 |
+
can change once it is created (e.g. part of the path turns out to be a symlink).
|
| 127 |
+
"""
|
| 128 |
+
candidate = Path(path) if path is not None else get_hermes_home()
|
| 129 |
+
raw = str(candidate)
|
| 130 |
+
cached = _HOME_KEY_CACHE.get(raw)
|
| 131 |
+
if cached is not None:
|
| 132 |
+
return cached
|
| 133 |
+
expanded = candidate.expanduser()
|
| 134 |
+
try:
|
| 135 |
+
resolved = expanded.resolve(strict=True)
|
| 136 |
+
except OSError:
|
| 137 |
+
# Not on disk yet: lenient resolve, not stored, so the real answer is
|
| 138 |
+
# picked up once the directory appears.
|
| 139 |
+
return os.path.normcase(str(expanded.resolve(strict=False)))
|
| 140 |
+
key = os.path.normcase(str(resolved))
|
| 141 |
+
_HOME_KEY_CACHE[raw] = key
|
| 142 |
+
return key
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def reset_hermes_home_key_cache() -> None:
|
| 146 |
+
"""Forget every remembered home key (for tests that move a home dir on disk)."""
|
| 147 |
+
_HOME_KEY_CACHE.clear()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_process_hermes_home() -> Path:
|
| 151 |
+
"""Hermes home of the running process, ignoring task overrides.
|
| 152 |
+
|
| 153 |
+
For process-level assets (theme YAML, dashboard plugin manifests) that must stay visible while a
|
| 154 |
+
request is scoped to another profile (e.g. embedded ``/chat`` under ``--open-profile``).
|
| 155 |
+
"""
|
| 156 |
+
val = os.environ.get("HERMES_HOME", "").strip()
|
| 157 |
+
return Path(val) if val else _get_platform_default_hermes_home()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# get_default_hermes_root() memo keyed on (native home, HERMES_HOME) so it stays
|
| 161 |
+
# fresh when a test or plugin mutates HERMES_HOME; saves ~80us/call at 31+ sites.
|
| 162 |
+
_default_hermes_root_memo: "tuple[str, str, Path] | None" = None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def get_default_hermes_root() -> Path:
|
| 166 |
+
"""Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""
|
| 167 |
+
global _default_hermes_root_memo
|
| 168 |
+
native_home = _get_platform_default_hermes_home()
|
| 169 |
+
env_home = os.environ.get("HERMES_HOME", "")
|
| 170 |
+
memo = _default_hermes_root_memo
|
| 171 |
+
if memo is not None and memo[:2] == (str(native_home), env_home):
|
| 172 |
+
return memo[2]
|
| 173 |
+
result = native_home
|
| 174 |
+
if env_home:
|
| 175 |
+
env_path = Path(env_home)
|
| 176 |
+
try:
|
| 177 |
+
env_path.resolve().relative_to(native_home.resolve()) # under ~/.hermes (normal or profile mode)
|
| 178 |
+
except ValueError: # Docker/custom root: <root>/profiles/<name> -> <root>, else HERMES_HOME itself
|
| 179 |
+
result = env_path.parent.parent if env_path.parent.name == "profiles" else env_path
|
| 180 |
+
_default_hermes_root_memo = (str(native_home), env_home, result)
|
| 181 |
+
return result
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# Tombstone lives beside the profile dir (not inside) so a stale mkdir or rmtree cannot erase it.
|
| 185 |
+
_DELETED_PROFILES_DIR = ".deleted"
|
| 186 |
+
# Files marking a real Hermes home; arbitrary dirs with a ``profiles`` segment lack them.
|
| 187 |
+
_HERMES_HOME_MARKERS = ("config.yaml", ".env", "state.db")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _is_hermes_profiles_root(profiles_dir: Path) -> bool:
|
| 191 |
+
"""True when *profiles_dir* is provably ``<hermes-home>/profiles``.
|
| 192 |
+
|
| 193 |
+
Accepts the classic ``~/.hermes`` layout, a root carrying Hermes-home marker files, a
|
| 194 |
+
``profiles/.deleted`` tombstone dir (only ``profile delete`` creates it), or the default root.
|
| 195 |
+
"""
|
| 196 |
+
root = profiles_dir.parent
|
| 197 |
+
if root.name == ".hermes":
|
| 198 |
+
return True
|
| 199 |
+
try:
|
| 200 |
+
if (profiles_dir / _DELETED_PROFILES_DIR).is_dir() or any(
|
| 201 |
+
(root / marker).exists() for marker in _HERMES_HOME_MARKERS
|
| 202 |
+
):
|
| 203 |
+
return True
|
| 204 |
+
except OSError:
|
| 205 |
+
pass
|
| 206 |
+
try:
|
| 207 |
+
return root.resolve(strict=False) == get_default_hermes_root().resolve(strict=False)
|
| 208 |
+
except OSError:
|
| 209 |
+
return False
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def named_profile_home(path: str | Path) -> Path | None:
|
| 213 |
+
"""Return ``<root>/profiles/<name>`` when *path* is that home or under it.
|
| 214 |
+
|
| 215 |
+
Requires ``<name>`` not to start with ``.`` and the ``profiles`` parent to be a real Hermes home;
|
| 216 |
+
a default home whose path merely contains a ``profiles`` segment is not a named profile.
|
| 217 |
+
"""
|
| 218 |
+
current = Path(path)
|
| 219 |
+
for candidate in (current, *current.parents):
|
| 220 |
+
if (candidate.parent.name == "profiles" and not candidate.name.startswith(".")
|
| 221 |
+
and _is_hermes_profiles_root(candidate.parent)):
|
| 222 |
+
return candidate
|
| 223 |
+
if candidate.name == ".hermes": # default home: a coincidental profiles/ ancestor is not a root
|
| 224 |
+
return None
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def profile_name_for_home(path: str | Path | None) -> str | None:
|
| 229 |
+
"""Return the canonical profile id owning *path*, or ``None`` when it is not a profile home.
|
| 230 |
+
|
| 231 |
+
The default home is the Hermes root itself, so its basename is an installation detail (``.hermes``
|
| 232 |
+
on POSIX and commonly ``hermes`` on Windows), not the profile id ``default``.
|
| 233 |
+
"""
|
| 234 |
+
if path is None or not str(path).strip():
|
| 235 |
+
return None
|
| 236 |
+
current = Path(path).expanduser()
|
| 237 |
+
try:
|
| 238 |
+
default_root = get_default_hermes_root()
|
| 239 |
+
for candidate in (current, current.resolve(strict=False)):
|
| 240 |
+
if candidate == default_root or candidate == default_root.resolve(strict=False):
|
| 241 |
+
return "default"
|
| 242 |
+
named = named_profile_home(candidate)
|
| 243 |
+
if named is not None:
|
| 244 |
+
return named.name
|
| 245 |
+
# A stored profile home is authoritative: its owner already resolved it, so the
|
| 246 |
+
# <root>/profiles/<name> shape names the profile even when <root> carries no markers.
|
| 247 |
+
if candidate.parent.name == "profiles" and not candidate.name.startswith("."):
|
| 248 |
+
return candidate.name
|
| 249 |
+
except (OSError, RuntimeError, ValueError):
|
| 250 |
+
return None
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def profile_tombstone_path(profile_home: Path) -> Path:
|
| 255 |
+
return profile_home.parent / _DELETED_PROFILES_DIR / profile_home.name
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def named_profile_is_deleted(profile_home: str | Path) -> bool:
|
| 259 |
+
return profile_tombstone_path(Path(profile_home)).exists()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def mark_named_profile_deleted(profile_home: str | Path) -> None:
|
| 263 |
+
marker = profile_tombstone_path(Path(profile_home))
|
| 264 |
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
| 265 |
+
marker.write_text("deleted\n", encoding="utf-8")
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def clear_named_profile_deleted(profile_home: str | Path) -> None:
|
| 269 |
+
profile_tombstone_path(Path(profile_home)).unlink(missing_ok=True)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def assert_named_profile_home_live(path: str | Path) -> None:
|
| 273 |
+
"""Refuse missing or tombstoned named profile homes."""
|
| 274 |
+
home = named_profile_home(path)
|
| 275 |
+
if home is not None and (named_profile_is_deleted(home) or not home.exists()):
|
| 276 |
+
raise FileNotFoundError(
|
| 277 |
+
f"Named profile home does not exist: {home}. "
|
| 278 |
+
"Create the profile explicitly before using it."
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def mkdir_under_hermes_home(path: str | Path) -> Path:
|
| 283 |
+
"""Create *path*, but never materialize a deleted/missing named profile."""
|
| 284 |
+
target = Path(path)
|
| 285 |
+
assert_named_profile_home_live(target)
|
| 286 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 287 |
+
return target
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _packaged_dir(env_var: str, default: Path | None, subdir: str) -> Path:
|
| 291 |
+
"""Resolve a package-manager-relocatable directory.
|
| 292 |
+
|
| 293 |
+
Order: *env_var* (Nix wrapper / explicit override) → caller ``default`` (source checkout) →
|
| 294 |
+
``<HERMES_HOME>/<subdir>``.
|
| 295 |
+
"""
|
| 296 |
+
override = os.getenv(env_var, "").strip()
|
| 297 |
+
return Path(override) if override else default if default is not None else get_hermes_home() / subdir
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
| 301 |
+
"""Return the optional-skills directory, honoring package-manager wrappers."""
|
| 302 |
+
return _packaged_dir("HERMES_OPTIONAL_SKILLS", default, "optional-skills")
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def get_optional_mcps_dir(default: Path | None = None) -> Path:
|
| 306 |
+
"""Return the optional-mcps directory, honoring package-manager wrappers (``HERMES_OPTIONAL_MCPS``)."""
|
| 307 |
+
return _packaged_dir("HERMES_OPTIONAL_MCPS", default, "optional-mcps")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def get_bundled_skills_dir(default: Path | None = None) -> Path:
|
| 311 |
+
"""Return the bundled skills directory, honoring package-manager wrappers (``HERMES_BUNDLED_SKILLS``)."""
|
| 312 |
+
return _packaged_dir("HERMES_BUNDLED_SKILLS", default, "skills")
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def get_hermes_dir(new_subpath: str, old_name: str, *, home: Path | None = None) -> Path:
|
| 316 |
+
"""Resolve a Hermes subdirectory, honouring a populated legacy ``<old_name>/`` (no migration).
|
| 317 |
+
|
| 318 |
+
An empty legacy dir does NOT count (install scaffolds, manual mkdir) so it cannot shadow the new path.
|
| 319 |
+
|
| 320 |
+
A bare empty ``<old_name>/`` directory does **not** count as "the legacy install is in use" — install
|
| 321 |
+
scaffolds, manual ``mkdir`` work, and cleared-then-abandoned locations all create empty stubs that would
|
| 322 |
+
otherwise silently shadow real data populated at ``<new_subpath>/``. See #27602 for the pairing-store
|
| 323 |
+
regression where a dormant empty ``pairing/`` orphaned approved-user data in ``platforms/pairing/``.
|
| 324 |
+
"""
|
| 325 |
+
home = home or get_hermes_home()
|
| 326 |
+
old_path = home / old_name
|
| 327 |
+
return old_path if _legacy_path_has_content(old_path) else home / new_subpath
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
|
| 331 |
+
"""Hermes-managed Node dirs in lookup order; both Windows and POSIX shapes so migrated installs work.
|
| 332 |
+
|
| 333 |
+
Keep in sync with hermesManagedNodePathEntries() in apps/desktop/electron/backend-env.ts.
|
| 334 |
+
"""
|
| 335 |
+
node_dir = (home or get_hermes_home()) / "node"
|
| 336 |
+
return [node_dir, node_dir / "bin"] if sys.platform == "win32" else [node_dir / "bin", node_dir]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
_WINDOWS_NODE_SHIMS = {
|
| 340 |
+
"npm": ["npm.cmd", "npm.exe", "npm"], "npx": ["npx.cmd", "npx.exe", "npx"], "node": ["node.exe", "node"],
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _candidate_node_command_names(command: str) -> list[str]:
|
| 345 |
+
base = Path(command).name
|
| 346 |
+
if sys.platform != "win32" or "." in base:
|
| 347 |
+
return [base]
|
| 348 |
+
# Prefer npm.cmd: PowerShell may block npm.ps1 by policy; CreateProcess cannot launch a bare .ps1.
|
| 349 |
+
return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base])
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _iter_managed_node_candidates(names: list[str], home: Path | None = None):
|
| 353 |
+
"""Yield existing (and on POSIX, executable) ``<node-dir>/<name>`` files."""
|
| 354 |
+
for directory in iter_hermes_node_dirs(home):
|
| 355 |
+
for name in names:
|
| 356 |
+
candidate = directory / name
|
| 357 |
+
if candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK)):
|
| 358 |
+
yield candidate
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _first_runnable_managed(names: list[str]) -> tuple[str | None, bool]:
|
| 362 |
+
"""Return ``(first runnable candidate, saw a broken one)``."""
|
| 363 |
+
broken = False
|
| 364 |
+
for candidate in map(str, _iter_managed_node_candidates(names)):
|
| 365 |
+
if node_tool_runnable(candidate):
|
| 366 |
+
return candidate, broken
|
| 367 |
+
broken = True
|
| 368 |
+
return None, broken
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _run_version_probe(argv: list[str], **kwargs):
|
| 372 |
+
"""Run a hidden ``--version`` probe; ``None`` when it cannot run."""
|
| 373 |
+
import subprocess
|
| 374 |
+
try:
|
| 375 |
+
from hermes_cli._subprocess_compat import windows_hide_flags
|
| 376 |
+
return subprocess.run(
|
| 377 |
+
argv, capture_output=True, timeout=10, creationflags=windows_hide_flags(), **kwargs
|
| 378 |
+
)
|
| 379 |
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
| 380 |
+
return None
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _version_probe_ok(path: str) -> bool:
|
| 384 |
+
"""True when ``<path> --version`` exits 0 under the Hermes-managed Node PATH."""
|
| 385 |
+
result = _run_version_probe([path, "--version"], env=with_hermes_node_path())
|
| 386 |
+
return result is not None and result.returncode == 0
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22"))
|
| 390 |
+
_managed_node_heal_attempted = False
|
| 391 |
+
_NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh"
|
| 392 |
+
|
| 393 |
+
# Install tree root (this file lives at <install_root>/hermes_constants.py). Used by secure_parent_dir() to
|
| 394 |
+
# skip chmod on the install dir — chmodding it 0700 breaks hermes-user traversal in Docker (UID 10000). See
|
| 395 |
+
# #25821, #93050.
|
| 396 |
+
_INSTALL_ROOT = Path(__file__).resolve().parent
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _is_executable_file(path: str) -> bool:
|
| 400 |
+
"""``exists()`` follows symlinks, so a dangling link never spawns a probe."""
|
| 401 |
+
return os.path.exists(path) and os.access(path, os.X_OK)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def node_tool_runnable(path: str | None) -> bool:
|
| 405 |
+
"""True only when *path* is a Node/npm/npx binary that actually runs (``--version`` probe)."""
|
| 406 |
+
if not path:
|
| 407 |
+
return False
|
| 408 |
+
present = Path(path).is_file() if sys.platform == "win32" else _is_executable_file(path)
|
| 409 |
+
return present and _version_probe_ok(path)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def hermes_managed_node_tree_present(home: Path | None = None) -> bool:
|
| 413 |
+
"""Return True when any Hermes-managed node/npm/npx shim exists on disk."""
|
| 414 |
+
names = [n for c in ("node", "npm", "npx") for n in _candidate_node_command_names(c)]
|
| 415 |
+
return next(_iter_managed_node_candidates(names, home), None) is not None
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _path_under_any(path: str, roots: list[str]) -> bool:
|
| 419 |
+
"""True when *path* sits inside one of *roots* (same drive).
|
| 420 |
+
|
| 421 |
+
Compared via ``normcase``: psutil and env vars can disagree on Windows drive-letter casing.
|
| 422 |
+
"""
|
| 423 |
+
path_norm = os.path.normcase(os.path.normpath(path))
|
| 424 |
+
for root in roots:
|
| 425 |
+
root_norm = os.path.normcase(os.path.normpath(root))
|
| 426 |
+
try:
|
| 427 |
+
if os.path.commonpath([path_norm, root_norm]) == root_norm:
|
| 428 |
+
return True
|
| 429 |
+
except ValueError: # different drives on Windows
|
| 430 |
+
continue
|
| 431 |
+
return False
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def managed_node_tree_in_use(home: Path | None = None) -> bool:
|
| 435 |
+
"""True when a running process executes from the managed Node tree.
|
| 436 |
+
|
| 437 |
+
Windows locks running executables against delete/overwrite, so the updater must not rewrite
|
| 438 |
+
``%HERMES_HOME%\\node`` while the desktop app holds it (``[WinError 5]`` on ``npm.cmd``).
|
| 439 |
+
|
| 440 |
+
Always ``False`` on POSIX, which has no equivalent lock semantics. See #80926.
|
| 441 |
+
"""
|
| 442 |
+
if sys.platform != "win32":
|
| 443 |
+
return False
|
| 444 |
+
try:
|
| 445 |
+
import psutil
|
| 446 |
+
except Exception:
|
| 447 |
+
return False
|
| 448 |
+
dirs: list[str] = []
|
| 449 |
+
for directory in iter_hermes_node_dirs(home):
|
| 450 |
+
try:
|
| 451 |
+
dirs.append(str(Path(directory).resolve()))
|
| 452 |
+
except OSError:
|
| 453 |
+
continue
|
| 454 |
+
if not dirs:
|
| 455 |
+
return False
|
| 456 |
+
try:
|
| 457 |
+
procs = psutil.process_iter(["exe", "cmdline"])
|
| 458 |
+
except Exception:
|
| 459 |
+
return False
|
| 460 |
+
for proc in procs:
|
| 461 |
+
try:
|
| 462 |
+
info = proc.info
|
| 463 |
+
except Exception:
|
| 464 |
+
continue
|
| 465 |
+
exe = info.get("exe")
|
| 466 |
+
if exe:
|
| 467 |
+
try:
|
| 468 |
+
exe = str(Path(exe).resolve())
|
| 469 |
+
except (OSError, ValueError):
|
| 470 |
+
exe = str(exe)
|
| 471 |
+
if any(_path_under_any(p, dirs) for p in ([exe] if exe else []) + list(info.get("cmdline") or [])):
|
| 472 |
+
return True
|
| 473 |
+
return False
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
_managed_node_in_use_notice_printed = False
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _print_managed_node_in_use_notice() -> None:
|
| 480 |
+
"""Print the managed-Node deferral notice once per process."""
|
| 481 |
+
global _managed_node_in_use_notice_printed
|
| 482 |
+
if _managed_node_in_use_notice_printed:
|
| 483 |
+
return
|
| 484 |
+
_managed_node_in_use_notice_printed = True
|
| 485 |
+
print(
|
| 486 |
+
"→ Hermes-managed Node.js is in use by a running app; deferring its "
|
| 487 |
+
"upgrade until the app is closed (re-run `hermes update` afterwards).", flush=True,
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _fetch_url(url: str, timeout: int) -> bytes | None:
|
| 492 |
+
import urllib.request
|
| 493 |
+
|
| 494 |
+
try:
|
| 495 |
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
| 496 |
+
return response.read()
|
| 497 |
+
except OSError:
|
| 498 |
+
return None
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def _stage_windows_node_zip(home: Path, node_arch: str) -> Path | None:
|
| 502 |
+
"""Download the target-major portable Node zip into a sibling ``node.new-*`` dir.
|
| 503 |
+
|
| 504 |
+
A sibling makes the later swap a same-volume rename. ``None`` on any failure.
|
| 505 |
+
"""
|
| 506 |
+
import tempfile
|
| 507 |
+
import uuid
|
| 508 |
+
import zipfile
|
| 509 |
+
|
| 510 |
+
index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/"
|
| 511 |
+
index_bytes = _fetch_url(index_url, 60)
|
| 512 |
+
if index_bytes is None:
|
| 513 |
+
return None
|
| 514 |
+
pattern = rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip"
|
| 515 |
+
match = re.search(pattern, index_bytes.decode("utf-8", errors="replace"))
|
| 516 |
+
if not match:
|
| 517 |
+
return None
|
| 518 |
+
zip_name = match.group(0)
|
| 519 |
+
zip_bytes = _fetch_url(f"{index_url}{zip_name}", 300)
|
| 520 |
+
if zip_bytes is None:
|
| 521 |
+
return None
|
| 522 |
+
staged = home / f"node.new-{uuid.uuid4().hex[:8]}"
|
| 523 |
+
try:
|
| 524 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 525 |
+
tmp_path = Path(tmp_dir)
|
| 526 |
+
(tmp_path / zip_name).write_bytes(zip_bytes)
|
| 527 |
+
extract_dir = tmp_path / "extract"
|
| 528 |
+
extract_dir.mkdir()
|
| 529 |
+
with zipfile.ZipFile(tmp_path / zip_name) as archive:
|
| 530 |
+
archive.extractall(extract_dir)
|
| 531 |
+
extracted = next(extract_dir.glob("node-v*"), None)
|
| 532 |
+
if extracted is None or not extracted.is_dir():
|
| 533 |
+
return None
|
| 534 |
+
shutil.move(str(extracted), str(staged))
|
| 535 |
+
except OSError:
|
| 536 |
+
return None
|
| 537 |
+
return staged
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _swap_node_tree(target: Path, staged: Path) -> bool | None:
|
| 541 |
+
"""Rename the live tree aside (``node.old-*``) and *staged* into place.
|
| 542 |
+
|
| 543 |
+
``None`` when the OS refuses to move the live tree (in use; untouched, retried next time),
|
| 544 |
+
``False`` when the staged tree cannot be moved in (live tree rolled back).
|
| 545 |
+
"""
|
| 546 |
+
backup = target.parent / f"node.old-{staged.name.removeprefix('node.new-')}"
|
| 547 |
+
had_live = target.exists()
|
| 548 |
+
if had_live:
|
| 549 |
+
try:
|
| 550 |
+
os.replace(str(target), str(backup))
|
| 551 |
+
except OSError:
|
| 552 |
+
_print_managed_node_in_use_notice()
|
| 553 |
+
shutil.rmtree(staged, ignore_errors=True)
|
| 554 |
+
return None
|
| 555 |
+
# Rename preserves mtime: touch the backup (best-effort) so a concurrent heal's
|
| 556 |
+
# litter sweep never removes it mid-swap.
|
| 557 |
+
with contextlib.suppress(OSError):
|
| 558 |
+
os.utime(backup, None)
|
| 559 |
+
try:
|
| 560 |
+
os.replace(str(staged), str(target))
|
| 561 |
+
except OSError:
|
| 562 |
+
if had_live: # roll the live tree back
|
| 563 |
+
with contextlib.suppress(OSError):
|
| 564 |
+
os.replace(str(backup), str(target))
|
| 565 |
+
shutil.rmtree(staged, ignore_errors=True)
|
| 566 |
+
return False
|
| 567 |
+
if had_live:
|
| 568 |
+
# Locked files may keep the old tree on disk until the next heal; safe.
|
| 569 |
+
shutil.rmtree(backup, ignore_errors=True)
|
| 570 |
+
return True
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def _heal_managed_node_windows(home: Path | None = None) -> bool | None:
|
| 574 |
+
"""Redownload the portable Node zip into ``%HERMES_HOME%\\node`` on Windows.
|
| 575 |
+
|
| 576 |
+
``True`` on success, ``False`` on genuine failure (offline, bad archive), ``None`` when the
|
| 577 |
+
tree is in use and the heal is deferred — callers must not record the once-per-process attempt
|
| 578 |
+
for ``None``. Staging-first (extract to ``node.new-*``, rename live aside, rename staged in) so
|
| 579 |
+
an interrupted heal cannot gut the install; a refused rename *is* the in-use signal.
|
| 580 |
+
|
| 581 |
+
The replacement is staging-first: the new tree is fully downloaded and extracted to a sibling
|
| 582 |
+
``node.new-*`` directory, then the live tree is renamed aside (``node.old-*``) and the staged tree
|
| 583 |
+
renamed into place. The live tree is never deleted before its replacement is ready, so an interrupted
|
| 584 |
+
heal cannot gut the running installation. Windows allows renaming a tree whose executables are running
|
| 585 |
+
(images are mapped with ``FILE_SHARE_DELETE`` — the same mechanism as the hermes.exe quarantine); when
|
| 586 |
+
the OS refuses the rename, that refusal *is* the in-use signal and the heal defers instead of forcing
|
| 587 |
+
the write and crashing with ``PermissionError: [WinError 5]`` on ``npm.cmd`` (#80926).
|
| 588 |
+
"""
|
| 589 |
+
import time
|
| 590 |
+
|
| 591 |
+
arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower()
|
| 592 |
+
node_arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "x86": "x86"}.get(arch)
|
| 593 |
+
if node_arch is None:
|
| 594 |
+
return False
|
| 595 |
+
home = home or get_hermes_home()
|
| 596 |
+
target = home / "node"
|
| 597 |
+
# Cheap pre-check; the rename-based swap is the authoritative guard.
|
| 598 |
+
if managed_node_tree_in_use(home):
|
| 599 |
+
_print_managed_node_in_use_notice()
|
| 600 |
+
return None
|
| 601 |
+
# Sweep litter from interrupted runs; only dirs >10 min old so a concurrent in-flight swap survives.
|
| 602 |
+
cutoff = time.time() - 600
|
| 603 |
+
for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")):
|
| 604 |
+
try:
|
| 605 |
+
if stale.stat().st_mtime < cutoff:
|
| 606 |
+
shutil.rmtree(stale, ignore_errors=True)
|
| 607 |
+
except OSError:
|
| 608 |
+
continue
|
| 609 |
+
staged = _stage_windows_node_zip(home, node_arch)
|
| 610 |
+
if staged is None:
|
| 611 |
+
return False
|
| 612 |
+
return _swap_node_tree(target, staged) and node_tool_runnable(str(target / "node.exe"))
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _run_node_bootstrap(func: str, *, timeout: int, **extra_env: str) -> bool:
|
| 616 |
+
"""Source ``scripts/lib/node-bootstrap.sh`` and run shell function *func*."""
|
| 617 |
+
if not _NODE_BOOTSTRAP_SCRIPT.is_file():
|
| 618 |
+
return False
|
| 619 |
+
import subprocess
|
| 620 |
+
try:
|
| 621 |
+
result = subprocess.run(
|
| 622 |
+
["bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && {func}'],
|
| 623 |
+
env={**os.environ, "HERMES_HOME": str(get_hermes_home()), **extra_env},
|
| 624 |
+
capture_output=True, timeout=timeout, check=False,
|
| 625 |
+
)
|
| 626 |
+
except (OSError, subprocess.SubprocessError):
|
| 627 |
+
return False
|
| 628 |
+
return result.returncode == 0
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def bootstrap_hermes_managed_node() -> str | None:
|
| 632 |
+
"""Install a Hermes-managed Node tree under ``$HERMES_HOME/node`` and return its npm path.
|
| 633 |
+
|
| 634 |
+
Hermes never modifies a user-owned toolchain (system, nvm, brew, Nix) that fails ``engines``.
|
| 635 |
+
"""
|
| 636 |
+
existing = find_hermes_node_executable("npm")
|
| 637 |
+
if existing:
|
| 638 |
+
return existing
|
| 639 |
+
if sys.platform == "win32":
|
| 640 |
+
ok = _heal_managed_node_windows()
|
| 641 |
+
else:
|
| 642 |
+
# HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin: never shadow the user toolchain.
|
| 643 |
+
ok = _run_node_bootstrap("_nb_install_bundled_node", timeout=600, HERMES_NODE_SKIP_LINKS="1")
|
| 644 |
+
if not ok:
|
| 645 |
+
return None
|
| 646 |
+
return _first_runnable_managed(_candidate_node_command_names("npm"))[0]
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def heal_hermes_managed_node() -> bool:
|
| 650 |
+
"""Redownload Hermes-managed Node when the tree exists but is broken; at most once per process.
|
| 651 |
+
|
| 652 |
+
A Windows in-use deferral does NOT record the attempt so a later call can heal once free.
|
| 653 |
+
|
| 654 |
+
POSIX installs shell out to ``heal_managed_node`` in ``scripts/lib/node-bootstrap.sh``; Windows
|
| 655 |
+
downloads the portable zip directly (same source as ``install.ps1``). See #80926.
|
| 656 |
+
"""
|
| 657 |
+
global _managed_node_heal_attempted
|
| 658 |
+
if _managed_node_heal_attempted or not hermes_managed_node_tree_present():
|
| 659 |
+
return False
|
| 660 |
+
if sys.platform == "win32":
|
| 661 |
+
result = _heal_managed_node_windows()
|
| 662 |
+
else:
|
| 663 |
+
result = _run_node_bootstrap("heal_managed_node", timeout=300)
|
| 664 |
+
if result is None: # in-use deferral: leave the attempt flag clear
|
| 665 |
+
return False
|
| 666 |
+
_managed_node_heal_attempted = True
|
| 667 |
+
return bool(result)
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def _managed_node_tree_outdated(home: Path | None = None) -> bool:
|
| 671 |
+
"""True when the managed node runs but is below the target major (heals like a broken tree)."""
|
| 672 |
+
for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home):
|
| 673 |
+
result = _run_version_probe([str(candidate), "--version"])
|
| 674 |
+
if result is None:
|
| 675 |
+
return False # broken, not outdated — the runnable probe handles it
|
| 676 |
+
try:
|
| 677 |
+
version = result.stdout.decode().strip().lstrip("v")
|
| 678 |
+
major = int(version.split(".")[0])
|
| 679 |
+
except (ValueError, IndexError):
|
| 680 |
+
return False
|
| 681 |
+
# A pre-release is outdated whatever its major: nodejs.org publishes headers only for
|
| 682 |
+
# final releases, so node-gyp cannot build node-pty. Mirrors node_satisfies_build() in install.sh.
|
| 683 |
+
if "-" in version:
|
| 684 |
+
return True
|
| 685 |
+
return major < _HERMES_NODE_TARGET_MAJOR
|
| 686 |
+
return False
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def find_hermes_node_executable(command: str) -> str | None:
|
| 690 |
+
"""Hermes-managed Node/npm path, healing broken/outdated trees; heal failure still returns old Node."""
|
| 691 |
+
names = _candidate_node_command_names(command)
|
| 692 |
+
resolved, broken_present = _first_runnable_managed(names)
|
| 693 |
+
needs_heal = broken_present or (resolved is not None and _managed_node_tree_outdated())
|
| 694 |
+
if needs_heal and heal_hermes_managed_node():
|
| 695 |
+
healed, _ = _first_runnable_managed(names)
|
| 696 |
+
if healed:
|
| 697 |
+
return healed
|
| 698 |
+
return resolved
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def find_node_executable_on_path(command: str) -> str | None:
|
| 702 |
+
"""Node/npm from PATH; on Windows prefer ``.cmd``/``.exe`` (CreateProcess cannot run the bare shim)."""
|
| 703 |
+
if sys.platform != "win32":
|
| 704 |
+
return shutil.which(command)
|
| 705 |
+
command_str = str(command)
|
| 706 |
+
if any(sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\")):
|
| 707 |
+
return command_str if Path(command_str).is_file() else None
|
| 708 |
+
directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d]
|
| 709 |
+
for name in _candidate_node_command_names(command_str):
|
| 710 |
+
for directory in directories:
|
| 711 |
+
if (Path(directory) / name).is_file():
|
| 712 |
+
return str(Path(directory) / name)
|
| 713 |
+
return None
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def find_node_executable(command: str) -> str | None:
|
| 717 |
+
"""Resolve a Node command, preferring a healthy managed install.
|
| 718 |
+
|
| 719 |
+
A managed tree that exists but cannot be healed yields ``None`` rather than system Node.
|
| 720 |
+
"""
|
| 721 |
+
managed = find_hermes_node_executable(command)
|
| 722 |
+
if managed:
|
| 723 |
+
return managed
|
| 724 |
+
if hermes_managed_node_tree_present():
|
| 725 |
+
return None
|
| 726 |
+
return find_node_executable_on_path(command)
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
|
| 730 |
+
"""Return *env* with Hermes-managed Node directories prepended to PATH."""
|
| 731 |
+
merged = dict(os.environ if env is None else env)
|
| 732 |
+
parts = [p for p in merged.get("PATH", "").split(os.pathsep) if p]
|
| 733 |
+
for entry in reversed([str(path) for path in iter_hermes_node_dirs() if path.is_dir()]):
|
| 734 |
+
if entry not in parts:
|
| 735 |
+
parts.insert(0, entry)
|
| 736 |
+
merged["PATH"] = os.pathsep.join(parts)
|
| 737 |
+
return merged
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def agent_browser_runnable(path: str | None) -> bool:
|
| 741 |
+
"""True when *path* is an agent-browser CLI that runs (``--version`` exits 0) or the npx fallback.
|
| 742 |
+
|
| 743 |
+
Dead/wrong-arch/hung binaries are rejected so callers try the next candidate.
|
| 744 |
+
|
| 745 |
+
A bare presence check (``shutil.which`` / ``Path.exists``) is not enough: agent-browser's npm
|
| 746 |
+
``postinstall`` re-points a *global* install symlink (e.g. ``/opt/homebrew/bin/agent-browser``) at our
|
| 747 |
+
local ``node_modules/agent-browser/bin/...`` binary, which then disappears on the next ``hermes update``
|
| 748 |
+
— leaving a **dangling symlink** that ``which`` still reports but exec fails on with exit 127 (issue
|
| 749 |
+
#48521). Callers that trust such a path silently break every browser tool.
|
| 750 |
+
"""
|
| 751 |
+
if not path:
|
| 752 |
+
return False
|
| 753 |
+
# The npx fallback is a two-token command string, not a path; npx validates at run time.
|
| 754 |
+
if " " in path and path.split()[0].endswith("npx"):
|
| 755 |
+
return True
|
| 756 |
+
return _is_executable_file(path) and _version_probe_ok(path)
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def _legacy_path_has_content(path: Path) -> bool:
|
| 760 |
+
"""True iff *path* is a non-directory file or a populated directory.
|
| 761 |
+
|
| 762 |
+
Non-not-found ``OSError`` means "assume occupied" (never orphan legacy data). Symlinks are
|
| 763 |
+
judged on their target; a dangling one does NOT count.
|
| 764 |
+
"""
|
| 765 |
+
try:
|
| 766 |
+
st = path.lstat()
|
| 767 |
+
if stat.S_ISLNK(st.st_mode):
|
| 768 |
+
st = path.stat() # judge a symlink on its target; dangling → FileNotFoundError
|
| 769 |
+
except FileNotFoundError:
|
| 770 |
+
return False
|
| 771 |
+
except OSError: # e.g. PermissionError on a parent: assume occupied
|
| 772 |
+
return True
|
| 773 |
+
if not stat.S_ISDIR(st.st_mode):
|
| 774 |
+
return True
|
| 775 |
+
try:
|
| 776 |
+
next(path.iterdir())
|
| 777 |
+
except StopIteration:
|
| 778 |
+
return False
|
| 779 |
+
except OSError:
|
| 780 |
+
pass
|
| 781 |
+
return True
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def display_hermes_home() -> str:
|
| 785 |
+
"""User-facing ``~/`` display string for HERMES_HOME (``~/.hermes/profiles/coder``)."""
|
| 786 |
+
home = get_hermes_home()
|
| 787 |
+
try: # as_posix(): str() on Windows yields chimeras like ~/AppData\Local\hermes/skills/
|
| 788 |
+
return "~/" + home.relative_to(Path.home()).as_posix()
|
| 789 |
+
except ValueError:
|
| 790 |
+
return str(home)
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def profile_cli_selector() -> str:
|
| 794 |
+
"""``-p <name> `` (trailing space) pinning copy-pasteable ``hermes ...`` guidance to the
|
| 795 |
+
active NAMED profile, else ``""``: a bare ``hermes`` follows the sticky ``active_profile``
|
| 796 |
+
file, which can name a different database than the one that failed (#105887). A custom
|
| 797 |
+
home outside the profile tree has no selector (only HERMES_HOME names it)."""
|
| 798 |
+
name = profile_name_for_home(get_hermes_home())
|
| 799 |
+
return f"-p {name} " if name and name != "default" else ""
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
def secure_parent_dir(path: Path) -> None:
|
| 803 |
+
"""Chmod ``0o700`` on *path*'s parent, refusing ``/`` and top-level dirs (misresolved HERMES_HOME)."""
|
| 804 |
+
parent = path.parent.resolve()
|
| 805 |
+
if parent == Path("/") or len(parent.parts) < 3:
|
| 806 |
+
return
|
| 807 |
+
# Refuse the install tree: chmod 0700 breaks hermes-user traversal in Docker (UID 10000).
|
| 808 |
+
# A credential file here means HERMES_HOME misresolved; surface it (caused production lockouts).
|
| 809 |
+
# See #25821, #93050.
|
| 810 |
+
if parent == _INSTALL_ROOT or _INSTALL_ROOT in parent.parents:
|
| 811 |
+
import logging
|
| 812 |
+
|
| 813 |
+
logging.getLogger(__name__).warning(
|
| 814 |
+
"Not restricting permissions on %s: it is inside the "
|
| 815 |
+
"hermes-agent install directory (%s). Credential files are "
|
| 816 |
+
"normally stored under the hermes home directory instead.", parent, _INSTALL_ROOT,
|
| 817 |
+
)
|
| 818 |
+
return
|
| 819 |
+
with contextlib.suppress(OSError):
|
| 820 |
+
os.chmod(parent, 0o700)
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
def _norm_home_path(path: str | None) -> str:
|
| 824 |
+
"""Return a comparable absolute path string, or ``""`` for empty input."""
|
| 825 |
+
raw = (path or "").strip()
|
| 826 |
+
if not raw:
|
| 827 |
+
return ""
|
| 828 |
+
try:
|
| 829 |
+
return os.path.normcase(os.path.abspath(os.path.expanduser(raw)))
|
| 830 |
+
except Exception:
|
| 831 |
+
return os.path.normcase(raw)
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def _profile_home_path(env: dict[str, str] | None = None) -> str | None:
|
| 835 |
+
"""Return ``{HERMES_HOME}/home`` when the profile-home directory exists."""
|
| 836 |
+
hermes_home = get_hermes_home_override() or (env or {}).get("HERMES_HOME") or os.getenv("HERMES_HOME")
|
| 837 |
+
if not hermes_home:
|
| 838 |
+
return None
|
| 839 |
+
profile_home = os.path.join(hermes_home, "home")
|
| 840 |
+
return profile_home if os.path.isdir(profile_home) else None
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def _is_profile_home(candidate: str | None, profile_home: str | None) -> bool:
|
| 844 |
+
return bool(candidate and profile_home and _norm_home_path(candidate) == _norm_home_path(profile_home))
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
def _env_get(env: dict[str, str], key: str, default: str = "") -> str:
|
| 848 |
+
"""Stripped *key* from *env*, falling back to the process environment."""
|
| 849 |
+
return str(env.get(key) or os.getenv(key, default)).strip()
|
| 850 |
+
|
| 851 |
+
|
| 852 |
+
def _iter_real_home_candidates(env: dict[str, str] | None = None) -> list[str]:
|
| 853 |
+
"""Return likely OS-user home candidates in trust order."""
|
| 854 |
+
env = env or {}
|
| 855 |
+
candidates = [_env_get(env, "HERMES_REAL_HOME"), _env_get(env, "HOME")]
|
| 856 |
+
with contextlib.suppress(Exception):
|
| 857 |
+
import pwd
|
| 858 |
+
candidates.append(pwd.getpwuid(os.getuid()).pw_dir.strip()) # windows-footgun: ok — POSIX-only module inside try/except
|
| 859 |
+
candidates.append(_env_get(env, "USERPROFILE"))
|
| 860 |
+
drive, path = _env_get(env, "HOMEDRIVE"), _env_get(env, "HOMEPATH")
|
| 861 |
+
if drive and path:
|
| 862 |
+
candidates.append(f"{drive}{path}" if path.startswith(("\\", "/")) else os.path.join(drive, path))
|
| 863 |
+
expanded = os.path.expanduser("~")
|
| 864 |
+
if expanded != "~":
|
| 865 |
+
candidates.append(expanded)
|
| 866 |
+
return [c for c in candidates if c]
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
def get_real_home(env: dict[str, str] | None = None) -> str:
|
| 870 |
+
"""The OS user's real home, avoiding the Hermes profile HOME.
|
| 871 |
+
|
| 872 |
+
``HOME`` belongs to the OS account and external CLIs keeping credentials under ``~``; a parent
|
| 873 |
+
already running with ``HOME={HERMES_HOME}/home`` is repaired back when possible.
|
| 874 |
+
"""
|
| 875 |
+
profile_home = _profile_home_path(env)
|
| 876 |
+
seen: set[str] = set()
|
| 877 |
+
for candidate in _iter_real_home_candidates(env):
|
| 878 |
+
key = _norm_home_path(candidate)
|
| 879 |
+
if not key or key in seen:
|
| 880 |
+
continue
|
| 881 |
+
seen.add(key)
|
| 882 |
+
if not _is_profile_home(candidate, profile_home):
|
| 883 |
+
return candidate
|
| 884 |
+
return "/tmp"
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
_HOME_MODE_ALIASES = {"isolated": "profile", "profile_home": "profile", "profile-home": "profile",
|
| 888 |
+
"host": "real", "user": "real", "real_home": "real", "real-home": "real"}
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def get_subprocess_home(env: dict[str, str] | None = None) -> str | None:
|
| 892 |
+
"""Subprocess ``HOME`` override, or ``None``.
|
| 893 |
+
|
| 894 |
+
``auto``: hosts keep real HOME (repairing a profile-home parent), containers use
|
| 895 |
+
``{HERMES_HOME}/home``; ``real``: always real HOME; ``profile``: always the profile home.
|
| 896 |
+
"""
|
| 897 |
+
env = env or {}
|
| 898 |
+
profile_home = _profile_home_path(env)
|
| 899 |
+
mode = _env_get(env, "TERMINAL_HOME_MODE", "auto").lower() or "auto"
|
| 900 |
+
mode = _HOME_MODE_ALIASES.get(mode, mode)
|
| 901 |
+
|
| 902 |
+
if mode == "profile":
|
| 903 |
+
return profile_home
|
| 904 |
+
real_home = get_real_home(env)
|
| 905 |
+
current_home = _env_get(env, "HOME")
|
| 906 |
+
repaired = real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None
|
| 907 |
+
if mode == "real":
|
| 908 |
+
return repaired
|
| 909 |
+
|
| 910 |
+
if profile_home and is_container():
|
| 911 |
+
return profile_home
|
| 912 |
+
if _is_profile_home(current_home, profile_home):
|
| 913 |
+
return repaired
|
| 914 |
+
return None
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def apply_subprocess_home_env(env: dict[str, str]) -> None:
|
| 918 |
+
"""Apply Hermes' subprocess HOME contract to *env* in-place."""
|
| 919 |
+
real_home = get_real_home(env)
|
| 920 |
+
if real_home:
|
| 921 |
+
env["HERMES_REAL_HOME"] = real_home
|
| 922 |
+
home = get_subprocess_home(env)
|
| 923 |
+
if home:
|
| 924 |
+
env["HOME"] = home
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def parse_reasoning_effort(effort) -> dict | None:
|
| 931 |
+
"""Parse a reasoning effort level into a config dict.
|
| 932 |
+
|
| 933 |
+
``None`` for empty/unrecognized input (caller uses the default); ``{"enabled": False}`` for
|
| 934 |
+
"none"/"false"/"disabled"/YAML False — ``reasoning_effort: false`` must mean disabled.
|
| 935 |
+
"""
|
| 936 |
+
if effort is None or effort is True:
|
| 937 |
+
return None
|
| 938 |
+
effort = str(effort).strip().lower() # False -> "false" -> disabled; "" matches neither set
|
| 939 |
+
if effort in {"none", "false", "disabled"}:
|
| 940 |
+
return {"enabled": False}
|
| 941 |
+
if effort in VALID_REASONING_EFFORTS:
|
| 942 |
+
return {"enabled": True, "effort": effort}
|
| 943 |
+
return None
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
def _canonical_model_variants(model: str) -> list[str]:
|
| 947 |
+
"""Spelling variants for tolerant override matching, exact first, deduped in order.
|
| 948 |
+
|
| 949 |
+
Dot/dash recovery runs on EACH base form so ``x-4.5``, ``x-4-5`` and ``x.4.5`` share one variant set.
|
| 950 |
+
"""
|
| 951 |
+
_dash_to_dot = lambda s: re.sub(r'(\d)-(\d)', r'\1.\2', s)
|
| 952 |
+
_dot_to_dash = lambda s: re.sub(r'(\d)\.(\d)', r'\1-\2', s)
|
| 953 |
+
seen: set[str] = set()
|
| 954 |
+
variants: list[str] = []
|
| 955 |
+
|
| 956 |
+
def _add(*values):
|
| 957 |
+
for v in values:
|
| 958 |
+
if v and v not in seen:
|
| 959 |
+
seen.add(v)
|
| 960 |
+
variants.append(v)
|
| 961 |
+
|
| 962 |
+
def _add_with_derivatives(s):
|
| 963 |
+
dashed, dotted = s.replace('.', '-'), s.replace('-', '.')
|
| 964 |
+
_add(s, dashed, dotted, _dash_to_dot(s), _dot_to_dash(s), _dash_to_dot(dashed), _dot_to_dash(dotted))
|
| 965 |
+
_add_with_derivatives(model)
|
| 966 |
+
parts = model.split('/')
|
| 967 |
+
if len(parts) >= 2: # bare model (strip provider/aggregator prefix)
|
| 968 |
+
_add_with_derivatives(parts[-1])
|
| 969 |
+
if len(parts) >= 3: # strip aggregator only: "openrouter/anthropic/x" → "anthropic/x"
|
| 970 |
+
_add_with_derivatives('/'.join(parts[1:]))
|
| 971 |
+
known_providers = (
|
| 972 |
+
'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral',
|
| 973 |
+
'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek',
|
| 974 |
+
)
|
| 975 |
+
for v in [v for v in variants if '/' not in v]:
|
| 976 |
+
_add(*(f"{provider}/{v}" for provider in known_providers))
|
| 977 |
+
known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together')
|
| 978 |
+
for v in [v for v in variants if v.count('/') == 1]:
|
| 979 |
+
_add(*(f"{agg}/{v}" for agg in known_aggregators))
|
| 980 |
+
return variants
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None:
|
| 984 |
+
"""Per-model reasoning_effort override with spelling tolerance; first non-None parse wins.
|
| 985 |
+
|
| 986 |
+
Order: exact → dots↔dashes → provider stripped → aggregator stripped → known prefixes added.
|
| 987 |
+
"""
|
| 988 |
+
if not overrides or not isinstance(overrides, dict) or not model:
|
| 989 |
+
return None
|
| 990 |
+
for variant in _canonical_model_variants(model):
|
| 991 |
+
if variant in overrides:
|
| 992 |
+
result = parse_reasoning_effort(overrides[variant])
|
| 993 |
+
if result is not None:
|
| 994 |
+
return result
|
| 995 |
+
return None
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
def resolve_per_model_provider_routing(model: str, models: dict | None) -> dict:
|
| 999 |
+
"""``provider_routing.models.<id>`` entry for *model*, spelling-tolerant like
|
| 1000 |
+
``reasoning_overrides``; ``{}`` when none matches. Only the keys a user sets per model
|
| 1001 |
+
are returned so unset ones fall through to the flat ``provider_routing`` values."""
|
| 1002 |
+
if not model or not isinstance(models, dict):
|
| 1003 |
+
return {}
|
| 1004 |
+
for variant in _canonical_model_variants(model):
|
| 1005 |
+
entry = models.get(variant)
|
| 1006 |
+
if isinstance(entry, dict):
|
| 1007 |
+
return entry
|
| 1008 |
+
return {}
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
|
| 1012 |
+
"""Effective reasoning config for *model*: per-model override, then global ``agent.reasoning_effort``.
|
| 1013 |
+
|
| 1014 |
+
Single chokepoint for every surface (CLI, gateway, TUI, cron, ``/model``, fallback activation).
|
| 1015 |
+
"""
|
| 1016 |
+
cfg = cfg if isinstance(cfg, dict) else {}
|
| 1017 |
+
agent_cfg = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
|
| 1018 |
+
|
| 1019 |
+
if not model:
|
| 1020 |
+
model_cfg = cfg.get("model")
|
| 1021 |
+
if isinstance(model_cfg, dict):
|
| 1022 |
+
model_cfg = model_cfg.get("default") or model_cfg.get("model") or ""
|
| 1023 |
+
model = model_cfg.strip() if isinstance(model_cfg, str) else ""
|
| 1024 |
+
per_model = resolve_per_model_reasoning_effort(model, agent_cfg.get("reasoning_overrides") or {})
|
| 1025 |
+
if per_model is not None:
|
| 1026 |
+
return per_model
|
| 1027 |
+
|
| 1028 |
+
# Keep the raw value: ``or ""`` would turn a YAML False into "" and silently re-enable thinking.
|
| 1029 |
+
effort = agent_cfg.get("reasoning_effort", "")
|
| 1030 |
+
result = parse_reasoning_effort(effort)
|
| 1031 |
+
if effort and str(effort).strip() and result is None:
|
| 1032 |
+
import logging
|
| 1033 |
+
logging.getLogger(__name__).warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
| 1034 |
+
return result
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
def is_termux() -> bool:
|
| 1038 |
+
"""True inside Termux (Android): ``TERMUX_VERSION`` or the Termux-specific ``PREFIX`` path."""
|
| 1039 |
+
prefix = os.getenv("PREFIX", "")
|
| 1040 |
+
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
| 1041 |
+
|
| 1042 |
+
|
| 1043 |
+
_wsl_detected: bool | None = None
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
def is_wsl() -> bool:
|
| 1047 |
+
"""True inside WSL1/WSL2 (``microsoft`` marker in ``/proc/version``); cached per process."""
|
| 1048 |
+
global _wsl_detected
|
| 1049 |
+
if _wsl_detected is None:
|
| 1050 |
+
try:
|
| 1051 |
+
with open("/proc/version", "r", encoding="utf-8") as f:
|
| 1052 |
+
_wsl_detected = "microsoft" in f.read().lower()
|
| 1053 |
+
except Exception:
|
| 1054 |
+
_wsl_detected = False
|
| 1055 |
+
return _wsl_detected
|
| 1056 |
+
|
| 1057 |
+
|
| 1058 |
+
def windows_path_to_wsl(path: str) -> str | None:
|
| 1059 |
+
"""Convert a Windows drive path (``C:\\...``) to its ``/mnt/<drive>/...`` form."""
|
| 1060 |
+
match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip())
|
| 1061 |
+
return f"/mnt/{match.group(1).lower()}/{match.group(2).replace(chr(92), '/')}" if match else None
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
def wsl_unc_path_to_posix(path: str) -> str | None:
|
| 1065 |
+
"""Convert a ``\\\\wsl.localhost\\<distro>\\...`` (or legacy ``\\\\wsl$``) UNC path to POSIX."""
|
| 1066 |
+
normalized = str(path or "").strip().replace("/", "\\")
|
| 1067 |
+
match = re.match(r"^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\(.*)$", normalized, re.IGNORECASE)
|
| 1068 |
+
return "/" + match.group(1).replace("\\", "/") if match else None
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def translate_cwd_for_wsl_backend(cwd: str) -> str:
|
| 1072 |
+
"""Map a Windows-host cwd (drive path or ``\\\\wsl.localhost\\`` UNC) to POSIX when Hermes runs in WSL.
|
| 1073 |
+
|
| 1074 |
+
No-op off WSL and for paths already POSIX.
|
| 1075 |
+
"""
|
| 1076 |
+
if not is_wsl():
|
| 1077 |
+
return cwd
|
| 1078 |
+
for translator in (wsl_unc_path_to_posix, windows_path_to_wsl):
|
| 1079 |
+
translated = translator(cwd)
|
| 1080 |
+
if translated is not None:
|
| 1081 |
+
return translated
|
| 1082 |
+
return cwd
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
_container_detected: bool | None = None
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
def is_container() -> bool:
|
| 1089 |
+
"""True inside a container (Docker/Podman/LXC/Kubernetes markers); cached per process.
|
| 1090 |
+
|
| 1091 |
+
See: NousResearch/hermes-agent#47111
|
| 1092 |
+
"""
|
| 1093 |
+
global _container_detected
|
| 1094 |
+
if _container_detected is None:
|
| 1095 |
+
_container_detected = _detect_container()
|
| 1096 |
+
return _container_detected
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
def _read_proc(path: str) -> str:
|
| 1100 |
+
try:
|
| 1101 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 1102 |
+
return f.read()
|
| 1103 |
+
except OSError:
|
| 1104 |
+
return ""
|
| 1105 |
+
|
| 1106 |
+
|
| 1107 |
+
def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool:
|
| 1108 |
+
content = _read_proc(path)
|
| 1109 |
+
return any(marker in content for marker in markers)
|
| 1110 |
+
|
| 1111 |
+
|
| 1112 |
+
def _detect_container() -> bool:
|
| 1113 |
+
if (
|
| 1114 |
+
os.path.exists("/.dockerenv")
|
| 1115 |
+
or os.path.exists("/run/.containerenv")
|
| 1116 |
+
or os.environ.get("KUBERNETES_SERVICE_HOST")
|
| 1117 |
+
or _proc_file_has_marker("/proc/1/cgroup", ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio"))
|
| 1118 |
+
):
|
| 1119 |
+
return True
|
| 1120 |
+
# cgroup v2: /proc/1/cgroup is just "0::/"; the runtime still shows in mountinfo — but ONLY on
|
| 1121 |
+
# the root ("/") mount line. A host that merely *runs* containers exposes every container's
|
| 1122 |
+
# overlay lowerdir (``lowerdir=/var/lib/containerd/...``) at non-root mount points, which a
|
| 1123 |
+
# whole-file scan misread as "inside a container" and flipped subprocess HOME (#58135).
|
| 1124 |
+
return _root_mount_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio"))
|
| 1125 |
+
|
| 1126 |
+
|
| 1127 |
+
def _root_mount_has_marker(path: str, markers: tuple[str, ...]) -> bool:
|
| 1128 |
+
"""mountinfo field 5 (index 4) is the mount point; only the root ("/") line is the process's own rootfs."""
|
| 1129 |
+
root_lines = [line for line in _read_proc(path).splitlines() if len(f := line.split()) >= 5 and f[4] == "/"]
|
| 1130 |
+
return any(marker in line for line in root_lines for marker in markers)
|
| 1131 |
+
|
| 1132 |
+
|
| 1133 |
+
def get_config_path() -> Path:
|
| 1134 |
+
"""Return the path to ``config.yaml`` under HERMES_HOME."""
|
| 1135 |
+
return get_hermes_home() / "config.yaml"
|
| 1136 |
+
|
| 1137 |
+
|
| 1138 |
+
def get_skills_dir() -> Path:
|
| 1139 |
+
"""Return the path to the skills directory under HERMES_HOME."""
|
| 1140 |
+
return get_hermes_home() / "skills"
|
| 1141 |
+
|
| 1142 |
+
|
| 1143 |
+
def get_env_path() -> Path:
|
| 1144 |
+
"""Return the path to the ``.env`` file under HERMES_HOME."""
|
| 1145 |
+
return get_hermes_home() / ".env"
|
| 1146 |
+
|
| 1147 |
+
|
| 1148 |
+
def apply_ipv4_preference(force: bool = False) -> None:
|
| 1149 |
+
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 when *force* is True.
|
| 1150 |
+
|
| 1151 |
+
Broken-IPv6 hosts hang on AAAA for the full TCP timeout; ``AF_UNSPEC`` resolves as ``AF_INET``,
|
| 1152 |
+
falling back to full resolution when no A record exists (pure-IPv6 hosts still work).
|
| 1153 |
+
"""
|
| 1154 |
+
if not force:
|
| 1155 |
+
return
|
| 1156 |
+
|
| 1157 |
+
import socket
|
| 1158 |
+
|
| 1159 |
+
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
|
| 1160 |
+
return
|
| 1161 |
+
_original_getaddrinfo = socket.getaddrinfo
|
| 1162 |
+
|
| 1163 |
+
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
| 1164 |
+
if family == 0: # AF_UNSPEC — caller didn't request a specific family
|
| 1165 |
+
try:
|
| 1166 |
+
return _original_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
|
| 1167 |
+
except socket.gaierror: # no A record — pure-IPv6 host
|
| 1168 |
+
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
| 1169 |
+
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
| 1170 |
+
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
|
| 1171 |
+
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
|
| 1172 |
+
|
| 1173 |
+
|
| 1174 |
+
PARTIAL_STREAM_STUB_ID = "partial-stream-stub"
|
| 1175 |
+
FINISH_REASON_LENGTH = "length"
|
| 1176 |
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
| 1177 |
+
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
| 1178 |
+
|
| 1179 |
+
# OpenRouter request-time routing variants (docs: guides/routing/model-variants).
|
| 1180 |
+
# These suffixes are per-request routing modifiers valid on ANY model id —
|
| 1181 |
+
# ":nitro" sorts the endpoint pool by throughput and admits priority-tier
|
| 1182 |
+
# endpoints, ":floor" sorts by price and admits flex-tier endpoints, ":exacto"
|
| 1183 |
+
# applies quality-first provider sorting, ":online" attaches the web plugin.
|
| 1184 |
+
# They are never separate catalog entries: /models lists only the base id, so
|
| 1185 |
+
# every catalog lookup must key on the BASE while the suffixed id stays on the
|
| 1186 |
+
# wire.
|
| 1187 |
+
# NOT in this set: ":free", ":batch", ":thinking", ":extended" — those ARE
|
| 1188 |
+
# distinct catalog SKUs with their own /models entries (and their own context
|
| 1189 |
+
# windows), so stripping them would resolve the wrong window.
|
| 1190 |
+
OPENROUTER_VARIANT_SUFFIXES: frozenset[str] = frozenset(
|
| 1191 |
+
{"nitro", "floor", "exacto", "online"}
|
| 1192 |
+
)
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def openrouter_variant_base(model_id: str) -> str | None:
|
| 1196 |
+
"""Return the base model id when ``model_id`` carries a recognized
|
| 1197 |
+
OpenRouter routing-variant suffix (e.g. ``x-ai/grok-4:nitro`` →
|
| 1198 |
+
``x-ai/grok-4``), else ``None``.
|
| 1199 |
+
|
| 1200 |
+
Lives here rather than in ``hermes_cli.models`` so the metadata layer
|
| 1201 |
+
(``agent.model_metadata``) can share one definition without importing the
|
| 1202 |
+
CLI — this module is dependency-free by contract.
|
| 1203 |
+
|
| 1204 |
+
>>> openrouter_variant_base("x-ai/grok-4:nitro")
|
| 1205 |
+
'x-ai/grok-4'
|
| 1206 |
+
>>> openrouter_variant_base("x-ai/grok-4:free") is None
|
| 1207 |
+
True
|
| 1208 |
+
>>> openrouter_variant_base("x-ai/grok-4") is None
|
| 1209 |
+
True
|
| 1210 |
+
"""
|
| 1211 |
+
base, sep, suffix = (model_id or "").rpartition(":")
|
| 1212 |
+
if not sep or not base:
|
| 1213 |
+
return None
|
| 1214 |
+
if suffix.lower() in OPENROUTER_VARIANT_SUFFIXES:
|
| 1215 |
+
return base
|
| 1216 |
+
return None
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
| 1220 |
+
|
| 1221 |
+
|
| 1222 |
+
def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path:
|
| 1223 |
+
"""Venv executable dir (``Scripts``/``bin``); *windows* lets tests exercise Windows paths on Linux.
|
| 1224 |
+
|
| 1225 |
+
Returned unconditionally — callers differ on whether a missing venv is an error.
|
| 1226 |
+
|
| 1227 |
+
Canonical helper for venv layout. This was open-coded in seven places across four ``hermes_cli`` modules
|
| 1228 |
+
using three different Windows predicates (``platform.system()``, ``is_windows()``, ``_is_windows()``);
|
| 1229 |
+
each new call site had to re-derive it, and #76091 shipped an eighth copy because the correct behaviour
|
| 1230 |
+
lived 2400 lines away in another function. A few sites outside ``hermes_cli``
|
| 1231 |
+
(``tools/code_execution_tool.py``, ``agent/lsp/install.py``, ``agent/lsp/servers.py``) still hand-roll
|
| 1232 |
+
it — convert them as they are touched.
|
| 1233 |
+
"""
|
| 1234 |
+
windows = sys.platform == "win32" if windows is None else windows
|
| 1235 |
+
return Path(venv_dir) / ("Scripts" if windows else "bin")
|
| 1236 |
+
|
| 1237 |
+
|
| 1238 |
+
def project_venv_dir(project_root) -> Path | None:
|
| 1239 |
+
"""The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``).
|
| 1240 |
+
|
| 1241 |
+
``uv venv`` defaults to ``.venv`` while our installers create ``venv``, so both layouts are in the wild.
|
| 1242 |
+
Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install — that is how the
|
| 1243 |
+
Windows shim-lock preflight skipped itself entirely (#79542). ``venv`` wins when both exist, matching
|
| 1244 |
+
what the installers write.
|
| 1245 |
+
"""
|
| 1246 |
+
root = Path(project_root)
|
| 1247 |
+
return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None)
|
| 1248 |
+
|
| 1249 |
+
|
| 1250 |
+
def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
|
| 1251 |
+
"""Path to the Python interpreter inside *venv_dir* (may not exist)."""
|
| 1252 |
+
bin_dir = venv_bin_dir(venv_dir, windows=windows)
|
| 1253 |
+
return bin_dir / ("python.exe" if bin_dir.name == "Scripts" else "python")
|
| 1254 |
+
|
| 1255 |
+
|
| 1256 |
+
# First-party roots: an ImportError naming one means our own tree is inconsistent. The
|
| 1257 |
+
# update post-probe shares this set so the guard that BLOCKS and the hint that EXPLAINS agree.
|
| 1258 |
+
FIRST_PARTY_MODULE_ROOTS = frozenset({
|
| 1259 |
+
"agent", "acp_adapter", "cli", "cron", "gateway", "model_tools", "plugins",
|
| 1260 |
+
"providers", "tools", "toolsets", "run_agent", "tui_gateway", "utils",
|
| 1261 |
+
})
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
def is_first_party_module(name: str | None) -> bool:
|
| 1265 |
+
"""True when *name* ships with Hermes (exact first segment; ``startswith`` would claim ``agentops``)."""
|
| 1266 |
+
root = str(name).split(".")[0] if name else ""
|
| 1267 |
+
return bool(root) and (root in FIRST_PARTY_MODULE_ROOTS or root.startswith("hermes_"))
|
| 1268 |
+
|
| 1269 |
+
|
| 1270 |
+
def partial_update_hint(exc: BaseException) -> list[str]:
|
| 1271 |
+
"""Recovery guidance lines when *exc* looks like a half-updated tree, else ``[]``."""
|
| 1272 |
+
# A missing third-party dep (bad venv, missing extra) is a different problem.
|
| 1273 |
+
if (not isinstance(exc, ImportError) or isinstance(exc, ModuleNotFoundError)
|
| 1274 |
+
or not is_first_party_module(getattr(exc, "name", None))):
|
| 1275 |
+
return []
|
| 1276 |
+
return [
|
| 1277 |
+
"",
|
| 1278 |
+
"This looks like a partially-updated install: one module was refreshed "
|
| 1279 |
+
"and a related one was not.",
|
| 1280 |
+
"Re-run the update to bring the whole tree to the same version:",
|
| 1281 |
+
" hermes update",
|
| 1282 |
+
"If that also fails, reinstall: https://hermes-agent.nousresearch.com",
|
| 1283 |
+
]
|
| 1284 |
+
|
| 1285 |
+
|
| 1286 |
+
def emit_partial_update_hint(exc: BaseException, *, file=None) -> bool:
|
| 1287 |
+
"""Print recovery guidance for a half-updated tree."""
|
| 1288 |
+
lines = partial_update_hint(exc)
|
| 1289 |
+
if not lines:
|
| 1290 |
+
return False
|
| 1291 |
+
for line in (f"Error: {exc}", *lines):
|
| 1292 |
+
print(line, file=sys.stderr if file is None else file)
|
| 1293 |
+
return True
|
hermes_state_registry.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Process-wide shared SessionDB registry.
|
| 2 |
+
|
| 3 |
+
Each bare ``SessionDB()`` mints its own writer connection, lock, close-time WAL checkpoint
|
| 4 |
+
and token writer thread, and one connection's close-time checkpoint can race another's
|
| 5 |
+
growth (lost/reordered-page-write corruption). This module owns that boundary: one shared
|
| 6 |
+
``SessionDB`` per resolved path per process, refcounted, with generation-aware retirement
|
| 7 |
+
when the file is replaced (snapshot restore, recovery swap).
|
| 8 |
+
|
| 9 |
+
Lifecycle rules:
|
| 10 |
+
- ``acquire(path)`` returns the current generation for *path* and bumps its refcount.
|
| 11 |
+
- ``close()`` on a shared instance RELEASES one refcount instead of tearing the
|
| 12 |
+
connection down: the registry owns the physical lifecycle and only closes on the
|
| 13 |
+
final release, so legacy call sites return their reference instead of leaking it.
|
| 14 |
+
- ``release(db)`` decrements the generation *db was acquired from* (object-keyed, so an
|
| 15 |
+
inode replacement cannot strand a still-owned generation); the final release of a
|
| 16 |
+
retired generation tears it down.
|
| 17 |
+
- On inode change the old generation is RETIRED (never lent again) but stays alive until
|
| 18 |
+
its holders release. If the replacement open fails the registry keeps NO path entry.
|
| 19 |
+
- All teardown happens OUTSIDE the registry lock: a final release's WAL checkpoint must
|
| 20 |
+
never stall acquisition for every state.db.
|
| 21 |
+
- A final close/checkpoint is serialized with the next open for the same path; no new
|
| 22 |
+
generation is published while the previous generation is still tearing down.
|
| 23 |
+
- A path can have SEVERAL closes admitted at once (the current generation's final release
|
| 24 |
+
plus a retired generation's drain). The path barrier COUNTS them and is lifted only by
|
| 25 |
+
the last one to settle, so neither ``acquire`` nor ``close_all`` / ``close_all_under``
|
| 26 |
+
can escape while any handle for that path is still inside checkpoint/WAL-unlink.
|
| 27 |
+
- Maintenance callers borrow handles with a temporary registry reference instead of
|
| 28 |
+
iterating an unpinned snapshot.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import contextlib
|
| 34 |
+
import logging
|
| 35 |
+
import os
|
| 36 |
+
import threading
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple
|
| 39 |
+
|
| 40 |
+
from hermes_state_common import stat_db_file_identity as _stat_db_file_identity
|
| 41 |
+
|
| 42 |
+
if TYPE_CHECKING: # pragma: no cover - import cycle guard, typed only
|
| 43 |
+
from hermes_state import SessionDB
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class _TeardownBarrier:
|
| 49 |
+
"""Accounting for every admitted-but-unfinished physical close of one path.
|
| 50 |
+
|
| 51 |
+
A path can own more than one close at a time: the current generation's final
|
| 52 |
+
release and a retired generation's drain are admitted independently under
|
| 53 |
+
``_lock`` and only meet at the lifecycle mutex. One event per path is honest
|
| 54 |
+
only if the LAST admitted teardown settles it. Signalling on the first lets
|
| 55 |
+
``close_all()`` return and ``acquire()`` publish a replacement while an older
|
| 56 |
+
handle is still inside ``PRAGMA wal_checkpoint``/sidecar unlink -- the exact
|
| 57 |
+
overlap (#102827) this registry exists to forbid.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
__slots__ = ("event", "pending")
|
| 61 |
+
|
| 62 |
+
def __init__(self) -> None:
|
| 63 |
+
self.event = threading.Event()
|
| 64 |
+
self.pending = 0
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _Generation:
|
| 68 |
+
"""One shared SessionDB generation: instance, refcount, file identity."""
|
| 69 |
+
|
| 70 |
+
__slots__ = ("path", "db", "refcount", "identity", "retired")
|
| 71 |
+
|
| 72 |
+
def __init__(self, path: Path, db: "SessionDB", identity: Optional[Tuple[int, int]]) -> None:
|
| 73 |
+
self.path = path
|
| 74 |
+
self.db = db
|
| 75 |
+
self.refcount = 1
|
| 76 |
+
self.identity = identity
|
| 77 |
+
self.retired = False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
_lock = threading.Lock()
|
| 81 |
+
# path → live generation; retired generations move to _retired (keyed by id(db)) until
|
| 82 |
+
# their last holder releases.
|
| 83 |
+
_generations: Dict[Path, _Generation] = {}
|
| 84 |
+
_retired: Dict[int, _Generation] = {}
|
| 85 |
+
# Paths whose next generation is being constructed. Construction runs outside _lock
|
| 86 |
+
# (schema reconciliation can take seconds), but peers for the SAME file must wait or
|
| 87 |
+
# every cold caller opens its own writer before a winner is chosen.
|
| 88 |
+
_opening: Dict[Path, threading.Event] = {}
|
| 89 |
+
# A final close/checkpoint must finish before a replacement writer is opened
|
| 90 |
+
# for the same path. The barrier is admitted while holding _lock and lifted
|
| 91 |
+
# only after the LAST admitted physical teardown, so acquire cannot slip
|
| 92 |
+
# through the generation-removal/open gap and close_all cannot report a
|
| 93 |
+
# finished sweep over a close that is still running.
|
| 94 |
+
_tearing_down: Dict[Path, _TeardownBarrier] = {}
|
| 95 |
+
# Open and close are both performed outside _lock. This per-path mutex closes
|
| 96 |
+
# the race between checking _tearing_down and entering sqlite3.connect(),
|
| 97 |
+
# including retired-generation drains after an inode replacement.
|
| 98 |
+
_path_lifecycle_locks: Dict[Path, threading.Lock] = {}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _open_session_db(path: Path) -> "SessionDB":
|
| 102 |
+
"""Construct the SessionDB for *path* (call-time import avoids cycles; tests patch this)."""
|
| 103 |
+
from hermes_state import SessionDB
|
| 104 |
+
|
| 105 |
+
return SessionDB(db_path=path)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _teardown(db: "SessionDB") -> None:
|
| 109 |
+
"""Close a shared instance, clearing its registry-owned flag first."""
|
| 110 |
+
with contextlib.suppress(Exception):
|
| 111 |
+
db._shared_registry_owned = False
|
| 112 |
+
_close_quietly(db, "Error closing shared SessionDB")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _close_quietly(db: "SessionDB", debug_message: str) -> None:
|
| 116 |
+
"""close() that never propagates. A lost WAL generation whose capture failed is data at risk,
|
| 117 |
+
not teardown noise: the handle stays open and the operator has to act, so that one surfaces."""
|
| 118 |
+
try:
|
| 119 |
+
db.close()
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
from hermes_state_dbfile import RetiredGenerationCaptureError
|
| 122 |
+
if isinstance(exc, RetiredGenerationCaptureError):
|
| 123 |
+
logger.error("SessionDB for %s did not settle at close: %s", _db_path_of(db), exc)
|
| 124 |
+
else:
|
| 125 |
+
logger.debug(debug_message, exc_info=True)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _path_lifecycle_lock_locked(path: Path) -> threading.Lock:
|
| 129 |
+
"""Return the lifecycle mutex for *path* (caller holds ``_lock``)."""
|
| 130 |
+
lock = _path_lifecycle_locks.get(path)
|
| 131 |
+
if lock is None:
|
| 132 |
+
lock = threading.Lock()
|
| 133 |
+
_path_lifecycle_locks[path] = lock
|
| 134 |
+
return lock
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _admit_teardown_locked(path: Path) -> _TeardownBarrier:
|
| 138 |
+
"""Register one pending physical close for *path* (caller holds ``_lock``).
|
| 139 |
+
|
| 140 |
+
Admission shares the lock section that removes the generation, so a peer
|
| 141 |
+
release, ``acquire`` or ``close_all`` taking the lock next always sees this
|
| 142 |
+
teardown accounted for.
|
| 143 |
+
"""
|
| 144 |
+
barrier = _tearing_down.get(path)
|
| 145 |
+
if barrier is None:
|
| 146 |
+
barrier = _tearing_down[path] = _TeardownBarrier()
|
| 147 |
+
barrier.pending += 1
|
| 148 |
+
return barrier
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _finish_teardown(path: Path, barrier: _TeardownBarrier) -> None:
|
| 152 |
+
"""Settle one admitted teardown; only the last one lifts the path barrier."""
|
| 153 |
+
with _lock:
|
| 154 |
+
barrier.pending -= 1
|
| 155 |
+
if barrier.pending > 0:
|
| 156 |
+
return
|
| 157 |
+
if _tearing_down.get(path) is barrier:
|
| 158 |
+
_tearing_down.pop(path, None)
|
| 159 |
+
barrier.event.set()
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _teardown_generation(
|
| 163 |
+
path: Path,
|
| 164 |
+
db: "SessionDB",
|
| 165 |
+
*,
|
| 166 |
+
barrier: Optional[_TeardownBarrier] = None,
|
| 167 |
+
) -> None:
|
| 168 |
+
"""Close *db* under its path lifecycle mutex, then settle its barrier slot."""
|
| 169 |
+
with _lock:
|
| 170 |
+
lifecycle_lock = _path_lifecycle_lock_locked(path)
|
| 171 |
+
try:
|
| 172 |
+
with lifecycle_lock:
|
| 173 |
+
_teardown(db)
|
| 174 |
+
finally:
|
| 175 |
+
if barrier is not None:
|
| 176 |
+
_finish_teardown(path, barrier)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _db_path_of(db: "SessionDB") -> Optional[Path]:
|
| 180 |
+
"""``Path(db.db_path)`` or None when absent/unconvertible."""
|
| 181 |
+
path = getattr(db, "db_path", None)
|
| 182 |
+
try:
|
| 183 |
+
return None if path is None else Path(path)
|
| 184 |
+
except (TypeError, ValueError):
|
| 185 |
+
return None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _finish_opening(path: Path, opening: threading.Event) -> None:
|
| 189 |
+
"""Drop the per-path construction marker and wake waiters (caller holds _lock)."""
|
| 190 |
+
if _opening.get(path) is opening:
|
| 191 |
+
_opening.pop(path, None)
|
| 192 |
+
opening.set()
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def acquire(db_path: Optional[Path] = None) -> "SessionDB":
|
| 196 |
+
"""Return the shared SessionDB for *db_path*, incrementing its refcount. If the file was
|
| 197 |
+
replaced (different inode) since the generation opened, that generation is RETIRED
|
| 198 |
+
but stays alive for its holders, and a fresh one is opened in its place. Raises
|
| 199 |
+
whatever ``SessionDB.__init__`` raises; on a replacement-open failure the registry
|
| 200 |
+
holds NO entry for the path."""
|
| 201 |
+
from hermes_state import _default_db_path
|
| 202 |
+
|
| 203 |
+
raw_path = Path(db_path) if db_path is not None else Path(_default_db_path())
|
| 204 |
+
try:
|
| 205 |
+
path = raw_path.resolve()
|
| 206 |
+
except OSError:
|
| 207 |
+
path = raw_path
|
| 208 |
+
|
| 209 |
+
while True:
|
| 210 |
+
wait_for: Optional[threading.Event] = None
|
| 211 |
+
with _lock:
|
| 212 |
+
generation = _generations.get(path)
|
| 213 |
+
if generation is not None:
|
| 214 |
+
current = _stat_db_file_identity(path)
|
| 215 |
+
if current is not None and generation.identity is not None and current != generation.identity:
|
| 216 |
+
# File replaced: retire this generation so it is never lent again, then elect one
|
| 217 |
+
# caller to open the replacement. It stays alive for its holders, tracked in
|
| 218 |
+
# ``_retired`` by ``id(db)`` so their releases find it after the path remaps.
|
| 219 |
+
generation.retired = True
|
| 220 |
+
del _generations[path]
|
| 221 |
+
_retired[id(generation.db)] = generation
|
| 222 |
+
else:
|
| 223 |
+
generation.refcount += 1
|
| 224 |
+
return generation.db
|
| 225 |
+
teardown = _tearing_down.get(path)
|
| 226 |
+
if teardown is not None:
|
| 227 |
+
wait_for = teardown.event
|
| 228 |
+
else:
|
| 229 |
+
opening = _opening.get(path)
|
| 230 |
+
if opening is None:
|
| 231 |
+
opening = _opening[path] = threading.Event()
|
| 232 |
+
lifecycle_lock = _path_lifecycle_lock_locked(path)
|
| 233 |
+
else:
|
| 234 |
+
wait_for = opening
|
| 235 |
+
if wait_for is not None:
|
| 236 |
+
# Another caller is constructing or closing this path; wait without holding the
|
| 237 |
+
# global lock. A failed opener signals too, so a waiter can retry.
|
| 238 |
+
wait_for.wait()
|
| 239 |
+
continue
|
| 240 |
+
|
| 241 |
+
# Open OUTSIDE the registry lock; the per-path marker prevents redundant writers without
|
| 242 |
+
# serialising other files, the lifecycle mutex keeps the open off a same-path close.
|
| 243 |
+
try:
|
| 244 |
+
with lifecycle_lock:
|
| 245 |
+
db = _open_session_db(path)
|
| 246 |
+
db._shared_registry_owned = True
|
| 247 |
+
identity = _stat_db_file_identity(path)
|
| 248 |
+
except BaseException:
|
| 249 |
+
with _lock:
|
| 250 |
+
_finish_opening(path, opening)
|
| 251 |
+
raise
|
| 252 |
+
|
| 253 |
+
with _lock:
|
| 254 |
+
teardown = _tearing_down.get(path)
|
| 255 |
+
if teardown is None:
|
| 256 |
+
existing = _generations.get(path)
|
| 257 |
+
if existing is not None: # Defensive: installed by explicit registry manipulation mid-open.
|
| 258 |
+
existing.refcount += 1
|
| 259 |
+
winner = existing.db
|
| 260 |
+
else:
|
| 261 |
+
_generations[path] = _Generation(path, db, identity)
|
| 262 |
+
winner = db
|
| 263 |
+
_finish_opening(path, opening)
|
| 264 |
+
if teardown is not None:
|
| 265 |
+
# A shutdown or retired-generation final release was admitted while this opener was
|
| 266 |
+
# constructing: never publish into that window — discard the speculative handle and
|
| 267 |
+
# go round again once the barrier lifts.
|
| 268 |
+
_teardown_generation(path, db)
|
| 269 |
+
teardown.event.wait()
|
| 270 |
+
continue
|
| 271 |
+
if winner is not db:
|
| 272 |
+
_teardown_generation(path, db)
|
| 273 |
+
return winner
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def release(db: "SessionDB") -> bool:
|
| 277 |
+
"""Decrement the refcount of a shared SessionDB. ``True`` if *db* was shared; ``False``
|
| 278 |
+
if it is not registry-managed (caller owns close()). The final release tears the
|
| 279 |
+
generation down OUTSIDE the registry lock. Lookup is object-keyed, so holders of an
|
| 280 |
+
old generation release into its retired record, not into whatever the path names."""
|
| 281 |
+
if db is None:
|
| 282 |
+
return False
|
| 283 |
+
key = id(db)
|
| 284 |
+
teardown_barrier: Optional[_TeardownBarrier] = None
|
| 285 |
+
with _lock:
|
| 286 |
+
generation = _retired.get(key)
|
| 287 |
+
if generation is None:
|
| 288 |
+
path = _db_path_of(db)
|
| 289 |
+
if path is None:
|
| 290 |
+
return False
|
| 291 |
+
generation = _generations.get(path)
|
| 292 |
+
if generation is None or generation.db is not db:
|
| 293 |
+
# Not shared (bare SessionDB()); the caller owns close().
|
| 294 |
+
return False
|
| 295 |
+
generation.refcount -= 1
|
| 296 |
+
needs_teardown = generation.refcount <= 0
|
| 297 |
+
if needs_teardown:
|
| 298 |
+
if generation.retired:
|
| 299 |
+
_retired.pop(key, None)
|
| 300 |
+
elif _generations.get(generation.path) is generation:
|
| 301 |
+
_generations.pop(generation.path, None)
|
| 302 |
+
# A retired generation's drain is admitted too: it checkpoints and unlinks the same
|
| 303 |
+
# sidecars as the current one, so a replacement writer must not open on top of it.
|
| 304 |
+
teardown_barrier = _admit_teardown_locked(generation.path)
|
| 305 |
+
# Teardown OUTSIDE the lock: stopping the token writer, WAL checkpoint and read-pool
|
| 306 |
+
# drain must not block acquisition for every other state.db.
|
| 307 |
+
if needs_teardown:
|
| 308 |
+
_teardown_generation(generation.path, db, barrier=teardown_barrier)
|
| 309 |
+
return True
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _path_is_under(path: Path, root: Path) -> bool:
|
| 313 |
+
"""True when *path* is *root* or a file inside it (normcase, resolved)."""
|
| 314 |
+
try:
|
| 315 |
+
path_key = os.path.normcase(str(path.resolve()))
|
| 316 |
+
root_key = os.path.normcase(str(root.resolve()))
|
| 317 |
+
except OSError:
|
| 318 |
+
path_key = os.path.normcase(str(path))
|
| 319 |
+
root_key = os.path.normcase(str(root))
|
| 320 |
+
return path_key == root_key or path_key.startswith(root_key + os.sep)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _teardown_swept_generations(
|
| 324 |
+
generations: List[_Generation],
|
| 325 |
+
teardown_barriers: Dict[Path, _TeardownBarrier],
|
| 326 |
+
active_teardowns: List[_TeardownBarrier],
|
| 327 |
+
) -> int:
|
| 328 |
+
"""Close *generations* outside the registry lock; wait for already-admitted teardowns."""
|
| 329 |
+
by_path: Dict[Path, List[_Generation]] = {}
|
| 330 |
+
for generation in generations:
|
| 331 |
+
by_path.setdefault(generation.path, []).append(generation)
|
| 332 |
+
for path, path_generations in by_path.items():
|
| 333 |
+
with _lock:
|
| 334 |
+
lifecycle_lock = _path_lifecycle_lock_locked(path)
|
| 335 |
+
try:
|
| 336 |
+
with lifecycle_lock:
|
| 337 |
+
for generation in path_generations:
|
| 338 |
+
_teardown(generation.db)
|
| 339 |
+
finally:
|
| 340 |
+
_finish_teardown(path, teardown_barriers[path])
|
| 341 |
+
for barrier in active_teardowns:
|
| 342 |
+
barrier.event.wait()
|
| 343 |
+
return len(generations)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def close_all() -> int:
|
| 347 |
+
"""Close every shared SessionDB regardless of refcount; returns the count. For gateway
|
| 348 |
+
shutdown, after all agents and cron jobs finished. Idempotent."""
|
| 349 |
+
teardown_barriers: Dict[Path, _TeardownBarrier] = {}
|
| 350 |
+
with _lock:
|
| 351 |
+
active_teardowns = list(_tearing_down.values())
|
| 352 |
+
generations = list(_generations.values()) + list(_retired.values())
|
| 353 |
+
for path in {generation.path for generation in generations}:
|
| 354 |
+
teardown_barriers[path] = _admit_teardown_locked(path)
|
| 355 |
+
_generations.clear()
|
| 356 |
+
_retired.clear()
|
| 357 |
+
for generation in generations:
|
| 358 |
+
generation.retired = True
|
| 359 |
+
return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def close_all_under(directory: str | Path) -> int:
|
| 363 |
+
"""Force-close every shared SessionDB whose file lives under *directory*; returns the count.
|
| 364 |
+
|
| 365 |
+
Profile delete rmtree (and a same-name recreate) fails while this process still holds
|
| 366 |
+
``state.db``. Same contract as ``MemoryStore.release_all_under``: a live holder is
|
| 367 |
+
expected to fail afterward; a process that holds none is a no-op returning 0.
|
| 368 |
+
|
| 369 |
+
A final ``release()`` can drop the generation and admit teardown before the physical
|
| 370 |
+
close finishes. Wait for those directory-matching barriers even when no generation
|
| 371 |
+
remains, otherwise rmtree still sees the open handle.
|
| 372 |
+
"""
|
| 373 |
+
try:
|
| 374 |
+
root = Path(directory).expanduser().resolve()
|
| 375 |
+
except OSError:
|
| 376 |
+
root = Path(directory).expanduser()
|
| 377 |
+
teardown_barriers: Dict[Path, _TeardownBarrier] = {}
|
| 378 |
+
with _lock:
|
| 379 |
+
generations = [
|
| 380 |
+
generation
|
| 381 |
+
for generation in list(_generations.values()) + list(_retired.values())
|
| 382 |
+
if _path_is_under(generation.path, root)
|
| 383 |
+
]
|
| 384 |
+
# Collect by directory, not by remaining generations: a last release already
|
| 385 |
+
# popped the generation and left only ``_tearing_down``.
|
| 386 |
+
active_teardowns = [
|
| 387 |
+
barrier for path, barrier in _tearing_down.items()
|
| 388 |
+
if _path_is_under(path, root)
|
| 389 |
+
]
|
| 390 |
+
selected_paths = {generation.path for generation in generations}
|
| 391 |
+
for path in selected_paths:
|
| 392 |
+
teardown_barriers[path] = _admit_teardown_locked(path)
|
| 393 |
+
for generation in generations:
|
| 394 |
+
generation.retired = True
|
| 395 |
+
if _generations.get(generation.path) is generation:
|
| 396 |
+
_generations.pop(generation.path, None)
|
| 397 |
+
_retired.pop(id(generation.db), None)
|
| 398 |
+
return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def live_shared_session_dbs() -> List["SessionDB"]:
|
| 402 |
+
"""Snapshot of every live (non-retired) shared SessionDB (refcounts untouched), for
|
| 403 |
+
in-process maintenance. A concurrent final release may close an instance, in which
|
| 404 |
+
case the callee sees ``_conn is None``."""
|
| 405 |
+
with _lock:
|
| 406 |
+
return [g.db for g in _generations.values() if not g.retired]
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@contextlib.contextmanager
|
| 410 |
+
def borrow_live_shared_session_dbs() -> Iterator[List["SessionDB"]]:
|
| 411 |
+
"""Borrow live handles with registry references pinned for the whole block.
|
| 412 |
+
|
| 413 |
+
Maintenance must not operate on the unowned snapshot returned by
|
| 414 |
+
:func:`live_shared_session_dbs`: the last real owner could otherwise
|
| 415 |
+
release and physically close the connection between the snapshot and the
|
| 416 |
+
maintenance call. Each borrowed generation gets one temporary reference;
|
| 417 |
+
the ``finally`` block releases it even when maintenance raises.
|
| 418 |
+
"""
|
| 419 |
+
with _lock:
|
| 420 |
+
borrowed_generations = [
|
| 421 |
+
generation for generation in _generations.values() if not generation.retired
|
| 422 |
+
]
|
| 423 |
+
borrowed = [generation.db for generation in borrowed_generations]
|
| 424 |
+
for generation in borrowed_generations:
|
| 425 |
+
generation.refcount += 1
|
| 426 |
+
try:
|
| 427 |
+
yield borrowed
|
| 428 |
+
finally:
|
| 429 |
+
for db in reversed(borrowed):
|
| 430 |
+
release(db)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def stats() -> Dict[str, int]:
|
| 434 |
+
"""Registry census for tests and diagnostics (no locks held long)."""
|
| 435 |
+
with _lock:
|
| 436 |
+
return {
|
| 437 |
+
"live_generations": len(_generations), "retired_generations": len(_retired),
|
| 438 |
+
"total_refcounts": sum(g.refcount for g in _generations.values()),
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def release_or_close(db: "SessionDB") -> None:
|
| 443 |
+
"""Release a shared instance, or close it when it is not registry-managed. Drop-in for a
|
| 444 |
+
plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back."""
|
| 445 |
+
if not release(db):
|
| 446 |
+
_close_quietly(db, "release_or_close fallback close failed")
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
| 450 |
+
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
| 451 |
+
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
| 452 |
+
# The whole block is removed by reverting the commit that added it.
|
| 453 |
+
|
| 454 |
+
def close_shared_session_dbs() -> int:
|
| 455 |
+
return close_all()
|
| 456 |
+
|
| 457 |
+
def get_shared_session_db(db_path: Optional[Path] = None) -> "SessionDB":
|
| 458 |
+
return acquire(db_path)
|
| 459 |
+
|
| 460 |
+
def release_shared_session_db(db: "SessionDB") -> bool:
|
| 461 |
+
return release(db)
|
| 462 |
+
# ---- END PLUGIN-COMPAT ----
|
hermes_state_repair.py
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""state.db repair, backup and writability preflight (split from hermes_state).
|
| 2 |
+
|
| 3 |
+
Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_repair.<name>``.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import contextlib
|
| 9 |
+
import datetime
|
| 10 |
+
import hashlib
|
| 11 |
+
import itertools
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import shutil
|
| 17 |
+
import sqlite3
|
| 18 |
+
import stat
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
from hermes_constants import get_hermes_home
|
| 24 |
+
from hermes_startup_watchdog import report_startup_progress
|
| 25 |
+
from hermes_state_holders import read_only_db_uri
|
| 26 |
+
from hermes_state_common import (
|
| 27 |
+
_acquire_db_flock, _clear_lock_holder_record, _describe_lock_holder, _read_lock_holder_record,
|
| 28 |
+
is_advisory_lock_contention,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Log-record parity with the origin module (caplog tests pin "hermes_state").
|
| 32 |
+
logger = logging.getLogger("hermes_state")
|
| 33 |
+
|
| 34 |
+
_REPAIR_LOCK_POLL_SECONDS = 0.1
|
| 35 |
+
# Snapshot copies are data transfer, not locking: bounded separately at 10 MiB/s (historical two-minute floor).
|
| 36 |
+
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND = 10 * 1024 * 1024
|
| 37 |
+
# ── Repair-loop bounding + dead-backup hygiene (#86747) ───────────────────── ``_claim_repair_attempt``
|
| 38 |
+
# above is an in-memory set: it bounds the loop only WITHIN one process. A corruption class the strategies
|
| 39 |
+
# cannot heal (b-tree page damage) failed repair on EVERY process start, and each pass took a fresh ~900MB
|
| 40 |
+
# forensic backup — 105 attempts / 89GB of identical dead copies in the reporting install. Two persistent
|
| 41 |
+
# bounds fix the class: * a sidecar attempt ledger (``<db>.repair-attempts.json``) that refuses further
|
| 42 |
+
# surgery after ``_MAX_PERSISTENT_REPAIR_ATTEMPTS`` failures on the SAME damaged file (fingerprint = size +
|
| 43 |
+
# a bounded content sample; any successful repair or replacement changes it and resets the count); * backup
|
| 44 |
+
# dedupe + a retention cap in ``_backup_db_file`` — an identical damaged file is never copied twice, and
|
| 45 |
+
# only the newest ``_MAX_MALFORMED_BACKUPS`` forensic copies are kept.
|
| 46 |
+
_MAX_PERSISTENT_REPAIR_ATTEMPTS = 3
|
| 47 |
+
_MAX_MALFORMED_BACKUPS = 3
|
| 48 |
+
# Sidecars copied with a damaged DB and pruned with it. ``-journal``: DELETE mode (the NFS/SMB/FUSE/ZFS and
|
| 49 |
+
# WAL-reset-bug fallback) leaves a hot journal whenever a transaction was open; without it the copy cannot roll back.
|
| 50 |
+
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
|
| 51 |
+
# Head/tail bytes sampled by ``_db_fingerprint``: changes on any real repair/truncation/restore, O(1) on a multi-GB file.
|
| 52 |
+
_FINGERPRINT_SAMPLE_BYTES = 65536
|
| 53 |
+
# Header ranges that move on ordinary commits, not on repair — file change counter (24-27), version-valid-for
|
| 54 |
+
# (92-95) — masked out of the sample. A malformed-SCHEMA DB still accepts writes and DELETE mode writes the main
|
| 55 |
+
# file directly, so without the mask any live write re-keys the ledger and the repair budget resets to 1
|
| 56 |
+
# forever. The page-1 sqlite_master b-tree (repair identity) sits after byte 100.
|
| 57 |
+
_FINGERPRINT_VOLATILE_HEADER_RANGES = ((24, 28), (92, 96))
|
| 58 |
+
# Headroom for the forensic backup (a full raw copy; a repair loop on a large state.db is a disk amplifier).
|
| 59 |
+
# Proportional, not a flat multi-GB floor: a refused backup is a HARD STOP, and a big reserve would make repair
|
| 60 |
+
# never run on small volumes.
|
| 61 |
+
# The backup is a full raw copy of the damaged DB (plus its -wal/-shm sidecars), so a repair loop on a large
|
| 62 |
+
# state.db is a disk amplifier: the reporting incident wrote ~98MB every ~10s until the volume was nearly
|
| 63 |
+
# full, which would have taken down every agent on the host. Require the copy itself plus a small slice of
|
| 64 |
+
# the volume, clamped to a modest floor. See #69603.
|
| 65 |
+
_REPAIR_BACKUP_MIN_FREE_BYTES = 256 * 1024 * 1024 # 256 MiB absolute floor
|
| 66 |
+
_REPAIR_BACKUP_FREE_FRACTION = 0.02 # plus 2% of the volume
|
| 67 |
+
_FTS_TABLES = ("messages_fts", "messages_fts_trigram", "messages_fts_cjk")
|
| 68 |
+
_MANUAL_RECOVER_HINT = ("Free disk space, then retry (or recover manually with "
|
| 69 |
+
"`hermes sessions recover --source {db_path} --inspect-only` first).")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _sidecars(db_path: Path):
|
| 73 |
+
"""The three SQLite sidecar paths for *db_path* (present or not)."""
|
| 74 |
+
return (db_path.with_name(db_path.name + suffix) for suffix in _DB_SIDECAR_SUFFIXES)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _read_offline(db_path: Path, what: str, reader) -> Optional[str]:
|
| 78 |
+
"""Run *reader()* under ``hermes_cli.sqlite_safe_read.offline_file_access``.
|
| 79 |
+
|
| 80 |
+
``close()`` on ANY raw descriptor cancels every POSIX advisory lock this process holds on the file,
|
| 81 |
+
including a peer connection's RESERVED lock (``sqlite_safe_read`` rule 1), so a raw read is only safe with
|
| 82 |
+
no live connection; ``None`` when that makes it unsafe or the file is unreadable. Scaffold/embed installs
|
| 83 |
+
without hermes_cli have no tracked connections."""
|
| 84 |
+
try:
|
| 85 |
+
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
|
| 86 |
+
except ImportError:
|
| 87 |
+
offline_file_access, LiveConnectionError = (lambda _p, **_k: contextlib.nullcontext()), OSError
|
| 88 |
+
try:
|
| 89 |
+
with offline_file_access(db_path, what=what):
|
| 90 |
+
return reader()
|
| 91 |
+
except (LiveConnectionError, OSError):
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _claim_repair_attempt(db_path: Path) -> bool:
|
| 96 |
+
"""Claim the one-shot per-process repair attempt for *db_path*: True for the first caller, False
|
| 97 |
+
afterwards (bounds the repair/reopen loop and stops concurrent callers racing surgery on one file)."""
|
| 98 |
+
from hermes_state import _repair_attempt_lock, _repair_attempted_paths
|
| 99 |
+
with _repair_attempt_lock:
|
| 100 |
+
if str(db_path) in _repair_attempted_paths:
|
| 101 |
+
return False
|
| 102 |
+
_repair_attempted_paths.add(str(db_path))
|
| 103 |
+
return True
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _open_lock_file(db_path: Path, suffix: str, what: str, tail: str):
|
| 107 |
+
"""Open ``<db>.<suffix>`` for locking; on failure warn and return None."""
|
| 108 |
+
lock_path = db_path.with_name(db_path.name + suffix)
|
| 109 |
+
try:
|
| 110 |
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
return lock_path, lock_path.open("a+b")
|
| 112 |
+
except OSError as exc:
|
| 113 |
+
logger.warning(f"Could not open state.db {what} lock %s (%s) — {tail}", lock_path, exc)
|
| 114 |
+
return lock_path, None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _msvcrt_lock(handle, flag_name: str) -> None:
|
| 118 |
+
import msvcrt
|
| 119 |
+
handle.seek(0)
|
| 120 |
+
msvcrt.locking(handle.fileno(), getattr(msvcrt, flag_name), 1) # type: ignore[attr-defined]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _try_lock_nonblocking(handle) -> None:
|
| 124 |
+
"""Take the advisory lock on *handle* without waiting (raises on contention)."""
|
| 125 |
+
from hermes_state import _IS_WINDOWS
|
| 126 |
+
if _IS_WINDOWS:
|
| 127 |
+
_msvcrt_lock(handle, "LK_NBLCK")
|
| 128 |
+
else:
|
| 129 |
+
import fcntl
|
| 130 |
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _release_lock_handle(handle, *, clear_record: bool = False) -> None:
|
| 134 |
+
"""Drop the advisory lock on *handle* (best effort) and close it."""
|
| 135 |
+
from hermes_state import _IS_WINDOWS
|
| 136 |
+
with contextlib.closing(handle), contextlib.suppress(OSError): # best-effort release; always close
|
| 137 |
+
if _IS_WINDOWS:
|
| 138 |
+
_msvcrt_lock(handle, "LK_UNLCK")
|
| 139 |
+
else:
|
| 140 |
+
import fcntl
|
| 141 |
+
if clear_record:
|
| 142 |
+
_clear_lock_holder_record(handle)
|
| 143 |
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _acquire_repair_lock_windows(lock_path: Path, handle, timeout: float):
|
| 147 |
+
"""Windows counterpart of ``_acquire_db_flock``: True / False (timed out) / None (non-contention error)."""
|
| 148 |
+
deadline = time.monotonic() + timeout
|
| 149 |
+
while True:
|
| 150 |
+
try:
|
| 151 |
+
_try_lock_nonblocking(handle)
|
| 152 |
+
return True
|
| 153 |
+
except (BlockingIOError, OSError) as exc:
|
| 154 |
+
if not is_advisory_lock_contention(exc):
|
| 155 |
+
logger.warning("Could not acquire state.db repair lock %s (%s) — skipping schema surgery on a "
|
| 156 |
+
"non-contention error.", lock_path, exc)
|
| 157 |
+
return None
|
| 158 |
+
if time.monotonic() >= deadline:
|
| 159 |
+
return False
|
| 160 |
+
time.sleep(_REPAIR_LOCK_POLL_SECONDS)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@contextlib.contextmanager
|
| 164 |
+
def _cross_process_repair_lock(db_path: Path):
|
| 165 |
+
"""Serialize state.db schema surgery across processes.
|
| 166 |
+
|
| 167 |
+
Yields True when this process holds the repair lock, False when the bounded acquire timed out or the lock
|
| 168 |
+
file could not be opened; on False the caller must NOT do surgery (unlocked surgery IS the interleaving
|
| 169 |
+
this prevents). ``flock``: the kernel drops it when the holder dies (a pidfile would wedge every future
|
| 170 |
+
repair); a forked child inheriting the fd is the exception, so the acquire records pid + start time and
|
| 171 |
+
breaks a provably dead holder's lock. Bounded because a live repairer can sit in ``VACUUM`` for minutes.
|
| 172 |
+
An unopenable lock file (no space/inodes/descriptors) fails closed too: a sibling that opened ITS handle
|
| 173 |
+
before the disk filled may be inside surgery.
|
| 174 |
+
|
| 175 |
+
The acquire is still bounded because a *live* repairer can legitimately sit in ``VACUUM`` for minutes on
|
| 176 |
+
a large DB, and an unbounded wait would hang the caller's open with no traceback (the failure shape of
|
| 177 |
+
#36644).
|
| 178 |
+
"""
|
| 179 |
+
from hermes_state import _IS_WINDOWS, _REPAIR_LOCK_TIMEOUT_SECONDS
|
| 180 |
+
lock_path, handle = _open_lock_file(
|
| 181 |
+
db_path, ".repair.lock", "repair", "skipping schema surgery rather than running it without cross-process authority.")
|
| 182 |
+
if handle is None:
|
| 183 |
+
yield False
|
| 184 |
+
return
|
| 185 |
+
acquired = False
|
| 186 |
+
try:
|
| 187 |
+
if _IS_WINDOWS:
|
| 188 |
+
acquired = _acquire_repair_lock_windows(lock_path, handle, _REPAIR_LOCK_TIMEOUT_SECONDS)
|
| 189 |
+
else:
|
| 190 |
+
acquired, handle = _acquire_db_flock(str(lock_path), handle, _REPAIR_LOCK_TIMEOUT_SECONDS,
|
| 191 |
+
_REPAIR_LOCK_POLL_SECONDS, "state.db repair lock")
|
| 192 |
+
if acquired is None:
|
| 193 |
+
acquired = False # non-contention failure already logged with its errno
|
| 194 |
+
elif not acquired:
|
| 195 |
+
logger.warning("state.db repair lock %s held by another process for more than %.0fs — skipping schema "
|
| 196 |
+
"surgery in this process to avoid racing the repairer. Recorded holder: %s.",
|
| 197 |
+
lock_path, _REPAIR_LOCK_TIMEOUT_SECONDS,
|
| 198 |
+
_describe_lock_holder(None if _IS_WINDOWS else _read_lock_holder_record(handle)))
|
| 199 |
+
yield acquired
|
| 200 |
+
finally:
|
| 201 |
+
if acquired:
|
| 202 |
+
_release_lock_handle(handle, clear_record=True)
|
| 203 |
+
else:
|
| 204 |
+
handle.close()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _try_acquire_auto_maintenance_lock(db_path: Path) -> Optional[Any]:
|
| 208 |
+
"""Non-blocking cross-process lock for one auto-maintenance pass (None = skip the pass: otherwise two startups
|
| 209 |
+
both pass the interval check and the second prunes a row the first has only just closed recoverably)."""
|
| 210 |
+
_lock_path, handle = _open_lock_file(db_path, ".auto-maintenance.lock", "auto-maintenance", "skipping automatic maintenance.")
|
| 211 |
+
try:
|
| 212 |
+
if handle is not None:
|
| 213 |
+
_try_lock_nonblocking(handle)
|
| 214 |
+
return handle
|
| 215 |
+
except (BlockingIOError, OSError):
|
| 216 |
+
handle.close()
|
| 217 |
+
return None
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
_release_auto_maintenance_lock = _release_lock_handle # release a _try_acquire_auto_maintenance_lock handle
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _bump_schema_cookie(conn: sqlite3.Connection) -> None:
|
| 224 |
+
"""Increment the schema cookie after direct ``sqlite_master`` surgery.
|
| 225 |
+
|
| 226 |
+
Ordinary DDL bumps it and peers compare it before running a prepared statement (how they discard a cached
|
| 227 |
+
schema); ``writable_schema=ON`` edits do NOT, so live connections would keep firing triggers into
|
| 228 |
+
``messages_fts*`` shadow tables that no longer exist. Best-effort, never raises."""
|
| 229 |
+
try:
|
| 230 |
+
current = conn.execute("PRAGMA schema_version").fetchone()[0]
|
| 231 |
+
# Wrap within SQLite's 32-bit signed range; peers compare for equality.
|
| 232 |
+
conn.execute(f"PRAGMA schema_version={(int(current) + 1) & 0x7FFFFFFF}")
|
| 233 |
+
except (sqlite3.DatabaseError, TypeError, IndexError) as exc:
|
| 234 |
+
logger.warning("Could not bump state.db schema cookie: %s", exc)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _mask_volatile_header(head: bytes) -> bytes:
|
| 238 |
+
"""Zero the commit-counter fields so ordinary writes don't re-key the ledger."""
|
| 239 |
+
if len(head) < 96:
|
| 240 |
+
return head
|
| 241 |
+
buf = bytearray(head)
|
| 242 |
+
for start, end in _FINGERPRINT_VOLATILE_HEADER_RANGES:
|
| 243 |
+
buf[start:end] = b"\x00" * (end - start)
|
| 244 |
+
return bytes(buf)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _repair_backup_headroom_bytes(total_bytes: int) -> int:
|
| 248 |
+
"""Free space required *beyond* the copy itself, for a volume of *total_bytes*."""
|
| 249 |
+
return max(_REPAIR_BACKUP_MIN_FREE_BYTES, int(total_bytes * _REPAIR_BACKUP_FREE_FRACTION))
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _disk_budget(db_path: Path, refusal: str) -> "Tuple[Optional[str], int, int, int]":
|
| 253 |
+
"""``(error, bundle_bytes, free_bytes, headroom_bytes)`` for *db_path*'s volume (main file plus every PRESENT
|
| 254 |
+
sidecar); *error* is set (and the sizes zero) on stat()/disk_usage() failure. Fails CLOSED: the nearly-full
|
| 255 |
+
volume these guards exist for is exactly where they are most likely to fail."""
|
| 256 |
+
try:
|
| 257 |
+
need = db_path.stat().st_size + sum(p.stat().st_size for p in _sidecars(db_path) if p.exists())
|
| 258 |
+
usage = shutil.disk_usage(db_path.parent)
|
| 259 |
+
except OSError as exc:
|
| 260 |
+
return (f"could not determine free space on {db_path.parent} ({exc}); refusing the {refusal} rather than risk "
|
| 261 |
+
"filling the volume"), 0, 0, 0
|
| 262 |
+
return None, need, usage.free, _repair_backup_headroom_bytes(usage.total)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _repair_scratch_space_error(db_path: Path) -> Optional[str]:
|
| 266 |
+
"""Return an error unless snapshot, VACUUM and promotion can fit safely."""
|
| 267 |
+
error, snapshot_bytes, free, headroom = _disk_budget(db_path, "repair snapshot")
|
| 268 |
+
if error is not None:
|
| 269 |
+
return error
|
| 270 |
+
# VACUUM on the staged DB may need up to 2x the database size (SQLite docs); the same reserve then covers
|
| 271 |
+
# transactional promotion into the live DB.
|
| 272 |
+
if free >= snapshot_bytes + (2 * snapshot_bytes) + headroom:
|
| 273 |
+
return None
|
| 274 |
+
return (f"only {free / 1e9:.2f}GB free on {db_path.parent}; the repair snapshot needs up to "
|
| 275 |
+
f"{snapshot_bytes / 1e9:.2f}GB, VACUUM may need another {(2 * snapshot_bytes) / 1e9:.2f}GB, and "
|
| 276 |
+
f"{headroom / 1e9:.2f}GB must remain as headroom. Free disk space, then retry.")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _backup_free_space_error(db_path: Path) -> Optional[str]:
|
| 280 |
+
"""Disk guard for the forensic copy: reason to refuse, or None. A full raw copy on a nearly-full volume (which a
|
| 281 |
+
preceding repair loop may itself have caused) can finish off the disk and every process on the machine."""
|
| 282 |
+
hint = _MANUAL_RECOVER_HINT.format(db_path=db_path)
|
| 283 |
+
error, need, free, headroom = _disk_budget(db_path, "forensic copy")
|
| 284 |
+
if error is not None:
|
| 285 |
+
return f"{error}. {hint}"
|
| 286 |
+
if free - need >= headroom:
|
| 287 |
+
return None
|
| 288 |
+
return (f"only {free / 1e9:.2f}GB free on {db_path.parent}; copying the damaged DB needs {need / 1e9:.2f}GB and must "
|
| 289 |
+
f"leave {headroom / 1e9:.2f}GB headroom. {hint}")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
|
| 293 |
+
"""Bound one SQLite snapshot by source size incl. sidecars (a WAL can hold committed rows not yet in the
|
| 294 |
+
main file), so a healthy large-database copy is not cut off by the repair-lock timeout."""
|
| 295 |
+
from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS
|
| 296 |
+
source_bytes = 0
|
| 297 |
+
for candidate in (source_path, *_sidecars(source_path)):
|
| 298 |
+
with contextlib.suppress(FileNotFoundError): # a sidecar may vanish mid-walk
|
| 299 |
+
source_bytes += candidate.stat().st_size
|
| 300 |
+
return max(_REPAIR_LOCK_TIMEOUT_SECONDS, source_bytes / _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _repair_failure_consumes_attempt(exc: BaseException) -> bool:
|
| 304 |
+
"""Whether a pre-strategy SQLite failure proves deterministic corruption.
|
| 305 |
+
|
| 306 |
+
Lock contention, timeouts, disk-full and I/O failures are environmental — a retry may succeed, so they
|
| 307 |
+
must not burn the repair ledger. Only SQLite's corruption/image result codes prove deterministic damage."""
|
| 308 |
+
if not isinstance(exc, sqlite3.DatabaseError):
|
| 309 |
+
return False
|
| 310 |
+
if isinstance(error_code := getattr(exc, "sqlite_errorcode", None), int):
|
| 311 |
+
# Extended result codes keep the primary code in the low byte.
|
| 312 |
+
return (error_code & 0xFF) in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB)
|
| 313 |
+
# Older sqlite3 without result-code attributes: narrow message match only, never turning generic
|
| 314 |
+
# "disk is full"/"readonly" into permanent failures.
|
| 315 |
+
return any(m in str(exc).lower() for m in ("file is not a database", "database disk image is malformed"))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _repair_ledger_path(db_path: Path) -> Path:
|
| 319 |
+
return db_path.with_name(db_path.name + ".repair-attempts.json")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _db_fingerprint(db_path: Path) -> "Optional[str]":
|
| 323 |
+
"""Cheap identity for a damaged DB file: size + a bounded content sample.
|
| 324 |
+
|
| 325 |
+
EXCLUDES mtime: a malformed-schema DB still accepts writes, so live writers, checkpoints and the
|
| 326 |
+
strategies move mtime between passes; keyed on mtime every pass looked like a NEW file and the attempt
|
| 327 |
+
counter reset forever. Hashing a multi-GB file per open is the cost this ledger avoids, so sample the
|
| 328 |
+
head/tail slices any real repair, truncation or restore must change.
|
| 329 |
+
|
| 330 |
+
``None`` = identity unavailable (:func:`_read_offline`; a live peer is expected here since this runs
|
| 331 |
+
BEFORE ``_backup_db_file``'s live-connection guard). Callers MUST NOT substitute a differently-shaped key:
|
| 332 |
+
the ledger compares for equality, so alternating shapes never match and the unbounded loop returns."""
|
| 333 |
+
def _sample() -> str:
|
| 334 |
+
st = db_path.stat()
|
| 335 |
+
with open(db_path, "rb") as fh:
|
| 336 |
+
head = fh.read(_FINGERPRINT_SAMPLE_BYTES)
|
| 337 |
+
fh.seek(max(0, st.st_size - _FINGERPRINT_SAMPLE_BYTES))
|
| 338 |
+
tail = fh.read(_FINGERPRINT_SAMPLE_BYTES) if st.st_size > _FINGERPRINT_SAMPLE_BYTES else b""
|
| 339 |
+
return f"{st.st_size}:{hashlib.sha256(_mask_volatile_header(head) + tail).hexdigest()[:32]}"
|
| 340 |
+
return _read_offline(db_path, "fingerprint", _sample)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _backup_content_identity(db_path: Path) -> "Optional[str]":
|
| 344 |
+
"""Recovery-image identity for forensic-backup dedupe: whole file + sidecars.
|
| 345 |
+
|
| 346 |
+
A DIFFERENT relation from :func:`_db_fingerprint` (never conflate them): the fingerprint answers "same
|
| 347 |
+
repair epoch?" from head+tail only, and a live writer can commit into an *interior* page while preserving
|
| 348 |
+
size and both 64 KiB slices — reusing a backup on that basis hands the operator a snapshot predating real
|
| 349 |
+
user data. A forensic copy must claim byte identity, so this digests the ENTIRE main file plus every
|
| 350 |
+
present sidecar (the WAL can hold uncheckpointed frames). ``None`` when a live connection makes the read
|
| 351 |
+
unsafe (:func:`_read_offline`) — the caller then takes a fresh backup, never a false reuse.
|
| 352 |
+
|
| 353 |
+
See #87409.
|
| 354 |
+
"""
|
| 355 |
+
def _digest() -> str:
|
| 356 |
+
hasher = hashlib.sha256()
|
| 357 |
+
members = [("main", db_path), *((sfx, p) for sfx, p in zip(_DB_SIDECAR_SUFFIXES, _sidecars(db_path)) if p.exists())]
|
| 358 |
+
for label, path in members:
|
| 359 |
+
# Length-delimit every member (main file included) so the concatenation is prefix-free; otherwise a
|
| 360 |
+
# main-file tail could coincide with a main+sidecar split and dedupe two images together.
|
| 361 |
+
hasher.update(f"\0{label}:{path.stat().st_size}\0".encode())
|
| 362 |
+
with open(path, "rb") as fh:
|
| 363 |
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
| 364 |
+
hasher.update(chunk)
|
| 365 |
+
return hasher.hexdigest()
|
| 366 |
+
return _read_offline(db_path, "backup-identity", _digest)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _read_repair_ledger(db_path: Path) -> "Dict[str, Any]":
|
| 370 |
+
with contextlib.suppress(OSError, ValueError):
|
| 371 |
+
raw = json.loads(_repair_ledger_path(db_path).read_text(encoding="utf-8"))
|
| 372 |
+
return raw if isinstance(raw, dict) else {}
|
| 373 |
+
return {}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _persistent_repair_attempts_exhausted(db_path: Path) -> bool:
|
| 377 |
+
"""Whether *db_path* has already burned its cross-restart repair budget.
|
| 378 |
+
|
| 379 |
+
True only when the ledger records ``_MAX_PERSISTENT_REPAIR_ATTEMPTS`` failures against the CURRENT
|
| 380 |
+
fingerprint. Never raises; a missing/corrupt ledger or unstatable DB reads as "not exhausted" (the
|
| 381 |
+
in-process claim and cross-process lock still bound one run). Fingerprint unavailable (live connection) ->
|
| 382 |
+
compare the SIZE the ledger recorded, otherwise a peer connection hides an exhausted budget on every pass."""
|
| 383 |
+
ledger = _read_repair_ledger(db_path)
|
| 384 |
+
recorded = ledger.get("fingerprint")
|
| 385 |
+
fp = _db_fingerprint(db_path)
|
| 386 |
+
try: # size is the one key component that needs no raw read
|
| 387 |
+
same = recorded == fp if fp is not None else (
|
| 388 |
+
isinstance(recorded, str) and recorded.startswith(f"{db_path.stat().st_size}:"))
|
| 389 |
+
except OSError:
|
| 390 |
+
return False
|
| 391 |
+
return same and int(ledger.get("failed_attempts", 0)) >= _MAX_PERSISTENT_REPAIR_ATTEMPTS
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _persistent_repair_exhausted_error(db_path: Path) -> str:
|
| 395 |
+
"""The stable operator-facing diagnostic for an exhausted repair budget. The ``hermes`` commands
|
| 396 |
+
carry the profile selector: a bare ``hermes`` follows ``active_profile`` (#105887)."""
|
| 397 |
+
from hermes_constants import profile_cli_selector
|
| 398 |
+
profile_arg = profile_cli_selector()
|
| 399 |
+
return (f"automatic repair has already failed {_MAX_PERSISTENT_REPAIR_ATTEMPTS} times on this exact file — the "
|
| 400 |
+
f"corruption is beyond the schema/FTS repair strategies (likely b-tree page damage). Manual recovery "
|
| 401 |
+
f"required: restore a backup, or salvage with `hermes {profile_arg}sessions recover --source {db_path} "
|
| 402 |
+
f"--inspect-only`, then (if it reports recoverable) `hermes {profile_arg}sessions recover --source {db_path} "
|
| 403 |
+
f"--output recovered-state.db` (recovery snapshots the damaged file first, then runs the page-level "
|
| 404 |
+
f"`.recover` lane on the copy; do NOT point a raw `sqlite3` shell at the live database). "
|
| 405 |
+
f"Delete {_repair_ledger_path(db_path).name} to force another automatic attempt.")
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _record_repair_outcome(db_path: Path, *, repaired: bool, fingerprint: "Optional[str]" = None) -> None:
|
| 409 |
+
"""Update the persistent attempt ledger after a repair pass. Never raises.
|
| 410 |
+
|
| 411 |
+
Keys on the post-attempt fingerprint (what the NEXT exhaustion probe sees). If a live connection makes it
|
| 412 |
+
unavailable, keep the recorded key and still increment — dropping the pass lets a peer reset the budget
|
| 413 |
+
every time. Never write a differently shaped key."""
|
| 414 |
+
ledger_path = _repair_ledger_path(db_path)
|
| 415 |
+
try:
|
| 416 |
+
if repaired:
|
| 417 |
+
ledger_path.unlink(missing_ok=True)
|
| 418 |
+
return
|
| 419 |
+
ledger = _read_repair_ledger(db_path)
|
| 420 |
+
recorded = ledger.get("fingerprint")
|
| 421 |
+
fp = fingerprint if fingerprint is not None else _db_fingerprint(db_path)
|
| 422 |
+
if fp is None:
|
| 423 |
+
if not isinstance(recorded, str):
|
| 424 |
+
# No prior key to extend and no safe way to mint one; the in-process claim and cross-process lock
|
| 425 |
+
# still bound this run.
|
| 426 |
+
return
|
| 427 |
+
fp = recorded
|
| 428 |
+
attempts = int(ledger.get("failed_attempts", 0)) + 1 if recorded == fp else 1
|
| 429 |
+
stamp = datetime.datetime.now().isoformat(timespec="seconds")
|
| 430 |
+
ledger_path.write_text(json.dumps({"fingerprint": fp, "failed_attempts": attempts, "last_attempt": stamp}), encoding="utf-8")
|
| 431 |
+
except Exception as exc: # pragma: no cover - best effort
|
| 432 |
+
logger.warning("Could not update state.db repair ledger: %s", exc)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _existing_malformed_backups(db_path: Path) -> "List[Path]":
|
| 436 |
+
"""Timestamped forensic backups of *db_path*, newest first."""
|
| 437 |
+
prefix = f"{db_path.name}.malformed-backup-"
|
| 438 |
+
try:
|
| 439 |
+
found = [p for p in db_path.parent.iterdir() if p.name.startswith(prefix) and not p.name.endswith(_DB_SIDECAR_SUFFIXES)]
|
| 440 |
+
except OSError:
|
| 441 |
+
return []
|
| 442 |
+
return sorted(found, key=lambda p: p.name, reverse=True)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _prune_malformed_backups(db_path: Path, keep: int = _MAX_MALFORMED_BACKUPS) -> None:
|
| 446 |
+
"""Delete all but the *keep* newest forensic backups (and sidecars)."""
|
| 447 |
+
for stale in _existing_malformed_backups(db_path)[keep:]:
|
| 448 |
+
for victim in (stale, *_sidecars(stale)):
|
| 449 |
+
try:
|
| 450 |
+
victim.unlink(missing_ok=True)
|
| 451 |
+
except OSError as exc: # pragma: no cover - best effort
|
| 452 |
+
logger.warning("Could not prune stale DB backup %s: %s", victim, exc)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def _publish_backup_bundle(db_path: Path, staging: Path, backup_path: Path) -> None:
|
| 456 |
+
"""Copy DB + sidecars to *staging* names, then rename each into place.
|
| 457 |
+
|
| 458 |
+
ORDER MATTERS: the main DB name is the bundle's commit marker (what ``_existing_malformed_backups``
|
| 459 |
+
counts), so sidecars publish FIRST and the main DB LAST — a failure partway never leaves a countable
|
| 460 |
+
backup over a missing sidecar. On failure, staging files AND anything promoted are removed."""
|
| 461 |
+
main = (db_path, staging, backup_path)
|
| 462 |
+
sidecars = [(sidecar, staging.with_name(staging.name + suffix), backup_path.with_name(backup_path.name + suffix))
|
| 463 |
+
for suffix, sidecar in zip(_DB_SIDECAR_SUFFIXES, _sidecars(db_path)) if sidecar.exists()]
|
| 464 |
+
published: "List[Path]" = []
|
| 465 |
+
try:
|
| 466 |
+
for src, staged, _dst in (main, *sidecars):
|
| 467 |
+
shutil.copy2(src, staged)
|
| 468 |
+
for _src, staged, dst in (*sidecars, main):
|
| 469 |
+
os.replace(staged, dst)
|
| 470 |
+
published.append(dst)
|
| 471 |
+
except Exception:
|
| 472 |
+
for victim in (*(staged for _s, staged, _d in (*sidecars, main)), *published):
|
| 473 |
+
with contextlib.suppress(OSError): # best effort; a failure here is never the caller's error
|
| 474 |
+
victim.unlink(missing_ok=True)
|
| 475 |
+
raise
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _backup_db_file(db_path: Path) -> "Tuple[Optional[Path], Optional[str]]":
|
| 479 |
+
"""Raw-copy a (possibly malformed) DB plus sidecars to a timestamped backup.
|
| 480 |
+
|
| 481 |
+
Raw bytes on purpose: the DB won't open cleanly, so preserve them exactly for forensics. Returns ``(backup_path,
|
| 482 |
+
None)`` or ``(None, reason)``; repair treats a refused backup as a HARD STOP because the bundle is the recovery
|
| 483 |
+
path when every strategy fails. Refuses while a connection to this DB is live in the process: the raw read would
|
| 484 |
+
``close()`` a descriptor and cancel that connection's POSIX advisory locks (``hermes_cli.sqlite_safe_read``) —
|
| 485 |
+
real case: one SessionDB enters repair while the gateway holds others.
|
| 486 |
+
|
| 487 |
+
Dedupe: reuse the newest backup when byte-identical to the current recovery image (``_backup_content_identity``
|
| 488 |
+
— NOT mtime, NOT ``_db_fingerprint``); a repair loop once re-copied the same bytes on every restart. Staging
|
| 489 |
+
names live OUTSIDE the ``.malformed-backup-`` prefix: inside it they count as a backup, sort NEWEST (prune kept
|
| 490 |
+
partials, deleted intact copies) and dedupe could return one with no real forensic copy on disk.
|
| 491 |
+
|
| 492 |
+
Refusal reasons (``_backup_free_space_error`` / ``_MANUAL_RECOVER_HINT``) point operators at the safe lane,
|
| 493 |
+
`hermes sessions recover --source <db> --inspect-only`, never at a raw sqlite3 shell on the live file.
|
| 494 |
+
|
| 495 |
+
See #69603.
|
| 496 |
+
"""
|
| 497 |
+
with contextlib.suppress(ImportError): # scaffold/embed installs without hermes_cli track no connections
|
| 498 |
+
from hermes_cli.sqlite_safe_read import has_live_connection
|
| 499 |
+
if has_live_connection(db_path):
|
| 500 |
+
reason = (f"a connection to {db_path} is still open in this process; raw-copying it would cancel that "
|
| 501 |
+
"connection's POSIX advisory locks. Close all SessionDB handles first.")
|
| 502 |
+
logger.error("Refusing to raw-copy %s for backup: %s", db_path, reason)
|
| 503 |
+
return None, reason
|
| 504 |
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 505 |
+
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
|
| 506 |
+
for seq in itertools.count(1): # same-second collision must not overwrite the earlier forensic copy
|
| 507 |
+
if not backup_path.exists():
|
| 508 |
+
break
|
| 509 |
+
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}_{seq}")
|
| 510 |
+
try:
|
| 511 |
+
# Sweep staging debris from an interrupted pass BEFORE the dedupe (it is byte-identical to the damaged
|
| 512 |
+
# DB, so dedupe would hand it back as a backup). Also the old ``.incomplete`` spelling, which
|
| 513 |
+
# prefix-matches as a backup, sorts NEWEST and would otherwise survive prune forever.
|
| 514 |
+
for pattern in (f"{db_path.name}.backup-staging-*", f"{db_path.name}.malformed-backup-*.incomplete*"):
|
| 515 |
+
for old in db_path.parent.glob(pattern):
|
| 516 |
+
with contextlib.suppress(OSError):
|
| 517 |
+
old.unlink(missing_ok=True)
|
| 518 |
+
with contextlib.suppress(OSError):
|
| 519 |
+
# Hash the source only when there is a candidate: hashing a multi-GB file before copying it is waste.
|
| 520 |
+
if newest := _existing_malformed_backups(db_path)[:1]:
|
| 521 |
+
src_id = _backup_content_identity(db_path)
|
| 522 |
+
if src_id is not None and _backup_content_identity(newest[0]) == src_id:
|
| 523 |
+
logger.info("Reusing existing forensic backup %s (identical to the damaged DB).", newest[0])
|
| 524 |
+
return newest[0], None
|
| 525 |
+
if (reason := _backup_free_space_error(db_path)) is not None:
|
| 526 |
+
logger.error("Refusing forensic backup of %s: %s", db_path, reason)
|
| 527 |
+
return None, reason
|
| 528 |
+
_publish_backup_bundle(db_path, db_path.with_name(f"{db_path.name}.backup-staging-{stamp}"), backup_path)
|
| 529 |
+
# Retention cap (#86747): keep only the newest few forensic copies.
|
| 530 |
+
_prune_malformed_backups(db_path)
|
| 531 |
+
return backup_path, None
|
| 532 |
+
except Exception as exc: # pragma: no cover - best effort
|
| 533 |
+
logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
|
| 534 |
+
return None, f"backup copy failed: {exc}"
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def preflight_db_writability(db_path: Path, *, db_label: str = "state.db") -> None:
|
| 538 |
+
"""Refuse-or-repair read-only DB files BEFORE the first connection opens.
|
| 539 |
+
|
| 540 |
+
A stray read-only ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored backup, copied dotfiles) otherwise
|
| 541 |
+
surfaces as an opaque "attempt to write a readonly database" inside ``_init_schema``, and the obvious wrong
|
| 542 |
+
"fix" (deleting the ``-wal``) loses committed transactions. ``chmod u+rw`` repair only inside the Hermes home
|
| 543 |
+
tree (Hermes owns those files; ``chmod`` fails on files the user doesn't own, bounding the repair exactly);
|
| 544 |
+
otherwise fail fast naming the file and command. Never deletes/truncates a WAL sidecar — once writable, the
|
| 545 |
+
normal open checkpoints it. ``:memory:``/``file:`` skipped. Shared with ``kanban_db``.
|
| 546 |
+
|
| 547 |
+
Port of Kilo-Org/kilocode#12508's startup preflight. This preflight:
|
| 548 |
+
"""
|
| 549 |
+
if str(db_path) == ":memory:" or str(db_path).startswith("file:"):
|
| 550 |
+
return
|
| 551 |
+
home: Optional[Path] = None
|
| 552 |
+
with contextlib.suppress(Exception): # pragma: no cover - defensive
|
| 553 |
+
home = Path(get_hermes_home()).resolve()
|
| 554 |
+
# SQLite needs a writable directory in every journal mode (WAL/SHM sidecars, or the DELETE-mode journal).
|
| 555 |
+
sidecars = (db_path.with_name(db_path.name + "-wal"), db_path.with_name(db_path.name + "-shm"))
|
| 556 |
+
for p, is_dir in [(db_path.parent, True), *((p, False) for p in (db_path, *sidecars) if p.is_file())]:
|
| 557 |
+
if (is_dir and not p.is_dir()) or os.access(p, os.R_OK | os.W_OK):
|
| 558 |
+
continue
|
| 559 |
+
x = "x" if is_dir else ""
|
| 560 |
+
in_scope = False
|
| 561 |
+
with contextlib.suppress(OSError, ValueError):
|
| 562 |
+
in_scope = home is not None and p.resolve().is_relative_to(home)
|
| 563 |
+
if in_scope:
|
| 564 |
+
os.chmod(p, p.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | (stat.S_IXUSR if is_dir else 0))
|
| 565 |
+
if in_scope and os.access(p, os.R_OK | os.W_OK):
|
| 566 |
+
logger.info("%s preflight: repaired read-only %s (chmod u+rw%s)", db_label, p, x)
|
| 567 |
+
continue
|
| 568 |
+
wal_note = (" Do NOT delete the -wal file — it contains committed data that "
|
| 569 |
+
"will be merged into the database once it is writable." if p.name.endswith("-wal") else "")
|
| 570 |
+
raise sqlite3.OperationalError(
|
| 571 |
+
f"{db_label} is not writable: {'directory' if is_dir else 'file'} {p} is read-only for this user. Hermes "
|
| 572 |
+
f"needs read-write access to open the database. Fix with: chmod u+rw{x} '{p}' (files owned by another "
|
| 573 |
+
f"user may need sudo/chown).{wal_note}")
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def _connect_repair_durable(db_path: Path, *, timeout: float = 5.0) -> sqlite3.Connection:
|
| 577 |
+
"""``sqlite3.connect`` for the repair/probe paths, with macOS write barriers.
|
| 578 |
+
|
| 579 |
+
These paths bypass ``SessionDB``/:func:`apply_wal_with_fallback`, so they inherited ``synchronous=NORMAL`` and
|
| 580 |
+
no ``checkpoint_fullfsync`` — on Darwin an interrupted ``REINDEX``/``VACUUM``/``writable_schema`` rewrite leaves
|
| 581 |
+
half-written b-tree pages. Autocommit (``isolation_level=None``): DDL and ``VACUUM`` are illegal inside an
|
| 582 |
+
implicit transaction. Barriers are best-effort: on a malformed schema even ``PRAGMA synchronous=FULL`` raises,
|
| 583 |
+
so whole-file rewrites call :func:`_reapply_durability_barriers` once the schema parses again."""
|
| 584 |
+
conn = sqlite3.connect(str(db_path), timeout=timeout, isolation_level=None)
|
| 585 |
+
_reapply_durability_barriers(conn)
|
| 586 |
+
return conn
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def _repair_conn(db_path: Path, *, timeout: float = 5.0):
|
| 590 |
+
"""A :func:`_connect_repair_durable` connection as a context manager, closed on exit."""
|
| 591 |
+
return contextlib.closing(_connect_repair_durable(db_path, timeout=timeout))
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
| 595 |
+
"""Best-effort (re)application of the macOS write barriers; True if accepted.
|
| 596 |
+
Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened
|
| 597 |
+
on a malformed schema could not take them at open time. Never raises."""
|
| 598 |
+
from hermes_state_wal import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
|
| 599 |
+
try:
|
| 600 |
+
_apply_macos_checkpoint_barrier(conn)
|
| 601 |
+
_enforce_macos_synchronous_full(conn)
|
| 602 |
+
return True
|
| 603 |
+
except Exception: # schema still unparseable, or anything else — pragmas cannot be set yet
|
| 604 |
+
return False
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
| 608 |
+
"""Durability barriers for guest users of ``state.db`` that must inherit its owner's journal mode. Also
|
| 609 |
+
applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only
|
| 610 |
+
rides on the journal-mode setup path guests must not run."""
|
| 611 |
+
from hermes_state_wal import _apply_synchronous_pragma
|
| 612 |
+
ok = _reapply_durability_barriers(conn)
|
| 613 |
+
with contextlib.suppress(Exception):
|
| 614 |
+
from hermes_cli.config import cfg_get, load_config_readonly # local: avoids an import cycle
|
| 615 |
+
if (raw_synchronous := cfg_get(load_config_readonly(), "database", "synchronous", default=None)) is not None:
|
| 616 |
+
_apply_synchronous_pragma(conn, raw_synchronous, db_label="state.db (guest)")
|
| 617 |
+
return ok
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _close_unpinned(conn: sqlite3.Connection) -> None:
|
| 621 |
+
"""Leave EXCLUSIVE locking mode (so the file is never left pinned) and close."""
|
| 622 |
+
with contextlib.suppress(Exception):
|
| 623 |
+
conn.execute("PRAGMA locking_mode=NORMAL")
|
| 624 |
+
conn.close()
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _open_exclusive(db_path: Path, begin: str) -> sqlite3.Connection:
|
| 628 |
+
"""Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back
|
| 629 |
+
*begin*; closed (unpinned) and re-raised when exclusion cannot be taken."""
|
| 630 |
+
conn = _connect_repair_durable(db_path, timeout=0.0)
|
| 631 |
+
try:
|
| 632 |
+
for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"):
|
| 633 |
+
conn.execute(statement)
|
| 634 |
+
except BaseException:
|
| 635 |
+
_close_unpinned(conn)
|
| 636 |
+
raise
|
| 637 |
+
return conn
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
@contextlib.contextmanager
|
| 641 |
+
def _exclusive_repair_db_guard(db_path: Path):
|
| 642 |
+
"""Yield ``(conn, None)`` — one live connection that excludes writers for repair surgery — or ``(None,
|
| 643 |
+
exc)`` when exclusion could not be taken.
|
| 644 |
+
|
| 645 |
+
``locking_mode=EXCLUSIVE`` retains file-level exclusion after the short ``BEGIN EXCLUSIVE`` is rolled
|
| 646 |
+
back; the rollback is essential because ``Connection.backup`` uses this connection as *source* and later
|
| 647 |
+
as the promotion *destination*, both transaction-free. It stays open across the snapshot -> strategies ->
|
| 648 |
+
promotion window so no writer can commit a change promotion would overwrite. Existing readers make
|
| 649 |
+
acquisition fail rather than being disturbed (fail closed). Timeout 0: the cross-process lock already
|
| 650 |
+
serializes repairers, and a partial repair is less safe than "stop the gateway and retry"."""
|
| 651 |
+
try:
|
| 652 |
+
guard = _open_exclusive(db_path, "BEGIN EXCLUSIVE")
|
| 653 |
+
except (sqlite3.Error, OSError) as exc:
|
| 654 |
+
yield None, exc
|
| 655 |
+
return
|
| 656 |
+
try:
|
| 657 |
+
yield guard, None
|
| 658 |
+
finally:
|
| 659 |
+
# Releasing the exclusive locks before close keeps a close-time checkpoint from being mistaken for a
|
| 660 |
+
# repair write by callers that reopen immediately.
|
| 661 |
+
_close_unpinned(guard)
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _copy_database_snapshot(source_path: Path, destination_path: Path, *,
|
| 665 |
+
source_connection: Optional[sqlite3.Connection] = None,
|
| 666 |
+
destination_connection: Optional[sqlite3.Connection] = None) -> None:
|
| 667 |
+
"""Copy one complete SQLite snapshot without replacing either file inode: the online backup API folds
|
| 668 |
+
committed WAL frames into the source snapshot and writes the destination in one transaction (rolled
|
| 669 |
+
back if interrupted), so ``state.db`` is never swapped out from under handles that refer to it."""
|
| 670 |
+
# Deadline first: a sidecar vanishing mid-stat must not leak a just-opened descriptor.
|
| 671 |
+
deadline_seconds = _repair_snapshot_timeout_seconds(source_path)
|
| 672 |
+
deadline = time.monotonic() + deadline_seconds
|
| 673 |
+
|
| 674 |
+
def _check_deadline(_status: int, _remaining: int, _total: int) -> None:
|
| 675 |
+
if time.monotonic() >= deadline:
|
| 676 |
+
raise TimeoutError(f"timed out copying SQLite repair snapshot after {deadline_seconds:.0f}s")
|
| 677 |
+
|
| 678 |
+
with contextlib.ExitStack() as owned: # closes only connections opened here, destination first
|
| 679 |
+
source = source_connection or owned.enter_context(_repair_conn(source_path))
|
| 680 |
+
if destination_connection is not None and destination_connection.in_transaction:
|
| 681 |
+
# sqlite3_backup needs a transaction-free destination (the guard holds exclusion via locking_mode).
|
| 682 |
+
raise sqlite3.ProgrammingError("SQLite repair backup destination has an active transaction")
|
| 683 |
+
destination = destination_connection or owned.enter_context(_repair_conn(destination_path))
|
| 684 |
+
source.backup(destination, pages=256, progress=_check_deadline, sleep=_REPAIR_LOCK_POLL_SECONDS)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
def _schema_not_built(exc: BaseException) -> bool:
|
| 688 |
+
"""``no such table/column``: FTS5 / core tables not created yet (brand new file mid-init)."""
|
| 689 |
+
return any(m in str(exc).lower() for m in ("no such table", "no such column"))
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
# Hermes-owned FTS5 objects: the virtual tables and their shadow b-trees. Full-matched, so a
|
| 693 |
+
# user-created lookalike (``archive_fts_data``) is not swept into the rebuildable set.
|
| 694 |
+
_FTS_OBJECT_RE = re.compile(
|
| 695 |
+
r"messages_fts(_trigram|_cjk)?(_data|_idx|_content|_docsize|_config|_segdir|_segments)?"
|
| 696 |
+
)
|
| 697 |
+
_INTEGRITY_TREE_RE = re.compile(r"\bTree (\d+)\b")
|
| 698 |
+
_INTEGRITY_MISSING_INDEX_RE = re.compile(r"missing from index (\S+)")
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def integrity_damage_is_structural(integrity_lines, master_rows) -> bool:
|
| 702 |
+
"""True when any damaged object named by ``PRAGMA integrity_check`` output lies outside
|
| 703 |
+
the FTS shadow set: a ``Tree N`` id mapped through ``sqlite_master.rootpage``, an index in
|
| 704 |
+
``row N missing from index X``, or the file's own freelist. Unparseable lines are not
|
| 705 |
+
counted (the FTS wording stays, which is incomplete rather than wrong). #88587: an FTS
|
| 706 |
+
rebuild cannot repair a canonical b-tree, and the ``.malformed-backup`` it leaves behind
|
| 707 |
+
is a snapshot of the same damage."""
|
| 708 |
+
name_by_rootpage = {int(rp): name for rp, _type, name in master_rows if rp}
|
| 709 |
+
for line in integrity_lines:
|
| 710 |
+
text = str(line)
|
| 711 |
+
if text.startswith("Freelist"):
|
| 712 |
+
return True
|
| 713 |
+
tree = _INTEGRITY_TREE_RE.search(text)
|
| 714 |
+
if tree:
|
| 715 |
+
name = name_by_rootpage.get(int(tree.group(1)), "")
|
| 716 |
+
if name and not _FTS_OBJECT_RE.fullmatch(name):
|
| 717 |
+
return True
|
| 718 |
+
missing = _INTEGRITY_MISSING_INDEX_RE.search(text)
|
| 719 |
+
if missing and not _FTS_OBJECT_RE.fullmatch(missing.group(1)):
|
| 720 |
+
return True
|
| 721 |
+
return False
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def state_db_has_structural_damage(db_path: Path) -> bool:
|
| 725 |
+
"""Read-only ``integrity_check`` + ``sqlite_master`` rootpage map on a fresh connection;
|
| 726 |
+
``integrity_damage_is_structural`` over the result. A check that RAISES instead of
|
| 727 |
+
reporting (a torn page under the walk) is structural too: no FTS-only fixture does that
|
| 728 |
+
while ``messages``/``sessions`` read cleanly, and the FTS rebuild ladder cannot help.
|
| 729 |
+
Cannot-open / locked stays False so the caller keeps the FTS path."""
|
| 730 |
+
try:
|
| 731 |
+
conn = sqlite3.connect(read_only_db_uri(db_path), uri=True, timeout=1.0)
|
| 732 |
+
except sqlite3.Error:
|
| 733 |
+
return False
|
| 734 |
+
try:
|
| 735 |
+
master_rows = [tuple(r) for r in conn.execute(
|
| 736 |
+
"SELECT rootpage, type, name FROM sqlite_master WHERE rootpage > 0").fetchall()]
|
| 737 |
+
lines = [str(r[0]) for r in conn.execute("PRAGMA integrity_check").fetchall()]
|
| 738 |
+
except sqlite3.OperationalError:
|
| 739 |
+
return False
|
| 740 |
+
except sqlite3.DatabaseError:
|
| 741 |
+
return True
|
| 742 |
+
finally:
|
| 743 |
+
conn.close()
|
| 744 |
+
return integrity_damage_is_structural(
|
| 745 |
+
itertools.chain.from_iterable(line.splitlines() for line in lines), master_rows)
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
|
| 749 |
+
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
|
| 750 |
+
|
| 751 |
+
Runs the first statement that trips the malformed-schema parse (``PRAGMA journal_mode``),
|
| 752 |
+
``integrity_check``, a ``sessions`` read, FTS5 MATCH probes and a rolled-back ``messages`` write — so FTS5
|
| 753 |
+
index corruption (reads and ``integrity_check`` pass, every ``INSERT INTO messages`` fails through the FTS
|
| 754 |
+
triggers) is reported as unhealthy.
|
| 755 |
+
|
| 756 |
+
See #50502.
|
| 757 |
+
"""
|
| 758 |
+
from hermes_state import SessionDB, load_fts5_cjk_extension
|
| 759 |
+
# ── Strategy 0.5: rebuild stale B-tree indexes (#63386) ── PRAGMA integrity_check can report "wrong #
|
| 760 |
+
# of entries in index" when a B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
|
| 761 |
+
# base table. REINDEX rewrites the index b-tree from the canonical table rows using the existing index
|
| 762 |
+
# definition, fixing the mismatch without touching data or FTS schema.
|
| 763 |
+
conn = _connect_repair_durable(db_path)
|
| 764 |
+
try:
|
| 765 |
+
with contextlib.closing(conn):
|
| 766 |
+
# Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61 before any statement can touch it;
|
| 767 |
+
# tokenizer absence must never classify as corruption.
|
| 768 |
+
load_fts5_cjk_extension(conn)
|
| 769 |
+
conn.execute("PRAGMA journal_mode").fetchone()
|
| 770 |
+
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
| 771 |
+
problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
|
| 772 |
+
if problems:
|
| 773 |
+
return "; ".join(problems[:3])
|
| 774 |
+
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
|
| 775 |
+
# FTS5 read probe: partial shadow-table corruption makes MATCH/snippet/rank raise while integrity_check
|
| 776 |
+
# reports healthy. MATCH '""' (empty phrase) parses, scans zero rows and exercises the shadow tables;
|
| 777 |
+
# FTS5 rejects MATCH ''.
|
| 778 |
+
for fts_table in _FTS_TABLES:
|
| 779 |
+
try:
|
| 780 |
+
conn.execute(f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1").fetchone()
|
| 781 |
+
except sqlite3.DatabaseError as exc:
|
| 782 |
+
# Builds without fts5/trigram raise "no such module|tokenizer"; calling that corruption would
|
| 783 |
+
# send the DB into repair, whose final fallback deletes messages_fts%. "no such table/column" =
|
| 784 |
+
# not built yet.
|
| 785 |
+
benign = SessionDB._is_fts5_unavailable_error(exc) or _schema_not_built(exc)
|
| 786 |
+
if not (isinstance(exc, sqlite3.OperationalError) and benign):
|
| 787 |
+
# This is the corruption class #66724 actually wants caught: partial shadow-table
|
| 788 |
+
# damage where MATCH / snippet / rank queries raise DatabaseError("database disk
|
| 789 |
+
# image is malformed") while reads of the FTS5 table itself parse fine.
|
| 790 |
+
return f"fts5 read probe failed on {fts_table}: {exc}"
|
| 791 |
+
# FTS write probe: drive a row through the messages_fts* triggers in a transaction that is always
|
| 792 |
+
# rolled back. The trigger INSERT alone only buffers the row in FTS5's in-memory segment; the
|
| 793 |
+
# ``flush`` command writes that segment to ``<fts>_idx``/``_data`` exactly as a committed append
|
| 794 |
+
# would, so a stale ``_idx`` row at the next segid (IntegrityError "constraint failed", the #100227
|
| 795 |
+
# class: integrity_check and MATCH both clean, every real append fails) is hit here rather than
|
| 796 |
+
# by the user's next message.
|
| 797 |
+
probe_session_id = f"_hermes_fts_health_probe_{time.time_ns()}"
|
| 798 |
+
try:
|
| 799 |
+
conn.execute("BEGIN IMMEDIATE")
|
| 800 |
+
conn.execute("INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
| 801 |
+
(probe_session_id, "_health_probe", time.time()))
|
| 802 |
+
conn.execute("INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
|
| 803 |
+
(probe_session_id, "user", "_fts_health_probe", time.time()))
|
| 804 |
+
for fts_table in _FTS_TABLES:
|
| 805 |
+
try:
|
| 806 |
+
conn.execute(f"INSERT INTO {fts_table}({fts_table}) VALUES('flush')")
|
| 807 |
+
except sqlite3.OperationalError as exc:
|
| 808 |
+
if not (SessionDB._is_fts5_unavailable_error(exc) or _schema_not_built(exc)):
|
| 809 |
+
raise
|
| 810 |
+
except sqlite3.DatabaseError as exc:
|
| 811 |
+
# IntegrityError is a DatabaseError sibling of OperationalError, not a child: catching only the
|
| 812 |
+
# latter let the trigram-segment collision report "healthy".
|
| 813 |
+
# Missing messages/sessions tables = brand new file mid-init, not corruption. "no such tokenizer":
|
| 814 |
+
# this process lacks the cjk extension the DB's index needs — capability gap; a tokenizer-less
|
| 815 |
+
# SessionDB drops the triggers itself.
|
| 816 |
+
if _schema_not_built(exc) or "no such tokenizer: cjk_unicode61" in str(exc).lower():
|
| 817 |
+
return None
|
| 818 |
+
return f"fts5 write probe failed: {exc}"
|
| 819 |
+
finally:
|
| 820 |
+
with contextlib.suppress(sqlite3.Error):
|
| 821 |
+
conn.execute("ROLLBACK")
|
| 822 |
+
return None
|
| 823 |
+
except sqlite3.DatabaseError as exc:
|
| 824 |
+
return str(exc)
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
def _live_writer_holds_db(db_path: Path) -> bool:
|
| 828 |
+
"""True when another process (or a connection outside this call) still holds ``db_path``.
|
| 829 |
+
|
| 830 |
+
The foreign-holder scan (``hermes_state_holders``) is the authority: any other process with the DB or a
|
| 831 |
+
WAL sidecar open, a deleted WAL generation, or an unknown/uninspectable holder fails CLOSED. The SQLite
|
| 832 |
+
probe (``locking_mode=EXCLUSIVE`` + ``BEGIN IMMEDIATE``) is only an additional positive signal — it cannot
|
| 833 |
+
see a ``journal_mode=DELETE`` reader and cannot run on a malformed file, which is why the scan comes first."""
|
| 834 |
+
import hermes_state_holders as _state_holders
|
| 835 |
+
return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect_repair_durable)
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def _repair_skip(report: Dict[str, Any], verb: str, error: str, exc: Optional[BaseException] = None) -> Dict[str, Any]:
|
| 839 |
+
"""Record *error* on *report* and log it as ``state.db repair <verb>``. An
|
| 840 |
+
*exc* proving deterministic corruption consumes the persistent repair budget
|
| 841 |
+
(private ``_repair_attempted`` marker, popped by the caller)."""
|
| 842 |
+
if exc is not None and _repair_failure_consumes_attempt(exc):
|
| 843 |
+
report["_repair_attempted"] = True
|
| 844 |
+
report["error"] = error
|
| 845 |
+
logger.error(f"state.db repair {verb}: %s", error)
|
| 846 |
+
return report
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
|
| 850 |
+
"""Repair a state.db whose ``sqlite_master`` is malformed or whose FTS indexes reject writes.
|
| 851 |
+
|
| 852 |
+
Two corruption classes: malformed schema / "duplicate object definition" (even ``PRAGMA`` fails), and FTS
|
| 853 |
+
write-corruption (reads and ``integrity_check`` pass, writes fail through ``messages_fts*`` triggers).
|
| 854 |
+
``_REPAIR_STRATEGIES`` run least-destructive first on a complete snapshot; a success is copied back
|
| 855 |
+
transactionally, so canonical rows are never modified by a failed attempt. A raw backup is taken first
|
| 856 |
+
unless ``backup=False``. Serialised across processes (gateway, Desktop backend and CLI open the same file;
|
| 857 |
+
concurrent ``writable_schema`` surgery is itself a corruption source). Returns ``{repaired, strategy,
|
| 858 |
+
backup_path, error}``.
|
| 859 |
+
|
| 860 |
+
See #50502.
|
| 861 |
+
"""
|
| 862 |
+
report: Dict[str, Any] = {"repaired": False, "strategy": None, "backup_path": None, "error": None}
|
| 863 |
+
# Startup-watchdog lease: repair is I/O-bound (near-zero CPU), which the watchdog's CPU fallback would
|
| 864 |
+
# misread as a parked deadlock. One lease (clamped to _MAX_LEASE_S=900) beats per-chunk renewal complexity.
|
| 865 |
+
report_startup_progress(900.0, phase="state_db_repair")
|
| 866 |
+
db_path = Path(db_path)
|
| 867 |
+
if not db_path.exists():
|
| 868 |
+
report["error"] = f"{db_path} does not exist"
|
| 869 |
+
return report
|
| 870 |
+
# Cross-restart cap: the in-memory claim bounds one process, but unhealable b-tree damage used to re-run
|
| 871 |
+
# surgery + a fresh backup on EVERY restart.
|
| 872 |
+
# Cross-restart attempt cap (#86747): the in-memory claim bounds one process, but a corruption class the
|
| 873 |
+
# strategies below cannot heal (b-tree page damage) previously re-ran the whole surgery — and took a
|
| 874 |
+
# fresh multi-hundred-MB forensic backup — on EVERY restart, forever. After
|
| 875 |
+
# _MAX_PERSISTENT_REPAIR_ATTEMPTS failures against the same damaged file, stop retrying and surface a
|
| 876 |
+
# terminal, actionable error.
|
| 877 |
+
if _persistent_repair_attempts_exhausted(db_path):
|
| 878 |
+
return _repair_skip(report, "skipped", _persistent_repair_exhausted_error(db_path))
|
| 879 |
+
|
| 880 |
+
with _cross_process_repair_lock(db_path) as holding_lock:
|
| 881 |
+
if not holding_lock:
|
| 882 |
+
# Another process holds the lock (or the lock file was unopenable); it may have healed the file
|
| 883 |
+
# already, so re-probe before failing.
|
| 884 |
+
if _db_opens_cleanly(db_path) is None:
|
| 885 |
+
report["repaired"], report["strategy"] = True, "repaired_by_other_process"
|
| 886 |
+
else:
|
| 887 |
+
report["error"] = ("could not obtain the state.db repair lock (held by another process, or the lock "
|
| 888 |
+
"file was unopenable); skipped schema surgery to avoid racing a concurrent repairer")
|
| 889 |
+
return report
|
| 890 |
+
|
| 891 |
+
result = report
|
| 892 |
+
# Recheck exhaustion after acquisition: a queued repairer can have recorded the final failure while
|
| 893 |
+
# this process waited.
|
| 894 |
+
if _persistent_repair_attempts_exhausted(db_path):
|
| 895 |
+
_repair_skip(report, "skipped", _persistent_repair_exhausted_error(db_path))
|
| 896 |
+
# WAL-holder preflight: fail closed for active readers before a backup is taken. Not the race defence — the
|
| 897 |
+
# exclusive guard in the locked routine excludes writers through promotion and sees DELETE-mode readers too.
|
| 898 |
+
elif _live_writer_holds_db(db_path):
|
| 899 |
+
_repair_skip(report, "skipped", "a live writer still holds state.db; skipped schema surgery to avoid tearing "
|
| 900 |
+
"b-tree pages under a concurrent writer. Stop the gateway (hermes gateway stop) and retry.")
|
| 901 |
+
else:
|
| 902 |
+
# Probe journal mode BEFORE surgery: a rebuilt file comes back in the default (delete) mode and nothing
|
| 903 |
+
# else records the flip. Unprobeable (damaged file) -> database.journal_mode is the restore target.
|
| 904 |
+
# The open-time WAL-reset gate never sees this flip because it happens inside the repair path
|
| 905 |
+
# (distinct from the open-time flip #89393 warns about).
|
| 906 |
+
before_mode = _probe_journal_mode_for_repair(db_path)
|
| 907 |
+
result = _repair_state_db_schema_locked(db_path, backup=backup, report=report,
|
| 908 |
+
journal_mode_before=before_mode)
|
| 909 |
+
if result.get("repaired"):
|
| 910 |
+
result["journal_mode_before"] = before_mode
|
| 911 |
+
# Environmental aborts (before a strategy mutates the snapshot) are retriable, not proof of exhaustion;
|
| 912 |
+
# the private marker stays out of the public report. The ledger update stays under the cross-process
|
| 913 |
+
# lock so two repairers cannot lose each other's updates; a queued loser must not record at all.
|
| 914 |
+
if result.pop("_repair_attempted", False) or result.get("repaired"):
|
| 915 |
+
_record_repair_outcome(db_path, repaired=bool(result.get("repaired")))
|
| 916 |
+
return result
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
|
| 920 |
+
"""Best-effort journal-mode probe: ``wal``/``delete``, or ``None`` when the file cannot be opened or
|
| 921 |
+
probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then
|
| 922 |
+
fall back to ``database.journal_mode``."""
|
| 923 |
+
from hermes_state_wal import _on_disk_journal_mode
|
| 924 |
+
try:
|
| 925 |
+
with _repair_conn(db_path) as conn:
|
| 926 |
+
return _on_disk_journal_mode(conn)
|
| 927 |
+
except (sqlite3.Error, OSError):
|
| 928 |
+
return None
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str], *, conn=None) -> None:
|
| 932 |
+
"""Re-apply the journal mode after schema surgery.
|
| 933 |
+
|
| 934 |
+
``conn`` must be the exclusive repair guard connection when called from the repair path: opening a fresh
|
| 935 |
+
connection AFTER the guard released let a writer still holding the unlinked old ``-wal`` inode coexist with
|
| 936 |
+
a brand-new ``state.db-wal`` — two generations of one store. The reopen is the hazard, not the mode.
|
| 937 |
+
|
| 938 |
+
A rebuilt file comes back in the default (delete) mode; without this a corruption event silently moves a
|
| 939 |
+
WAL store out of WAL (the open-time WAL-reset gate never sees a flip made inside repair). Routed through
|
| 940 |
+
:func:`apply_wal_with_fallback`, not a direct pragma, so it inherits the WAL-reset gate (a vulnerable
|
| 941 |
+
runtime deliberately keeps DELETE; the journal_mode-changed WARNING is expected there), the macOS-NFS
|
| 942 |
+
silent-refusal handling and the WAL companions. ``before_mode`` is only for the log comparison; the target
|
| 943 |
+
is ``database.journal_mode``. Best-effort: the repair already succeeded, so failures log at WARNING.
|
| 944 |
+
|
| 945 |
+
See #89674.
|
| 946 |
+
See #89393.
|
| 947 |
+
The transactional promotion already leaves the destination in its pre-repair mode, so on that path this
|
| 948 |
+
is mostly the WAL-companion re-assertion; the reopen is the hazard, not the mode. See #101064.
|
| 949 |
+
"""
|
| 950 |
+
from hermes_state_wal import apply_wal_with_fallback
|
| 951 |
+
try:
|
| 952 |
+
if conn is None:
|
| 953 |
+
with _repair_conn(db_path) as owned:
|
| 954 |
+
after = apply_wal_with_fallback(owned, db_label=db_path.name)
|
| 955 |
+
else:
|
| 956 |
+
after = apply_wal_with_fallback(conn, db_label=db_path.name)
|
| 957 |
+
if before_mode and after != before_mode:
|
| 958 |
+
logger.warning("state.db repair changed journal_mode %r -> %r (pre-surgery probe %r; restore resolved "
|
| 959 |
+
"through apply_wal_with_fallback per database.journal_mode and the WAL-reset gate)",
|
| 960 |
+
before_mode, after, before_mode)
|
| 961 |
+
except (sqlite3.Error, OSError) as exc:
|
| 962 |
+
logger.warning("state.db repair at %s: post-surgery journal-mode restore failed (%s); verify with PRAGMA "
|
| 963 |
+
"journal_mode on the next open", db_path, exc)
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
def _repair_state_db_schema_locked(
|
| 967 |
+
db_path: Path, *, backup: bool, report: Dict[str, Any], journal_mode_before: Optional[str] = None,
|
| 968 |
+
) -> Dict[str, Any]:
|
| 969 |
+
"""Repair strategies for :func:`repair_state_db_schema`; caller holds the cross-process repair lock.
|
| 970 |
+
|
| 971 |
+
Strategies run on a SCRATCH COPY, copied back through SQLite's transactional backup API only once proven to open
|
| 972 |
+
cleanly, so a failed repair cannot modify or lose committed data (a WAL checkpoint of committed frames on guard
|
| 973 |
+
release is not a repair mutation). WHY not in place: the final strategy ends in ``VACUUM``, which rebuilds the
|
| 974 |
+
file from the schema SQLite can still parse — when the damage IS in the schema b-tree (the ``malformed database
|
| 975 |
+
schema ()`` class) every table hanging off the unreadable part is silently dropped, the probe still reports
|
| 976 |
+
malformed, and repair returned ``repaired=False`` having destroyed what it was asked to save.
|
| 977 |
+
|
| 978 |
+
The pre-repair backup (#69603) does not close this: it is a forensic artefact that nothing reads back,
|
| 979 |
+
so recovery still depends on a human noticing a ``.malformed-backup-*`` file and knowing what to do with
|
| 980 |
+
it. Not mutating the original in the first place is the property that holds without a human in the loop.
|
| 981 |
+
"""
|
| 982 |
+
scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
|
| 983 |
+
if (cleanup_error := _unlink_db_triple(scratch)) is not None:
|
| 984 |
+
return _repair_skip(report, "aborted", f"could not remove a stale repair snapshot before probing state.db: {cleanup_error}")
|
| 985 |
+
# Re-probe under the lock: a process we queued behind may have just repaired the file; redoing surgery
|
| 986 |
+
# would undo it (the repair/re-corrupt cascade).
|
| 987 |
+
if _db_opens_cleanly(db_path) is None:
|
| 988 |
+
report["repaired"], report["strategy"] = True, "already_healthy"
|
| 989 |
+
return report
|
| 990 |
+
if backup:
|
| 991 |
+
bpath, backup_error = _backup_db_file(db_path)
|
| 992 |
+
report["backup_path"] = str(bpath) if bpath else None
|
| 993 |
+
if bpath is None: # HARD STOP: the forensic image is the recovery path when every strategy fails.
|
| 994 |
+
return _repair_skip(report, "aborted", "pre-repair backup refused; aborting schema repair to avoid "
|
| 995 |
+
f"mutating the only copy of the damaged DB: {backup_error}")
|
| 996 |
+
# The forensic copy precedes this guard on purpose: its live-holder checks would be poisoned by our own
|
| 997 |
+
# exclusive connection. Everything touching the repair image or live promotion happens under writer exclusion.
|
| 998 |
+
with _exclusive_repair_db_guard(db_path) as (live_guard, guard_error):
|
| 999 |
+
if live_guard is None:
|
| 1000 |
+
return _repair_skip(report, "skipped", "could not acquire exclusive state.db repair ownership; skipped "
|
| 1001 |
+
f"schema surgery to avoid overwriting a concurrent writer. Stop the gateway and retry: "
|
| 1002 |
+
f"{guard_error}", exc=guard_error)
|
| 1003 |
+
if (space_error := _repair_scratch_space_error(db_path)) is not None:
|
| 1004 |
+
return _repair_skip(report, "aborted", space_error)
|
| 1005 |
+
try:
|
| 1006 |
+
# Source = live_guard: it owns the exclusion, and a second connection could be blocked by our own
|
| 1007 |
+
# EXCLUSIVE lock on some SQLite builds.
|
| 1008 |
+
_copy_database_snapshot(db_path, scratch, source_connection=live_guard)
|
| 1009 |
+
except (OSError, sqlite3.Error, TimeoutError) as exc:
|
| 1010 |
+
_unlink_db_triple(scratch)
|
| 1011 |
+
return _repair_skip(report, "aborted", f"could not stage a complete SQLite repair snapshot of {db_path}: {exc}", exc=exc)
|
| 1012 |
+
try:
|
| 1013 |
+
# Private marker for the outer wrapper: a strategy failure consumes the persistent budget; a
|
| 1014 |
+
# promotion failure is classified separately.
|
| 1015 |
+
report["_repair_attempted"] = True
|
| 1016 |
+
_run_repair_strategies(scratch, report)
|
| 1017 |
+
if report.get("repaired"):
|
| 1018 |
+
# Never ``os.replace`` the live DB: Windows rejects replacement under open handles and POSIX would
|
| 1019 |
+
# leave those handles on the old inode. The guard keeps writer exclusion throughout.
|
| 1020 |
+
try:
|
| 1021 |
+
_copy_database_snapshot(scratch, db_path, destination_connection=live_guard)
|
| 1022 |
+
except (OSError, sqlite3.Error, TimeoutError) as exc:
|
| 1023 |
+
report.update(repaired=False, strategy=None,
|
| 1024 |
+
_repair_attempted=_repair_failure_consumes_attempt(exc))
|
| 1025 |
+
report["error"] = f"repaired snapshot could not be promoted transactionally: {exc}"
|
| 1026 |
+
logger.error("state.db repair promotion failed: %s", exc)
|
| 1027 |
+
else:
|
| 1028 |
+
logger.warning("state.db repaired via '%s' and promoted transactionally: %s",
|
| 1029 |
+
report.get("strategy"), db_path)
|
| 1030 |
+
_restore_journal_mode_after_repair(db_path, journal_mode_before, conn=live_guard)
|
| 1031 |
+
if not report.get("repaired"):
|
| 1032 |
+
# Logged HERE, not in the strategies: they see the scratch copy, and the message a human acts on
|
| 1033 |
+
# must name a path that still exists.
|
| 1034 |
+
logger.error("state.db schema repair could not recover %s automatically (no committed canonical data "
|
| 1035 |
+
"was modified or lost; backup: %s); manual restore from backup may be required.",
|
| 1036 |
+
db_path, report["backup_path"])
|
| 1037 |
+
return report
|
| 1038 |
+
finally:
|
| 1039 |
+
# Never leave a half-repaired file beside the DB to be mistaken for the real thing.
|
| 1040 |
+
if (cleanup_error := _unlink_db_triple(scratch)) is not None:
|
| 1041 |
+
logger.warning("Could not remove state.db repair snapshot after repair: %s", cleanup_error)
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
def _unlink_db_triple(path: Path) -> Optional[str]:
|
| 1045 |
+
"""Remove *path* and every SQLite sidecar; return any cleanup failure."""
|
| 1046 |
+
from hermes_state import _IS_WINDOWS
|
| 1047 |
+
failures: List[str] = []
|
| 1048 |
+
for victim in (path, *_sidecars(path)):
|
| 1049 |
+
for attempt in range(10):
|
| 1050 |
+
try:
|
| 1051 |
+
victim.unlink(missing_ok=True)
|
| 1052 |
+
except OSError as exc:
|
| 1053 |
+
# Windows may retain a just-closed SQLite handle for a few scheduler ticks; bounded retry.
|
| 1054 |
+
if isinstance(exc, PermissionError) and _IS_WINDOWS and attempt < 9:
|
| 1055 |
+
time.sleep(0.05)
|
| 1056 |
+
continue
|
| 1057 |
+
failures.append(f"{victim}: {exc}")
|
| 1058 |
+
break
|
| 1059 |
+
return "; ".join(failures) or None
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
# ── Repair strategies, least destructive first (each mutates its connection's DB) ──
|
| 1063 |
+
|
| 1064 |
+
def _edit_sqlite_master(conn: sqlite3.Connection, edit) -> None:
|
| 1065 |
+
"""Run *edit* under ``writable_schema=ON``; bump the schema cookie when it
|
| 1066 |
+
reports a change so live peers discard their cached schema."""
|
| 1067 |
+
conn.execute("PRAGMA writable_schema=ON")
|
| 1068 |
+
if edit():
|
| 1069 |
+
_bump_schema_cookie(conn)
|
| 1070 |
+
conn.execute("PRAGMA writable_schema=OFF")
|
| 1071 |
+
conn.commit()
|
| 1072 |
+
|
| 1073 |
+
|
| 1074 |
+
def _strategy_rebuild_fts(conn: sqlite3.Connection) -> None:
|
| 1075 |
+
"""FTS5 'rebuild' rewrites each index from the content table: the least-
|
| 1076 |
+
destructive fix for an index that rejects writes while reads work."""
|
| 1077 |
+
from hermes_state import load_fts5_cjk_extension
|
| 1078 |
+
# The cjk index can only be rebuilt with its tokenizer loaded (best-effort).
|
| 1079 |
+
load_fts5_cjk_extension(conn)
|
| 1080 |
+
for table_name in _FTS_TABLES:
|
| 1081 |
+
with contextlib.suppress(sqlite3.OperationalError): # table absent (FTS disabled / trigram off / cjk absent)
|
| 1082 |
+
conn.execute(f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')")
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
def _strategy_reindex(conn: sqlite3.Connection) -> None:
|
| 1086 |
+
"""integrity_check reports "wrong # of entries in index" when a B-tree
|
| 1087 |
+
index drifts from its base table; REINDEX rewrites it from canonical rows."""
|
| 1088 |
+
# REINDEX rewrites every index b-tree; take the barriers now that the schema parses.
|
| 1089 |
+
_reapply_durability_barriers(conn)
|
| 1090 |
+
conn.execute("REINDEX")
|
| 1091 |
+
conn.commit()
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
def _strategy_dedup_schema(conn: sqlite3.Connection) -> None:
|
| 1095 |
+
"""De-duplicate sqlite_master (lowest rowid per type/name wins), keeping FTS."""
|
| 1096 |
+
def _dedup() -> bool:
|
| 1097 |
+
dupes = conn.execute(
|
| 1098 |
+
"SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep FROM sqlite_master GROUP BY type, name HAVING c > 1"
|
| 1099 |
+
).fetchall()
|
| 1100 |
+
for type_, name, _count, keep in dupes:
|
| 1101 |
+
conn.execute("DELETE FROM sqlite_master WHERE type IS ? AND name IS ? AND rowid <> ?",
|
| 1102 |
+
(type_, name, keep))
|
| 1103 |
+
return bool(dupes)
|
| 1104 |
+
|
| 1105 |
+
_edit_sqlite_master(conn, _dedup)
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
def _strategy_drop_fts_vacuum(conn: sqlite3.Connection) -> None:
|
| 1109 |
+
"""Drop all FTS schema and VACUUM; indexes rebuild on the next open. The
|
| 1110 |
+
destructive one, and why strategies run on a scratch copy: on a damaged
|
| 1111 |
+
schema b-tree VACUUM silently drops every table hanging off the unreadable part."""
|
| 1112 |
+
_edit_sqlite_master(conn, lambda: conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'") or True)
|
| 1113 |
+
# The schema parses now, so the barriers can stick — VACUUM rewrites the whole file.
|
| 1114 |
+
_reapply_durability_barriers(conn)
|
| 1115 |
+
conn.execute("VACUUM")
|
| 1116 |
+
|
| 1117 |
+
|
| 1118 |
+
# (name, body, success log, failure log) in escalation order. failure log None =
|
| 1119 |
+
# final strategy: its failure lands in report["error"] instead of logged-and-skipped.
|
| 1120 |
+
_REPAIR_STRATEGIES = (
|
| 1121 |
+
("rebuild_fts", _strategy_rebuild_fts, "state.db FTS indexes rebuilt in place (schema preserved): %s",
|
| 1122 |
+
"state.db FTS in-place rebuild pass failed: %s"),
|
| 1123 |
+
("reindex_btree", _strategy_reindex, "state.db B-tree indexes rebuilt via REINDEX: %s",
|
| 1124 |
+
"state.db REINDEX pass failed: %s"),
|
| 1125 |
+
("dedup_schema", _strategy_dedup_schema,
|
| 1126 |
+
"state.db schema repaired by de-duplicating sqlite_master (FTS index preserved): %s",
|
| 1127 |
+
"state.db dedup repair pass failed: %s"),
|
| 1128 |
+
("drop_fts_rebuild", _strategy_drop_fts_vacuum,
|
| 1129 |
+
"state.db schema repaired by dropping FTS schema; indexes will rebuild from messages on next open: %s", None),
|
| 1130 |
+
)
|
| 1131 |
+
|
| 1132 |
+
|
| 1133 |
+
def _run_repair_strategies(db_path: Path, report: Dict[str, Any]) -> Dict[str, Any]:
|
| 1134 |
+
"""Escalating repair attempts, applied to *db_path* IN PLACE — only ever a scratch copy nothing else holds open,
|
| 1135 |
+
never the user's database. The "could not recover" log lives in the caller so it names the user's database."""
|
| 1136 |
+
|
| 1137 |
+
for name, body, success_msg, failure_msg in _REPAIR_STRATEGIES:
|
| 1138 |
+
try:
|
| 1139 |
+
with _repair_conn(db_path) as conn:
|
| 1140 |
+
body(conn)
|
| 1141 |
+
reason = _db_opens_cleanly(db_path)
|
| 1142 |
+
except sqlite3.DatabaseError as exc:
|
| 1143 |
+
reason = str(exc)
|
| 1144 |
+
if failure_msg is not None:
|
| 1145 |
+
logger.warning(failure_msg, exc)
|
| 1146 |
+
if reason is None:
|
| 1147 |
+
report["repaired"], report["strategy"] = True, name
|
| 1148 |
+
logger.warning(success_msg, db_path)
|
| 1149 |
+
return report
|
| 1150 |
+
if failure_msg is None:
|
| 1151 |
+
report["error"] = reason
|
| 1152 |
+
return report
|
hermes_state_sessions.py
ADDED
|
@@ -0,0 +1,1627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session lifecycle mixin for SessionDB: row upsert/inheritance, lifecycle
|
| 2 |
+
flags (end/reopen/archive/pin/hide/read), model_config patching, listing and
|
| 3 |
+
counting, delete cascades, and the auto-archive sweep."""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
import sqlite3
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
from agent.session_activity import (
|
| 14 |
+
ActivityProvenance, bound_activity_description, normalize_activity_provenance,
|
| 15 |
+
)
|
| 16 |
+
from hermes_state_common import (
|
| 17 |
+
_LISTABLE_CHILD_SQL, _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _RECOVERABLE_END_REASONS,
|
| 18 |
+
_RECOVERABLE_END_REASONS_SQL, _RESET_END_REASONS, _legacy_reset_child_sql, _shape_preview,
|
| 19 |
+
_sql_json_extract, _sql_session_last_active, _sql_session_last_active_by_id, escape_like as _escape_like,
|
| 20 |
+
_SQL_IN_CHUNK, _id_chunks, _placeholders as _session_ids_placeholders,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# caplog tests pin the "hermes_state" logger name.
|
| 24 |
+
logger = logging.getLogger("hermes_state")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def workspace_key(row: Dict[str, Any]) -> Optional[str]:
|
| 28 |
+
"""Workspace grouping key: git repo root, else cwd, else None (branch excluded: a checkout must not
|
| 29 |
+
fragment history)."""
|
| 30 |
+
return (row.get("git_repo_root") or "").strip() or (row.get("cwd") or "").strip() or None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _delegate_from_json(col: str = "model_config") -> str:
|
| 34 |
+
return _sql_json_extract(col, "$._delegate_from")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# _merge_model_config_json's "no such row" result — distinct from the legal None
|
| 38 |
+
# ("merged config is empty → store NULL").
|
| 39 |
+
_MODEL_CONFIG_ROW_MISSING = object()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _parse_model_config(raw: Any) -> Dict[str, Any]:
|
| 43 |
+
"""Tolerant ``model_config`` decode: JSON text or dict -> dict copy; anything else -> {}."""
|
| 44 |
+
if isinstance(raw, str) and raw.strip():
|
| 45 |
+
try:
|
| 46 |
+
raw = json.loads(raw)
|
| 47 |
+
except (json.JSONDecodeError, TypeError):
|
| 48 |
+
return {}
|
| 49 |
+
return dict(raw) if isinstance(raw, dict) else {}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
|
| 53 |
+
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
|
| 54 |
+
# ``_``/``%`` are LIKE wildcards but ordinary path characters: unescaped, a
|
| 55 |
+
# prefix also matches sibling directories. The ``=`` arm keeps the raw prefix.
|
| 56 |
+
esc = _escape_like(prefix)
|
| 57 |
+
return (
|
| 58 |
+
"(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')",
|
| 59 |
+
[prefix, f"{esc}/%", f"{esc}\\\\%"],
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
|
| 64 |
+
"""WHERE for ``workspace_key(row) == key``: git_repo_root equals ``key``, or (rows predating
|
| 65 |
+
per-session git metadata) cwd is at/under ``key``."""
|
| 66 |
+
prefix = key.rstrip("/\\") or key
|
| 67 |
+
cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
|
| 68 |
+
return (
|
| 69 |
+
f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
|
| 70 |
+
[prefix, *cwd_params],
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# First user message of a session, shaped by _shape_preview() in Python.
|
| 75 |
+
# The indentation is part of the list_sessions_rich SQL text.
|
| 76 |
+
_PREVIEW_COL_SQL = f"""COALESCE(
|
| 77 |
+
(SELECT {_PREVIEW_RAW_SELECT}
|
| 78 |
+
FROM messages m
|
| 79 |
+
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
| 80 |
+
AND {_PREVIEW_ELIGIBLE_SQL}
|
| 81 |
+
ORDER BY m.timestamp, m.id LIMIT 1),
|
| 82 |
+
''
|
| 83 |
+
) AS _preview_raw"""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _where_sql(clauses: List[str], lead: str = "") -> str:
|
| 87 |
+
"""``WHERE a AND b`` (with *lead* prefix) or "" when there are no clauses."""
|
| 88 |
+
return f"{lead}WHERE {' AND '.join(clauses)}" if clauses else ""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _session_filter_where(
|
| 92 |
+
*, exclude_children: bool = False, source: str = None, sources: List[str] = None,
|
| 93 |
+
session_key: str = None, exclude_sources: List[str] = None, cwd_prefix: str = None,
|
| 94 |
+
min_message_count: int = 0, archived_only: bool = False, include_archived: bool = False,
|
| 95 |
+
) -> Tuple[List[str], List[Any]]:
|
| 96 |
+
"""Shared ``sessions s`` WHERE builder so counts line up with listed rows. ``exclude_children``
|
| 97 |
+
hides sub-agent runs and compression continuations but keeps branch/reset children
|
| 98 |
+
(``_LISTABLE_CHILD_SQL``). Clause order is part of the SQL text contract."""
|
| 99 |
+
where: List[str] = []
|
| 100 |
+
params: List[Any] = []
|
| 101 |
+
if exclude_children:
|
| 102 |
+
where += [_LISTABLE_CHILD_SQL, f"{_delegate_from_json('s.model_config')} IS NULL"]
|
| 103 |
+
# Show roots and user-visible branch/reset sessions, while still hiding sub-agent runs and compression
|
| 104 |
+
# continuations. All four carry parent_session_id, so the shared predicate classifies the edge from
|
| 105 |
+
# stable markers plus legacy-compatible parent metadata. Branch sessions are identified two ways, OR'd
|
| 106 |
+
# for robustness: 1. A stable ``_branched_from`` marker in model_config, written by /branch at creation
|
| 107 |
+
# time. This survives the parent being reopened and re-ended with a different end_reason (e.g.
|
| 108 |
+
# tui_shutdown overwriting 'branched'), which otherwise hides the branch — see issue #20856. 2. The
|
| 109 |
+
# legacy heuristic (parent ended with 'branched' before the child started), covering branch sessions
|
| 110 |
+
# created before the marker existed.
|
| 111 |
+
include_sources = [source] if source else list(sources or [])
|
| 112 |
+
for clause, values in (
|
| 113 |
+
(f"s.source IN ({_session_ids_placeholders(include_sources)})", include_sources),
|
| 114 |
+
("s.session_key = ?", [session_key] if session_key else []),
|
| 115 |
+
(f"s.source NOT IN ({_session_ids_placeholders(exclude_sources or ())})", exclude_sources or []),
|
| 116 |
+
(_cwd_prefix_clause(cwd_prefix) if cwd_prefix else ("", [])),
|
| 117 |
+
("s.message_count >= ?", [min_message_count] if min_message_count > 0 else []),
|
| 118 |
+
):
|
| 119 |
+
if values:
|
| 120 |
+
where.append(clause)
|
| 121 |
+
params.extend(values)
|
| 122 |
+
if archived_only:
|
| 123 |
+
where.append("s.archived = 1")
|
| 124 |
+
elif not include_archived:
|
| 125 |
+
where.append("s.archived = 0")
|
| 126 |
+
return where, params
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
|
| 130 |
+
"""Delegate-subagent ids (``_delegate_from`` marker, walked recursively) to cascade-delete with
|
| 131 |
+
*parent_ids*; untagged children stay orphaned, not deleted."""
|
| 132 |
+
df = _delegate_from_json()
|
| 133 |
+
seeds = {sid for sid in parent_ids if sid}
|
| 134 |
+
# Seed visited with the parents: a marker chain can loop back onto a parent,
|
| 135 |
+
# which would then be collected as its own descendant. Never return parents.
|
| 136 |
+
# A delegation marker chain can loop back onto a parent — a cycle, or a parent that is also another
|
| 137 |
+
# parent's delegate child when several ids are deleted at once — and without this guard that parent
|
| 138 |
+
# would be collected as one of its own descendants and cascade-deleted along with all of its messages.
|
| 139 |
+
# Callers delete the parents separately, so parents must never appear in the returned child set.
|
| 140 |
+
# (#49148)
|
| 141 |
+
found: set[str] = set(seeds)
|
| 142 |
+
frontier = list(seeds)
|
| 143 |
+
while frontier:
|
| 144 |
+
next_frontier: List[str] = []
|
| 145 |
+
for chunk in _id_chunks(frontier, _SQL_IN_CHUNK // 2): # each id is bound twice below
|
| 146 |
+
ph = _session_ids_placeholders(chunk)
|
| 147 |
+
cursor = conn.execute(
|
| 148 |
+
f"SELECT id FROM sessions WHERE {df} IN ({ph}) "
|
| 149 |
+
f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)", chunk + chunk,
|
| 150 |
+
)
|
| 151 |
+
for row in cursor.fetchall():
|
| 152 |
+
if row["id"] not in found:
|
| 153 |
+
found.add(row["id"])
|
| 154 |
+
next_frontier.append(row["id"])
|
| 155 |
+
frontier = next_frontier
|
| 156 |
+
return [sid for sid in found if sid not in seeds]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
|
| 160 |
+
ids = _collect_delegate_child_ids(conn, parent_ids)
|
| 161 |
+
for chunk in _id_chunks(ids):
|
| 162 |
+
ph = _session_ids_placeholders(chunk)
|
| 163 |
+
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
|
| 164 |
+
# FK safety: orphan any untagged stragglers pointing at a doomed row.
|
| 165 |
+
conn.execute(f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk)
|
| 166 |
+
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
|
| 167 |
+
return ids
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# Lifecycle statuses surfaced by session pickers; classified from the final
|
| 171 |
+
# message row ONLY so it stays O(1) per session.
|
| 172 |
+
SESSION_STATUS_COMPLETE = "complete"
|
| 173 |
+
SESSION_STATUS_INTERRUPTED = "interrupted"
|
| 174 |
+
SESSION_STATUS_ERROR = "error"
|
| 175 |
+
SESSION_STATUS_EMPTY = "empty"
|
| 176 |
+
|
| 177 |
+
# finish_reason values meaning the turn ended in a provider/agent error.
|
| 178 |
+
_ERROR_FINISH_REASONS = frozenset({"error", "agent_error", "content_filter"})
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def classify_session_status(role: Optional[str], has_tool_calls: bool, finish_reason: Optional[str]) -> str:
|
| 182 |
+
"""Error finish → ``error``; assistant with pending tool_calls or a trailing user/tool row →
|
| 183 |
+
``interrupted``; otherwise ``complete`` (benign default: pickers must not alarm on unknown shapes)."""
|
| 184 |
+
if (finish_reason or "").strip().lower() in _ERROR_FINISH_REASONS:
|
| 185 |
+
return SESSION_STATUS_ERROR
|
| 186 |
+
r = (role or "").strip().lower()
|
| 187 |
+
if r in {"user", "tool"} or (r == "assistant" and has_tool_calls):
|
| 188 |
+
return SESSION_STATUS_INTERRUPTED
|
| 189 |
+
return SESSION_STATUS_COMPLETE
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# Parent→child profile_name inheritance fence: keyless rows inherit freely; two
|
| 193 |
+
# ``agent:<ns>:...`` keyed rows must agree on the namespace.
|
| 194 |
+
# ``agent:<ns>:...`` gateway keys encode the profile namespace; a keyless row (CLI / subagent lineage)
|
| 195 |
+
# carries none and inherits freely. Two keyed rows must agree on ``agent:<ns>:`` — a default child
|
| 196 |
+
# (``agent:main:``) forked from a sibling profile's row must not be durably mislabelled as that profile's.
|
| 197 |
+
# See #88381.
|
| 198 |
+
_SAME_KEY_NAMESPACE_SQL = (
|
| 199 |
+
"p.session_key IS NULL OR sessions.session_key IS NULL"
|
| 200 |
+
" OR substr(p.session_key, 1, instr(substr(p.session_key, 7), ':') + 6)"
|
| 201 |
+
" = substr(sessions.session_key, 1, instr(substr(sessions.session_key, 7), ':') + 6)"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# Upsert tail of _insert_session_row: every routing/metadata column keeps the value an
|
| 206 |
+
# earlier writer set (whitespace is part of the SQL text).
|
| 207 |
+
_UPSERT_KEEP_EXISTING_SQL = ",\n".join(
|
| 208 |
+
f" {col} = COALESCE(sessions.{col}, excluded.{col})" for col in (
|
| 209 |
+
"session_key", "chat_id", "chat_type", "thread_id", "parent_session_id", "cwd", "profile_name",
|
| 210 |
+
"git_repo_root", "origin_json", "display_name",
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _inherit_col_sql(col: str, extra: str = "") -> str:
|
| 216 |
+
"""``col = COALESCE(sessions.col, (SELECT p.col FROM parent))`` (whitespace is part of the SQL text)."""
|
| 217 |
+
pad = " " * (30 + len(col))
|
| 218 |
+
return (
|
| 219 |
+
f"{col} = COALESCE(sessions.{col},\n{pad}(SELECT p.{col} FROM sessions p\n"
|
| 220 |
+
f"{pad} WHERE p.id = sessions.parent_session_id{extra}))"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
_INHERIT_SEP = ",\n" + " " * 27
|
| 225 |
+
_INHERIT_PARENT_META_SQL = (
|
| 226 |
+
"UPDATE sessions\n SET "
|
| 227 |
+
+ _INHERIT_SEP.join((
|
| 228 |
+
*(_inherit_col_sql(c) for c in ("cwd", "git_repo_root", "git_branch")),
|
| 229 |
+
_inherit_col_sql("profile_name", "\n" + " " * 46 + f"AND ({_SAME_KEY_NAMESPACE_SQL})"),
|
| 230 |
+
))
|
| 231 |
+
+ "\n WHERE id = ? AND parent_session_id IS NOT NULL"
|
| 232 |
+
)
|
| 233 |
+
_INHERIT_PARENT_ROUTING_SQL = (
|
| 234 |
+
"UPDATE sessions\n SET "
|
| 235 |
+
+ _INHERIT_SEP.join(_inherit_col_sql(c) for c in (
|
| 236 |
+
"user_id", "session_key", "chat_id", "chat_type", "thread_id", "display_name", "origin_json",
|
| 237 |
+
))
|
| 238 |
+
+ "\n WHERE id = ? AND parent_session_id IS NOT NULL\n"
|
| 239 |
+
" AND EXISTS (\n"
|
| 240 |
+
" SELECT 1 FROM sessions p\n"
|
| 241 |
+
" WHERE p.id = sessions.parent_session_id\n"
|
| 242 |
+
" AND p.end_reason = 'compression'\n"
|
| 243 |
+
" )"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
class SessionSessionsMixin:
|
| 248 |
+
"""Session rows: create/inherit, lifecycle flags, model_config, listing, deletion."""
|
| 249 |
+
|
| 250 |
+
def _own_profile_name(self) -> Optional[str]:
|
| 251 |
+
"""The profile owning THIS store, from ``db_path`` alone (``<root>/state.db`` → default,
|
| 252 |
+
``<root>/profiles/<name>/state.db`` → name): a gateway serving a NON-launch profile opens that
|
| 253 |
+
profile's store. None outside the profile tree — NULL beats a fabricated owner."""
|
| 254 |
+
try:
|
| 255 |
+
from hermes_constants import get_default_hermes_root
|
| 256 |
+
root = get_default_hermes_root().resolve()
|
| 257 |
+
parent = Path(self.db_path).resolve().parent
|
| 258 |
+
if parent == root:
|
| 259 |
+
return "default"
|
| 260 |
+
is_profile_dir = parent.parent == root / "profiles"
|
| 261 |
+
if is_profile_dir and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", parent.name):
|
| 262 |
+
return parent.name
|
| 263 |
+
except Exception:
|
| 264 |
+
logger.debug("own-profile derivation failed", exc_info=True)
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
@staticmethod
|
| 268 |
+
def _inherit_parent_session_metadata(conn, session_id: str) -> None:
|
| 269 |
+
"""NULL-fill a child's cwd/git/profile from its parent (profile_name only within the same
|
| 270 |
+
``agent:<ns>:`` namespace). Gateway routing columns are inherited ONLY by compression forks
|
| 271 |
+
(a crash before the gateway re-records the peer would strand the child unroutable); delegate
|
| 272 |
+
children must NOT inherit them (peer recovery could repoint traffic into a subagent's session)."""
|
| 273 |
+
conn.execute(_INHERIT_PARENT_META_SQL, (session_id,))
|
| 274 |
+
conn.execute(_INHERIT_PARENT_ROUTING_SQL, (session_id,))
|
| 275 |
+
|
| 276 |
+
def _insert_session_row(
|
| 277 |
+
self, session_id: str, source: str, model: str = None, model_config: Dict[str, Any] = None,
|
| 278 |
+
system_prompt: str = None, user_id: str = None, session_key: Optional[str] = None,
|
| 279 |
+
chat_id: str = None, chat_type: str = None, thread_id: str = None,
|
| 280 |
+
parent_session_id: str = None, cwd: str = None, profile_name: Optional[str] = None,
|
| 281 |
+
git_repo_root: str = None, origin_json: str = None, display_name: str = None,
|
| 282 |
+
) -> None:
|
| 283 |
+
"""Upsert a session row, never overwriting what an earlier writer set (the gateway creates a
|
| 284 |
+
bare row before create_session carries the real model/prompt). chat_id/thread_id scope gateway
|
| 285 |
+
/resume (IDOR). Children backfill from the parent; a missing profile_name is stamped with THIS
|
| 286 |
+
store's own (NULL reads as unowned).
|
| 287 |
+
|
| 288 |
+
When ``parent_session_id`` is set (compression fork, delegate/subagent spawn, branch continuation)
|
| 289 |
+
and this row's own ``cwd``/``git_repo_root``/ ``git_branch``/``profile_name`` are still NULL after
|
| 290 |
+
the insert, they are backfilled from the parent row. Callers of ``create_session`` for a child
|
| 291 |
+
session historically didn't propagate these fields themselves (e.g. the compression-fork path), so a
|
| 292 |
+
lineage could silently lose its working directory and drop out of the project sidebar every time it
|
| 293 |
+
forked (#64709), or lose its owning profile and be aggregated as "default" every time it rotated or
|
| 294 |
+
branched (the cross-profile session-jump bug). This only fills NULLs — an explicit value on the
|
| 295 |
+
child is never overwritten. For compression forks specifically (parent ended with
|
| 296 |
+
``end_reason='compression'``), the gateway origin columns
|
| 297 |
+
(``user_id``/``session_key``/``chat_id``/``chat_type``/
|
| 298 |
+
``thread_id``/``display_name``/``origin_json``) are inherited too, so a crash before the gateway
|
| 299 |
+
re-records the peer can't strand the child without a recoverable routing mapping (#59527).
|
| 300 |
+
When the caller passes no ``profile_name`` at all, the row is stamped with THIS store's own profile
|
| 301 |
+
(:meth:`_own_profile_name`) instead of NULL. Every ``state.db`` belongs to exactly one profile — the
|
| 302 |
+
same single-match contract :meth:`backfill_null_session_profiles` relies on — so the stamp is
|
| 303 |
+
derivation, not a guess. Rows minted NULL after that one-shot #94724 backfill ran stayed NULL
|
| 304 |
+
forever, and profile-keyed consumers (desktop sidebar scope matching, ``@session:<profile>/<id>``
|
| 305 |
+
deep links, the fail-closed owner ladder) treat NULL as unowned: the session vanishes from the
|
| 306 |
+
sidebar even though its transcript is intact (#99222). Stores outside the profile tree (explicit
|
| 307 |
+
``db_path`` in tests, ad-hoc copies) derive nothing and keep NULL — never guess.
|
| 308 |
+
"""
|
| 309 |
+
if not (profile_name or "").strip():
|
| 310 |
+
profile_name = self._own_profile_name()
|
| 311 |
+
def _do(conn):
|
| 312 |
+
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
|
| 313 |
+
conn.execute(
|
| 314 |
+
"""INSERT INTO sessions (
|
| 315 |
+
id, source, user_id, session_key, chat_id, chat_type, thread_id,
|
| 316 |
+
model, model_config, system_prompt, system_prompt_hash,
|
| 317 |
+
parent_session_id, cwd, profile_name, git_repo_root,
|
| 318 |
+
origin_json, display_name, started_at
|
| 319 |
+
)
|
| 320 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 321 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 322 |
+
model = COALESCE(sessions.model, excluded.model),
|
| 323 |
+
model_config = CASE
|
| 324 |
+
WHEN excluded.model_config IS NOT NULL
|
| 325 |
+
AND json_type(
|
| 326 |
+
sessions.model_config, '$._reset_from'
|
| 327 |
+
) IS NOT NULL
|
| 328 |
+
AND json_remove(
|
| 329 |
+
sessions.model_config, '$._reset_from'
|
| 330 |
+
) = '{}'
|
| 331 |
+
THEN json_set(
|
| 332 |
+
excluded.model_config,
|
| 333 |
+
'$._reset_from',
|
| 334 |
+
json_extract(
|
| 335 |
+
sessions.model_config, '$._reset_from'
|
| 336 |
+
)
|
| 337 |
+
)
|
| 338 |
+
ELSE COALESCE(
|
| 339 |
+
sessions.model_config, excluded.model_config
|
| 340 |
+
)
|
| 341 |
+
END,
|
| 342 |
+
system_prompt_hash = COALESCE(
|
| 343 |
+
sessions.system_prompt_hash,
|
| 344 |
+
excluded.system_prompt_hash
|
| 345 |
+
),
|
| 346 |
+
system_prompt = CASE
|
| 347 |
+
WHEN sessions.system_prompt_hash IS NULL
|
| 348 |
+
AND excluded.system_prompt_hash IS NOT NULL
|
| 349 |
+
THEN NULL
|
| 350 |
+
ELSE sessions.system_prompt
|
| 351 |
+
END,
|
| 352 |
+
""" + _UPSERT_KEEP_EXISTING_SQL,
|
| 353 |
+
(
|
| 354 |
+
session_id, source, user_id, session_key, chat_id, chat_type, thread_id, model,
|
| 355 |
+
json.dumps(model_config) if model_config else None, system_prompt_hash,
|
| 356 |
+
parent_session_id, cwd, profile_name, git_repo_root, origin_json, display_name,
|
| 357 |
+
time.time(),
|
| 358 |
+
),
|
| 359 |
+
)
|
| 360 |
+
if system_prompt_hash is not None:
|
| 361 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 362 |
+
if parent_session_id:
|
| 363 |
+
self._inherit_parent_session_metadata(conn, session_id)
|
| 364 |
+
# Transcript-critical: a failed row creation aborts the turn.
|
| 365 |
+
self._execute_write(_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S)
|
| 366 |
+
|
| 367 |
+
def create_session(self, session_id: str, source: str, **kwargs) -> str:
|
| 368 |
+
"""Create (upsert) a session record. Returns the session_id."""
|
| 369 |
+
self._insert_session_row(session_id, source, **kwargs)
|
| 370 |
+
return session_id
|
| 371 |
+
|
| 372 |
+
def ensure_session(self, session_id: str, source: str = "unknown", model: str = None, **kwargs) -> str:
|
| 373 |
+
"""Ensure a session row exists (upsert). Accepts optional kwargs."""
|
| 374 |
+
self._insert_session_row(session_id, source, model=model, **kwargs)
|
| 375 |
+
return session_id
|
| 376 |
+
|
| 377 |
+
def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
|
| 378 |
+
"""Mirror ``SessionEntry.expiry_finalized`` so it survives a lost sessions.json.
|
| 379 |
+
|
| 380 |
+
See #9006.
|
| 381 |
+
"""
|
| 382 |
+
if not session_id:
|
| 383 |
+
return
|
| 384 |
+
self._write_sql(
|
| 385 |
+
"UPDATE sessions SET expiry_finalized = ? WHERE id = ?", (1 if finalized else 0, session_id),
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
def find_session_by_origin(
|
| 389 |
+
self, *, platform: str, chat_id: str, thread_id: Optional[str] = None,
|
| 390 |
+
user_id: Optional[str] = None,
|
| 391 |
+
) -> Optional[str]:
|
| 392 |
+
"""Most recent live session_id for source + chat_id (+ thread_id). With ``user_id`` exact sender
|
| 393 |
+
matches win; several distinct users and no match → None (never another participant's session)."""
|
| 394 |
+
if not platform or chat_id in (None, ""):
|
| 395 |
+
return None
|
| 396 |
+
query = """
|
| 397 |
+
SELECT id, user_id, started_at FROM sessions
|
| 398 |
+
WHERE LOWER(source) = LOWER(?)
|
| 399 |
+
AND session_key IS NOT NULL
|
| 400 |
+
AND chat_id = ?
|
| 401 |
+
AND ended_at IS NULL
|
| 402 |
+
"""
|
| 403 |
+
params: list = [platform, str(chat_id)]
|
| 404 |
+
if thread_id is not None:
|
| 405 |
+
query += " AND COALESCE(thread_id, '') = ?"
|
| 406 |
+
params.append(str(thread_id))
|
| 407 |
+
rows = [dict(r) for r in self._read_all(query + " ORDER BY started_at DESC", params)]
|
| 408 |
+
if not rows:
|
| 409 |
+
return None
|
| 410 |
+
if user_id:
|
| 411 |
+
exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)]
|
| 412 |
+
if exact:
|
| 413 |
+
return str(exact[0]["id"])
|
| 414 |
+
if len(rows) > 1:
|
| 415 |
+
return None
|
| 416 |
+
elif len({u for u in (str(r.get("user_id") or "").strip() for r in rows) if u}) > 1:
|
| 417 |
+
return None
|
| 418 |
+
return str(rows[0]["id"])
|
| 419 |
+
|
| 420 |
+
# Orphaned gateway-session repair: widest plausible gap between a keyed predecessor going
|
| 421 |
+
# quiet and its unkeyed successor (incident was ~60s; 15 min without spanning conversations).
|
| 422 |
+
_ORPHAN_ADOPTION_MAX_GAP_S = 900.0
|
| 423 |
+
|
| 424 |
+
# Children that are NOT compression continuations (branches, delegates, tool sessions). Markers
|
| 425 |
+
# are bound to the queried parent id: continuations inherit model_config verbatim, so
|
| 426 |
+
# presence-matching misclassified them as delegates.
|
| 427 |
+
_NON_CONTINUATION_CHILD_FILTER_SQL = (
|
| 428 |
+
f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._branched_from')}, '') != ?\n"
|
| 429 |
+
f" AND COALESCE({_sql_json_extract('{alias}model_config', '$._delegate_from')}, '') != ?\n"
|
| 430 |
+
" AND COALESCE({alias}source, '') != 'tool'\n"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
def end_session(self, session_id: str, end_reason: str) -> None:
|
| 434 |
+
"""Mark a session ended; the first end_reason wins (a compression split must keep
|
| 435 |
+
``'compression'`` even if a stale end_session() lands later); reopen_session() to re-end."""
|
| 436 |
+
self._execute_write(lambda conn: self._end_and_bump(
|
| 437 |
+
conn, "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
|
| 438 |
+
(time.time(), end_reason, session_id), session_id, end_reason,
|
| 439 |
+
))
|
| 440 |
+
|
| 441 |
+
def _end_and_bump(self, conn, sql: str, params: tuple, session_id: str, reason: str) -> int:
|
| 442 |
+
"""Run an end-stamp UPDATE; only a boundary this call actually wrote advances the
|
| 443 |
+
conversation generation (a no-op must not rotate the peer). Returns rowcount."""
|
| 444 |
+
changed = conn.execute(sql, params).rowcount
|
| 445 |
+
if changed:
|
| 446 |
+
self._bump_conversation_generation(conn, session_id, reason)
|
| 447 |
+
return changed
|
| 448 |
+
|
| 449 |
+
def reopen_session(self, session_id: str) -> None:
|
| 450 |
+
"""Clear ended_at/end_reason so a session can be resumed; first stamp markerless legacy reset
|
| 451 |
+
children that depend on the parent's mutable end_reason (WHERE shared with the listing predicate
|
| 452 |
+
so they cannot drift)."""
|
| 453 |
+
def _do(conn):
|
| 454 |
+
conn.execute(
|
| 455 |
+
"UPDATE sessions AS child SET model_config = json_set("
|
| 456 |
+
"COALESCE(child.model_config, '{}'), '$._reset_from', child.parent_session_id) "
|
| 457 |
+
f"WHERE child.parent_session_id = ? AND {_sql_json_extract('child.model_config', '$._reset_from')} IS NULL "
|
| 458 |
+
f"AND {_legacy_reset_child_sql('child', _session_ids_placeholders(_RESET_END_REASONS))}",
|
| 459 |
+
(session_id, *_RESET_END_REASONS),
|
| 460 |
+
)
|
| 461 |
+
conn.execute(
|
| 462 |
+
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (session_id,),
|
| 463 |
+
)
|
| 464 |
+
self._execute_write(_do)
|
| 465 |
+
|
| 466 |
+
def promote_to_session_reset(self, session_id: str, reason: str = "session_reset") -> bool:
|
| 467 |
+
"""Durably mark an intentional reset boundary on live rows or rows with a *recoverable* accidental
|
| 468 |
+
end_reason (explicit boundaries are preserved): an ``agent_close`` row left recoverable would be
|
| 469 |
+
resurrected by stale-route recovery. Keep in sync with find_latest_gateway_session_for_peer.
|
| 470 |
+
|
| 471 |
+
Plain ``end_session()`` is NOT sufficient for reset boundaries: it no-ops on an already-ended row,
|
| 472 |
+
so a row that agent cleanup already closed as ``agent_close`` would stay recoverable and stale-route
|
| 473 |
+
recovery would resurrect the reset session with its full history (#61220, #61993, #63539).
|
| 474 |
+
"""
|
| 475 |
+
if not session_id:
|
| 476 |
+
return False
|
| 477 |
+
now = time.time()
|
| 478 |
+
# /new and policy auto-resets promote rather than end_session, so the
|
| 479 |
+
# generation advances here too — same transaction, only when written.
|
| 480 |
+
try:
|
| 481 |
+
return bool(self._execute_write(lambda conn: self._end_and_bump(
|
| 482 |
+
conn, "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND (ended_at IS NULL "
|
| 483 |
+
f"OR end_reason IN ({_RECOVERABLE_END_REASONS_SQL}))",
|
| 484 |
+
(now, reason, session_id), session_id, reason,
|
| 485 |
+
)))
|
| 486 |
+
except Exception:
|
| 487 |
+
return False
|
| 488 |
+
|
| 489 |
+
def update_session_cwd(
|
| 490 |
+
self, session_id: str, cwd: str, git_branch: Optional[str] = None,
|
| 491 |
+
git_repo_root: Optional[str] = None, replace_git_meta: bool = False,
|
| 492 |
+
) -> Optional[int]:
|
| 493 |
+
"""Persist the authoritative cwd and claim a Git metadata generation. git fields are written
|
| 494 |
+
only when non-empty (a probe failure never clobbers a value) except under ``replace_git_meta``
|
| 495 |
+
(a workspace MOVE overwrites the old repo identity). Async probes publish with the returned
|
| 496 |
+
generation so an older worker cannot overwrite a newer claim (A -> B -> A)."""
|
| 497 |
+
if not session_id or not cwd:
|
| 498 |
+
return None
|
| 499 |
+
branch = (git_branch or "").strip()
|
| 500 |
+
repo_root = (git_repo_root or "").strip()
|
| 501 |
+
def _do(conn):
|
| 502 |
+
current = conn.execute("SELECT cwd FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
| 503 |
+
if current is None:
|
| 504 |
+
return None
|
| 505 |
+
sets = ["cwd = ?", "git_metadata_generation = COALESCE(git_metadata_generation, 0) + 1"]
|
| 506 |
+
params: List[Any] = [cwd]
|
| 507 |
+
if current[0] != cwd or replace_git_meta:
|
| 508 |
+
sets.extend(("git_branch = ?", "git_repo_root = ?"))
|
| 509 |
+
params.extend((branch or None, repo_root or None))
|
| 510 |
+
else: # same cwd: only overwrite with captured (non-empty) values
|
| 511 |
+
for col, val in (("git_branch", branch), ("git_repo_root", repo_root)):
|
| 512 |
+
if val:
|
| 513 |
+
sets.append(f"{col} = ?")
|
| 514 |
+
params.append(val)
|
| 515 |
+
conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", [*params, session_id])
|
| 516 |
+
row = conn.execute(
|
| 517 |
+
"SELECT git_metadata_generation FROM sessions WHERE id = ?", (session_id,),
|
| 518 |
+
).fetchone()
|
| 519 |
+
return None if row is None else int(row[0])
|
| 520 |
+
return self._execute_write(_do)
|
| 521 |
+
|
| 522 |
+
def publish_session_git_metadata(
|
| 523 |
+
self, session_id: str, cwd: str, generation: int, git_branch: Optional[str] = None,
|
| 524 |
+
git_repo_root: Optional[str] = None,
|
| 525 |
+
) -> bool:
|
| 526 |
+
"""Publish async Git enrichment only while its cwd claim is current."""
|
| 527 |
+
valid_generation = isinstance(generation, int) and not isinstance(generation, bool) and generation >= 1
|
| 528 |
+
if not session_id or not cwd or not valid_generation:
|
| 529 |
+
return False
|
| 530 |
+
fields = [
|
| 531 |
+
(col, val) for col, val in (
|
| 532 |
+
("git_branch", (git_branch or "").strip()), ("git_repo_root", (git_repo_root or "").strip()),
|
| 533 |
+
) if val
|
| 534 |
+
]
|
| 535 |
+
if not fields:
|
| 536 |
+
return False
|
| 537 |
+
return self._write_rowcount(
|
| 538 |
+
f"UPDATE sessions SET {', '.join(f'{col} = ?' for col, _ in fields)} "
|
| 539 |
+
"WHERE id = ? AND cwd = ? AND git_metadata_generation = ?",
|
| 540 |
+
[val for _, val in fields] + [session_id, cwd, generation],
|
| 541 |
+
) == 1
|
| 542 |
+
|
| 543 |
+
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
|
| 544 |
+
"""Backfill git repo roots for cwds without one; never clobbers a recorded root."""
|
| 545 |
+
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
|
| 546 |
+
if pairs:
|
| 547 |
+
self._write_sql(
|
| 548 |
+
"UPDATE sessions SET git_repo_root = ? WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
|
| 549 |
+
pairs, many=True,
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
def touch_session_activity(
|
| 553 |
+
self, session_id: str, ts: Optional[float] = None, *, description: Optional[str] = None,
|
| 554 |
+
provenance: Optional[ActivityProvenance] = None,
|
| 555 |
+
) -> None:
|
| 556 |
+
"""Stamp durable mid-turn activity (observation-only; rate-limited by the caller) so surfaces see
|
| 557 |
+
activity before any message row lands. Never moves ``last_activity_at`` backwards.
|
| 558 |
+
|
| 559 |
+
Called (rate-limited) from ``AIAgent._touch_activity`` so gateway/CLI surfaces and stall consumers
|
| 560 |
+
observe API/tool/compaction activity even when no new message row has been written yet (#72016 /
|
| 561 |
+
#72039).
|
| 562 |
+
"""
|
| 563 |
+
if not session_id:
|
| 564 |
+
return
|
| 565 |
+
when = float(ts if ts is not None else time.time())
|
| 566 |
+
self._write_sql(
|
| 567 |
+
"UPDATE sessions SET last_activity_at = ?, "
|
| 568 |
+
"last_activity_description = ?, last_activity_provenance = ? "
|
| 569 |
+
"WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)",
|
| 570 |
+
(
|
| 571 |
+
when, bound_activity_description(description),
|
| 572 |
+
normalize_activity_provenance(provenance).value, session_id, when,
|
| 573 |
+
),
|
| 574 |
+
patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
# Observation-only write: never let it ride the full routine write-patience budget (#76354 review S1).
|
| 578 |
+
# Under contention a heartbeat that waits ~20s would delay the response-critical path it is merely
|
| 579 |
+
# observing; give up after a sub-second budget instead (the next due window retries naturally).
|
| 580 |
+
def clear_session_activity_labels(self, session_id: str) -> None:
|
| 581 |
+
"""Clear activity labels after a turn (``last_activity_at`` is kept so idle / watchdog clocks stay
|
| 582 |
+
continuous). A no-op clear skips the write transaction.
|
| 583 |
+
|
| 584 |
+
Description and provenance are observation labels for *what was happening at* that timestamp during
|
| 585 |
+
an active turn; once the turn is idle they must not keep advertising "compressing" / "executing
|
| 586 |
+
tool" (#72039).
|
| 587 |
+
Response-critical-path contract (#76354 review S1): runs in the turn's ``finally``; a no-op clear
|
| 588 |
+
(labels already empty) skips the write transaction entirely, and a real clear uses the same short
|
| 589 |
+
sub-second busy budget as :meth:`touch_session_activity` instead of the full routine write patience.
|
| 590 |
+
"""
|
| 591 |
+
if not session_id:
|
| 592 |
+
return
|
| 593 |
+
try:
|
| 594 |
+
row = self._read_one(
|
| 595 |
+
"SELECT last_activity_description, last_activity_provenance FROM sessions WHERE id = ?",
|
| 596 |
+
(session_id,),
|
| 597 |
+
)
|
| 598 |
+
except sqlite3.Error:
|
| 599 |
+
row = None
|
| 600 |
+
if row is not None and not row[0] and (not row[1] or row[1] == ActivityProvenance.UNKNOWN.value):
|
| 601 |
+
return
|
| 602 |
+
self._write_sql(
|
| 603 |
+
"UPDATE sessions SET last_activity_description = ?, last_activity_provenance = ? WHERE id = ?",
|
| 604 |
+
("", ActivityProvenance.UNKNOWN.value, session_id), patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
def update_session_meta(
|
| 608 |
+
self, session_id: str, model_config_json: str, model: Optional[str] = None,
|
| 609 |
+
) -> None:
|
| 610 |
+
"""Update model_config and (COALESCE) optionally model."""
|
| 611 |
+
self.flush_token_counts() # barrier against queued token deltas — see update_session_model
|
| 612 |
+
self._write_sql(
|
| 613 |
+
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
| 614 |
+
(model_config_json, model, session_id),
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
def update_system_prompt(self, session_id: str, system_prompt: Optional[str]) -> None:
|
| 618 |
+
"""Store the full assembled system prompt snapshot."""
|
| 619 |
+
def _do(conn):
|
| 620 |
+
conn.execute(
|
| 621 |
+
"UPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?",
|
| 622 |
+
(self._store_system_prompt(conn, system_prompt), session_id),
|
| 623 |
+
)
|
| 624 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 625 |
+
self._execute_write(_do)
|
| 626 |
+
|
| 627 |
+
def update_session_tool_names(self, session_id: str, tool_names: Optional[List[str]]) -> None:
|
| 628 |
+
"""Persist the resolved ``tools[]`` name order so a rebuilt AIAgent can't fork the cached tool
|
| 629 |
+
prefix on a flipped check_fn verdict; ``None`` clears."""
|
| 630 |
+
payload = json.dumps(list(tool_names)) if tool_names is not None else None
|
| 631 |
+
self._write_sql("UPDATE sessions SET tool_names = ? WHERE id = ?", (payload, session_id))
|
| 632 |
+
|
| 633 |
+
def update_session_model(self, session_id: str, model: str, provider: Optional[str] = None) -> None:
|
| 634 |
+
"""Set the model after a mid-session /model switch (unconditionally), null system_prompt so
|
| 635 |
+
stale Model:/Provider: footers rebuild, and drop any Browser runtime lock (lineage markers
|
| 636 |
+
survive). *provider* is merged into model_config so resume recombines model and provider.
|
| 637 |
+
|
| 638 |
+
When *provider* is given, it is merged into ``model_config`` alongside the model (``$.model`` /
|
| 639 |
+
``$.provider``) so a later resume recombines the persisted model with the provider that actually
|
| 640 |
+
serves it instead of the config.yaml primary provider (#79536). Callers without provider knowledge
|
| 641 |
+
leave any stored provider untouched.
|
| 642 |
+
"""
|
| 643 |
+
# Flush first: a still-queued pre-switch delta applied after this UPDATE would trip the
|
| 644 |
+
# first_accounted_route overwrite and resurrect the old route.
|
| 645 |
+
self.flush_token_counts()
|
| 646 |
+
patch: Dict[str, Any] = {"browser_model_lock": None}
|
| 647 |
+
if model:
|
| 648 |
+
patch["model"] = model
|
| 649 |
+
if provider:
|
| 650 |
+
patch["provider"] = provider
|
| 651 |
+
self._write_model_config_patch(
|
| 652 |
+
session_id, patch, "UPDATE sessions SET model = ?, model_config = ?, "
|
| 653 |
+
"system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?",
|
| 654 |
+
lambda merged: (model, merged, session_id),
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
def _write_model_config_patch(
|
| 658 |
+
self, session_id: str, patch: Dict[str, Any],
|
| 659 |
+
sql: str = "UPDATE sessions SET model_config = ? WHERE id = ?",
|
| 660 |
+
params: Optional[Callable[[Optional[str]], tuple]] = None,
|
| 661 |
+
) -> None:
|
| 662 |
+
"""Merge ``patch`` into model_config then run ``sql`` with ``params(merged)`` in one write
|
| 663 |
+
transaction; no-op when the row doesn't exist. Custom ``sql`` (prompt-nulling) also GCs prompts."""
|
| 664 |
+
def _do(conn):
|
| 665 |
+
merged = self._merge_model_config_json(conn, session_id, patch)
|
| 666 |
+
if merged is _MODEL_CONFIG_ROW_MISSING:
|
| 667 |
+
return
|
| 668 |
+
conn.execute(sql, params(merged) if params else (merged, session_id))
|
| 669 |
+
if params is not None:
|
| 670 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 671 |
+
self._execute_write(_do)
|
| 672 |
+
|
| 673 |
+
def _merge_model_config_json(
|
| 674 |
+
self, conn, session_id: str, patch: Dict[str, Any], *, on_missing: str = "skip",
|
| 675 |
+
):
|
| 676 |
+
"""SELECT + tolerant-parse + merge ``patch`` into model_config (the one place that keeps
|
| 677 |
+
``_branched_from``/``_delegate_from`` alive); ``None`` deletes a key. Returns serialized JSON
|
| 678 |
+
(``None`` when empty) or ``_MODEL_CONFIG_ROW_MISSING`` (``on_missing="raise"`` → ValueError)."""
|
| 679 |
+
row = conn.execute("SELECT model_config FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
| 680 |
+
if row is None:
|
| 681 |
+
if on_missing == "raise":
|
| 682 |
+
raise ValueError(f"Session not found: {session_id}")
|
| 683 |
+
return _MODEL_CONFIG_ROW_MISSING
|
| 684 |
+
config = _parse_model_config(row[0])
|
| 685 |
+
for key, value in patch.items():
|
| 686 |
+
if value is None:
|
| 687 |
+
config.pop(key, None)
|
| 688 |
+
else:
|
| 689 |
+
config[key] = value
|
| 690 |
+
return json.dumps(config) if config else None
|
| 691 |
+
|
| 692 |
+
def patch_session_model_config(self, session_id: str, patch: Dict[str, Any]) -> None:
|
| 693 |
+
"""Merge ``patch`` into model_config atomically (``None`` removes a key);
|
| 694 |
+
no-op when the row or patch is empty."""
|
| 695 |
+
if not session_id or not patch:
|
| 696 |
+
return
|
| 697 |
+
self._write_model_config_patch(session_id, patch)
|
| 698 |
+
|
| 699 |
+
def get_session_model_config_value(self, session_id: str, key: str, default: Any = None) -> Any:
|
| 700 |
+
"""Read one key out of a session's model_config JSON (tolerant parse)."""
|
| 701 |
+
session = self.get_session(session_id) or {}
|
| 702 |
+
return _parse_model_config(session.get("model_config")).get(key, default)
|
| 703 |
+
|
| 704 |
+
def update_session_runtime_lock(
|
| 705 |
+
self, session_id: str, *, model: Optional[str] = None, provider: Optional[str] = None,
|
| 706 |
+
model_options: Optional[Dict[str, Any]] = None, route_source: Optional[str] = None,
|
| 707 |
+
confirmed: bool = False,
|
| 708 |
+
) -> None:
|
| 709 |
+
"""Persist a Browser / API-client runtime lock into model_config (lineage markers survive); null
|
| 710 |
+
system_prompt so cached footers cannot lie."""
|
| 711 |
+
lock = {
|
| 712 |
+
"provider": provider or "", "model": model or "", "model_options": model_options or {},
|
| 713 |
+
"route_source": route_source or "", "confirmed": bool(confirmed), "updated_at": time.time(),
|
| 714 |
+
}
|
| 715 |
+
self._write_model_config_patch(
|
| 716 |
+
session_id, {"browser_model_lock": lock},
|
| 717 |
+
"""UPDATE sessions SET
|
| 718 |
+
model_config = ?,
|
| 719 |
+
model = COALESCE(?, model),
|
| 720 |
+
system_prompt = NULL,
|
| 721 |
+
system_prompt_hash = NULL
|
| 722 |
+
WHERE id = ?""",
|
| 723 |
+
lambda merged: (merged, model, session_id),
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
def set_session_yolo(self, session_id: str, enabled: bool) -> None:
|
| 727 |
+
"""Persist the per-session YOLO flag so ``/yolo`` survives ``--resume``; no-op without a row."""
|
| 728 |
+
if not session_id:
|
| 729 |
+
return
|
| 730 |
+
self._write_model_config_patch(session_id, {"yolo_mode": bool(enabled)})
|
| 731 |
+
|
| 732 |
+
@staticmethod
|
| 733 |
+
def session_yolo_enabled(session_meta: Optional[Dict[str, Any]]) -> bool:
|
| 734 |
+
"""Persisted YOLO flag; False on any parse failure (resume must never enable the bypass)."""
|
| 735 |
+
return bool(_parse_model_config((session_meta or {}).get("model_config")).get("yolo_mode"))
|
| 736 |
+
|
| 737 |
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 738 |
+
"""Get a session by ID (drains queued token deltas first so cost readers see exact totals)."""
|
| 739 |
+
self.flush_token_counts()
|
| 740 |
+
row = self._read_one(
|
| 741 |
+
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
|
| 742 |
+
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.id = ?",
|
| 743 |
+
(session_id,),
|
| 744 |
+
)
|
| 745 |
+
return self._session_row_dict(row) if row else None
|
| 746 |
+
|
| 747 |
+
def get_recent_session_model_route(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 748 |
+
"""Most recently used main-loop model route as one coherent per-call tuple
|
| 749 |
+
(``session_model_usage`` keeps model+provider together; ``sessions`` mixes route changes).
|
| 750 |
+
Recency, not lifetime call count: on a long session a route retired weeks ago can hold the
|
| 751 |
+
highest ``api_call_count`` forever, and /status and /usage would keep calling it current.
|
| 752 |
+
``rowid DESC`` breaks same-timestamp ties toward the route that first appeared later; without
|
| 753 |
+
it SQLite's temp-sort order is unspecified and the retired route can win."""
|
| 754 |
+
self.flush_token_counts()
|
| 755 |
+
row = self._read_one(
|
| 756 |
+
"""SELECT model, billing_provider, billing_base_url, billing_mode,
|
| 757 |
+
api_call_count
|
| 758 |
+
FROM session_model_usage
|
| 759 |
+
WHERE session_id = ?
|
| 760 |
+
AND task = ''
|
| 761 |
+
AND model <> 'unknown'
|
| 762 |
+
AND billing_provider <> ''
|
| 763 |
+
ORDER BY last_seen DESC, rowid DESC
|
| 764 |
+
LIMIT 1""",
|
| 765 |
+
(session_id,),
|
| 766 |
+
)
|
| 767 |
+
return dict(row) if row else None
|
| 768 |
+
|
| 769 |
+
def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
|
| 770 |
+
"""Exact id, else the single unambiguous prefix match, else None."""
|
| 771 |
+
exact = self.get_session(session_id_or_prefix)
|
| 772 |
+
if exact:
|
| 773 |
+
return exact["id"]
|
| 774 |
+
matches = self._read_all(
|
| 775 |
+
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
|
| 776 |
+
(f"{_escape_like(session_id_or_prefix)}%",),
|
| 777 |
+
)
|
| 778 |
+
return matches[0]["id"] if len(matches) == 1 else None
|
| 779 |
+
|
| 780 |
+
def backfill_null_session_profiles(self, profile_name: str) -> int:
|
| 781 |
+
"""Stamp this store's own profile onto legacy ``profile_name IS NULL`` rows, which the fail-closed
|
| 782 |
+
owner ladder cannot route. Never overwrites a non-NULL owner. Returns rows stamped.
|
| 783 |
+
|
| 784 |
+
Sessions created before the durable-ownership work (#95407 lineage) carry ``profile_name = NULL``.
|
| 785 |
+
On single-backend installs that was harmless, but once a Desktop registers a second connection the
|
| 786 |
+
fail-closed owner ladder (which is correct for new sessions) can no longer route those rows anywhere
|
| 787 |
+
— every pre-campaign session becomes unresumable after upgrade (#94724, field report).
|
| 788 |
+
"""
|
| 789 |
+
stamp = (profile_name or "").strip()
|
| 790 |
+
if not stamp:
|
| 791 |
+
return 0
|
| 792 |
+
return int(self._write_rowcount(
|
| 793 |
+
"""UPDATE sessions
|
| 794 |
+
SET profile_name = ?
|
| 795 |
+
WHERE profile_name IS NULL OR TRIM(profile_name) = ''""",
|
| 796 |
+
(stamp,),
|
| 797 |
+
) or 0)
|
| 798 |
+
|
| 799 |
+
def _set_lineage_column(self, column: str, session_id: str, value: Any) -> bool:
|
| 800 |
+
"""Set one ``sessions`` column across a whole compression lineage: Desktop projects roots
|
| 801 |
+
forward to their tip, so updating only the tip would let the root resurrect it on refresh."""
|
| 802 |
+
return self._write_rowcount(
|
| 803 |
+
f"""
|
| 804 |
+
WITH RECURSIVE
|
| 805 |
+
ancestors(id) AS (
|
| 806 |
+
SELECT ?
|
| 807 |
+
UNION
|
| 808 |
+
SELECT parent.id
|
| 809 |
+
FROM ancestors a
|
| 810 |
+
JOIN sessions child ON child.id = a.id
|
| 811 |
+
JOIN sessions parent ON parent.id = child.parent_session_id
|
| 812 |
+
WHERE parent.end_reason = 'compression'
|
| 813 |
+
),
|
| 814 |
+
descendants(id) AS (
|
| 815 |
+
SELECT ?
|
| 816 |
+
UNION
|
| 817 |
+
SELECT child.id
|
| 818 |
+
FROM descendants d
|
| 819 |
+
JOIN sessions parent ON parent.id = d.id
|
| 820 |
+
JOIN sessions child ON child.parent_session_id = parent.id
|
| 821 |
+
WHERE parent.end_reason = 'compression'
|
| 822 |
+
),
|
| 823 |
+
lineage(id) AS (
|
| 824 |
+
SELECT id FROM ancestors
|
| 825 |
+
UNION
|
| 826 |
+
SELECT id FROM descendants
|
| 827 |
+
)
|
| 828 |
+
UPDATE sessions
|
| 829 |
+
SET {column} = ?
|
| 830 |
+
WHERE id IN (SELECT id FROM lineage)
|
| 831 |
+
""",
|
| 832 |
+
(session_id, session_id, value),
|
| 833 |
+
) > 0
|
| 834 |
+
|
| 835 |
+
def set_session_archived(self, session_id: str, archived: bool) -> bool:
|
| 836 |
+
"""Soft-hide (or unhide) a session and its compression lineage; messages are kept."""
|
| 837 |
+
return self._set_lineage_column("archived", session_id, int(archived))
|
| 838 |
+
|
| 839 |
+
# Accidental end reasons recovery treats as resumable (also interpolated into
|
| 840 |
+
# the recovery/promotion SQL so literals cannot drift).
|
| 841 |
+
RECOVERABLE_END_REASONS = _RECOVERABLE_END_REASONS
|
| 842 |
+
|
| 843 |
+
def unarchive_recoverable_session(self, session_id: str) -> bool:
|
| 844 |
+
"""Un-archive a session archived by a recoverable accident (ws_orphan_reap, agent_close);
|
| 845 |
+
deliberate archives are left alone. True when un-archived.
|
| 846 |
+
|
| 847 |
+
Registry-style lookups (Bot Mode's canonical "Bot Chat") use this to resurrect a row the ws-orphan
|
| 848 |
+
reaper (``ws_orphan_reap``) or older agent cleanup (``agent_close``) archived: those ends are
|
| 849 |
+
accidents, not user intent, so the identity-scoped canonical chat must survive them (#92687).
|
| 850 |
+
Sessions archived with no end_reason or an explicit boundary reason (user archived deliberately,
|
| 851 |
+
``session_reset``, …) are left untouched — returns ``False`` for those, ``True`` only when the row
|
| 852 |
+
was archived for a recoverable reason and is now un-archived (whole compression lineage, via
|
| 853 |
+
:meth:`set_session_archived`).
|
| 854 |
+
"""
|
| 855 |
+
if not session_id:
|
| 856 |
+
return False
|
| 857 |
+
try:
|
| 858 |
+
row = self.get_session(session_id)
|
| 859 |
+
except Exception:
|
| 860 |
+
return False
|
| 861 |
+
if not row or not row.get("archived"):
|
| 862 |
+
return False
|
| 863 |
+
# The accidental stamp lives on the live TIP; judge recoverability there.
|
| 864 |
+
tip = row
|
| 865 |
+
try:
|
| 866 |
+
tip_id = self.get_compression_tip(session_id) or session_id
|
| 867 |
+
if tip_id != session_id:
|
| 868 |
+
tip = self.get_session(tip_id) or row
|
| 869 |
+
except Exception:
|
| 870 |
+
pass
|
| 871 |
+
if (tip.get("end_reason") or "") not in self.RECOVERABLE_END_REASONS:
|
| 872 |
+
return False
|
| 873 |
+
if not self.set_session_archived(session_id, False):
|
| 874 |
+
return False
|
| 875 |
+
# Clear the accidental end stamp, or a LATER deliberate archive (which never
|
| 876 |
+
# writes end_reason) would auto-resurrect on the next lookup.
|
| 877 |
+
self._write_sql(
|
| 878 |
+
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (tip["id"],),
|
| 879 |
+
)
|
| 880 |
+
return True
|
| 881 |
+
|
| 882 |
+
def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
|
| 883 |
+
"""Pin/unpin a session and its compression lineage (pins are exempt from the auto_archive sweep).
|
| 884 |
+
Pinning also clears ``hidden``: a pin means "keep this visible", and a hidden+pinned row is
|
| 885 |
+
otherwise absent from both the default listing and the pinned back-fill (see #106171).
|
| 886 |
+
Exempt the canonical Bot Chat (hidden + exact registry title): the desktop contract keeps it
|
| 887 |
+
hidden and reachable only through the bot row, and unhiding it would also disable the
|
| 888 |
+
rename guard in ``_set_session_title`` that protects its identity (see review on #106180)."""
|
| 889 |
+
result = self._set_lineage_column("pinned", session_id, int(pinned))
|
| 890 |
+
if pinned:
|
| 891 |
+
row = self.get_session(session_id)
|
| 892 |
+
is_canonical_bot_chat = bool(row) and bool(row.get("hidden")) and (
|
| 893 |
+
(row.get("title") or "") == self.CANONICAL_BOT_CHAT_TITLE
|
| 894 |
+
)
|
| 895 |
+
if not is_canonical_bot_chat:
|
| 896 |
+
self._set_lineage_column("hidden", session_id, 0)
|
| 897 |
+
return result
|
| 898 |
+
|
| 899 |
+
def set_session_hidden(self, session_id: str, hidden: bool) -> bool:
|
| 900 |
+
"""Hide/unhide a session and its compression lineage from the default listing; still resumable."""
|
| 901 |
+
return self._set_lineage_column("hidden", session_id, int(hidden))
|
| 902 |
+
|
| 903 |
+
def set_session_read(self, session_id: str, read: bool = True) -> bool:
|
| 904 |
+
"""Mark read/unread across the compression lineage. ``last_read_at`` is a watermark: unread when
|
| 905 |
+
activity postdates it (no write on the message path). NULL = never tracked = read; 0 = unread."""
|
| 906 |
+
return self._set_lineage_column("last_read_at", session_id, time.time() if read else 0.0)
|
| 907 |
+
|
| 908 |
+
@staticmethod
|
| 909 |
+
def session_unread(session_row: Dict[str, Any]) -> bool:
|
| 910 |
+
"""Unread = activity postdates the ``last_read_at`` watermark (NULL = read)."""
|
| 911 |
+
last_read = session_row.get("last_read_at")
|
| 912 |
+
if last_read is None:
|
| 913 |
+
return False
|
| 914 |
+
last_active = session_row.get("last_active") or session_row.get("started_at")
|
| 915 |
+
return float(last_active or 0) > float(last_read)
|
| 916 |
+
|
| 917 |
+
# compact_rows excludes only payload-heavy blobs no list consumer renders.
|
| 918 |
+
_SESSION_COMPACT_EXCLUDED = frozenset(
|
| 919 |
+
{"system_prompt", "system_prompt_hash", "git_metadata_generation"}
|
| 920 |
+
)
|
| 921 |
+
_session_compact_cols_sql: Optional[str] = None
|
| 922 |
+
|
| 923 |
+
@staticmethod
|
| 924 |
+
def _chain_search_where(where_sql: str, id_needle: str, search_needle: str) -> Tuple[str, List[Any]]:
|
| 925 |
+
"""Extend ``where_sql`` with the id_query / search_query filters: a row is admitted when its own
|
| 926 |
+
id or any id in its forward compression chain matches (search also matches titles and a
|
| 927 |
+
punctuation-stripped form so ``an94`` finds ``AN-94``); chain membership bounds the LIKE."""
|
| 928 |
+
params: List[Any] = []
|
| 929 |
+
clauses: List[str] = []
|
| 930 |
+
def like(needle: str) -> str:
|
| 931 |
+
return f"%{_escape_like(needle)}%"
|
| 932 |
+
if id_needle:
|
| 933 |
+
clauses.append(
|
| 934 |
+
"EXISTS (SELECT 1 FROM chain cq WHERE cq.root_id = s.id"
|
| 935 |
+
" AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
|
| 936 |
+
)
|
| 937 |
+
params.append(like(id_needle))
|
| 938 |
+
if search_needle:
|
| 939 |
+
compact_needle = re.sub(r"[\W_]+", "", search_needle)
|
| 940 |
+
search_clause = (
|
| 941 |
+
"EXISTS (SELECT 1 FROM chain cq JOIN sessions cs ON cs.id = cq.cur_id"
|
| 942 |
+
" WHERE cq.root_id = s.id AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\\'"
|
| 943 |
+
" OR LOWER(cq.cur_id) LIKE ? ESCAPE '\\'"
|
| 944 |
+
)
|
| 945 |
+
params.extend([like(search_needle)] * 2)
|
| 946 |
+
if compact_needle:
|
| 947 |
+
search_clause += (
|
| 948 |
+
" OR REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE(cs.title, '')),"
|
| 949 |
+
" '-', ''), '_', ''), '.', ''), ' ', '') LIKE ? ESCAPE '\\'"
|
| 950 |
+
)
|
| 951 |
+
params.append(like(compact_needle))
|
| 952 |
+
clauses.append(search_clause + "))")
|
| 953 |
+
if not clauses:
|
| 954 |
+
return where_sql, params
|
| 955 |
+
combined = " AND ".join(clauses)
|
| 956 |
+
return (f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}"), params
|
| 957 |
+
|
| 958 |
+
def _project_compression_tips(self, sessions: List[Dict[str, Any]], compact_rows: bool) -> List[Dict[str, Any]]:
|
| 959 |
+
"""Replace each compression root's surfaced fields with its live tip's (root ``started_at`` kept
|
| 960 |
+
for stable ordering), one batched query. ``_lineage_ids`` carries every chain id (a tile may
|
| 961 |
+
hold a MIDDLE segment's id)."""
|
| 962 |
+
chain_by_root: Dict[str, List[str]] = {} # only roots whose tip differs from themselves
|
| 963 |
+
for s in sessions:
|
| 964 |
+
if s.get("end_reason") == "compression":
|
| 965 |
+
chain = self.get_compression_chain(s["id"])
|
| 966 |
+
if chain and chain[-1] != s["id"]:
|
| 967 |
+
chain_by_root[s["id"]] = chain
|
| 968 |
+
tip_rows = (
|
| 969 |
+
self._get_session_rich_rows_batch(
|
| 970 |
+
{chain[-1] for chain in chain_by_root.values()}, compact_rows=compact_rows,
|
| 971 |
+
) if chain_by_root else {}
|
| 972 |
+
)
|
| 973 |
+
projected = []
|
| 974 |
+
for s in sessions:
|
| 975 |
+
chain = chain_by_root.get(s["id"])
|
| 976 |
+
tip_row = tip_rows.get(chain[-1]) if chain else None
|
| 977 |
+
if not tip_row:
|
| 978 |
+
projected.append(s)
|
| 979 |
+
continue
|
| 980 |
+
merged = dict(s)
|
| 981 |
+
for key in (
|
| 982 |
+
"id", "ended_at", "end_reason", "message_count", "tool_call_count", "title", "last_active",
|
| 983 |
+
"preview", "model", "system_prompt", "cwd", "git_branch", "git_repo_root",
|
| 984 |
+
):
|
| 985 |
+
if key in tip_row:
|
| 986 |
+
merged[key] = tip_row[key]
|
| 987 |
+
if merged.get("title") is None:
|
| 988 |
+
# The title is carried root->tip AFTER the publish transaction; a rotation cut off in
|
| 989 |
+
# between leaves it on the ended root, and exact-title lookups (`hermes peer dm` ->
|
| 990 |
+
# canonical "Bot Chat") must still see the lineage under its name (#106165).
|
| 991 |
+
merged["title"] = s.get("title")
|
| 992 |
+
merged["_lineage_root_id"] = s["id"]
|
| 993 |
+
merged["_lineage_ids"] = chain
|
| 994 |
+
projected.append(merged)
|
| 995 |
+
return projected
|
| 996 |
+
|
| 997 |
+
def list_recent_sessions_bounded(
|
| 998 |
+
self,
|
| 999 |
+
*,
|
| 1000 |
+
limit: int = 20,
|
| 1001 |
+
exclude_sources: List[str] = None,
|
| 1002 |
+
timeout_seconds: float = 3.0,
|
| 1003 |
+
candidate_limit: int = None,
|
| 1004 |
+
lineage_limit: int = None,
|
| 1005 |
+
) -> List[Dict[str, Any]]:
|
| 1006 |
+
"""Latency-bounded recent-conversation browse (``session_search()``): preselect a small candidate set
|
| 1007 |
+
from the indexed durable activity timestamp (fallback ``started_at``), resolve only those across
|
| 1008 |
+
compression ancestry/chains, then hydrate activity/previews for that bounded set. Lineage traversal
|
| 1009 |
+
uses ``UNION`` plus a total-row ceiling so a corrupt cycle or a deep/branching lineage cannot defeat
|
| 1010 |
+
the bound; a lineage that hits the ceiling before a terminal root/tip is omitted, not expanded.
|
| 1011 |
+
A cooperative SQLite progress deadline interrupts sustained work past ``timeout_seconds`` and raises
|
| 1012 |
+
``TimeoutError`` (cheap statements may finish between callbacks). Supports only the agent-tool
|
| 1013 |
+
browse filters; rich callers keep using :meth:`list_sessions_rich`."""
|
| 1014 |
+
limit = max(1, int(limit))
|
| 1015 |
+
timeout_seconds = max(0.0, float(timeout_seconds))
|
| 1016 |
+
if candidate_limit is None:
|
| 1017 |
+
candidate_limit = max(128, limit * 8)
|
| 1018 |
+
candidate_limit = max(limit, min(int(candidate_limit), 2048))
|
| 1019 |
+
if lineage_limit is None:
|
| 1020 |
+
lineage_limit = min(8192, candidate_limit * 8)
|
| 1021 |
+
lineage_limit = max(candidate_limit, min(int(lineage_limit), 8192))
|
| 1022 |
+
|
| 1023 |
+
candidate_clauses = [
|
| 1024 |
+
"s.archived = 0",
|
| 1025 |
+
"s.hidden = 0",
|
| 1026 |
+
f"{_delegate_from_json('s.model_config')} IS NULL",
|
| 1027 |
+
]
|
| 1028 |
+
candidate_params: List[Any] = []
|
| 1029 |
+
if exclude_sources:
|
| 1030 |
+
placeholders = ",".join("?" for _ in exclude_sources)
|
| 1031 |
+
candidate_clauses.append(f"s.source NOT IN ({placeholders})")
|
| 1032 |
+
candidate_params.extend(exclude_sources)
|
| 1033 |
+
candidate_where = " AND ".join(candidate_clauses)
|
| 1034 |
+
|
| 1035 |
+
# A compression continuation is an implementation edge, unlike /new
|
| 1036 |
+
# reset and /branch children which are independent user-visible
|
| 1037 |
+
# conversations. The same predicate is used in both directions so a
|
| 1038 |
+
# candidate tip maps to its logical root and the root maps back to the
|
| 1039 |
+
# freshest live tip.
|
| 1040 |
+
compression_parent_edge = f"""
|
| 1041 |
+
parent.end_reason = 'compression'
|
| 1042 |
+
AND child.parent_session_id = parent.id
|
| 1043 |
+
AND json_extract(
|
| 1044 |
+
COALESCE(child.model_config, '{{}}'), '$._branched_from'
|
| 1045 |
+
) IS NULL
|
| 1046 |
+
AND {_delegate_from_json('child.model_config')} IS NULL
|
| 1047 |
+
AND COALESCE(child.source, '') != 'tool'
|
| 1048 |
+
"""
|
| 1049 |
+
|
| 1050 |
+
query = f"""
|
| 1051 |
+
WITH RECURSIVE
|
| 1052 |
+
recent_candidates(id) AS (
|
| 1053 |
+
SELECT s.id
|
| 1054 |
+
FROM sessions s
|
| 1055 |
+
WHERE {candidate_where}
|
| 1056 |
+
ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC,
|
| 1057 |
+
s.started_at DESC, s.id DESC
|
| 1058 |
+
LIMIT ?
|
| 1059 |
+
),
|
| 1060 |
+
ancestors(candidate_id, cur_id) AS (
|
| 1061 |
+
SELECT id, id FROM recent_candidates
|
| 1062 |
+
UNION
|
| 1063 |
+
SELECT a.candidate_id, parent.id
|
| 1064 |
+
FROM ancestors a
|
| 1065 |
+
JOIN sessions child ON child.id = a.cur_id
|
| 1066 |
+
JOIN sessions parent ON {compression_parent_edge}
|
| 1067 |
+
LIMIT ?
|
| 1068 |
+
),
|
| 1069 |
+
candidate_roots(root_id) AS (
|
| 1070 |
+
SELECT DISTINCT a.cur_id
|
| 1071 |
+
FROM ancestors a
|
| 1072 |
+
JOIN sessions child ON child.id = a.cur_id
|
| 1073 |
+
WHERE NOT EXISTS (
|
| 1074 |
+
SELECT 1
|
| 1075 |
+
FROM sessions parent
|
| 1076 |
+
WHERE {compression_parent_edge}
|
| 1077 |
+
)
|
| 1078 |
+
),
|
| 1079 |
+
chain(root_id, cur_id) AS (
|
| 1080 |
+
SELECT root_id, root_id FROM candidate_roots
|
| 1081 |
+
UNION
|
| 1082 |
+
SELECT c.root_id, child.id
|
| 1083 |
+
FROM chain c
|
| 1084 |
+
JOIN sessions parent ON parent.id = c.cur_id
|
| 1085 |
+
JOIN sessions child ON {compression_parent_edge}
|
| 1086 |
+
LIMIT ?
|
| 1087 |
+
),
|
| 1088 |
+
chain_rows AS (
|
| 1089 |
+
SELECT
|
| 1090 |
+
c.root_id,
|
| 1091 |
+
c.cur_id,
|
| 1092 |
+
{_sql_session_last_active_by_id('c.cur_id')} AS activity,
|
| 1093 |
+
CASE WHEN EXISTS (
|
| 1094 |
+
SELECT 1
|
| 1095 |
+
FROM sessions parent
|
| 1096 |
+
JOIN sessions child ON {compression_parent_edge}
|
| 1097 |
+
WHERE parent.id = c.cur_id
|
| 1098 |
+
) THEN 0 ELSE 1 END AS is_tip
|
| 1099 |
+
FROM chain c
|
| 1100 |
+
),
|
| 1101 |
+
ranked_tips AS (
|
| 1102 |
+
SELECT root_id, cur_id, activity,
|
| 1103 |
+
ROW_NUMBER() OVER (
|
| 1104 |
+
PARTITION BY root_id
|
| 1105 |
+
ORDER BY activity DESC, cur_id DESC
|
| 1106 |
+
) AS rank_in_root
|
| 1107 |
+
FROM chain_rows
|
| 1108 |
+
WHERE is_tip = 1
|
| 1109 |
+
)
|
| 1110 |
+
SELECT
|
| 1111 |
+
tip.id,
|
| 1112 |
+
tip.source,
|
| 1113 |
+
tip.model,
|
| 1114 |
+
COALESCE(tip.title, s.title) AS title,
|
| 1115 |
+
s.started_at AS started_at,
|
| 1116 |
+
tip.ended_at,
|
| 1117 |
+
tip.end_reason,
|
| 1118 |
+
tip.message_count,
|
| 1119 |
+
tip.tool_call_count,
|
| 1120 |
+
rt.activity AS last_active,
|
| 1121 |
+
COALESCE(
|
| 1122 |
+
(SELECT {_PREVIEW_RAW_SELECT}
|
| 1123 |
+
FROM messages m
|
| 1124 |
+
WHERE m.session_id = tip.id
|
| 1125 |
+
AND m.role = 'user'
|
| 1126 |
+
AND m.content IS NOT NULL
|
| 1127 |
+
AND {_PREVIEW_ELIGIBLE_SQL}
|
| 1128 |
+
ORDER BY m.timestamp, m.id LIMIT 1),
|
| 1129 |
+
''
|
| 1130 |
+
) AS _preview_raw,
|
| 1131 |
+
CASE WHEN s.id != tip.id THEN s.id ELSE NULL END
|
| 1132 |
+
AS _lineage_root_id
|
| 1133 |
+
FROM ranked_tips rt
|
| 1134 |
+
JOIN sessions s ON s.id = rt.root_id
|
| 1135 |
+
JOIN sessions tip ON tip.id = rt.cur_id
|
| 1136 |
+
WHERE rt.rank_in_root = 1
|
| 1137 |
+
AND s.archived = 0
|
| 1138 |
+
AND s.hidden = 0
|
| 1139 |
+
AND {_LISTABLE_CHILD_SQL}
|
| 1140 |
+
AND {_delegate_from_json('s.model_config')} IS NULL
|
| 1141 |
+
ORDER BY rt.activity DESC, s.started_at DESC, tip.id DESC
|
| 1142 |
+
LIMIT ?
|
| 1143 |
+
"""
|
| 1144 |
+
params = candidate_params + [
|
| 1145 |
+
candidate_limit,
|
| 1146 |
+
lineage_limit,
|
| 1147 |
+
lineage_limit,
|
| 1148 |
+
limit,
|
| 1149 |
+
]
|
| 1150 |
+
deadline = time.monotonic() + timeout_seconds
|
| 1151 |
+
interrupted_by_deadline = False
|
| 1152 |
+
|
| 1153 |
+
def _deadline_progress_handler() -> int:
|
| 1154 |
+
nonlocal interrupted_by_deadline
|
| 1155 |
+
if time.monotonic() >= deadline:
|
| 1156 |
+
interrupted_by_deadline = True
|
| 1157 |
+
return 1
|
| 1158 |
+
return 0
|
| 1159 |
+
|
| 1160 |
+
try:
|
| 1161 |
+
with self._read_ctx() as conn:
|
| 1162 |
+
conn.set_progress_handler(_deadline_progress_handler, 1000)
|
| 1163 |
+
try:
|
| 1164 |
+
rows = conn.execute(query, params).fetchall()
|
| 1165 |
+
finally:
|
| 1166 |
+
conn.set_progress_handler(None, 0)
|
| 1167 |
+
except sqlite3.OperationalError as exc:
|
| 1168 |
+
if interrupted_by_deadline and "interrupt" in str(exc).lower():
|
| 1169 |
+
raise TimeoutError(
|
| 1170 |
+
f"recent-session browse exceeded {timeout_seconds:g}s deadline"
|
| 1171 |
+
) from exc
|
| 1172 |
+
raise
|
| 1173 |
+
|
| 1174 |
+
sessions = []
|
| 1175 |
+
for row in rows:
|
| 1176 |
+
session = self._session_row_dict(row)
|
| 1177 |
+
session["preview"] = _shape_preview(session.pop("_preview_raw", ""))
|
| 1178 |
+
session["unread"] = self.session_unread(session)
|
| 1179 |
+
sessions.append(session)
|
| 1180 |
+
return sessions
|
| 1181 |
+
|
| 1182 |
+
@classmethod
|
| 1183 |
+
def _list_row(cls, row: sqlite3.Row) -> Dict[str, Any]:
|
| 1184 |
+
"""Project a list_sessions_rich row: shape the preview, drop internal ordering columns."""
|
| 1185 |
+
s = cls._session_row_dict(row)
|
| 1186 |
+
s["preview"] = _shape_preview(s.pop("_preview_raw", ""))
|
| 1187 |
+
s.pop("_effective_last_active", None)
|
| 1188 |
+
return s
|
| 1189 |
+
|
| 1190 |
+
def list_sessions_rich(
|
| 1191 |
+
self, source: str = None, sources: List[str] = None, exclude_sources: List[str] = None,
|
| 1192 |
+
cwd_prefix: str = None, limit: int = 20, offset: int = 0, include_children: bool = False,
|
| 1193 |
+
min_message_count: int = 0, project_compression_tips: bool = True,
|
| 1194 |
+
order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False,
|
| 1195 |
+
id_query: str = None, search_query: str = None, compact_rows: bool = False,
|
| 1196 |
+
include_pinned: bool = False, session_key: str = None, include_hidden: bool = False,
|
| 1197 |
+
) -> List[Dict[str, Any]]:
|
| 1198 |
+
"""List sessions with preview and ``last_active`` in one query. ``order_by_last_active`` sorts
|
| 1199 |
+
by the chain TIP via a recursive CTE (the only path honouring ``id_query`` / ``search_query``);
|
| 1200 |
+
``include_pinned`` back-fills pins the page missed, still obeying the other filters."""
|
| 1201 |
+
self.flush_token_counts() # rows carry token/cost totals
|
| 1202 |
+
where_clauses, params = _session_filter_where(
|
| 1203 |
+
exclude_children=not include_children, source=source, sources=sources, session_key=session_key,
|
| 1204 |
+
exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
|
| 1205 |
+
archived_only=archived_only, include_archived=include_archived,
|
| 1206 |
+
)
|
| 1207 |
+
if not include_hidden:
|
| 1208 |
+
where_clauses.append("s.hidden = 0")
|
| 1209 |
+
where_sql = _where_sql(where_clauses)
|
| 1210 |
+
base_where_params = list(params) # pinned back-fill reuses the WHERE before LIMIT/OFFSET
|
| 1211 |
+
# Shared projection head of the three list queries (whitespace is part of the SQL text).
|
| 1212 |
+
select_head = (
|
| 1213 |
+
f"SELECT {self._compact_session_cols() if compact_rows else 's.*'}"
|
| 1214 |
+
+ ("" if compact_rows else ", COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved")
|
| 1215 |
+
+ f",\n {_PREVIEW_COL_SQL},\n "
|
| 1216 |
+
)
|
| 1217 |
+
prompt_join = (
|
| 1218 |
+
"" if compact_rows else "LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash"
|
| 1219 |
+
)
|
| 1220 |
+
from_sessions = f"FROM sessions s\n {prompt_join}"
|
| 1221 |
+
if order_by_last_active:
|
| 1222 |
+
# The CTE walks compression-continuation edges forward from the admitted
|
| 1223 |
+
# rows; MAX over the chain gives effective_last_active in SQL. Do NOT
|
| 1224 |
+
# require child.started_at >= parent.ended_at: races insert the
|
| 1225 |
+
# continuation before ended_at is written.
|
| 1226 |
+
outer_where, id_params = self._chain_search_where(
|
| 1227 |
+
where_sql, (id_query or "").strip().lower(), (search_query or "").strip().lower(),
|
| 1228 |
+
)
|
| 1229 |
+
query = f"""
|
| 1230 |
+
WITH RECURSIVE chain(root_id, cur_id) AS (
|
| 1231 |
+
SELECT s.id, s.id FROM sessions s {where_sql}
|
| 1232 |
+
UNION ALL
|
| 1233 |
+
SELECT c.root_id, child.id
|
| 1234 |
+
FROM chain c
|
| 1235 |
+
JOIN sessions parent ON parent.id = c.cur_id
|
| 1236 |
+
JOIN sessions child ON child.parent_session_id = c.cur_id
|
| 1237 |
+
WHERE parent.end_reason = 'compression'
|
| 1238 |
+
AND {_sql_json_extract('child.model_config', '$._branched_from')} IS NULL
|
| 1239 |
+
AND {_sql_json_extract('child.model_config', '$._delegate_from')} IS NULL
|
| 1240 |
+
AND COALESCE(child.source, '') != 'tool'
|
| 1241 |
+
),
|
| 1242 |
+
chain_max AS (
|
| 1243 |
+
SELECT
|
| 1244 |
+
root_id,
|
| 1245 |
+
MAX({_sql_session_last_active_by_id("cur_id")}) AS effective_last_active
|
| 1246 |
+
FROM chain
|
| 1247 |
+
GROUP BY root_id
|
| 1248 |
+
)
|
| 1249 |
+
{select_head}{_sql_session_last_active("s")} AS last_active,
|
| 1250 |
+
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
|
| 1251 |
+
FROM sessions s
|
| 1252 |
+
LEFT JOIN chain_max cm ON cm.root_id = s.id
|
| 1253 |
+
{prompt_join}
|
| 1254 |
+
{outer_where}
|
| 1255 |
+
ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
|
| 1256 |
+
LIMIT ? OFFSET ?
|
| 1257 |
+
"""
|
| 1258 |
+
params = params + params + id_params + [limit, offset] # WHERE binds twice (seed + outer)
|
| 1259 |
+
else:
|
| 1260 |
+
query = f"""
|
| 1261 |
+
{select_head}{_sql_session_last_active("s")} AS last_active
|
| 1262 |
+
{from_sessions}
|
| 1263 |
+
{where_sql}
|
| 1264 |
+
ORDER BY s.started_at DESC
|
| 1265 |
+
LIMIT ? OFFSET ?
|
| 1266 |
+
"""
|
| 1267 |
+
params.extend([limit, offset])
|
| 1268 |
+
sessions = [self._list_row(row) for row in self._read_all(query, params)]
|
| 1269 |
+
# Pinned back-fill runs BEFORE compression projection so a back-filled root
|
| 1270 |
+
# projects to its tip like any other row.
|
| 1271 |
+
if include_pinned:
|
| 1272 |
+
seen_ids = {s["id"] for s in sessions}
|
| 1273 |
+
pinned_where = f"{where_sql} AND s.pinned = 1" if where_sql else "WHERE s.pinned = 1"
|
| 1274 |
+
pinned_query = f"""
|
| 1275 |
+
{select_head}COALESCE(
|
| 1276 |
+
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
|
| 1277 |
+
s.started_at
|
| 1278 |
+
) AS last_active
|
| 1279 |
+
{from_sessions}
|
| 1280 |
+
{pinned_where}
|
| 1281 |
+
ORDER BY s.started_at DESC
|
| 1282 |
+
"""
|
| 1283 |
+
for row in self._read_all(pinned_query, base_where_params):
|
| 1284 |
+
s = self._list_row(row)
|
| 1285 |
+
if s["id"] not in seen_ids:
|
| 1286 |
+
seen_ids.add(s["id"])
|
| 1287 |
+
sessions.append(s)
|
| 1288 |
+
if project_compression_tips and not include_children:
|
| 1289 |
+
sessions = self._project_compression_tips(sessions, compact_rows)
|
| 1290 |
+
# last_read_at is lineage-stamped, so root and tip watermarks agree.
|
| 1291 |
+
for s in sessions:
|
| 1292 |
+
s["unread"] = self.session_unread(s)
|
| 1293 |
+
return sessions
|
| 1294 |
+
|
| 1295 |
+
def session_lifecycle_statuses(self, session_ids: List[str]) -> Dict[str, str]:
|
| 1296 |
+
"""``{session_id: status}`` from each session's LAST message row (``'empty'`` when none); one
|
| 1297 |
+
query, MAX(id) per session joined back — never scans transcripts."""
|
| 1298 |
+
ids = [sid for sid in (session_ids or []) if sid]
|
| 1299 |
+
if not ids:
|
| 1300 |
+
return {}
|
| 1301 |
+
statuses: Dict[str, str] = {sid: "empty" for sid in ids}
|
| 1302 |
+
rows = self._read_all(f"""
|
| 1303 |
+
SELECT m.session_id, m.role,
|
| 1304 |
+
m.tool_calls IS NOT NULL AS has_tool_calls,
|
| 1305 |
+
m.finish_reason
|
| 1306 |
+
FROM messages m
|
| 1307 |
+
JOIN (
|
| 1308 |
+
SELECT session_id, MAX(id) AS max_id
|
| 1309 |
+
FROM messages
|
| 1310 |
+
WHERE session_id IN ({_session_ids_placeholders(ids)})
|
| 1311 |
+
GROUP BY session_id
|
| 1312 |
+
) latest ON m.id = latest.max_id
|
| 1313 |
+
""", ids)
|
| 1314 |
+
for row in rows:
|
| 1315 |
+
statuses[row["session_id"]] = classify_session_status(
|
| 1316 |
+
role=row["role"], has_tool_calls=bool(row["has_tool_calls"]),
|
| 1317 |
+
finish_reason=row["finish_reason"],
|
| 1318 |
+
)
|
| 1319 |
+
return statuses
|
| 1320 |
+
|
| 1321 |
+
def assert_export_safe(self, session_id: str, max_messages: Optional[int] = None) -> int:
|
| 1322 |
+
"""Active row count of this segment, or raise SessionExportTooLargeError (the LIMITed subquery
|
| 1323 |
+
stops once the bound is exceeded). ``None`` resolves ``sessions.max_export_messages``; 0 disables
|
| 1324 |
+
the guard."""
|
| 1325 |
+
from hermes_state import SessionExportTooLargeError, resolved_max_export_messages
|
| 1326 |
+
if max_messages is None:
|
| 1327 |
+
max_messages = resolved_max_export_messages()
|
| 1328 |
+
if max_messages < 0:
|
| 1329 |
+
raise ValueError("max_messages must be non-negative")
|
| 1330 |
+
if max_messages == 0:
|
| 1331 |
+
return 0
|
| 1332 |
+
row = self._read_one(
|
| 1333 |
+
"SELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)",
|
| 1334 |
+
(session_id, max_messages + 1),
|
| 1335 |
+
)
|
| 1336 |
+
message_count = int(row[0] if row else 0)
|
| 1337 |
+
if message_count > max_messages:
|
| 1338 |
+
raise SessionExportTooLargeError(session_id, message_count, max_messages)
|
| 1339 |
+
return message_count
|
| 1340 |
+
|
| 1341 |
+
def _is_explicit_branch_session(self, session_id: str) -> bool:
|
| 1342 |
+
"""Copied user-facing branch (``_branched_from``)? Branches own a copied transcript;
|
| 1343 |
+
compression continuations need the parent's archived rows."""
|
| 1344 |
+
if not session_id:
|
| 1345 |
+
return False
|
| 1346 |
+
row = self._read_one("SELECT model_config FROM sessions WHERE id = ?", (session_id,))
|
| 1347 |
+
return row is not None and bool(_parse_model_config(row[0]).get("_branched_from"))
|
| 1348 |
+
|
| 1349 |
+
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
|
| 1350 |
+
if not session_id:
|
| 1351 |
+
return [session_id]
|
| 1352 |
+
chain: List[str] = []
|
| 1353 |
+
current = session_id
|
| 1354 |
+
with self._read_ctx() as conn:
|
| 1355 |
+
while current and current not in chain and len(chain) < 100:
|
| 1356 |
+
chain.append(current)
|
| 1357 |
+
row = conn.execute(
|
| 1358 |
+
"SELECT parent_session_id FROM sessions WHERE id = ?", (current,),
|
| 1359 |
+
).fetchone()
|
| 1360 |
+
if row is None:
|
| 1361 |
+
break
|
| 1362 |
+
current = row[0]
|
| 1363 |
+
return list(reversed(chain)) or [session_id]
|
| 1364 |
+
|
| 1365 |
+
def search_sessions(
|
| 1366 |
+
self, source: str = None, limit: int = 20, offset: int = 0, workspace_key: str = None,
|
| 1367 |
+
) -> List[Dict[str, Any]]:
|
| 1368 |
+
"""Sessions MRU-first with a computed ``last_active``; ``workspace_key`` scopes to one workspace
|
| 1369 |
+
so ``hermes -c``/``--resume`` picks its last session."""
|
| 1370 |
+
where_clauses = []
|
| 1371 |
+
params: list = []
|
| 1372 |
+
if source:
|
| 1373 |
+
where_clauses.append("s.source = ?")
|
| 1374 |
+
params.append(source)
|
| 1375 |
+
if workspace_key:
|
| 1376 |
+
ws_clause, ws_params = _workspace_key_clause(workspace_key)
|
| 1377 |
+
where_clauses.append(ws_clause)
|
| 1378 |
+
params.extend(ws_params)
|
| 1379 |
+
return [self._session_row_dict(row) for row in self._read_all(
|
| 1380 |
+
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, "
|
| 1381 |
+
f"{_sql_session_last_active('s')} AS last_active "
|
| 1382 |
+
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
|
| 1383 |
+
f"{_where_sql(where_clauses, ' ')} "
|
| 1384 |
+
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
|
| 1385 |
+
[*params, limit, offset],
|
| 1386 |
+
)]
|
| 1387 |
+
|
| 1388 |
+
def session_count(
|
| 1389 |
+
self, source: str = None, sources: List[str] = None, cwd_prefix: str = None,
|
| 1390 |
+
min_message_count: int = 0, include_archived: bool = False, archived_only: bool = False,
|
| 1391 |
+
exclude_children: bool = False, exclude_sources: List[str] = None,
|
| 1392 |
+
) -> int:
|
| 1393 |
+
"""Count sessions with list_sessions_rich's filters so a paired "load more" total matches."""
|
| 1394 |
+
where_clauses, params = _session_filter_where(
|
| 1395 |
+
exclude_children=exclude_children, source=source, sources=sources,
|
| 1396 |
+
exclude_sources=exclude_sources, cwd_prefix=cwd_prefix, min_message_count=min_message_count,
|
| 1397 |
+
archived_only=archived_only, include_archived=include_archived,
|
| 1398 |
+
)
|
| 1399 |
+
return self._read_one(f"SELECT COUNT(*) FROM sessions s{_where_sql(where_clauses, ' ')}", params)[0]
|
| 1400 |
+
|
| 1401 |
+
def session_count_ge(self, n: int = 1) -> bool:
|
| 1402 |
+
"""At least N sessions exist (archived included); LIMIT short-circuits session_count()'s scan."""
|
| 1403 |
+
return len(self._read_all("SELECT 1 FROM sessions LIMIT ?", (n,))) >= n
|
| 1404 |
+
|
| 1405 |
+
def session_count_by_source(
|
| 1406 |
+
self, *, include_archived: bool = False, archived_only: bool = False,
|
| 1407 |
+
exclude_children: bool = False,
|
| 1408 |
+
) -> Dict[str, int]:
|
| 1409 |
+
"""``{source: count}`` via one GROUP BY; ``exclude_children`` mirrors listing visibility."""
|
| 1410 |
+
where_clauses, params = _session_filter_where(
|
| 1411 |
+
exclude_children=exclude_children, archived_only=archived_only,
|
| 1412 |
+
include_archived=include_archived,
|
| 1413 |
+
)
|
| 1414 |
+
with self._read_ctx() as conn:
|
| 1415 |
+
if self._conn is None:
|
| 1416 |
+
raise RuntimeError("SessionDB connection is closed")
|
| 1417 |
+
rows = conn.execute(
|
| 1418 |
+
"SELECT COALESCE(NULLIF(s.source, ''), 'cli') AS source, COUNT(*) AS count "
|
| 1419 |
+
f"FROM sessions s{_where_sql(where_clauses, ' ')} "
|
| 1420 |
+
"GROUP BY COALESCE(NULLIF(s.source, ''), 'cli') ORDER BY count DESC", params,
|
| 1421 |
+
).fetchall()
|
| 1422 |
+
return {str(row["source"]): int(row["count"] or 0) for row in rows}
|
| 1423 |
+
|
| 1424 |
+
def declared_scope_identity(self, session_id: str) -> Tuple[bool, str]:
|
| 1425 |
+
"""(is_fork_child, source) in ONE read (prompt_cache_scope needs both from the same row).
|
| 1426 |
+
Missing row → (False, ""); DB errors propagate (fail closed).
|
| 1427 |
+
|
| 1428 |
+
``agent/prompt_cache_scope.py`` needs both to resolve a host-declared conversation scope, and both
|
| 1429 |
+
live on the same ``sessions`` row; asking for them separately read that row twice per resolution
|
| 1430 |
+
(@teknium1 on 98811). The marker rules stay here, beside :meth:`is_explicit_fork_child`, instead of
|
| 1431 |
+
being re-implemented by the caller. See #98811.
|
| 1432 |
+
"""
|
| 1433 |
+
session = self.get_session(session_id)
|
| 1434 |
+
if not session:
|
| 1435 |
+
return False, ""
|
| 1436 |
+
return self._is_explicit_fork_child_row(session), str(session.get("source") or "").strip()
|
| 1437 |
+
|
| 1438 |
+
@staticmethod
|
| 1439 |
+
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
|
| 1440 |
+
"""Remove ``<id>.json``/``.jsonl`` and gateway ``request_dump_<id>_*.json``; OSError is swallowed
|
| 1441 |
+
so a filesystem hiccup never blocks a DB operation."""
|
| 1442 |
+
if sessions_dir is None:
|
| 1443 |
+
return
|
| 1444 |
+
targets = [sessions_dir / f"{session_id}{suffix}" for suffix in (".json", ".jsonl")]
|
| 1445 |
+
try:
|
| 1446 |
+
targets.extend(sessions_dir.glob(f"request_dump_{session_id}_*.json"))
|
| 1447 |
+
except OSError:
|
| 1448 |
+
pass
|
| 1449 |
+
for p in targets:
|
| 1450 |
+
try:
|
| 1451 |
+
p.unlink(missing_ok=True)
|
| 1452 |
+
except OSError:
|
| 1453 |
+
pass
|
| 1454 |
+
|
| 1455 |
+
def get_session_delete_targets(self, session_id: str) -> List[str]:
|
| 1456 |
+
"""Rows :meth:`delete_session` would remove: the session, then its recursive delegate children
|
| 1457 |
+
(branch/compression children are orphaned, not deleted)."""
|
| 1458 |
+
with self._read_ctx() as conn:
|
| 1459 |
+
if not conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone():
|
| 1460 |
+
return []
|
| 1461 |
+
# Use the borrowed read connection, never self._conn: handing the shared writer connection to a
|
| 1462 |
+
# helper here executes on it without self._lock — the same unsynchronized-read class as
|
| 1463 |
+
# #99349/#90734.
|
| 1464 |
+
delegate_ids = _collect_delegate_child_ids(conn, [session_id])
|
| 1465 |
+
return [session_id, *sorted(delegate_ids)]
|
| 1466 |
+
|
| 1467 |
+
def delete_session(
|
| 1468 |
+
self, session_id: str, sessions_dir: Optional[Path] = None,
|
| 1469 |
+
expected_delete_ids: Optional[List[str]] = None,
|
| 1470 |
+
) -> bool:
|
| 1471 |
+
"""Delete a session and its messages; delegate children cascade, branch/compression children
|
| 1472 |
+
are orphaned. *expected_delete_ids*: proceed only if parent + delegate cascade still equals that
|
| 1473 |
+
set (re-walked inside the transaction on purpose: export-before-delete fails closed)."""
|
| 1474 |
+
removed_ids: List[str] = []
|
| 1475 |
+
expected_ids = set(expected_delete_ids) if expected_delete_ids is not None else None
|
| 1476 |
+
def _do(conn):
|
| 1477 |
+
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
|
| 1478 |
+
return False
|
| 1479 |
+
if expected_ids is not None and expected_ids != {
|
| 1480 |
+
session_id, *_collect_delegate_child_ids(conn, [session_id])
|
| 1481 |
+
}:
|
| 1482 |
+
return False
|
| 1483 |
+
removed_ids.extend(_delete_delegate_children(conn, [session_id]))
|
| 1484 |
+
conn.execute( # orphan remaining children (branches) so FK is satisfied
|
| 1485 |
+
"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", (session_id,),
|
| 1486 |
+
)
|
| 1487 |
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
| 1488 |
+
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
| 1489 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 1490 |
+
removed_ids.append(session_id)
|
| 1491 |
+
return True
|
| 1492 |
+
deleted = self._execute_write(_do)
|
| 1493 |
+
for sid in removed_ids:
|
| 1494 |
+
self._remove_session_files(sessions_dir, sid)
|
| 1495 |
+
return bool(deleted)
|
| 1496 |
+
|
| 1497 |
+
def delete_session_if_empty(self, session_id: str, sessions_dir: Optional[Path] = None) -> bool:
|
| 1498 |
+
"""Delete *session_id* only if it has no messages, no title and no children; check and delete
|
| 1499 |
+
share one transaction so a concurrent flush can't be lost."""
|
| 1500 |
+
def _do(conn):
|
| 1501 |
+
cursor = conn.execute(
|
| 1502 |
+
"""
|
| 1503 |
+
DELETE FROM sessions
|
| 1504 |
+
WHERE id = ?
|
| 1505 |
+
AND title IS NULL
|
| 1506 |
+
AND NOT EXISTS (
|
| 1507 |
+
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
|
| 1508 |
+
)
|
| 1509 |
+
AND NOT EXISTS (
|
| 1510 |
+
SELECT 1 FROM sessions child
|
| 1511 |
+
WHERE child.parent_session_id = sessions.id
|
| 1512 |
+
)
|
| 1513 |
+
""",
|
| 1514 |
+
(session_id,),
|
| 1515 |
+
)
|
| 1516 |
+
if cursor.rowcount > 0:
|
| 1517 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 1518 |
+
return cursor.rowcount > 0
|
| 1519 |
+
deleted = self._execute_write(_do)
|
| 1520 |
+
if deleted:
|
| 1521 |
+
self._remove_session_files(sessions_dir, session_id)
|
| 1522 |
+
return deleted
|
| 1523 |
+
|
| 1524 |
+
def delete_sessions(self, session_ids: List[str], sessions_dir: Optional[Path] = None) -> int:
|
| 1525 |
+
"""Bulk delete with :meth:`delete_session` semantics per row, in ONE transaction. Unknown ids
|
| 1526 |
+
are skipped (UI selection can race another tab's delete). Returns the number deleted."""
|
| 1527 |
+
unique_ids = list({sid for sid in session_ids or () if isinstance(sid, str) and sid})
|
| 1528 |
+
if not unique_ids:
|
| 1529 |
+
return 0
|
| 1530 |
+
removed_ids: list[str] = []
|
| 1531 |
+
def _do(conn):
|
| 1532 |
+
existing = [row["id"] for chunk in _id_chunks(unique_ids) for row in conn.execute(
|
| 1533 |
+
f"SELECT id FROM sessions WHERE id IN ({_session_ids_placeholders(chunk)})", chunk,
|
| 1534 |
+
).fetchall()]
|
| 1535 |
+
if not existing:
|
| 1536 |
+
return 0
|
| 1537 |
+
removed_ids.extend(_delete_delegate_children(conn, existing))
|
| 1538 |
+
for chunk in _id_chunks(existing):
|
| 1539 |
+
ph = _session_ids_placeholders(chunk)
|
| 1540 |
+
conn.execute( # orphan children whose parent is in the kill list (FK)
|
| 1541 |
+
f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk,
|
| 1542 |
+
)
|
| 1543 |
+
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
|
| 1544 |
+
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
|
| 1545 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 1546 |
+
removed_ids.extend(existing)
|
| 1547 |
+
return len(existing)
|
| 1548 |
+
count = self._execute_write(_do)
|
| 1549 |
+
for sid in removed_ids:
|
| 1550 |
+
self._remove_session_files(sessions_dir, sid)
|
| 1551 |
+
return count
|
| 1552 |
+
|
| 1553 |
+
# Shared by count_empty_sessions / delete_empty_sessions so badge and sweep agree. message_count
|
| 1554 |
+
# counts live rows only (rewind/compaction keep dropped turns as active = 0): NOT EXISTS is authority.
|
| 1555 |
+
# The ``NOT EXISTS`` probe is the authority; : ``message_count = 0`` stays as a cheap prefilter. Same
|
| 1556 |
+
# shape as every : other emptiness guard in this module. (#95868)
|
| 1557 |
+
_EMPTY_SESSION_WHERE = (
|
| 1558 |
+
"message_count = 0 AND ended_at IS NOT NULL AND archived = 0 AND NOT EXISTS ("
|
| 1559 |
+
"SELECT 1 FROM messages WHERE messages.session_id = sessions.id)"
|
| 1560 |
+
)
|
| 1561 |
+
|
| 1562 |
+
def count_empty_sessions(self) -> int:
|
| 1563 |
+
"""Count of empty, ended, non-archived sessions; ended_at guards a fresh session's first message."""
|
| 1564 |
+
return self._read_one(f"SELECT COUNT(*) FROM sessions WHERE {self._EMPTY_SESSION_WHERE}")[0]
|
| 1565 |
+
|
| 1566 |
+
def delete_empty_sessions(self, sessions_dir: Optional[Path] = None) -> int:
|
| 1567 |
+
"""Delete every empty, ended, non-archived session in one transaction, orphaning (not cascading)
|
| 1568 |
+
children; transcript files are swept too."""
|
| 1569 |
+
removed_ids: list[str] = []
|
| 1570 |
+
def _do(conn):
|
| 1571 |
+
session_ids = {row["id"] for row in conn.execute(
|
| 1572 |
+
f"SELECT id FROM sessions WHERE {self._EMPTY_SESSION_WHERE}"
|
| 1573 |
+
).fetchall()}
|
| 1574 |
+
if not session_ids:
|
| 1575 |
+
return 0
|
| 1576 |
+
for chunk in _id_chunks(session_ids):
|
| 1577 |
+
ph = _session_ids_placeholders(chunk)
|
| 1578 |
+
conn.execute(f"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ({ph})", chunk)
|
| 1579 |
+
# DELETE FROM messages: a row inserted between the SELECT and here
|
| 1580 |
+
# would otherwise dangle (clean FK state).
|
| 1581 |
+
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", chunk)
|
| 1582 |
+
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", chunk)
|
| 1583 |
+
removed_ids.extend(chunk)
|
| 1584 |
+
self._delete_unreferenced_system_prompts(conn)
|
| 1585 |
+
return len(session_ids)
|
| 1586 |
+
count = self._execute_write(_do)
|
| 1587 |
+
for sid in removed_ids:
|
| 1588 |
+
self._remove_session_files(sessions_dir, sid)
|
| 1589 |
+
return count
|
| 1590 |
+
|
| 1591 |
+
def archive_sessions(
|
| 1592 |
+
self, older_than_days: Optional[float] = None, source: str = None, **filters,
|
| 1593 |
+
) -> int:
|
| 1594 |
+
"""Bulk soft-hide with prune_sessions' filter surface, via set_session_archived so each lineage
|
| 1595 |
+
flips as a unit; idempotent. Returns matches."""
|
| 1596 |
+
filters.setdefault("archived", False)
|
| 1597 |
+
rows = self.list_prune_candidates(older_than_days=older_than_days, source=source, **filters)
|
| 1598 |
+
for row in rows:
|
| 1599 |
+
self.set_session_archived(row["id"], True)
|
| 1600 |
+
return len(rows)
|
| 1601 |
+
|
| 1602 |
+
def maybe_auto_archive(
|
| 1603 |
+
self, idle_days: float = 3, min_interval_hours: int = 24, exclude_pinned: bool = True,
|
| 1604 |
+
) -> Dict[str, Any]:
|
| 1605 |
+
"""Idempotent, non-destructive auto-archive of sessions idle for ``idle_days``; state_meta
|
| 1606 |
+
``last_auto_archive`` gates runs within ``min_interval_hours``. Never raises."""
|
| 1607 |
+
result: Dict[str, Any] = {"skipped": False, "archived": 0}
|
| 1608 |
+
try:
|
| 1609 |
+
now = time.time()
|
| 1610 |
+
try:
|
| 1611 |
+
last = float(self.get_meta("last_auto_archive") or 0.0)
|
| 1612 |
+
except (TypeError, ValueError):
|
| 1613 |
+
last = 0.0 # corrupt meta; treat as no prior run
|
| 1614 |
+
if last and now - last < min_interval_hours * 3600:
|
| 1615 |
+
result["skipped"] = True
|
| 1616 |
+
return result
|
| 1617 |
+
archived = result["archived"] = self.archive_stale_sessions(idle_days, exclude_pinned=exclude_pinned)
|
| 1618 |
+
# Record even a zero-archive run so we don't re-sweep every call.
|
| 1619 |
+
self.set_meta("last_auto_archive", str(now))
|
| 1620 |
+
if archived > 0:
|
| 1621 |
+
logger.info(
|
| 1622 |
+
"state.db auto-archive: archived %d session(s) idle >= %s days", archived, idle_days,
|
| 1623 |
+
)
|
| 1624 |
+
except Exception as exc:
|
| 1625 |
+
logger.warning("state.db auto-archive failed: %s", exc)
|
| 1626 |
+
result["error"] = str(exc)
|
| 1627 |
+
return result
|
hermes_state_telegram.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram DM topic-mode mixin for :class:`hermes_state.SessionDB`."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import contextlib
|
| 6 |
+
import logging
|
| 7 |
+
import sqlite3
|
| 8 |
+
import time
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from hermes_state_common import _PREVIEW_ELIGIBLE_SQL, _PREVIEW_RAW_SELECT, _sql_session_last_active
|
| 12 |
+
|
| 13 |
+
# caplog tests pin the "hermes_state" logger name.
|
| 14 |
+
logger = logging.getLogger("hermes_state")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _normalize_telegram_topic_profile_name(profile_name: Optional[str] = None) -> str:
|
| 18 |
+
"""Empty/missing → ``"default"`` (single namespace for non-multiplexed gateways).
|
| 19 |
+
Multiplexed callers must pass the *routed* profile (``source.profile``), never the
|
| 20 |
+
process-global active profile."""
|
| 21 |
+
name = str(profile_name or "").strip()
|
| 22 |
+
return name if name else "default"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# (table, column list, DDL body). profile_name leads the PK: a private chat_id is the
|
| 26 |
+
# user id, identical across bots sharing one state.db.
|
| 27 |
+
_TOPIC_TABLES = (
|
| 28 |
+
(
|
| 29 |
+
"telegram_dm_topic_mode",
|
| 30 |
+
"profile_name, chat_id, user_id, enabled, activated_at, updated_at, "
|
| 31 |
+
"has_topics_enabled, allows_users_to_create_topics, capability_checked_at, intro_message_id, pinned_message_id",
|
| 32 |
+
"""
|
| 33 |
+
profile_name TEXT NOT NULL DEFAULT 'default',
|
| 34 |
+
chat_id TEXT NOT NULL,
|
| 35 |
+
user_id TEXT NOT NULL,
|
| 36 |
+
enabled INTEGER NOT NULL DEFAULT 1,
|
| 37 |
+
activated_at REAL NOT NULL,
|
| 38 |
+
updated_at REAL NOT NULL,
|
| 39 |
+
has_topics_enabled INTEGER,
|
| 40 |
+
allows_users_to_create_topics INTEGER,
|
| 41 |
+
capability_checked_at REAL,
|
| 42 |
+
intro_message_id TEXT,
|
| 43 |
+
pinned_message_id TEXT,
|
| 44 |
+
PRIMARY KEY (profile_name, chat_id)
|
| 45 |
+
""",
|
| 46 |
+
),
|
| 47 |
+
(
|
| 48 |
+
"telegram_dm_topic_bindings",
|
| 49 |
+
"profile_name, chat_id, thread_id, user_id, session_key, session_id, managed_mode, linked_at, updated_at",
|
| 50 |
+
"""
|
| 51 |
+
profile_name TEXT NOT NULL DEFAULT 'default',
|
| 52 |
+
chat_id TEXT NOT NULL,
|
| 53 |
+
thread_id TEXT NOT NULL,
|
| 54 |
+
user_id TEXT NOT NULL,
|
| 55 |
+
session_key TEXT NOT NULL,
|
| 56 |
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
| 57 |
+
managed_mode TEXT NOT NULL DEFAULT 'auto',
|
| 58 |
+
linked_at REAL NOT NULL,
|
| 59 |
+
updated_at REAL NOT NULL,
|
| 60 |
+
PRIMARY KEY (profile_name, chat_id, thread_id)
|
| 61 |
+
""",
|
| 62 |
+
),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Shared SELECT for the unlinked-session listing; the profile/bindings clauses are
|
| 66 |
+
# spliced in only when the bindings table exists.
|
| 67 |
+
_UNLINKED_SELECT_HEAD = f"""
|
| 68 |
+
SELECT s.*,
|
| 69 |
+
COALESCE(sp.prompt, s.system_prompt)
|
| 70 |
+
AS _system_prompt_resolved,
|
| 71 |
+
COALESCE(
|
| 72 |
+
(SELECT {_PREVIEW_RAW_SELECT}
|
| 73 |
+
FROM messages m
|
| 74 |
+
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
| 75 |
+
AND {_PREVIEW_ELIGIBLE_SQL}
|
| 76 |
+
ORDER BY m.timestamp, m.id LIMIT 1),
|
| 77 |
+
''
|
| 78 |
+
) AS _preview_raw,
|
| 79 |
+
{_sql_session_last_active("s")} AS last_active
|
| 80 |
+
FROM sessions s
|
| 81 |
+
LEFT JOIN system_prompts sp
|
| 82 |
+
ON sp.hash = s.system_prompt_hash
|
| 83 |
+
WHERE s.source = 'telegram'
|
| 84 |
+
AND s.user_id = ?
|
| 85 |
+
"""
|
| 86 |
+
_UNLINKED_SELECT_TAIL = """ ORDER BY last_active DESC, s.started_at DESC
|
| 87 |
+
LIMIT ?
|
| 88 |
+
"""
|
| 89 |
+
# sessions.profile_name is NULL/empty for legacy rows → treat as default.
|
| 90 |
+
_UNLINKED_SCOPE_CLAUSES = """ AND COALESCE(NULLIF(TRIM(s.profile_name), ''), 'default') = ?
|
| 91 |
+
AND NOT EXISTS (
|
| 92 |
+
SELECT 1 FROM telegram_dm_topic_bindings b
|
| 93 |
+
WHERE b.session_id = s.id
|
| 94 |
+
)
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class SessionTelegramTopicsMixin:
|
| 99 |
+
"""Telegram DM topic-mode tables, bindings and lookups. Read paths tolerate absent
|
| 100 |
+
tables (nobody ran ``/topic``) by returning their empty value; only
|
| 101 |
+
``enable``/``bind`` run the migration."""
|
| 102 |
+
|
| 103 |
+
def _topic_read_one(self, sql: str, params):
|
| 104 |
+
"""``fetchone`` that treats an unmigrated table as None."""
|
| 105 |
+
try:
|
| 106 |
+
return self._read_one(sql, params)
|
| 107 |
+
except sqlite3.OperationalError:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
def apply_telegram_topic_migration(self) -> None:
|
| 111 |
+
"""Create Telegram DM topic-mode tables on explicit /topic opt-in. Deliberately NOT
|
| 112 |
+
part of startup reconciliation: operators can upgrade and keep the old bot
|
| 113 |
+
behavior until a user runs /topic. Schema versions: v1 initial; v2 session_id FK
|
| 114 |
+
ON DELETE CASCADE (pruning clears bindings); v3 ``profile_name`` on both tables so
|
| 115 |
+
multiplexed gateways sharing one state.db isolate topic state per profile.
|
| 116 |
+
|
| 117 |
+
See #76423.
|
| 118 |
+
"""
|
| 119 |
+
def _do(conn):
|
| 120 |
+
for table, columns, ddl in _TOPIC_TABLES:
|
| 121 |
+
conn.execute(f"CREATE TABLE IF NOT EXISTS {table} ({ddl})")
|
| 122 |
+
have = {row[1] for row in conn.execute(f"PRAGMA table_info('{table}')")}
|
| 123 |
+
if "profile_name" in have:
|
| 124 |
+
continue
|
| 125 |
+
# v1/v2 → v3. SQLite can't ALTER a PK or FK, so rebuild (also supplies v2's
|
| 126 |
+
# ON DELETE CASCADE). Legacy rows land in "default" only.
|
| 127 |
+
legacy_columns = columns.replace("profile_name, ", "", 1)
|
| 128 |
+
conn.executescript(f"""
|
| 129 |
+
CREATE TABLE {table}_new ({ddl});
|
| 130 |
+
INSERT INTO {table}_new ({columns})
|
| 131 |
+
SELECT 'default', {legacy_columns} FROM {table};
|
| 132 |
+
DROP TABLE {table};
|
| 133 |
+
ALTER TABLE {table}_new RENAME TO {table};
|
| 134 |
+
""")
|
| 135 |
+
# Indexes after any rebuild: the user index needs profile_name.
|
| 136 |
+
conn.executescript("""
|
| 137 |
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
|
| 138 |
+
ON telegram_dm_topic_bindings(session_id);
|
| 139 |
+
|
| 140 |
+
CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
|
| 141 |
+
ON telegram_dm_topic_bindings(profile_name, user_id, chat_id);
|
| 142 |
+
""")
|
| 143 |
+
conn.execute(
|
| 144 |
+
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
| 145 |
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
| 146 |
+
("telegram_dm_topic_schema_version", "3"),
|
| 147 |
+
)
|
| 148 |
+
self._execute_write(_do)
|
| 149 |
+
|
| 150 |
+
def enable_telegram_topic_mode(
|
| 151 |
+
self, *, chat_id: str, user_id: str, profile_name: str="default", has_topics_enabled: Optional[bool]=None,
|
| 152 |
+
allows_users_to_create_topics: Optional[bool]=None,
|
| 153 |
+
) -> None:
|
| 154 |
+
"""Enable Telegram DM topic mode for one private chat/user. Owns the explicit topic
|
| 155 |
+
migration; SessionDB startup must not create these tables.
|
| 156 |
+
|
| 157 |
+
``profile_name`` namespaces rows under a shared multiplex ``state.db`` (issue #76423). Callers
|
| 158 |
+
handling a multiplexed event must pass the routed profile from ``source.profile``, not the
|
| 159 |
+
process-global active profile.
|
| 160 |
+
"""
|
| 161 |
+
self.apply_telegram_topic_migration()
|
| 162 |
+
now = time.time()
|
| 163 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 164 |
+
|
| 165 |
+
def _to_int(value: Optional[bool]) -> Optional[int]:
|
| 166 |
+
return None if value is None else (1 if value else 0)
|
| 167 |
+
|
| 168 |
+
self._write_sql("""
|
| 169 |
+
INSERT INTO telegram_dm_topic_mode (
|
| 170 |
+
profile_name, chat_id, user_id, enabled, activated_at, updated_at,
|
| 171 |
+
has_topics_enabled, allows_users_to_create_topics,
|
| 172 |
+
capability_checked_at
|
| 173 |
+
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)
|
| 174 |
+
ON CONFLICT(profile_name, chat_id) DO UPDATE SET
|
| 175 |
+
user_id = excluded.user_id,
|
| 176 |
+
enabled = 1,
|
| 177 |
+
updated_at = excluded.updated_at,
|
| 178 |
+
has_topics_enabled = excluded.has_topics_enabled,
|
| 179 |
+
allows_users_to_create_topics = excluded.allows_users_to_create_topics,
|
| 180 |
+
capability_checked_at = excluded.capability_checked_at
|
| 181 |
+
""", (profile_name, str(chat_id), str(user_id), now, now,
|
| 182 |
+
_to_int(has_topics_enabled), _to_int(allows_users_to_create_topics), now))
|
| 183 |
+
|
| 184 |
+
def disable_telegram_topic_mode(
|
| 185 |
+
self, *, chat_id: str, profile_name: str = "default", clear_bindings: bool = True
|
| 186 |
+
) -> None:
|
| 187 |
+
"""Disable Telegram DM topic mode for one private chat. ``clear_bindings`` also drops
|
| 188 |
+
the chat's bindings so a later re-enable starts clean. Never creates the tables;
|
| 189 |
+
absent tables are a no-op."""
|
| 190 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 191 |
+
|
| 192 |
+
def _do(conn):
|
| 193 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 194 |
+
conn.execute(
|
| 195 |
+
"UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
|
| 196 |
+
"WHERE profile_name = ? AND chat_id = ?",
|
| 197 |
+
(time.time(), profile_name, str(chat_id)),
|
| 198 |
+
)
|
| 199 |
+
if clear_bindings:
|
| 200 |
+
conn.execute(
|
| 201 |
+
"DELETE FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ?",
|
| 202 |
+
(profile_name, str(chat_id)),
|
| 203 |
+
)
|
| 204 |
+
self._execute_write(_do)
|
| 205 |
+
|
| 206 |
+
def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str, profile_name: str = "default") -> bool:
|
| 207 |
+
"""Return whether Telegram DM topic mode is enabled for this chat/user."""
|
| 208 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 209 |
+
row = self._topic_read_one("""
|
| 210 |
+
SELECT enabled FROM telegram_dm_topic_mode
|
| 211 |
+
WHERE profile_name = ? AND chat_id = ? AND user_id = ?
|
| 212 |
+
""", (profile_name, str(chat_id), str(user_id)))
|
| 213 |
+
return bool(row[0]) if row is not None else False
|
| 214 |
+
|
| 215 |
+
def get_telegram_topic_binding(
|
| 216 |
+
self, *, chat_id: str, thread_id: str, profile_name: str = "default"
|
| 217 |
+
) -> Optional[Dict[str, Any]]:
|
| 218 |
+
"""Return the session binding for a Telegram DM topic, if present."""
|
| 219 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 220 |
+
row = self._topic_read_one("""
|
| 221 |
+
SELECT * FROM telegram_dm_topic_bindings
|
| 222 |
+
WHERE profile_name = ? AND chat_id = ? AND thread_id = ?
|
| 223 |
+
""", (profile_name, str(chat_id), str(thread_id)))
|
| 224 |
+
return dict(row) if row else None
|
| 225 |
+
|
| 226 |
+
def list_telegram_topic_bindings_for_chat(
|
| 227 |
+
self, *, chat_id: str, profile_name: str = "default"
|
| 228 |
+
) -> List[Dict[str, Any]]:
|
| 229 |
+
"""All bindings for one chat, newest first ([] when the table is absent)."""
|
| 230 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 231 |
+
try:
|
| 232 |
+
rows = self._read_all(
|
| 233 |
+
"SELECT * FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ? ORDER BY updated_at DESC",
|
| 234 |
+
(profile_name, str(chat_id)),
|
| 235 |
+
)
|
| 236 |
+
except sqlite3.OperationalError:
|
| 237 |
+
return []
|
| 238 |
+
return [dict(row) for row in rows]
|
| 239 |
+
|
| 240 |
+
def get_telegram_topic_binding_by_session(self, *, session_id: str) -> Optional[Dict[str, Any]]:
|
| 241 |
+
"""Reverse lookup via the UNIQUE INDEX on session_id; None when unbound."""
|
| 242 |
+
row = self._topic_read_one("""
|
| 243 |
+
SELECT * FROM telegram_dm_topic_bindings
|
| 244 |
+
WHERE session_id = ?
|
| 245 |
+
""", (str(session_id),))
|
| 246 |
+
return dict(row) if row else None
|
| 247 |
+
|
| 248 |
+
def delete_telegram_topic_binding(self, *, chat_id: str, thread_id: str, profile_name: str = "default") -> int:
|
| 249 |
+
"""Remove the binding row for one (chat, thread) pair. Called when the Bot API confirms
|
| 250 |
+
a topic was deleted externally (``Thread not found`` after the same-thread retry
|
| 251 |
+
failed); otherwise ``gateway.run._recover_telegram_topic_thread_id`` keeps
|
| 252 |
+
redirecting inbound messages to the dead topic. If this removes the chat's *last*
|
| 253 |
+
binding, ``telegram_dm_topic_mode`` is flipped to ``enabled = 0`` in the same
|
| 254 |
+
transaction, or a user who disabled topics in the Telegram client (not via
|
| 255 |
+
``/topic off``) stays stuck. Returns the number of rows deleted; absent binding or
|
| 256 |
+
unmigrated tables are silent no-ops (never raise from a cleanup hot path).
|
| 257 |
+
|
| 258 |
+
Without this prune, the stale row keeps living in ``telegram_dm_topic_bindings`` and the recovery
|
| 259 |
+
logic in ``gateway.run._recover_telegram_topic_thread_id`` cheerfully redirects future inbound
|
| 260 |
+
messages to the deleted topic, causing tool progress, approvals, and replies to land in the wrong
|
| 261 |
+
place. Issue #31501.
|
| 262 |
+
"""
|
| 263 |
+
chat_id, thread_id = str(chat_id), str(thread_id)
|
| 264 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 265 |
+
|
| 266 |
+
def _do(conn) -> int:
|
| 267 |
+
try:
|
| 268 |
+
deleted = conn.execute("""
|
| 269 |
+
DELETE FROM telegram_dm_topic_bindings
|
| 270 |
+
WHERE profile_name = ? AND chat_id = ? AND thread_id = ?
|
| 271 |
+
""", (profile_name, chat_id, thread_id)).rowcount or 0
|
| 272 |
+
except sqlite3.OperationalError:
|
| 273 |
+
return 0
|
| 274 |
+
if not deleted:
|
| 275 |
+
return 0
|
| 276 |
+
# Last binding gone → disable topic mode in the same transaction (no
|
| 277 |
+
# read-after-prune race). telegram_dm_topic_mode absent — binding prune still stands.
|
| 278 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 279 |
+
remaining = conn.execute("""
|
| 280 |
+
SELECT 1 FROM telegram_dm_topic_bindings
|
| 281 |
+
WHERE profile_name = ? AND chat_id = ? LIMIT 1
|
| 282 |
+
""", (profile_name, chat_id)).fetchone()
|
| 283 |
+
if remaining is None:
|
| 284 |
+
conn.execute(
|
| 285 |
+
"UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
|
| 286 |
+
"WHERE profile_name = ? AND chat_id = ?",
|
| 287 |
+
(time.time(), profile_name, chat_id),
|
| 288 |
+
)
|
| 289 |
+
return deleted
|
| 290 |
+
|
| 291 |
+
return self._execute_write(_do)
|
| 292 |
+
|
| 293 |
+
def bind_telegram_topic(
|
| 294 |
+
self, *, chat_id: str, thread_id: str, user_id: str, session_key: str,
|
| 295 |
+
session_id: str, managed_mode: str = "auto", profile_name: str = "default",
|
| 296 |
+
) -> None:
|
| 297 |
+
"""Bind one Telegram DM topic thread to one Hermes session. A session may be linked to
|
| 298 |
+
only one topic: rebinding the same pair is idempotent; linking the session to a
|
| 299 |
+
different topic raises ValueError."""
|
| 300 |
+
self.apply_telegram_topic_migration()
|
| 301 |
+
now = time.time()
|
| 302 |
+
chat_id, thread_id, user_id = str(chat_id), str(thread_id), str(user_id)
|
| 303 |
+
session_key, session_id = str(session_key), str(session_id)
|
| 304 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 305 |
+
|
| 306 |
+
def _do(conn):
|
| 307 |
+
existing_session = conn.execute("""
|
| 308 |
+
SELECT profile_name, chat_id, thread_id
|
| 309 |
+
FROM telegram_dm_topic_bindings
|
| 310 |
+
WHERE session_id = ?
|
| 311 |
+
""", (session_id,)).fetchone()
|
| 312 |
+
if existing_session is not None:
|
| 313 |
+
linked_profile, linked_chat, linked_thread = existing_session
|
| 314 |
+
if (str(linked_profile), str(linked_chat), str(linked_thread)) != (profile_name, chat_id, thread_id):
|
| 315 |
+
raise ValueError("session is already linked to another Telegram topic")
|
| 316 |
+
conn.execute("""
|
| 317 |
+
INSERT INTO telegram_dm_topic_bindings (
|
| 318 |
+
profile_name, chat_id, thread_id, user_id, session_key, session_id,
|
| 319 |
+
managed_mode, linked_at, updated_at
|
| 320 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 321 |
+
ON CONFLICT(profile_name, chat_id, thread_id) DO UPDATE SET
|
| 322 |
+
user_id = excluded.user_id,
|
| 323 |
+
session_key = excluded.session_key,
|
| 324 |
+
session_id = excluded.session_id,
|
| 325 |
+
managed_mode = excluded.managed_mode,
|
| 326 |
+
updated_at = excluded.updated_at
|
| 327 |
+
""", (profile_name, chat_id, thread_id, user_id, session_key, session_id, managed_mode, now, now))
|
| 328 |
+
self._execute_write(_do)
|
| 329 |
+
|
| 330 |
+
def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
|
| 331 |
+
"""True if the session is bound to any Telegram DM topic (absent tables → False)."""
|
| 332 |
+
row = self._topic_read_one("""
|
| 333 |
+
SELECT 1 FROM telegram_dm_topic_bindings
|
| 334 |
+
WHERE session_id = ?
|
| 335 |
+
LIMIT 1
|
| 336 |
+
""", (str(session_id),))
|
| 337 |
+
return row is not None
|
| 338 |
+
|
| 339 |
+
def list_unlinked_telegram_sessions_for_user(
|
| 340 |
+
self, *, chat_id: str, user_id: str, profile_name: str = "default", limit: int = 10
|
| 341 |
+
) -> List[Dict[str, Any]]:
|
| 342 |
+
"""This user's Telegram sessions not bound to a topic. Read-only: if the bindings table
|
| 343 |
+
is absent, every session is unlinked and the profile-unscoped query is used.
|
| 344 |
+
Scoped by ``profile_name`` so multiplexed profiles do not surface each other.
|
| 345 |
+
|
| 346 |
+
See #76423.
|
| 347 |
+
"""
|
| 348 |
+
profile_name = _normalize_telegram_topic_profile_name(profile_name)
|
| 349 |
+
with self._read_ctx() as conn:
|
| 350 |
+
try:
|
| 351 |
+
rows = conn.execute(
|
| 352 |
+
_UNLINKED_SELECT_HEAD + _UNLINKED_SCOPE_CLAUSES + _UNLINKED_SELECT_TAIL,
|
| 353 |
+
(str(user_id), profile_name, int(limit)),
|
| 354 |
+
).fetchall()
|
| 355 |
+
except sqlite3.OperationalError:
|
| 356 |
+
rows = conn.execute(
|
| 357 |
+
_UNLINKED_SELECT_HEAD + _UNLINKED_SELECT_TAIL, (str(user_id), int(limit)),
|
| 358 |
+
).fetchall()
|
| 359 |
+
return [self._rich_row(row) for row in rows]
|
hermes_state_titles.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session title mixin for SessionDB: sanitizing, auto/user provenance ranking, and
|
| 2 |
+
lineage-aware lookups."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
from typing import Any, Dict, Optional
|
| 9 |
+
|
| 10 |
+
from agent.message_sanitization import _sanitize_surrogates
|
| 11 |
+
from hermes_state_common import _COMPRESSION_CHILD_SQL, escape_like as _escape_like
|
| 12 |
+
|
| 13 |
+
# caplog tests pin the "hermes_state" logger name.
|
| 14 |
+
logger = logging.getLogger("hermes_state")
|
| 15 |
+
|
| 16 |
+
# ASCII controls (keeping \t \n \r for the whitespace collapse), then zero-width,
|
| 17 |
+
# bidi override, object-replacement and interlinear-annotation code points.
|
| 18 |
+
_TITLE_CONTROL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
|
| 19 |
+
_TITLE_INVISIBLE_RE = re.compile(r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]')
|
| 20 |
+
_NUMBERED_TITLE_RE = re.compile(r'^(.*?) #(\d+)$')
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class SessionTitlesMixin:
|
| 24 |
+
"""Sanitizing, ranking auto/user titles, lineage-aware lookups."""
|
| 25 |
+
|
| 26 |
+
@classmethod
|
| 27 |
+
def _title_rank(cls, source: Optional[str]) -> int:
|
| 28 |
+
"""Rank a stored title_source. NULL (pre-provenance rows) is indistinguishable from a
|
| 29 |
+
manual ``/title`` of that era, so it ranks as ``user``."""
|
| 30 |
+
rank = cls._TITLE_SOURCE_RANK
|
| 31 |
+
return rank[cls.TITLE_SOURCE_USER] if source is None else rank.get(str(source), 0)
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
def sanitize_title(title: Optional[str]) -> Optional[str]:
|
| 35 |
+
"""Strip control/zero-width/bidi chars (and lone surrogates sqlite3 cannot bind),
|
| 36 |
+
collapse whitespace, normalize empty to None. ValueError past MAX_TITLE_LENGTH."""
|
| 37 |
+
from hermes_state import SessionDB
|
| 38 |
+
if not title:
|
| 39 |
+
return None
|
| 40 |
+
cleaned = _TITLE_INVISIBLE_RE.sub('', _TITLE_CONTROL_RE.sub('', _sanitize_surrogates(title)))
|
| 41 |
+
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
| 42 |
+
if not cleaned:
|
| 43 |
+
return None
|
| 44 |
+
if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
|
| 45 |
+
raise ValueError(f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})")
|
| 46 |
+
return cleaned
|
| 47 |
+
|
| 48 |
+
def _is_compression_ancestor(self, conn, *, ancestor_id: str, descendant_id: str) -> bool:
|
| 49 |
+
"""True if *ancestor_id* is a compression predecessor of *descendant_id*, via the
|
| 50 |
+
canonical continuation edge ``_COMPRESSION_CHILD_SQL`` (excludes delegate/branch
|
| 51 |
+
children that also carry ``parent_session_id``)."""
|
| 52 |
+
if not ancestor_id or not descendant_id or ancestor_id == descendant_id:
|
| 53 |
+
return False
|
| 54 |
+
edge = _COMPRESSION_CHILD_SQL.format(a="child")
|
| 55 |
+
return conn.execute(f"""
|
| 56 |
+
WITH RECURSIVE ancestors(id) AS (
|
| 57 |
+
SELECT ?
|
| 58 |
+
UNION
|
| 59 |
+
SELECT parent.id
|
| 60 |
+
FROM ancestors a
|
| 61 |
+
JOIN sessions child ON child.id = a.id
|
| 62 |
+
JOIN sessions parent ON parent.id = child.parent_session_id
|
| 63 |
+
WHERE {edge}
|
| 64 |
+
)
|
| 65 |
+
SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
|
| 66 |
+
""", (descendant_id, ancestor_id, descendant_id)).fetchone() is not None
|
| 67 |
+
|
| 68 |
+
def _set_session_title(self, session_id: str, title: str, *, source: str) -> bool:
|
| 69 |
+
"""Write a title, enforcing provenance precedence. A ``user`` write always lands;
|
| 70 |
+
``derived``/``llm`` land only when the row is untitled or holds strictly lower
|
| 71 |
+
authority (derived upgrades to llm exactly once, nothing overwrites a user name,
|
| 72 |
+
re-running the titler on an llm row is a no-op). No writer may move a hidden
|
| 73 |
+
canonical Bot Chat off its title. Read and write are one compare-and-swap
|
| 74 |
+
transaction, so a manual ``/title`` racing an in-flight generation is not clobbered."""
|
| 75 |
+
title = self.sanitize_title(title)
|
| 76 |
+
is_user = source == self.TITLE_SOURCE_USER
|
| 77 |
+
new_rank = self._title_rank(source) if not is_user else None
|
| 78 |
+
|
| 79 |
+
def _do(conn):
|
| 80 |
+
current = conn.execute(
|
| 81 |
+
"SELECT title, title_source, hidden FROM sessions WHERE id = ?", (session_id,),
|
| 82 |
+
).fetchone()
|
| 83 |
+
if current is None:
|
| 84 |
+
return 0
|
| 85 |
+
# The canonical Bot Chat's NAME is its identity (Bot Mode resolves it by
|
| 86 |
+
# exact-title lookup on every open), so a rename orphans the conversation. Hidden
|
| 87 |
+
# is the discriminator: canonical chats are born hidden; a visible session merely
|
| 88 |
+
# named "Bot Chat" stays renameable. Provenance-blind.
|
| 89 |
+
if ((current["title"] or "") == self.CANONICAL_BOT_CHAT_TITLE and bool(current["hidden"])
|
| 90 |
+
and title != self.CANONICAL_BOT_CHAT_TITLE):
|
| 91 |
+
if is_user:
|
| 92 |
+
raise ValueError("This is the bot's canonical Bot Chat — its name is its "
|
| 93 |
+
"identity, and renaming it would orphan the conversation. "
|
| 94 |
+
"To start fresh, create a new bot instead.")
|
| 95 |
+
return 0
|
| 96 |
+
if not is_user and current["title"] is not None and self._title_rank(current["title_source"]) >= new_rank:
|
| 97 |
+
return 0
|
| 98 |
+
if title:
|
| 99 |
+
conflict = conn.execute(
|
| 100 |
+
"SELECT id FROM sessions WHERE title = ? AND id != ?", (title, session_id),
|
| 101 |
+
).fetchone()
|
| 102 |
+
if conflict:
|
| 103 |
+
conflict_id = conflict["id"]
|
| 104 |
+
# A hidden compressed ancestor holding the title cannot be freed by the
|
| 105 |
+
# user, so transfer it onto the tip (uniqueness + lineage kept).
|
| 106 |
+
if self._is_compression_ancestor(conn, ancestor_id=conflict_id, descendant_id=session_id):
|
| 107 |
+
conn.execute("UPDATE sessions SET title = NULL WHERE id = ?", (conflict_id,))
|
| 108 |
+
else:
|
| 109 |
+
raise ValueError(f"Title '{title}' is already in use by session {conflict_id}")
|
| 110 |
+
# CAS on the values just read (``IS`` is NULL-safe): a concurrent write between
|
| 111 |
+
# the SELECT and here loses instead of being overwritten.
|
| 112 |
+
return conn.execute(
|
| 113 |
+
"UPDATE sessions SET title = ?, title_source = ? WHERE id = ? AND title IS ? AND title_source IS ?",
|
| 114 |
+
(title, source if title else None, session_id, current["title"], current["title_source"]),
|
| 115 |
+
).rowcount
|
| 116 |
+
|
| 117 |
+
return self._execute_write(_do) > 0
|
| 118 |
+
|
| 119 |
+
def set_session_title(self, session_id: str, title: str) -> bool:
|
| 120 |
+
"""Set a title on the user's behalf (``user`` provenance). Empty clears it. Raises
|
| 121 |
+
ValueError on conflict or validation failure."""
|
| 122 |
+
return self._set_session_title(session_id, title, source=self.TITLE_SOURCE_USER)
|
| 123 |
+
|
| 124 |
+
def set_auto_title(self, session_id: str, title: str, *, source: str) -> bool:
|
| 125 |
+
"""Set an automatic title; False (untouched) when a higher-authority title holds the row."""
|
| 126 |
+
if source not in (self.TITLE_SOURCE_DERIVED, self.TITLE_SOURCE_LLM):
|
| 127 |
+
raise ValueError(f"invalid automatic title source: {source!r}")
|
| 128 |
+
return self._set_session_title(session_id, title, source=source)
|
| 129 |
+
|
| 130 |
+
def get_session_title(self, session_id: str) -> Optional[str]:
|
| 131 |
+
"""Get the title for a session, or None."""
|
| 132 |
+
row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,))
|
| 133 |
+
return row["title"] if row else None
|
| 134 |
+
|
| 135 |
+
def get_session_title_source(self, session_id: str) -> Optional[str]:
|
| 136 |
+
"""Get the provenance of a session's title, or None when untitled."""
|
| 137 |
+
row = self._read_one("SELECT title, title_source FROM sessions WHERE id = ?", (session_id,))
|
| 138 |
+
return row["title_source"] if row and row["title"] is not None else None
|
| 139 |
+
|
| 140 |
+
def set_session_title_source(self, session_id: str, source: str) -> bool:
|
| 141 |
+
"""Overwrite a title's provenance without touching the text (a title copied across a
|
| 142 |
+
compression rotation keeps the original's authority)."""
|
| 143 |
+
if source not in self._TITLE_SOURCE_RANK:
|
| 144 |
+
raise ValueError(f"invalid title source: {source!r}")
|
| 145 |
+
return self._write_rowcount(
|
| 146 |
+
"UPDATE sessions SET title_source = ? WHERE id = ? AND title IS NOT NULL", (source, session_id)
|
| 147 |
+
) > 0
|
| 148 |
+
|
| 149 |
+
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
| 150 |
+
"""Look up a session by exact title. Returns session dict or None."""
|
| 151 |
+
row = self._read_one(
|
| 152 |
+
"SELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved "
|
| 153 |
+
"FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash "
|
| 154 |
+
"WHERE s.title = ?", (title,))
|
| 155 |
+
return self._session_row_dict(row) if row else None
|
| 156 |
+
|
| 157 |
+
def resolve_session_by_title(self, title: str) -> Optional[str]:
|
| 158 |
+
"""Resolve a title to a session ID, preferring the latest "title #N" continuation."""
|
| 159 |
+
exact = self.get_session_by_title(title)
|
| 160 |
+
# Escape LIKE wildcards so "%"/"_" in titles cannot false-match.
|
| 161 |
+
numbered = self._read_all(
|
| 162 |
+
"SELECT id, title, started_at FROM sessions "
|
| 163 |
+
"WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
|
| 164 |
+
(f"{_escape_like(title)} #%",))
|
| 165 |
+
return numbered[0]["id"] if numbered else (exact["id"] if exact else None)
|
| 166 |
+
|
| 167 |
+
def get_next_title_in_lineage(self, base_title: str) -> str:
|
| 168 |
+
"""Next title in a lineage ("my session" -> "my session #2"): strip any " #N" suffix,
|
| 169 |
+
then increment the highest existing number."""
|
| 170 |
+
match = _NUMBERED_TITLE_RE.match(base_title)
|
| 171 |
+
base = match.group(1) if match else base_title
|
| 172 |
+
rows = self._read_all(
|
| 173 |
+
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
|
| 174 |
+
(base, f"{_escape_like(base)} #%"))
|
| 175 |
+
if not rows:
|
| 176 |
+
return base
|
| 177 |
+
# The unnumbered original counts as #1.
|
| 178 |
+
numbers = [int(m.group(2)) for m in (_NUMBERED_TITLE_RE.match(row["title"]) for row in rows) if m]
|
| 179 |
+
return f"{base} #{max([1, *numbers]) + 1}"
|
hermes_state_wal.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state).
|
| 2 |
+
|
| 3 |
+
Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.<name>``.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import contextlib
|
| 9 |
+
import functools
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import sqlite3
|
| 13 |
+
import sys
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
from typing import Any, Dict, Optional
|
| 17 |
+
|
| 18 |
+
from hermes_cli.sqlite_runtime import is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable
|
| 19 |
+
|
| 20 |
+
# Log-record parity with the origin module (caplog tests pin "hermes_state").
|
| 21 |
+
logger = logging.getLogger("hermes_state")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# WAL needs mmap shared memory + fcntl byte-range locks. Network filesystems (NFS, SMB/CIFS, some FUSE, WSL1) raise
|
| 25 |
+
# ``locking protocol``; ZFS corrupts the -shm file under concurrent bursts (COW + mmap) -> ``disk I/O error``.
|
| 26 |
+
# Either would silently break everything on state.db/kanban.db, so fall back to DELETE (readers block on writes).
|
| 27 |
+
# "not authorized": some FUSE mounts block the WAL pragma outright.
|
| 28 |
+
_WAL_INCOMPAT_MARKERS = ("locking protocol", "not authorized", "disk i/o error")
|
| 29 |
+
# SQLite's default journal_size_limit is -1 (unlimited); see _apply_wal_size_limit.
|
| 30 |
+
_WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
|
| 31 |
+
|
| 32 |
+
# Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped
|
| 33 |
+
# line would repeat per connection). Tests clear these via ``hermes_state_wal.<name>``.
|
| 34 |
+
_wal_fallback_warned_paths: set[str] = set()
|
| 35 |
+
_wal_fallback_warned_lock = threading.Lock()
|
| 36 |
+
|
| 37 |
+
# Dedup for the both-pragmas-failed WARNING (WAL *and* DELETE rejected, e.g. APFS external SSDs under contention).
|
| 38 |
+
_wal_delete_fallback_failed_paths: set[str] = set()
|
| 39 |
+
_wal_delete_fallback_failed_lock = threading.Lock()
|
| 40 |
+
|
| 41 |
+
# Dedup for the probe-unknown WARNING (on-disk journal mode unreadable, nothing touched).
|
| 42 |
+
_wal_probe_unknown_paths: set[str] = set()
|
| 43 |
+
_wal_probe_unknown_lock = threading.Lock()
|
| 44 |
+
_wal_reset_bug_warned_paths: set[str] = set()
|
| 45 |
+
_wal_reset_bug_warned_lock = threading.Lock()
|
| 46 |
+
_delete_overridden_warned_paths: set[str] = set()
|
| 47 |
+
_delete_overridden_warned_lock = threading.Lock()
|
| 48 |
+
_journal_upgrade_warned_paths: set = set()
|
| 49 |
+
_journal_upgrade_warned_lock = threading.Lock()
|
| 50 |
+
|
| 51 |
+
_CANNOT_VERIFY_DELETE_MSG = ("could not verify journal mode before applying configured journal_mode=delete (database "
|
| 52 |
+
"is locked — possible concurrent openers); refusing to downgrade a database this process "
|
| 53 |
+
"does not exclusively own")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _mode_from_row(row) -> str:
|
| 57 |
+
"""Lower-cased mode from a ``PRAGMA journal_mode`` row, ``""`` if no row."""
|
| 58 |
+
return str(row[0]).strip().lower() if row and row[0] is not None else ""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
|
| 62 |
+
"""Read the journal mode from the DB header; ``None`` if undeterminable (new DB, or PRAGMA failed) ->
|
| 63 |
+
callers take their fail-closed "refuse to downgrade" branch. ``disk i/o error`` can be transient on
|
| 64 |
+
virtualized block devices (XFS on cloud hosts), so it is retried a few times first."""
|
| 65 |
+
for _ in range(4):
|
| 66 |
+
try:
|
| 67 |
+
row = conn.execute("PRAGMA journal_mode").fetchone()
|
| 68 |
+
except sqlite3.OperationalError as exc:
|
| 69 |
+
if "disk i/o error" not in str(exc).lower():
|
| 70 |
+
return None
|
| 71 |
+
last_exc = exc
|
| 72 |
+
time.sleep(0.05)
|
| 73 |
+
continue
|
| 74 |
+
mode = row[0] if row else None
|
| 75 |
+
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
|
| 76 |
+
try:
|
| 77 |
+
mode = mode.decode("ascii")
|
| 78 |
+
except UnicodeDecodeError:
|
| 79 |
+
return None
|
| 80 |
+
return str(mode).strip().lower() if mode is not None else None
|
| 81 |
+
logger.debug("_on_disk_journal_mode: retries exhausted on disk read (%s)", last_exc)
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _apply_wal_size_limit(conn: sqlite3.Connection) -> None:
|
| 86 |
+
"""Bound the WAL so it returns space after big transactions. With the default (-1) a checkpointed WAL is
|
| 87 |
+
reused in place, never truncated, so ``state.db-wal`` keeps the high-water mark of the largest
|
| 88 |
+
transaction ever (a 3 GB optimize left a 3 GB WAL). Best-effort: failure only costs disk slack."""
|
| 89 |
+
try:
|
| 90 |
+
conn.execute(f"PRAGMA journal_size_limit={_WAL_SIZE_LIMIT_BYTES}")
|
| 91 |
+
except sqlite3.OperationalError as exc: # pragma: no cover - defensive
|
| 92 |
+
logger.debug("journal_size_limit not applied: %s", exc)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _darwin_pragma(conn: sqlite3.Connection, pragma: str) -> None:
|
| 96 |
+
"""Best-effort PRAGMA on macOS only (no-op elsewhere, never raises)."""
|
| 97 |
+
if sys.platform == "darwin":
|
| 98 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 99 |
+
conn.execute(pragma)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
|
| 103 |
+
"""Enable ``PRAGMA checkpoint_fullfsync`` on macOS. Apple's ``fsync(2)`` guarantees neither data-on-platter nor
|
| 104 |
+
ordering, so without ``F_FULLFSYNC`` a launchd shutdown can turn a "durable" checkpoint into a malformed
|
| 105 |
+
``state.db``. Checkpoint boundaries only (~+0.1 ms/commit vs ~+4 ms for ``fullfsync=1``).
|
| 106 |
+
|
| 107 |
+
During a launchd *system* shutdown/reboot the OS page cache is dropped (effectively a power-loss event
|
| 108 |
+
for in-flight pages), so a WAL checkpoint whose ``fsync()`` "reported" durable may never have hit the
|
| 109 |
+
platter — corrupting ``state.db`` with a malformed image. This is the trigger in issue #30636 ("SIGTERM
|
| 110 |
+
during launchd shutdown under high load"), distinct from a plain in-session kill (which the page cache
|
| 111 |
+
survives and SQLite recovers from).
|
| 112 |
+
"""
|
| 113 |
+
_darwin_pragma(conn, "PRAGMA checkpoint_fullfsync=1")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
|
| 117 |
+
"""Enforce ``PRAGMA synchronous=FULL`` on macOS: with NORMAL a WAL checkpoint
|
| 118 |
+
racing process termination leaves half-written btree pages (``btreeInitPage
|
| 119 |
+
error 11``). Called after every WAL activation so a prior NORMAL never sticks."""
|
| 120 |
+
_darwin_pragma(conn, "PRAGMA synchronous=FULL")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _apply_wal_companions(conn: sqlite3.Connection) -> None:
|
| 124 |
+
"""The settings every WAL activation carries: size limit + macOS barriers."""
|
| 125 |
+
_apply_wal_size_limit(conn)
|
| 126 |
+
_apply_macos_checkpoint_barrier(conn)
|
| 127 |
+
_enforce_macos_synchronous_full(conn)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def is_sqlite_wal_reset_vulnerable(version_info: Optional[tuple] = None) -> bool:
|
| 131 |
+
"""True when the linked SQLite has the WAL-reset bug (3.7.0–3.51.2; fixed 3.51.3+, backports 3.50.7 /
|
| 132 |
+
3.44.6). Pre-WAL libraries are safe. https://sqlite.org/wal.html#walresetbug"""
|
| 133 |
+
return _is_sqlite_wal_reset_vulnerable(sqlite3.sqlite_version_info if version_info is None else version_info)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def sqlite_source_id() -> str:
|
| 137 |
+
"""Return ``sqlite_source_id()``, or an empty string when unavailable."""
|
| 138 |
+
try:
|
| 139 |
+
with contextlib.closing(sqlite3.connect(":memory:")) as conn:
|
| 140 |
+
row = conn.execute("SELECT sqlite_source_id()").fetchone()
|
| 141 |
+
except sqlite3.Error:
|
| 142 |
+
return ""
|
| 143 |
+
return str(row[0]) if row and row[0] is not None else ""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _database_has_content(conn: sqlite3.Connection) -> bool:
|
| 147 |
+
"""Whether the file already holds pages (existing vs brand-new DB); lock-free header read. Fail-quiet False: the
|
| 148 |
+
only caller gates a warning on this and an unknown-answer warning would fire on every fresh database."""
|
| 149 |
+
try:
|
| 150 |
+
row = conn.execute("PRAGMA page_count").fetchone()
|
| 151 |
+
return bool(row) and row[0] is not None and int(row[0]) > 0
|
| 152 |
+
except (sqlite3.Error, TypeError, ValueError):
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def resolve_journal_mode() -> str:
|
| 157 |
+
"""The configured ``database.journal_mode`` (``wal`` default; ``delete`` for filesystems without WAL-safe
|
| 158 |
+
durability: macOS virtiofs, NFS, SMB). Invalid values fail safe to ``wal``."""
|
| 159 |
+
try:
|
| 160 |
+
from hermes_cli.config import load_config_readonly
|
| 161 |
+
database = (load_config_readonly() or {}).get("database", {})
|
| 162 |
+
raw = database.get("journal_mode", "wal") if isinstance(database, dict) else "wal"
|
| 163 |
+
except Exception:
|
| 164 |
+
return "wal"
|
| 165 |
+
mode = raw.strip().lower() if isinstance(raw, str) else ""
|
| 166 |
+
return mode if mode in ("wal", "delete") else "wal"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class WalUnsupportedError(sqlite3.OperationalError):
|
| 170 |
+
"""Raised by :func:`apply_wal_with_fallback` under ``require_wal=True`` when
|
| 171 |
+
the filesystem cannot provide WAL (SQLITE_PROTOCOL raised, or macOS-NFS silent
|
| 172 |
+
refusal) or the on-disk mode cannot be verified (probe blocked by a concurrent
|
| 173 |
+
opener). Subclasses ``OperationalError`` so DB-init handlers still catch it."""
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Docker Desktop / OrbStack / Podman expose host bind mounts to the guest as ``fuse.virtiofs`` or ``9p``. WAL's
|
| 177 |
+
# -shm file must be coherent shared memory for every opener; across the VM boundary it is not, and under write
|
| 178 |
+
# pressure the failure is SILENT (zero-filled pages), so the reactive ``_WAL_INCOMPAT_MARKERS`` fallback in
|
| 179 |
+
# ``_enable_wal`` never gets a signal — WAL must be refused before the pragma. Port of openclaw/openclaw#120597.
|
| 180 |
+
# Detection is Linux ``/proc/self/mountinfo`` only (statvfs carries no fstype); elsewhere, or when the table is
|
| 181 |
+
# unreadable, the answer is False and behaviour is unchanged. Nothing else (ext4/btrfs/xfs/zfs/tmpfs/overlay/nfs)
|
| 182 |
+
# is ever flagged here — those keep the existing reactive paths.
|
| 183 |
+
_CROSS_VM_FSTYPES = frozenset({"virtiofs", "fuse.virtiofs", "9p", "9p2000", "9p2000.l", "9p2000.u"})
|
| 184 |
+
_cross_vm_fs_cache: Dict[str, bool] = {} # per DB directory; kanban_db.connect() opens per operation
|
| 185 |
+
_cross_vm_fs_cache_lock = threading.Lock()
|
| 186 |
+
_cross_vm_warned_paths: set[str] = set()
|
| 187 |
+
_cross_vm_warned_lock = threading.Lock()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _mountinfo_fstype(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> str:
|
| 191 |
+
"""fstype of the longest mount point that is a prefix of ``directory`` (``""`` if unreadable / no match)."""
|
| 192 |
+
try:
|
| 193 |
+
with open(mountinfo_path, "r", encoding="utf-8", errors="replace") as fh:
|
| 194 |
+
lines = fh.read().splitlines()
|
| 195 |
+
except OSError:
|
| 196 |
+
return ""
|
| 197 |
+
best_len, best_fstype = -1, ""
|
| 198 |
+
for line in lines:
|
| 199 |
+
# proc(5): <id> <parent> <maj:min> <root> <mount point> <opts> [optional...] - <fstype> <source> <super opts>
|
| 200 |
+
fields, _, tail = line.partition(" - ")
|
| 201 |
+
parts = fields.split()
|
| 202 |
+
if len(parts) < 5 or not tail:
|
| 203 |
+
continue
|
| 204 |
+
mount_point = parts[4]
|
| 205 |
+
if "\\" in mount_point: # octal escapes (\040 = space)
|
| 206 |
+
mount_point = mount_point.encode("latin-1", "ignore").decode("unicode_escape")
|
| 207 |
+
if directory == mount_point or directory.startswith(mount_point.rstrip("/") + "/"):
|
| 208 |
+
if len(mount_point) > best_len:
|
| 209 |
+
best_len, best_fstype = len(mount_point), tail.split()[0]
|
| 210 |
+
return best_fstype.lower()
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _detect_cross_vm_fs(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> bool:
|
| 214 |
+
"""True only when ``directory`` sits on a virtiofs/9p mount per ``mountinfo_path``."""
|
| 215 |
+
if sys.platform != "linux":
|
| 216 |
+
return False
|
| 217 |
+
return _mountinfo_fstype(directory, mountinfo_path) in _CROSS_VM_FSTYPES
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _path_on_cross_vm_fs(path: str) -> bool:
|
| 221 |
+
"""True when ``path`` resides on a virtiofs/9p (cross-VM) filesystem; cached per resolved directory."""
|
| 222 |
+
try:
|
| 223 |
+
directory = os.path.dirname(os.path.realpath(path)) or "/"
|
| 224 |
+
except (OSError, ValueError):
|
| 225 |
+
return False
|
| 226 |
+
with _cross_vm_fs_cache_lock:
|
| 227 |
+
cached = _cross_vm_fs_cache.get(directory)
|
| 228 |
+
if cached is None:
|
| 229 |
+
cached = _detect_cross_vm_fs(directory)
|
| 230 |
+
with _cross_vm_fs_cache_lock:
|
| 231 |
+
_cross_vm_fs_cache[directory] = cached
|
| 232 |
+
return cached
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _connection_db_file(conn: sqlite3.Connection) -> str:
|
| 236 |
+
"""Filesystem path of the ``main`` database, ``""`` for in-memory / unknown."""
|
| 237 |
+
try:
|
| 238 |
+
row = conn.execute("PRAGMA database_list").fetchone()
|
| 239 |
+
return str(row[2]) if row and row[2] else ""
|
| 240 |
+
except (sqlite3.OperationalError, IndexError, TypeError):
|
| 241 |
+
return ""
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _verify_configured_delete(actual: str) -> str:
|
| 245 |
+
"""Raise unless SQLite reported ``delete`` for an explicit operator request."""
|
| 246 |
+
if actual != "delete":
|
| 247 |
+
raise sqlite3.OperationalError(f"could not set configured journal_mode=delete (got {actual or 'no result'})")
|
| 248 |
+
return actual
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
|
| 252 |
+
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
|
| 253 |
+
|
| 254 |
+
Returns the mode actually set — or, when the read-only mode probe is blocked by a concurrent opener, ``"wal"``
|
| 255 |
+
as the assumed mode with nothing touched (``require_wal=True`` raises instead). Shared by :class:`SessionDB`
|
| 256 |
+
and ``hermes_cli.kanban_db_connect.connect``. WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
|
| 257 |
+
macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
|
| 258 |
+
``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
|
| 259 |
+
builds (https://sqlite.org/wal.html#walresetbug) never enable WAL on non-WAL files; an already-WAL DB keeps WAL
|
| 260 |
+
with a warning. Gate deliberately RETAINED: re-measured on the bundled 3.50.4 there is no evidence WAL is safer.
|
| 261 |
+
|
| 262 |
+
Invariant on every path: never downgrade to DELETE if the on-disk header reports WAL or cannot be read — other
|
| 263 |
+
gateway/cron/worker connections may hold the DB open, and a live downgrade destroys their uncheckpointed
|
| 264 |
+
commits.
|
| 265 |
+
|
| 266 |
+
An earlier revision of the lock-cancellation fix (#71724) reverted it on the theory that DELETE was "the
|
| 267 |
+
mode that corrupts", but that comparison was confounded: the clean WAL result came from SQLite 3.53.1,
|
| 268 |
+
which carries BOTH the WAL-reset fix AND 3.51.0's defenses against close()-broken POSIX locks, so it
|
| 269 |
+
says nothing about 3.50.4. Re-measured on the actually-bundled 3.50.4 with the lock fix in place, WAL
|
| 270 |
+
and DELETE are both clean (0/3 each) — i.e. there is no evidence that WAL is safer here, and upstream
|
| 271 |
+
still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed
|
| 272 |
+
runtime is delivered, keep new databases out of WAL.
|
| 273 |
+
"""
|
| 274 |
+
configured = resolve_journal_mode()
|
| 275 |
+
|
| 276 |
+
# Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE
|
| 277 |
+
# request is still verified).
|
| 278 |
+
if is_sqlite_wal_reset_vulnerable():
|
| 279 |
+
return _apply_delete_for_wal_reset_bug(conn, db_label=db_label, require_delete=configured == "delete")
|
| 280 |
+
# Read-only probe (no flock/checkpoint/WAL-SHM unlink): WAL-init must not unlink files other connections hold.
|
| 281 |
+
current_mode = _on_disk_journal_mode(conn)
|
| 282 |
+
if current_mode == "wal":
|
| 283 |
+
if configured == "delete":
|
| 284 |
+
# Never-live-downgrade keeps WAL; tell the operator their delete did not apply.
|
| 285 |
+
_log_configured_delete_overridden_once(db_label)
|
| 286 |
+
_apply_wal_companions(conn)
|
| 287 |
+
return "wal"
|
| 288 |
+
|
| 289 |
+
if configured == "delete":
|
| 290 |
+
# #68545: honor the canonical database.journal_mode setting. Existing on-disk WAL databases were
|
| 291 |
+
# returned above and are never live-downgraded.
|
| 292 |
+
if current_mode is None:
|
| 293 |
+
# Probe failed (locked/busy): ownership not provably exclusive. Fail loudly.
|
| 294 |
+
raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
|
| 295 |
+
return _verify_configured_delete(_set_journal_mode_no_wait(conn, "DELETE"))
|
| 296 |
+
if current_mode is None:
|
| 297 |
+
# Probe failed (locked/busy): ownership not provably exclusive, same as the DELETE branch above. A fresh
|
| 298 |
+
# 0-page DB probes "delete" cleanly, so this is a real file some other connection holds — running WAL-init
|
| 299 |
+
# would unlink its -wal/-shm sidecars. Touch nothing: the connection inherits whatever mode the header has.
|
| 300 |
+
if require_wal:
|
| 301 |
+
raise WalUnsupportedError("could not verify the on-disk journal mode (database is locked — possible "
|
| 302 |
+
"concurrent openers); cannot guarantee WAL")
|
| 303 |
+
_log_once("wal_probe_unknown", db_label)
|
| 304 |
+
return "wal"
|
| 305 |
+
# Cross-VM bind mount (virtiofs/9p): WAL corrupts silently there, so refuse to ENABLE it. On-disk WAL
|
| 306 |
+
# databases were returned above (never live-downgrade); a 0-page / DELETE file just stays DELETE.
|
| 307 |
+
db_file = _connection_db_file(conn)
|
| 308 |
+
if db_file and _path_on_cross_vm_fs(db_file):
|
| 309 |
+
if require_wal:
|
| 310 |
+
raise WalUnsupportedError("journal_mode=WAL refused: database is on a cross-VM filesystem (virtiofs/9p) "
|
| 311 |
+
"where WAL shared-memory silently corrupts")
|
| 312 |
+
_log_once("cross_vm_fs", db_label)
|
| 313 |
+
return _set_journal_mode_no_wait(conn, "DELETE") or "delete"
|
| 314 |
+
return _enable_wal(conn, db_label, require_wal, current_mode)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _enable_wal(conn: sqlite3.Connection, db_label: str, require_wal: bool, current_mode: Optional[str]) -> str:
|
| 318 |
+
"""Flip a non-WAL, non-vulnerable connection to WAL, or fall back to DELETE."""
|
| 319 |
+
# Decide BEFORE the flip whether it overwrites a mode somebody chose (probe and page_count are only readable
|
| 320 |
+
# while the file is untouched). A 0-page DB has no prior choice, and every caller reaches this before schema.
|
| 321 |
+
upgrading_existing_db = current_mode is not None and current_mode != "wal" and _database_has_content(conn)
|
| 322 |
+
|
| 323 |
+
def _wal_activated() -> str:
|
| 324 |
+
if upgrading_existing_db:
|
| 325 |
+
_log_journal_mode_upgrade_once(db_label, current_mode)
|
| 326 |
+
_apply_wal_companions(conn)
|
| 327 |
+
return "wal"
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
# ``PRAGMA journal_mode=WAL`` RETURNS the resulting mode: macOS NFS, SMB/CIFS and the AgentFS overlay
|
| 331 |
+
# refuse WITHOUT raising. Trust the row, not the absence of an exception.
|
| 332 |
+
mode = _mode_from_row(conn.execute("PRAGMA journal_mode=WAL").fetchone())
|
| 333 |
+
if mode == "wal":
|
| 334 |
+
return _wal_activated()
|
| 335 |
+
silent_exc = WalUnsupportedError(f"journal_mode=WAL refused without raising (still {mode!r})")
|
| 336 |
+
if require_wal:
|
| 337 |
+
raise silent_exc
|
| 338 |
+
_log_wal_fallback_once(db_label, silent_exc)
|
| 339 |
+
return mode or "delete"
|
| 340 |
+
except WalUnsupportedError:
|
| 341 |
+
raise # the require_wal silent-refusal raise above — propagate unchanged
|
| 342 |
+
except sqlite3.OperationalError as exc:
|
| 343 |
+
msg = str(exc).lower()
|
| 344 |
+
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
|
| 345 |
+
raise # unrelated OperationalError — don't silently swallow
|
| 346 |
+
# ``disk i/o error`` is ambiguous: on ZFS / APFS-CoW it is a deterministic WAL-incompatibility (SHM
|
| 347 |
+
# corruption under concurrent connection bursts — #55305, #71498), but it can also be a one-shot
|
| 348 |
+
# transient EIO (page-cache pressure, brief lock contention). Treating a transient EIO as a
|
| 349 |
+
# permanent downgrade signal produced the mixed-journal-mode corruption pattern fixed in 5c49cd0ed0
|
| 350 |
+
# (process A downgrades to DELETE while sibling processes set WAL). Disambiguate by retrying the
|
| 351 |
+
# pragma a couple of times: transient EIO clears and we return "wal"; the deterministic filesystem
|
| 352 |
+
# cases keep failing and fall through to the guarded DELETE fallback.
|
| 353 |
+
if "disk i/o error" in msg:
|
| 354 |
+
# Retry twice: EIO is either deterministic WAL-incompatibility (ZFS / APFS-CoW) or a one-shot transient,
|
| 355 |
+
# and treating a transient as a permanent downgrade produced mixed-mode corruption (A downgrades to
|
| 356 |
+
# DELETE while siblings set WAL). A non-EIO retry error propagates.
|
| 357 |
+
for _ in range(2):
|
| 358 |
+
time.sleep(0.05)
|
| 359 |
+
try:
|
| 360 |
+
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
| 361 |
+
except sqlite3.OperationalError as retry_exc:
|
| 362 |
+
if "disk i/o error" not in str(retry_exc).lower():
|
| 363 |
+
raise
|
| 364 |
+
exc = retry_exc
|
| 365 |
+
continue
|
| 366 |
+
if _mode_from_row(row) == "wal":
|
| 367 |
+
return _wal_activated()
|
| 368 |
+
break
|
| 369 |
+
# Never downgrade if WAL is on disk or the mode cannot be read (probe blocked by a concurrent opener) —
|
| 370 |
+
# ownership is not provably exclusive either way.
|
| 371 |
+
if _on_disk_journal_mode(conn) in ("wal", None):
|
| 372 |
+
raise
|
| 373 |
+
if require_wal:
|
| 374 |
+
raise WalUnsupportedError(str(exc)) from exc
|
| 375 |
+
_log_wal_fallback_once(db_label, exc)
|
| 376 |
+
try:
|
| 377 |
+
_set_journal_mode_no_wait(conn, "DELETE")
|
| 378 |
+
except sqlite3.OperationalError as delete_exc:
|
| 379 |
+
# Filesystems that reject BOTH pragmas (APFS external SSDs under heavy contention raise
|
| 380 |
+
# "disk I/O error" on DELETE too). The connection keeps its current mode — typically the
|
| 381 |
+
# SQLite default DELETE — and stays usable for reads/writes, so propagating would crash
|
| 382 |
+
# every DB-init caller (SessionDB, kanban_db, ResponseStore) for no benefit. Log once
|
| 383 |
+
# per db_label and return the mode actually in effect, read back rather than guessed.
|
| 384 |
+
_log_once("wal_delete_fallback_failed", db_label, exc, delete_exc)
|
| 385 |
+
try:
|
| 386 |
+
read_back = _mode_from_row(conn.execute("PRAGMA journal_mode").fetchone())
|
| 387 |
+
except sqlite3.OperationalError:
|
| 388 |
+
read_back = ""
|
| 389 |
+
return read_back or "delete"
|
| 390 |
+
return "delete"
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _set_journal_mode_no_wait(conn: sqlite3.Connection, mode: str) -> str:
|
| 394 |
+
"""Execute ``PRAGMA journal_mode=<mode>`` without waiting on other openers.
|
| 395 |
+
|
| 396 |
+
The ONLY place a non-WAL journal-mode switch may be issued. ``busy_timeout=0`` turns SQLite's exclusivity
|
| 397 |
+
requirement into a concurrent-opener detector: leaving WAL needs exclusive access, so if ANY other connection
|
| 398 |
+
holds the DB the pragma fails immediately with ``database is locked`` instead of sneaking the flip between a
|
| 399 |
+
writer's transactions (how uncheckpointed WAL commits die). Callers must treat a raised ``OperationalError`` as
|
| 400 |
+
"not exclusively owned: leave the mode alone", never as retryable. Returns the reported mode, ``""`` if no row."""
|
| 401 |
+
try:
|
| 402 |
+
row = conn.execute("PRAGMA busy_timeout").fetchone()
|
| 403 |
+
previous_timeout = int(row[0]) if row and row[0] is not None else 0
|
| 404 |
+
except (sqlite3.OperationalError, TypeError, ValueError):
|
| 405 |
+
previous_timeout = 0
|
| 406 |
+
conn.execute("PRAGMA busy_timeout=0")
|
| 407 |
+
try:
|
| 408 |
+
return _mode_from_row(conn.execute(f"PRAGMA journal_mode={mode}").fetchone())
|
| 409 |
+
finally:
|
| 410 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 411 |
+
conn.execute(f"PRAGMA busy_timeout={previous_timeout}")
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _apply_delete_for_wal_reset_bug(conn: sqlite3.Connection, *, db_label: str, require_delete: bool = False) -> str:
|
| 415 |
+
"""Avoid enabling WAL when the linked SQLite has the WAL-reset bug.
|
| 416 |
+
|
| 417 |
+
Already-WAL on disk: keep WAL (no live downgrade) and warn. Mode unreadable (probe blocked by a concurrent
|
| 418 |
+
opener): not provably exclusive — leave it and warn; treating "could not read" as "not WAL" once flipped a live
|
| 419 |
+
WAL state.db to DELETE under a writer, destroying its uncheckpointed commits. Otherwise set DELETE without
|
| 420 |
+
waiting out openers and warn; an explicit operator request additionally verifies SQLite accepted DELETE."""
|
| 421 |
+
current = _on_disk_journal_mode(conn)
|
| 422 |
+
if current == "wal":
|
| 423 |
+
_log_wal_reset_bug_once(db_label, kept_wal=True)
|
| 424 |
+
if require_delete:
|
| 425 |
+
# Upgrading SQLite doesn't help here; emit the actionable message last.
|
| 426 |
+
_log_configured_delete_overridden_once(db_label)
|
| 427 |
+
_apply_wal_companions(conn)
|
| 428 |
+
return "wal"
|
| 429 |
+
if current is None:
|
| 430 |
+
if require_delete:
|
| 431 |
+
raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
|
| 432 |
+
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
|
| 433 |
+
return "wal"
|
| 434 |
+
actual = ""
|
| 435 |
+
try:
|
| 436 |
+
actual = _set_journal_mode_no_wait(conn, "DELETE")
|
| 437 |
+
except sqlite3.OperationalError as exc:
|
| 438 |
+
if require_delete:
|
| 439 |
+
raise
|
| 440 |
+
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
| 441 |
+
# A concurrent opener appeared between probe and flip: leave the mode as is.
|
| 442 |
+
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
|
| 443 |
+
return current or "delete"
|
| 444 |
+
# Best-effort otherwise: DELETE is already the default for new file-backed DBs.
|
| 445 |
+
if require_delete:
|
| 446 |
+
_verify_configured_delete(actual)
|
| 447 |
+
_log_wal_reset_bug_once(db_label, kept_wal=False)
|
| 448 |
+
return "delete"
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _wal_reset_repair_hint() -> str:
|
| 452 |
+
"""Repair hint matching what ``hermes update`` can actually do for this install type.
|
| 453 |
+
|
| 454 |
+
See #75153.
|
| 455 |
+
"""
|
| 456 |
+
try:
|
| 457 |
+
from hermes_cli.config import detect_install_method, get_project_root, recommended_update_command_for_method
|
| 458 |
+
method = detect_install_method(get_project_root())
|
| 459 |
+
cmd = recommended_update_command_for_method(method)
|
| 460 |
+
if method in {"git", "unknown"}:
|
| 461 |
+
return f"Hermes-managed installs can repair the embedded runtime with `{cmd}`"
|
| 462 |
+
return f"update the container image with `{cmd}`" if method == "docker" else cmd # else nix/nixos
|
| 463 |
+
except Exception:
|
| 464 |
+
return "install a Python build bundled with SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6) and restart Hermes"
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# Once-per-(process, db_label) log table. Levels are deliberate: falling back to DELETE and an ignored
|
| 468 |
+
# ``journal_mode: delete`` are real losses (ERROR); a non-WAL -> WAL flip is normally desirable (WARNING).
|
| 469 |
+
_WAL_RESET_BUG_ACTIONS = {
|
| 470 |
+
"indeterminate": ("journal mode could not be verified or exclusively switched (database is locked — possible "
|
| 471 |
+
"concurrent openers); leaving the journal mode untouched (no live downgrade under concurrent "
|
| 472 |
+
"openers)"),
|
| 473 |
+
"kept_wal": "is already in WAL mode — leaving WAL in place (no live downgrade under concurrent openers)",
|
| 474 |
+
"delete": "using journal_mode=DELETE instead of enabling WAL",
|
| 475 |
+
}
|
| 476 |
+
_ONCE_LOGS = {
|
| 477 |
+
"wal_reset_bug": (_wal_reset_bug_warned_lock, "_wal_reset_bug_warned_paths", logging.WARNING,
|
| 478 |
+
# Install-type-aware so the warning never promises a repair path that doesn't exist for git/pip installs.
|
| 479 |
+
"%s: linked SQLite %s (interpreter %s) is vulnerable to the WAL-reset corruption bug "
|
| 480 |
+
"(https://sqlite.org/wal.html#walresetbug) — %s. Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); "
|
| 481 |
+
"%s. See `hermes doctor`. This warning fires once per process per database."),
|
| 482 |
+
"journal_upgrade": (_journal_upgrade_warned_lock, "_journal_upgrade_warned_paths", logging.WARNING,
|
| 483 |
+
# journal_mode is a property of the FILE: switching an existing DB to WAL rewrites its header and outlives
|
| 484 |
+
# the process. Operators set DELETE on the file (the WAL-reset-bug mitigation) and nothing told them the
|
| 485 |
+
# next open would silently put WAL back.
|
| 486 |
+
"%s: on-disk journal_mode was %s and has been switched to WAL. This rewrites the database header and "
|
| 487 |
+
"persists after this process exits. If %s was a deliberate choice (for example the mitigation for the SQLite "
|
| 488 |
+
"WAL-reset bug, or a WAL-unsafe filesystem), setting it with PRAGMA on the file will not survive -- every "
|
| 489 |
+
"open re-applies the configured mode. Set `database.journal_mode: delete` in config.yaml to make it stick. "
|
| 490 |
+
"This message fires once per process per database."),
|
| 491 |
+
"wal_fallback": (_wal_fallback_warned_lock, "_wal_fallback_warned_paths", logging.ERROR,
|
| 492 |
+
# Under kanban dispatcher + workers a DELETE-mode write blocks readers as SQLITE_BUSY.
|
| 493 |
+
"%s: WAL journal_mode unsupported on this filesystem (%s) — falling back to journal_mode=DELETE (slower "
|
| 494 |
+
"rollback-journal mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See "
|
| 495 |
+
"https://www.sqlite.org/wal.html for details. This message fires once per process per database."),
|
| 496 |
+
"wal_delete_fallback_failed": (_wal_delete_fallback_failed_lock, "_wal_delete_fallback_failed_paths", logging.WARNING,
|
| 497 |
+
# Both pragmas rejected (observed on APFS external SSDs under heavy contention): the connection keeps
|
| 498 |
+
# whatever mode it has (typically the SQLite default DELETE) and stays usable for reads/writes, so this
|
| 499 |
+
# is WARNING, not ERROR — propagating would crash every DB-init caller. Deduped per db_label because
|
| 500 |
+
# kanban_db.connect() runs on every kanban operation.
|
| 501 |
+
"%s: both WAL and DELETE journal_mode failed (WAL: %s; DELETE: %s) — continuing with the connection's "
|
| 502 |
+
"current journal mode (reads/writes still work). Typically seen on APFS external SSDs under heavy "
|
| 503 |
+
"contention. This message fires once per process per database."),
|
| 504 |
+
"delete_overridden": (_delete_overridden_warned_lock, "_delete_overridden_warned_paths", logging.ERROR,
|
| 505 |
+
# Never-live-downgrade keeps WAL; without this the operator never learns their delete had no effect.
|
| 506 |
+
"%s: database.journal_mode=delete is configured but the on-disk database is already WAL; keeping WAL (a live "
|
| 507 |
+
"downgrade under open connections can corrupt the DB). To apply journal_mode=DELETE, stop all connections to "
|
| 508 |
+
"this DB and run a one-time offline 'PRAGMA journal_mode=DELETE' on the file. This message fires once per "
|
| 509 |
+
"process per database."),
|
| 510 |
+
"wal_probe_unknown": (_wal_probe_unknown_lock, "_wal_probe_unknown_paths", logging.WARNING,
|
| 511 |
+
# WARNING, not ERROR: the connection inherits the header's mode, so an already-WAL file (the common case)
|
| 512 |
+
# keeps working; only a true-DELETE file stays DELETE for this connection.
|
| 513 |
+
"%s: could not verify the on-disk journal mode (database is locked / busy); not issuing a journal-mode "
|
| 514 |
+
"set-pragma while another connection may hold the file (it could unlink the -wal/-shm sidecars that "
|
| 515 |
+
"connection still uses). Leaving the file untouched; this connection inherits the on-disk mode. This message fires once per process per database."),
|
| 516 |
+
"cross_vm_fs": (_cross_vm_warned_lock, "_cross_vm_warned_paths", logging.WARNING,
|
| 517 |
+
# The DB keeps working in DELETE mode; the loss is concurrency, so WARNING with the actionable fix.
|
| 518 |
+
"%s: database directory is on a cross-VM filesystem (virtiofs/9p — typical for Docker Desktop / OrbStack / "
|
| 519 |
+
"Podman host bind mounts). SQLite WAL shared-memory is not coherent across the VM boundary and can silently "
|
| 520 |
+
"corrupt the database, so journal_mode=DELETE is used instead. To restore WAL concurrency, move the database "
|
| 521 |
+
"onto a native volume (e.g. a named Docker volume) instead of a host bind mount. This message fires once per "
|
| 522 |
+
"process per database."),
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _log_once(kind: str, db_label: str, *args: Any) -> None:
|
| 527 |
+
"""Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are
|
| 528 |
+
resolved only after the dedupe check, so install-method probes run once."""
|
| 529 |
+
lock, set_name, level, message = _ONCE_LOGS[kind]
|
| 530 |
+
seen = globals()[set_name]
|
| 531 |
+
with lock:
|
| 532 |
+
if db_label in seen:
|
| 533 |
+
return
|
| 534 |
+
seen.add(db_label)
|
| 535 |
+
logger.log(level, message, db_label, *(a() if callable(a) else a for a in args))
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def _log_wal_reset_bug_once(db_label: str, *, kept_wal: bool, indeterminate: bool = False) -> None:
|
| 539 |
+
"""Log once per (process, db_label) about the WAL-reset vulnerability path."""
|
| 540 |
+
action = _WAL_RESET_BUG_ACTIONS["indeterminate" if indeterminate else "kept_wal" if kept_wal else "delete"]
|
| 541 |
+
_log_once("wal_reset_bug", db_label, sqlite3.sqlite_version, sys.executable, action, _wal_reset_repair_hint)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def _log_journal_mode_upgrade_once(db_label: str, previous_mode: str) -> None:
|
| 545 |
+
"""Single WARNING per (process, db_label) about a non-WAL -> WAL flip."""
|
| 546 |
+
_log_once("journal_upgrade", db_label, previous_mode, previous_mode)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
# Single ERROR per (process, db_label): WAL fallback / configured delete ignored (DB already WAL).
|
| 550 |
+
_log_wal_fallback_once = functools.partial(_log_once, "wal_fallback")
|
| 551 |
+
_log_configured_delete_overridden_once = functools.partial(_log_once, "delete_overridden")
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
# Operators write synchronous as a name; mapped so a typo becomes a warning, not a silently different level.
|
| 555 |
+
_SYNCHRONOUS_LEVELS: Dict[str, int] = {"OFF": 0, "NORMAL": 1, "FULL": 2, "EXTRA": 3}
|
| 556 |
+
_SYNCHRONOUS_NAMES: Dict[int, str] = {v: k for k, v in _SYNCHRONOUS_LEVELS.items()}
|
| 557 |
+
_SYNCHRONOUS_FULL = 2
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def resolve_synchronous_level(raw_value: Any) -> Optional[int]:
|
| 561 |
+
"""Map ``database.synchronous`` (``OFF``/``NORMAL``/``FULL``/``EXTRA`` any
|
| 562 |
+
case, or ``0``-``3``) to its PRAGMA integer; None for anything else so the
|
| 563 |
+
caller warns and leaves the level untouched (guessing at durability is worse)."""
|
| 564 |
+
if isinstance(raw_value, bool):
|
| 565 |
+
# bool is an int subclass and YAML turns bare `on`/`off` into one; "off" is a real choice, True meaningless.
|
| 566 |
+
return 0 if raw_value is False else None
|
| 567 |
+
if isinstance(raw_value, int):
|
| 568 |
+
return raw_value if raw_value in _SYNCHRONOUS_NAMES else None
|
| 569 |
+
text = str(raw_value).strip()
|
| 570 |
+
if text.upper() in _SYNCHRONOUS_LEVELS:
|
| 571 |
+
return _SYNCHRONOUS_LEVELS[text.upper()]
|
| 572 |
+
with contextlib.suppress(TypeError, ValueError):
|
| 573 |
+
return int(text) if int(text) in _SYNCHRONOUS_NAMES else None
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _apply_synchronous_pragma(conn: sqlite3.Connection, raw_value: Any, *, db_label: str) -> None:
|
| 578 |
+
"""Set ``PRAGMA synchronous`` from config, never below FULL on macOS.
|
| 579 |
+
|
| 580 |
+
Kept out of the integer loop in :func:`apply_database_pragmas`: this PRAGMA decides whether a commit is on
|
| 581 |
+
the platter, so an unrecognised value must not fall through to "SQLite default" the way a bad
|
| 582 |
+
``cache_size`` can. Darwin floor: this runs after :func:`_enforce_macos_synchronous_full`, so a configured
|
| 583 |
+
``NORMAL`` would silently undo the btree protection — raising is allowed, lowering is refused out loud."""
|
| 584 |
+
if (level := resolve_synchronous_level(raw_value)) is None:
|
| 585 |
+
logger.warning("%s: ignoring unrecognized database.synchronous=%r (expected OFF, NORMAL, FULL, EXTRA, or 0-3)",
|
| 586 |
+
db_label, raw_value)
|
| 587 |
+
return
|
| 588 |
+
if sys.platform == "darwin" and level < _SYNCHRONOUS_FULL:
|
| 589 |
+
logger.warning("%s: refusing database.synchronous=%s on macOS; keeping FULL. Darwin's fsync() does not "
|
| 590 |
+
"guarantee write ordering, so a lower level readmits the half-written btree pages FULL exists "
|
| 591 |
+
"to prevent.", db_label, _SYNCHRONOUS_NAMES[level])
|
| 592 |
+
return
|
| 593 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 594 |
+
conn.execute(f"PRAGMA synchronous={level}")
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def apply_database_pragmas(conn: sqlite3.Connection, *, db_label: str = "state.db") -> None:
|
| 598 |
+
"""Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.
|
| 599 |
+
|
| 600 |
+
Journal mode is NOT handled here (owned by :func:`apply_wal_with_fallback`). ``database:`` keys: ``cache_size``
|
| 601 |
+
(negative = KiB, positive = pages), ``mmap_size`` (bytes, 0 = off), ``temp_store`` (0-3), ``wal_autocheckpoint``
|
| 602 |
+
(pages), ``journal_size_limit`` (bytes), ``synchronous`` (unset leaves the compile-time default, which differs
|
| 603 |
+
between bundled/distro/Homebrew builds). Best-effort: failures are ignored so DB init never breaks on a
|
| 604 |
+
malformed section. Applied to ALL connection types: writer, read_only, WAL readers."""
|
| 605 |
+
try:
|
| 606 |
+
from hermes_cli.config import cfg_get, load_config_readonly # local: avoids a circular import
|
| 607 |
+
cfg = load_config_readonly()
|
| 608 |
+
except Exception:
|
| 609 |
+
return
|
| 610 |
+
for pragma_name in ("cache_size", "mmap_size", "temp_store", "wal_autocheckpoint", "journal_size_limit"):
|
| 611 |
+
if (raw_value := cfg_get(cfg, "database", pragma_name, default=None)) is None:
|
| 612 |
+
continue
|
| 613 |
+
try:
|
| 614 |
+
value = int(str(raw_value).strip())
|
| 615 |
+
except (TypeError, ValueError):
|
| 616 |
+
logger.warning("%s: ignoring non-integer database.%s=%r", db_label, pragma_name, raw_value)
|
| 617 |
+
continue
|
| 618 |
+
with contextlib.suppress(sqlite3.OperationalError):
|
| 619 |
+
conn.execute(f"PRAGMA {pragma_name}={value}")
|
| 620 |
+
# Last: sizing pragmas cannot change durability (see _apply_synchronous_pragma).
|
| 621 |
+
if (raw_synchronous := cfg_get(cfg, "database", "synchronous", default=None)) is not None:
|
| 622 |
+
_apply_synchronous_pragma(conn, raw_synchronous, db_label=db_label)
|
optional-skills/DESCRIPTION.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optional Skills
|
| 2 |
+
|
| 3 |
+
Official skills maintained by Nous Research that are **not activated by default**.
|
| 4 |
+
|
| 5 |
+
These skills ship with the hermes-agent repository but are not copied to
|
| 6 |
+
`~/.hermes/skills/` during setup. They are discoverable via the Skills Hub:
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
hermes skills browse # browse all skills, official shown first
|
| 10 |
+
hermes skills browse --source official # browse only official optional skills
|
| 11 |
+
hermes skills search <query> # finds optional skills labeled "official"
|
| 12 |
+
hermes skills install <identifier> # copies to ~/.hermes/skills/ and activates
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## Why optional?
|
| 16 |
+
|
| 17 |
+
Some skills are useful but not broadly needed by every user:
|
| 18 |
+
|
| 19 |
+
- **Niche integrations** — specific paid services, specialized tools
|
| 20 |
+
- **Experimental features** — promising but not yet proven
|
| 21 |
+
- **Heavyweight dependencies** — require significant setup (API keys, installs)
|
| 22 |
+
|
| 23 |
+
By keeping them optional, we keep the default skill set lean while still
|
| 24 |
+
providing curated, tested, official skills for users who want them.
|