diff --git a/hermes_state_maintenance.py b/hermes_state_maintenance.py index 680a2d3fd6..31132f98fe 100644 --- a/hermes_state_maintenance.py +++ b/hermes_state_maintenance.py @@ -247,7 +247,9 @@ class SessionMaintenanceMixin: latest message / ``started_at``); may archive unended sessions. ``archived = 0`` makes repeats no-ops; only lineage tips (``end_reason <> 'compression'``) are candidates — a stale tip archives its chain via :meth:`set_session_archived`, so an old compressed-away - root with a recent continuation is never matched.""" + root with a recent continuation is never matched. The hidden canonical Bot Chat (same + predicate as :meth:`set_session_pinned`) is exempt: only a deliberate archive may retire + it, since archiving releases its registry title to the next Bot open.""" if idle_days is None or idle_days < 0: return 0 cutoff = time.time() - float(idle_days) * 86400.0 @@ -258,9 +260,10 @@ class SessionMaintenanceMixin: WHERE s.archived = 0 AND COALESCE(s.end_reason, '') <> 'compression' {pin_clause} + AND NOT (COALESCE(s.hidden, 0) <> 0 AND COALESCE(s.title, '') = ?) AND {_sql_session_last_active("s")} < ? ORDER BY s.started_at ASC - """, (cutoff,)) + """, (self.CANONICAL_BOT_CHAT_TITLE, cutoff)) for row in rows: self.set_session_archived(row[0], True) return len(rows) diff --git a/tests/hermes_state/test_canonical_title_guard.py b/tests/hermes_state/test_canonical_title_guard.py index de75a5043e..a7a6e060f6 100644 --- a/tests/hermes_state/test_canonical_title_guard.py +++ b/tests/hermes_state/test_canonical_title_guard.py @@ -78,6 +78,22 @@ def test_deliberately_archived_canonical_chat_releases_name_for_replacement(db): assert row and row["id"] == "replacement" +def test_auto_archive_sweep_skips_the_canonical_chat(db): + """Only a deliberate archive may retire a Bot Chat; the idle sweep must not + (it would strand an unrecoverable, soon-to-be-untitled row).""" + import time + + forever = _make_canonical(db) + db.create_session("ordinary", source="desktop") + stale = time.time() - 10 * 86400 + db._write_sql("UPDATE sessions SET started_at = ?, last_activity_at = ?", (stale, stale)) + + assert db.archive_stale_sessions(3) == 1 + assert db.get_session("ordinary")["archived"] + assert not db.get_session(forever)["archived"] + assert db.get_session(forever)["title"] == SessionDB.CANONICAL_BOT_CHAT_TITLE + + def test_auto_titler_still_cannot_touch_the_canonical_row(db): # Pre-existing provenance contract, re-pinned here: user-authority title # outranks derived/llm, so the turn-start auto-titler can never displace diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 70f1cb3aad..4c13fec82e 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -25,7 +25,7 @@ The roster shows one row per agent profile: avatar, latest-message preview, and :::note The canonical Bot Chat is a forever-chat Typing `/new` (or `/reset`) inside a Bot's canonical chat would fork the relationship into a scratch session — the one thing Bot Mode promises never happens. The composer reroutes it to `/compact` instead: fresh working context, same conversation. Regular sessions on the same profile keep full `/new` freedom. -Archiving a Bot Chat from the sidebar retires it: the next click on the Bot starts a fresh conversation that becomes the new canonical Bot Chat, and the archived one keeps its history (untitled) in the archive. +Archiving a Bot Chat from the sidebar retires it: the next click on the Bot starts a fresh conversation that becomes the new canonical Bot Chat. The retired chat stays archived and hidden — its history is preserved in the database, but it is no longer reachable from the Bot or the archive view. The automatic idle-archive sweep (`sessions.auto_archive`) never retires a Bot Chat; only an explicit archive does. ::: ### Organize bots into sections