"""HERMES_HOME state checks for hermes doctor: directories, memory files, state.db health, skills hub, memory provider, profiles. Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so ``hermes_cli.doctor.`` keeps resolving (and monkeypatching) as before. """ from __future__ import annotations import subprocess from pathlib import Path from hermes_cli.doctor_report import ( Finding, _fail_and_issue, _section, check_info, check_ok, check_warn, ) from hermes_cli.sizefmt import format_bytes as _human_bytes def _honcho_is_configured_for_doctor() -> bool: """Return True when Honcho is configured, even if this process has no active session.""" try: from plugins.memory.honcho.client import HonchoClientConfig cfg = HonchoClientConfig.from_global_config() return bool(cfg.enabled and (cfg.api_key or cfg.base_url)) except Exception: return False def _doctor_memory_config(hermes_home: Path | None = None) -> dict: """Return the effective memory section used by doctor diagnostics.""" from hermes_cli.doctor import HERMES_HOME home = hermes_home if hermes_home is not None else HERMES_HOME try: from hermes_cli.config import _expand_env_vars, read_user_config_raw config_path = home / "config.yaml" if not config_path.exists(): return {} config = _expand_env_vars(read_user_config_raw(config_path)) try: from hermes_cli import managed_scope config = managed_scope.apply_managed_overlay(config) except Exception: pass section = config.get("memory") if isinstance(config, dict) else None return section if isinstance(section, dict) else {} except Exception: return {} # ── state.db health/stats thresholds (advisory only — module constants, # deliberately NOT config: doctor warnings are guidance, not policy) ── STATE_DB_SIZE_WARN_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB logical size def _render_state_db_stats(stats: dict, holders=None) -> list: """Turn a collect_state_db_stats() dict into doctor output lines. Returns a list of ``(kind, text, detail)`` tuples where kind is one of 'info' / 'warn'. Pure formatting — no I/O — so it is unit-testable without spawning the doctor CLI. Tolerates None in every field. """ lines: list = [] stats = stats or {} logical = stats.get("logical_size_bytes") wal = stats.get("wal_size_bytes") freelist = stats.get("freelist_count") size_bits = [] if logical is not None: size_bits.append(f"logical size {_human_bytes(logical)}") if stats.get("page_count") is not None: size_bits.append(f"{stats['page_count']:,} pages") if freelist is not None: size_bits.append(f"{freelist:,} free") if wal is not None: size_bits.append(f"WAL {_human_bytes(wal)}") if size_bits: lines.append(("info", "state.db " + ", ".join(size_bits), "")) row_bits = [] if stats.get("messages") is not None: row_bits.append(f"{stats['messages']:,} messages") if stats.get("sessions") is not None: row_bits.append(f"{stats['sessions']:,} sessions") if stats.get("journal_mode"): row_bits.append(f"journal_mode={stats['journal_mode']}") if holders is not None: row_bits.append(f"{holders} process(es) holding the DB open") if row_bits: lines.append(("info", ", ".join(row_bits), "")) fts = stats.get("fts_tables") if fts: present = [t for t, ok in fts.items() if ok] lines.append(( "info", "FTS tables: " + (", ".join(present) if present else "none"), "", )) deferral = stats.get("fts_rebuild_deferral") if isinstance(deferral, dict): attempts = deferral.get("attempts") pids = deferral.get("holder_pids") or [] lines.append(( "warn", f"state.db FTS repair is blocked after {attempts or '?'} " f"deferral(s) by PID(s) {pids or 'unknown'}", "(stop the listed processes, then run 'hermes sessions " "optimize-storage' with the gateway stopped)", )) # Advisory: oversized database. Suggest auto_prune, and — when the v23 # FTS rebuild is pending OR the DB still carries the legacy inline # trigram layout (fts_storage_version marker absent) — the offline # optimize-storage pass that migrates/compacts the FTS indexes. if logical is not None and logical > STATE_DB_SIZE_WARN_BYTES: detail = ( "consider enabling sessions.auto_prune in config.yaml " "to bound growth" ) legacy_trigram = ( fts is not None and fts.get("messages_fts_trigram") and stats.get("fts_storage_version") is None ) if stats.get("fts_rebuild_pending") or legacy_trigram: detail += ( "; run 'hermes sessions optimize-storage' offline " "(with the gateway stopped) to compact FTS storage" ) lines.append(( "warn", f"state.db is large ({_human_bytes(logical)})", f"({detail})", )) # WAL runaway is deliberately NOT warned here: the pre-existing WAL # check later in the state.db section already warns above 50 MB and # offers a checkpoint via --fix; a second warning at a higher threshold # would only duplicate it. return lines def _memory_store_flags(hermes_home: Path) -> tuple: from tools.memory_tool import get_builtin_memory_store_flags return get_builtin_memory_store_flags({"memory": _doctor_memory_config(hermes_home)}) def _check_directory_structure(should_fix: bool) -> Finding: """HERMES_HOME, expected subdirs, SOUL.md, and the enabled built-in memory files.""" from hermes_cli.doctor import HERMES_HOME, _DHH f = Finding() hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") elif should_fix: hermes_home.mkdir(parents=True, exist_ok=True) check_ok(f"Created {_DHH} directory") f.fixed += 1 else: check_warn(f"{_DHH} not found", "(will be created on first use)") _memory_enabled, _user_profile_enabled = _memory_store_flags(hermes_home) # Check expected subdirectories. The built-in file store does not create or # consume memories/ when both targets are disabled, so stale migration files # are not an active diagnostic surface. expected_subdirs = ["cron", "sessions", "logs", "skills"] if _memory_enabled or _user_profile_enabled: expected_subdirs.append("memories") for subdir_name in expected_subdirs: subdir_path = hermes_home / subdir_name if subdir_path.exists(): check_ok(f"{_DHH}/{subdir_name}/ exists") elif should_fix: subdir_path.mkdir(parents=True, exist_ok=True) check_ok(f"Created {_DHH}/{subdir_name}/") f.fixed += 1 else: check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") # Check for SOUL.md persona file soul_path = hermes_home / "SOUL.md" if soul_path.exists(): content = soul_path.read_text(encoding="utf-8").strip() # Check if it's just the template comments (no real content) lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("", "#"))] if lines: check_ok(f"{_DHH}/SOUL.md exists (persona configured)") else: check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality") else: check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)") if should_fix: soul_path.parent.mkdir(parents=True, exist_ok=True) soul_path.write_text( "# Hermes Agent Persona\n\n" "\n\n" "You are Hermes, a helpful AI assistant.\n", encoding="utf-8", ) check_ok(f"Created {_DHH}/SOUL.md with basic template") f.fixed += 1 # Check only enabled built-in stores. External providers are additive, but # users can explicitly disable either legacy file target; stale files left # by a migration must not be presented as active memory usage. memories_dir = hermes_home / "memories" if not (_memory_enabled or _user_profile_enabled): check_info("Built-in memory files disabled by config") elif memories_dir.exists(): check_ok(f"{_DHH}/memories/ directory exists") for enabled, fname in ((_memory_enabled, "MEMORY.md"), (_user_profile_enabled, "USER.md")): if not enabled: continue mem_file = memories_dir / fname if mem_file.exists(): check_ok(f"{fname} exists ({len(mem_file.read_text(encoding='utf-8').strip())} chars)") else: check_info(f"{fname} not created yet (will be created when the agent first writes a memory)") else: check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)") if should_fix: memories_dir.mkdir(parents=True, exist_ok=True) check_ok(f"Created {_DHH}/memories/") f.fixed += 1 return f def _session_count(state_db_path: Path): import sqlite3 conn = sqlite3.connect(str(state_db_path)) try: return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] finally: conn.close() def _repair_state_db(f: Finding, should_fix: bool, state_db_path: Path, *, ok_label, not_fixed_label: str, failed_issue: str, fix_hint: str) -> None: """Shared --fix path for both state.db corruption classes (FTS write health, malformed schema). ``ok_label`` is a str, or a callable given the recovered session count. """ if not should_fix: f.issues.append(fix_hint) return from hermes_state import repair_state_db_schema report = repair_state_db_schema(state_db_path) if not report.get("repaired"): check_warn(not_fixed_label, f"({report.get('error')}; backup: {report.get('backup_path')})") f.issues.append(failed_issue) return if callable(ok_label): try: count = _session_count(state_db_path) except Exception: count = "?" ok_label = ok_label(count) backup_name = Path(report["backup_path"]).name if report.get("backup_path") else "n/a" check_ok(ok_label, f"(strategy: {report.get('strategy')}; backup: {backup_name})") f.fixed += 1 def _check_state_db(should_fix: bool) -> Finding: """state.db session count, FTS write health, schema repair, stats snapshot, WAL size.""" from hermes_cli.doctor import HERMES_HOME, _DHH f = Finding() issues = f.issues hermes_home = HERMES_HOME state_db_path = hermes_home / "state.db" if state_db_path.exists(): try: check_ok(f"{_DHH}/state.db exists ({_session_count(state_db_path)} sessions)") # `SELECT COUNT(*)` succeeds even when the FTS index is corrupt and # every message write fails through the triggers; _db_opens_cleanly # drives a rolled-back write to surface that silent class. from hermes_state import _db_opens_cleanly _write_reason = _db_opens_cleanly(state_db_path) if _write_reason is not None: check_warn( f"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)", f"({_write_reason})", ) _repair_state_db( f, should_fix, state_db_path, ok_label="Repaired state.db FTS write health", not_fixed_label="state.db FTS write-health repair did not recover automatically", failed_issue="state.db FTS write corruption and auto-repair failed — " "restore from the backup copy beside state.db", fix_hint="state.db FTS write corruption — run 'hermes doctor --fix' " "(or 'hermes sessions repair') to rebuild the FTS index", ) except Exception as e: from hermes_state import is_malformed_db_error if is_malformed_db_error(e): # sqlite_master itself is malformed (e.g. duplicate messages_fts): # every statement fails before it runs, so this is NOT a plain FTS # rebuild — repair sqlite_master in place (backup first). check_warn(f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)", f"({e})") _repair_state_db( f, should_fix, state_db_path, ok_label=lambda count: f"Repaired state.db schema ({count} sessions recovered)", not_fixed_label="state.db schema repair did not recover automatically", failed_issue="state.db schema malformed and auto-repair failed — " "restore from the backup copy beside state.db", fix_hint="state.db schema malformed — run 'hermes doctor --fix' " "(or 'hermes sessions repair') to recover hidden sessions", ) else: check_warn(f"{_DHH}/state.db exists but has issues: {e}") # Health/stats snapshot (#statedb-visibility): a multi-GB state.db # with a runaway WAL was previously invisible to every Hermes # surface. Strictly read-only (mode=ro) so it is safe against a # live DB held by the gateway; any failure degrades to one info # line rather than failing doctor. try: from hermes_state import collect_state_db_stats, count_db_holders _db_stats = collect_state_db_stats(state_db_path) _db_holders = count_db_holders(state_db_path) for _kind, _text, _detail in _render_state_db_stats( _db_stats, holders=_db_holders ): if _kind == "warn": check_warn(_text, _detail) if "auto_prune" in _detail: issues.append( "state.db is large — enable sessions.auto_prune " "in config.yaml" + ( " and run 'hermes sessions optimize-storage' " "offline (gateway stopped)" if "optimize-storage" in _detail else "" ) ) else: check_info(_text + (f" {_detail}" if _detail else "")) except Exception as _stats_exc: check_info(f"state.db stats unavailable ({_stats_exc})") else: check_info(f"{_DHH}/state.db not created yet (will be created on first session)") # Check WAL file size (unbounded growth indicates missed checkpoints) wal_path = hermes_home / "state.db-wal" if wal_path.exists(): try: wal_size = wal_path.stat().st_size if wal_size > 50 * 1024 * 1024: # 50 MB check_warn( f"WAL file is large ({wal_size // (1024*1024)} MB)", "(may indicate missed checkpoints)" ) if should_fix: import sqlite3 conn = sqlite3.connect(str(state_db_path)) conn.execute("PRAGMA wal_checkpoint(PASSIVE)") conn.close() new_size = wal_path.stat().st_size if wal_path.exists() else 0 check_ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)") f.fixed += 1 else: issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint") elif wal_size > 10 * 1024 * 1024: # 10 MB check_info(f"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)") except Exception: pass return f def _gh_authenticated() -> bool: """Check if gh CLI is authenticated via token file or device flow.""" try: result = subprocess.run(["gh", "auth", "status", "--json", "authenticated"], capture_output=True, timeout=10) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False def _check_skills_hub(should_fix: bool) -> Finding: from hermes_cli.doctor import HERMES_HOME, _DHH f = Finding() hub_dir = HERMES_HOME / "skills" / ".hub" if hub_dir.exists(): check_ok("Skills Hub directory exists") lock_file = hub_dir / "lock.json" if lock_file.exists(): try: import json lock_data = json.loads(lock_file.read_text(encoding="utf-8")) count = len(lock_data.get("installed", {})) check_ok(f"Lock file OK ({count} hub-installed skill(s))") except Exception: check_warn("Lock file", "(corrupted or unreadable)") quarantine = hub_dir / "quarantine" q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0 if q_count > 0: check_warn(f"{q_count} skill(s) in quarantine", "(pending review)") else: check_warn("Skills Hub directory not initialized", "(run: hermes skills list)") from hermes_cli.config import get_env_value if get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN"): check_ok("GitHub token configured (authenticated API access)") elif _gh_authenticated(): check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)") else: check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") return f def _memory_provider_honcho(issues: list) -> None: from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path hcfg = HonchoClientConfig.from_global_config() cfg_path = resolve_config_path() if not cfg_path.exists(): # Config file missing — env-var fallback may still have resolved it. if hcfg.api_key or hcfg.base_url: check_ok("Honcho configured via environment variables", f"config file {cfg_path} not found, using HONCHO_API_KEY env var") else: check_warn("Honcho config not found", "run: hermes memory setup") elif not hcfg.enabled: check_info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)") elif not (hcfg.api_key or hcfg.base_url): _fail_and_issue("Honcho API key or base URL not set", "run: hermes memory setup", "No Honcho API key — run 'hermes memory setup'", issues) else: from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client reset_honcho_client() try: get_honcho_client(hcfg) check_ok("Honcho connected", f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}") except Exception as _e: _fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues) def _memory_provider_mem0(issues: list) -> None: from plugins.memory.mem0 import _load_config as _load_mem0_config mem0_cfg = _load_mem0_config() if mem0_cfg.get("api_key", ""): check_ok("Mem0 API key configured") check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}") else: _fail_and_issue("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)", "Mem0 is set as memory provider but API key is missing", issues) # provider -> (checker, ImportError row, ImportError issue, label for "check failed") _MEMORY_PROVIDER_CHECKS = { "honcho": (_memory_provider_honcho, ("honcho-ai not installed", "pip install honcho-ai"), "Honcho is set as memory provider but honcho-ai is not installed", "Honcho"), "mem0": (_memory_provider_mem0, ("Mem0 plugin not loadable", "pip install mem0ai"), "Mem0 is set as memory provider but mem0ai is not installed", "Mem0"), } def _check_memory_provider(should_fix: bool) -> Finding: from hermes_cli.doctor import HERMES_HOME f = Finding() name = _doctor_memory_config(HERMES_HOME).get("provider", "") if not name: check_ok("Built-in memory active", "(no external provider configured — this is fine)") return f if name in _MEMORY_PROVIDER_CHECKS: checker, missing_row, missing_issue, label = _MEMORY_PROVIDER_CHECKS[name] try: checker(f.issues) except ImportError: _fail_and_issue(*missing_row, missing_issue, f.issues) except Exception as _e: check_warn(f"{label} check failed", str(_e)) return f # Generic check for other memory providers (openviking, hindsight, etc.) try: from plugins.memory import load_memory_provider _provider = load_memory_provider(name) if _provider and _provider.is_available(): check_ok(f"{name} provider active") elif _provider: check_warn(f"{name} configured but not available", "run: hermes memory status") else: check_warn(f"{name} plugin not found", "run: hermes memory setup") except Exception as _e: check_warn(f"{name} check failed", str(_e)) return f def _check_profiles(should_fix: bool) -> Finding: f = Finding() try: from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists import re as _re named_profiles = [p for p in list_profiles() if not p.is_default] if named_profiles: _section("Profiles") check_ok(f"{len(named_profiles)} profile(s) found") wrapper_dir = _get_wrapper_dir() for p in named_profiles: parts = [] if p.gateway_running: parts.append("gateway running") if p.model: parts.append(p.model[:30]) if not (p.path / "config.yaml").exists(): parts.append("⚠ missing config") if not (p.path / ".env").exists(): parts.append("no .env") wrapper = wrapper_dir / p.name if not wrapper.exists(): parts.append("no alias") status = ", ".join(parts) if parts else "configured" check_ok(f" {p.name}: {status}") # Check for orphan wrappers if wrapper_dir.is_dir(): for wrapper in wrapper_dir.iterdir(): if not wrapper.is_file(): continue try: content = wrapper.read_text(encoding="utf-8") if "hermes -p" in content: _m = _re.search(r"hermes -p (\S+)", content) if _m and not profile_exists(_m.group(1)): check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists") except Exception: pass except Exception: pass return f