diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index fcb89ce5d0..91c26092c8 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -35,6 +35,7 @@ from hermes_cli.colors import Colors, color from hermes_cli.models import _HERMES_USER_AGENT from hermes_cli.vercel_auth import describe_vercel_auth from hermes_constants import OPENROUTER_MODELS_URL +from hermes_state_common import FTS_STORAGE_VERSION from utils import base_url_host_matches @@ -496,21 +497,20 @@ def _render_state_db_stats(stats: dict, holders=None) -> list: "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 + # Advisory: oversized database. Suggest auto_prune, and — when the FTS + # rebuild is pending OR the DB predates the current trigram layout — 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 = ( + stale_trigram = ( fts is not None and fts.get("messages_fts_trigram") - and stats.get("fts_storage_version") is None + and (stats.get("fts_storage_version") or 0) < FTS_STORAGE_VERSION ) - if stats.get("fts_rebuild_pending") or legacy_trigram: + if stats.get("fts_rebuild_pending") or stale_trigram: detail += ( "; run 'hermes sessions optimize-storage' offline " "(with the gateway stopped) to compact FTS storage" diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 27de8d778a..8553ba3de4 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1050,9 +1050,9 @@ def _print_curator_first_run_notice() -> None: def _print_fts_optimize_available_notice() -> None: """Advertise the opt-in v23 search-index optimization after `hermes update`. - Only fires when the current profile's state.db is still on the legacy - (pre-v23) inline FTS layout. Leads with the reclaimable-space figure and - points at the exact command. Honors ``sessions.fts_optimize_notice``: + Only fires when the current profile's state.db still needs an FTS storage + rebuild. Leads with the reclaimable-space figure and points at the exact + command. Honors ``sessions.fts_optimize_notice``: ``advise`` (default) prints an advisory notice, ``require`` prints a firmer required-upgrade notice, ``off`` suppresses it. Silent for fresh/already-optimized installs. @@ -1088,13 +1088,17 @@ def _print_fts_optimize_available_notice() -> None: return db = None interrupted = False + needs_upgrade = False try: db = SessionDB(db_path=db_path, read_only=True) - # read_only opens skip schema init, so probe the layout directly. + # read_only opens skip schema init, so probe the stored layout directly. row = db._conn.execute( "SELECT sql FROM sqlite_master " "WHERE type = 'table' AND name = 'messages_fts'" ).fetchone() + needs_upgrade = bool(row) and getattr( + db, "_db_needs_fts_storage_upgrade" + )(db._conn) # An interrupted `optimize-storage` run: the table is already the # v23 shape, but backfill markers / demoted trash tables remain. # Offer the command again — re-running resumes and finishes it. @@ -1120,9 +1124,8 @@ def _print_fts_optimize_available_notice() -> None: db.close() except Exception: pass - sql = (row[0] if row else "") or "" - if not sql or ("tool_name" in sql and not interrupted): - # v23 layout already present (fresh/optimized) — nothing to offer. + if not needs_upgrade and not interrupted: + # Current layout already present (fresh/optimized) — nothing to offer. return if interrupted: diff --git a/tests/hermes_cli/test_fts_optimize_notice.py b/tests/hermes_cli/test_fts_optimize_notice.py new file mode 100644 index 0000000000..09c571d565 --- /dev/null +++ b/tests/hermes_cli/test_fts_optimize_notice.py @@ -0,0 +1,42 @@ +"""Regression coverage for FTS storage upgrade discoverability.""" + +import sqlite3 +from types import SimpleNamespace + + +def test_update_notice_offers_v1_trigram_tool_calls_rebuild(tmp_path, monkeypatch, capsys): + """A deployed v1 trigram projection still receives the opt-in notice.""" + from hermes_cli import update_cmd + import hermes_constants + import hermes_state + + db_path = tmp_path / "state.db" + db_path.touch() + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE messages_fts (content TEXT, tool_name TEXT, tool_calls TEXT); + CREATE TABLE messages_fts_trigram (content TEXT, tool_name TEXT, tool_calls TEXT); + """ + ) + + class FakeSessionDB: + def __init__(self, **_kwargs): + self._conn = conn + + def close(self): + pass + + _db_needs_fts_storage_upgrade = staticmethod( + hermes_state.SessionDB._db_needs_fts_storage_upgrade + ) + + monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr(hermes_state, "SessionDB", FakeSessionDB) + monkeypatch.setattr(update_cmd.Path, "stat", lambda _path: SimpleNamespace(st_size=512 * 1024 ** 2)) + + update_cmd._print_fts_optimize_available_notice() + + assert "hermes sessions optimize-storage" in capsys.readouterr().out + conn.close() diff --git a/tests/test_state_db_stats.py b/tests/test_state_db_stats.py index f64ce4821e..8e51cc73ac 100644 --- a/tests/test_state_db_stats.py +++ b/tests/test_state_db_stats.py @@ -236,6 +236,20 @@ def test_render_large_db_legacy_trigram_suggests_optimize(): assert "optimize-storage" in blob +def test_render_large_db_v1_trigram_suggests_optimize(): + from hermes_cli.doctor import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats + + lines = _render_state_db_stats( + _base_stats( + logical_size_bytes=STATE_DB_SIZE_WARN_BYTES + 1, + fts_storage_version=1, + ), + holders=None, + ) + blob = " ".join(" ".join(str(p) for p in line) for line in lines) + assert "optimize-storage" in blob + + def test_render_does_not_duplicate_legacy_wal_warning(): """A large WAL must NOT warn here: doctor's pre-existing WAL check (50 MB threshold, with a --fix checkpoint) already covers it, and a