From a228b81501a01f947fcb2cfc3136d1ec8aaf9e07 Mon Sep 17 00:00:00 2001 From: Frowtek Date: Sat, 25 Jul 2026 00:45:09 +0300 Subject: [PATCH] fix(sessions): preserve recently active sessions during pruning --- gateway/run.py | 4 +- hermes_cli/config.py | 9 ++- hermes_cli/main.py | 16 ++-- hermes_cli/session_filters.py | 52 +++++++++--- hermes_cli/web_server.py | 13 ++- hermes_state.py | 79 ++++++++++++++----- .../test_dashboard_admin_endpoints.py | 2 + tests/hermes_cli/test_session_filters.py | 23 ++++-- tests/hermes_cli/test_sessions_delete.py | 14 ++-- .../hermes_cli/test_sessions_export_md_cli.py | 6 +- tests/test_hermes_state.py | 58 ++++++++++++++ website/docs/user-guide/sessions.md | 16 ++-- 12 files changed, 224 insertions(+), 68 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 927bd6069fec..e78b9de77dc2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3655,8 +3655,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # hermes_state.get_last_init_error() for slash-command error strings. logger.warning("SQLite session store not available: %s", e) - # Opportunistic state.db maintenance: prune ended sessions older - # than sessions.retention_days + optional VACUUM. Tracks last-run + # Opportunistic state.db maintenance: prune ended sessions inactive + # for sessions.retention_days + optional VACUUM. Tracks last-run # in state_meta so it only actually executes once per # sessions.min_interval_hours. Gateway is long-lived so blocking # a few seconds once per day is acceptable; failures are logged diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 342631126d96..6a5f39028ff9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3308,14 +3308,15 @@ DEFAULT_CONFIG = { # reports 384MB+ databases with 68K+ messages, which slows down FTS5 # inserts, /resume listing, and insights queries. "sessions": { - # When true, prune ended sessions older than retention_days once + # When true, prune ended sessions inactive for retention_days once # per (roughly) min_interval_hours at CLI/gateway/cron startup. - # Only touches ended sessions — active sessions are always preserved. + # Activity is the latest message timestamp, falling back to creation + # time for empty sessions. Active sessions are always preserved. # Default false: session history is valuable for search recall, and # silently deleting it could surprise users. Opt in explicitly. "auto_prune": False, - # How many days of ended-session history to keep. Matches the - # default of ``hermes sessions prune``. + # How many inactive days of ended-session history to keep. Matches + # the default of ``hermes sessions prune``. "retention_days": 90, # When true, auto-archive (soft-hide, never delete) sessions that # haven't been touched in ``auto_archive_days`` days, once per diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8b55fc17df03..95e79e905fd9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -16201,7 +16201,7 @@ def main(): p.add_argument( "--newer-than", metavar="AGE", - help="Only match sessions started within the last AGE " + help="Only match sessions active within the last AGE " "(e.g. '5h', '2d') or after an ISO timestamp", ) p.add_argument( @@ -17358,12 +17358,14 @@ def main(): print(f"No sessions match ({describe_filters(filters)}).") return - # Candidates are ordered oldest-first — surface the age span so - # the confirmation makes the blast radius obvious. - _oldest = candidates[0].get("started_at") - _newest = candidates[-1].get("started_at") + # Candidates are ordered by activity oldest-first. Surface that + # span so a long-lived but recently used conversation cannot look + # old merely because of its creation date. + _oldest = candidates[0].get("last_active") + _newest = candidates[-1].get("last_active") _span = ( - f"oldest {format_epoch(_oldest)}, newest {format_epoch(_newest)}" + f"oldest activity {format_epoch(_oldest)}, " + f"newest activity {format_epoch(_newest)}" ) if args.dry_run or not args.yes: @@ -17376,7 +17378,7 @@ def main(): title = (s.get("title") or "")[:36] model = (s.get("model") or "-").split("/")[-1][:24] print( - f" {s['id']} {format_epoch(s['started_at']):<17} " + f" {s['id']} {format_epoch(s.get('last_active')):<17} " f"{s['source']:<10} {model:<24} " f"{s['message_count']:>4} msgs {title}" ) diff --git a/hermes_cli/session_filters.py b/hermes_cli/session_filters.py index c05164695af3..6633f9e3c02b 100644 --- a/hermes_cli/session_filters.py +++ b/hermes_cli/session_filters.py @@ -86,13 +86,15 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: ``--after``, ``--source``, ``--title``, ``--end-reason``, ``--cwd``, ``--min-messages``, ``--max-messages``, ``--archived``/``--no-archived``. - ``--before``/``--older-than`` both set the upper bound (started_before); - ``--after``/``--newer-than`` both set the lower bound (started_after). - When both a duration flag and an absolute flag target the same bound, - the tighter (more restrictive) bound wins. + ``--older-than`` / ``--newer-than`` bound last activity, while + ``--before`` / ``--after`` explicitly bound session start time. Last + activity is the latest message timestamp, falling back to ``started_at`` + for empty sessions. Raises ``ValueError`` on unparseable values or an empty/inverted window. """ + last_active_before: Optional[float] = None + last_active_after: Optional[float] = None started_before: Optional[float] = None started_after: Optional[float] = None @@ -103,19 +105,23 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: older_than = getattr(args, "older_than", None) if older_than is not None: - started_before = _tighter( - started_before, parse_point_in_time(older_than, "--older-than"), True + last_active_before = _tighter( + last_active_before, + parse_point_in_time(older_than, "--older-than"), + True, + ) + newer_than = getattr(args, "newer_than", None) + if newer_than is not None: + last_active_after = _tighter( + last_active_after, + parse_point_in_time(newer_than, "--newer-than"), + False, ) before = getattr(args, "before", None) if before is not None: started_before = _tighter( started_before, parse_point_in_time(before, "--before"), True ) - newer_than = getattr(args, "newer_than", None) - if newer_than is not None: - started_after = _tighter( - started_after, parse_point_in_time(newer_than, "--newer-than"), False - ) after = getattr(args, "after", None) if after is not None: started_after = _tighter( @@ -128,9 +134,19 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: and started_after >= started_before ): raise ValueError( - "Empty time window: the --after/--newer-than bound " + "Empty start-time window: the --after bound " f"({format_epoch(started_after)}) is not earlier than the " - f"--before/--older-than bound ({format_epoch(started_before)})." + f"--before bound ({format_epoch(started_before)})." + ) + if ( + last_active_before is not None + and last_active_after is not None + and last_active_after >= last_active_before + ): + raise ValueError( + "Empty activity window: the --newer-than bound " + f"({format_epoch(last_active_after)}) is not earlier than the " + f"--older-than bound ({format_epoch(last_active_before)})." ) filters: Dict[str, Any] = { @@ -138,6 +154,8 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: # Without this, prune_sessions' default 90-day cutoff would silently # cap an --after/--newer-than-only window. "older_than_days": None, + "last_active_before": last_active_before, + "last_active_after": last_active_after, "started_before": started_before, "started_after": started_after, "source": getattr(args, "source", None), @@ -165,6 +183,14 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: def describe_filters(filters: Dict[str, Any]) -> str: """Human-readable summary of active filters for confirmation prompts.""" parts = [] + if filters.get("last_active_before") is not None: + parts.append( + f"last active before {format_epoch(filters['last_active_before'])}" + ) + if filters.get("last_active_after") is not None: + parts.append( + f"last active after {format_epoch(filters['last_active_after'])}" + ) if filters.get("started_before") is not None: parts.append(f"started before {format_epoch(filters['started_before'])}") if filters.get("started_after") is not None: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9f7933286f22..5b136bd820e6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12022,9 +12022,15 @@ def _prune_sessions(body: SessionPrune): "ok": True, "removed": 0, "matched": len(rows), - # Rows are ordered oldest-first. - "oldest_started_at": rows[0]["started_at"] if rows else None, - "newest_started_at": rows[-1]["started_at"] if rows else None, + # Rows are ordered by last activity, not creation time. + "oldest_last_active": rows[0]["last_active"] if rows else None, + "newest_last_active": rows[-1]["last_active"] if rows else None, + "oldest_started_at": ( + min(r["started_at"] for r in rows) if rows else None + ), + "newest_started_at": ( + max(r["started_at"] for r in rows) if rows else None + ), "sessions": [ { "id": r["id"], @@ -12032,6 +12038,7 @@ def _prune_sessions(body: SessionPrune): "title": r.get("title"), "model": r.get("model"), "started_at": r["started_at"], + "last_active": r["last_active"], "message_count": r["message_count"], } for r in rows diff --git a/hermes_state.py b/hermes_state.py index 32fcffac585f..9ad8761670dc 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -9615,6 +9615,8 @@ class SessionDB: @staticmethod def _prune_filter_where( *, + last_active_before: Optional[float] = None, + last_active_after: Optional[float] = None, started_before: Optional[float] = None, started_after: Optional[float] = None, source: Optional[str] = None, @@ -9657,6 +9659,24 @@ class SessionDB: """ clauses = ["s.ended_at IS NOT NULL"] params: list = [] + if last_active_before is not None: + clauses.append( + """COALESCE( + (SELECT MAX(m.timestamp) FROM messages m + WHERE m.session_id = s.id), + s.started_at + ) < ?""" + ) + params.append(last_active_before) + if last_active_after is not None: + clauses.append( + """COALESCE( + (SELECT MAX(m.timestamp) FROM messages m + WHERE m.session_id = s.id), + s.started_at + ) >= ?""" + ) + params.append(last_active_after) if started_before is not None: clauses.append("s.started_at < ?") params.append(started_before) @@ -9744,18 +9764,31 @@ class SessionDB: Backs ``--dry-run`` and pre-confirmation counts. Accepts the same keyword filters as :meth:`_prune_filter_where` (unknown names raise ``TypeError`` there). Rows are ordered oldest-first and carry - ``id, source, title, model, started_at, ended_at, message_count, - archived``. + ``id, source, title, model, started_at, last_active, ended_at, + message_count, archived``. ``older_than_days`` is an inactivity + threshold: it uses the latest message timestamp, falling back to + ``started_at`` for sessions without messages. """ - if filters.get("started_before") is None and older_than_days is not None: - filters["started_before"] = time.time() - (older_than_days * 86400) + if ( + filters.get("last_active_before") is None + and filters.get("started_before") is None + and older_than_days is not None + ): + filters["last_active_before"] = time.time() - ( + older_than_days * 86400 + ) where, params = self._prune_filter_where(source=source, **filters) with self._lock: cursor = self._conn.execute( f"""SELECT s.id, s.source, s.title, s.model, s.started_at, + COALESCE( + (SELECT MAX(m.timestamp) FROM messages m + WHERE m.session_id = s.id), + s.started_at + ) AS last_active, s.ended_at, s.message_count, s.archived FROM sessions s WHERE {where} - ORDER BY s.started_at ASC""", + ORDER BY last_active ASC, s.started_at ASC""", params, ) return [dict(row) for row in cursor.fetchall()] @@ -9794,8 +9827,8 @@ class SessionDB: "Touched" is the latest message timestamp (falling back to ``started_at``) — i.e. real recency, not creation time — so a session created long ago but active yesterday is spared, while an old - abandoned one (even a still-open one) is swept. This differs from - :meth:`archive_sessions`, which ages on ``started_at`` and only ended + abandoned one (even a still-open one) is swept. Unlike + :meth:`archive_sessions`, this method can also archive unended sessions. Guards: @@ -9844,15 +9877,19 @@ class SessionDB: ) -> int: """Delete sessions matching the filters. Returns count deleted. - Default behavior (no keyword filters) is unchanged: delete ended - sessions older than ``older_than_days`` days, optionally restricted - to ``source``. Additional keyword filters AND together — the full - set is defined by :meth:`_prune_filter_where`: + By default, delete ended sessions inactive for + ``older_than_days`` days, optionally restricted to ``source``. + Activity is the latest message timestamp, falling back to + ``started_at`` for sessions without messages. Additional keyword + filters AND together — the full set is defined by + :meth:`_prune_filter_where`: + * ``last_active_before`` / ``last_active_after`` — epoch bounds on + the latest message timestamp (falling back to ``started_at``). * ``started_before`` / ``started_after`` — epoch bounds on - ``started_at``. ``started_before`` overrides ``older_than_days``; - pass ``older_than_days=None`` for no upper age bound (e.g. when - only pruning a recent window via ``started_after``). + ``started_at``. An explicit ``started_before`` overrides the + default ``older_than_days`` inactivity cutoff; pass + ``older_than_days=None`` for no implicit upper age bound. * ``title_like`` / ``model_like`` / ``branch_like`` — case-insensitive substring matches. * ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` / @@ -9874,8 +9911,14 @@ class SessionDB: ``request_dump_*``) for every pruned session, outside the DB transaction. """ - if filters.get("started_before") is None and older_than_days is not None: - filters["started_before"] = time.time() - (older_than_days * 86400) + if ( + filters.get("last_active_before") is None + and filters.get("started_before") is None + and older_than_days is not None + ): + filters["last_active_before"] = time.time() - ( + older_than_days * 86400 + ) where, where_params = self._prune_filter_where(source=source, **filters) removed_ids: list[str] = [] @@ -10613,7 +10656,7 @@ class SessionDB: vacuum: bool = True, sessions_dir: Optional[Path] = None, ) -> Dict[str, Any]: - """Idempotent auto-maintenance: prune old sessions + optional VACUUM. + """Idempotent auto-maintenance: prune inactive sessions + optional VACUUM. Records the last run timestamp in state_meta so subsequent calls within ``min_interval_hours`` no-op. Designed to be called once at @@ -10667,7 +10710,7 @@ class SessionDB: if pruned > 0: logger.info( - "state.db auto-maintenance: pruned %d session(s) older than %d days%s", + "state.db auto-maintenance: pruned %d session(s) inactive for %d days%s", pruned, retention_days, " + VACUUM" if result["vacuumed"] else "", diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 974fe734ddf9..693a056cbf1a 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -740,6 +740,8 @@ class TestSessionManagementEndpoints: body = r.json() assert body["matched"] >= 1 assert "oldest_started_at" in body and "newest_started_at" in body + assert "oldest_last_active" in body and "newest_last_active" in body + assert all("last_active" in session for session in body["sessions"]) def test_prune_explicit_older_than_kept_with_attr_filter(self): # Explicit older_than_days is honored even alongside attribute filters. diff --git a/tests/hermes_cli/test_session_filters.py b/tests/hermes_cli/test_session_filters.py index 8eb721ae41e6..165b3ccb2e4e 100644 --- a/tests/hermes_cli/test_session_filters.py +++ b/tests/hermes_cli/test_session_filters.py @@ -72,14 +72,18 @@ class TestBuildPruneFilters: def test_newer_than_sets_lower_bound_only(self): f = build_prune_filters(_ns(newer_than="5h")) assert f["started_before"] is None - assert f["started_after"] == pytest.approx(time.time() - 18000, abs=5) + assert f["started_after"] is None + assert f["last_active_after"] == pytest.approx( + time.time() - 18000, abs=5 + ) assert f["older_than_days"] is None # no implicit 90d cap def test_older_than_bare_days(self): f = build_prune_filters(_ns(older_than="90")) - assert f["started_before"] == pytest.approx( + assert f["last_active_before"] == pytest.approx( time.time() - 90 * 86400, abs=5 ) + assert f["started_before"] is None assert f["started_after"] is None def test_window_before_and_after(self): @@ -87,14 +91,19 @@ class TestBuildPruneFilters: assert f["started_after"] < f["started_before"] def test_inverted_window_rejected(self): - with pytest.raises(ValueError, match="Empty time window"): + with pytest.raises(ValueError, match="Empty start-time window"): build_prune_filters(_ns(after="2h", before="10h")) - def test_tighter_bound_wins(self): - # --older-than 1d and --before 5h both set the upper bound; - # 1d ago is earlier (tighter for "older than") so it wins. + def test_inverted_activity_window_rejected(self): + with pytest.raises(ValueError, match="Empty activity window"): + build_prune_filters(_ns(newer_than="2h", older_than="10h")) + + def test_activity_and_start_bounds_are_independent(self): f = build_prune_filters(_ns(older_than="1d", before="5h")) assert f["started_before"] == pytest.approx( + time.time() - 5 * 3600, abs=5 + ) + assert f["last_active_before"] == pytest.approx( time.time() - 86400, abs=5 ) @@ -141,7 +150,7 @@ class TestBuildPruneFilters: def test_describe_filters_mentions_active_parts(self): f = build_prune_filters(_ns(newer_than="5h", source="cli")) desc = describe_filters(f) - assert "started after" in desc + assert "last active after" in desc assert "source 'cli'" in desc def test_describe_filters_empty(self): diff --git a/tests/hermes_cli/test_sessions_delete.py b/tests/hermes_cli/test_sessions_delete.py index 0c54da1cf15b..a52b415c1a23 100644 --- a/tests/hermes_cli/test_sessions_delete.py +++ b/tests/hermes_cli/test_sessions_delete.py @@ -145,6 +145,7 @@ def _run_prune(monkeypatch, capsys, argv_tail, candidates=None): "source": "cron", "title": "oldest run", "started_at": 1_600_000_000.0, + "last_active": 1_600_000_050.0, "ended_at": 1_600_000_100.0, "message_count": 2, "archived": 0, @@ -154,6 +155,7 @@ def _run_prune(monkeypatch, capsys, argv_tail, candidates=None): "source": "cron", "title": "newest run", "started_at": 1_700_000_000.0, + "last_active": 1_700_000_050.0, "ended_at": 1_700_000_100.0, "message_count": 4, "archived": 0, @@ -185,8 +187,8 @@ def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys): import time as _time filters, _out = _run_prune(monkeypatch, capsys, []) - assert filters["started_before"] is not None - assert filters["started_before"] == pytest.approx( + assert filters["last_active_before"] is not None + assert filters["last_active_before"] == pytest.approx( _time.time() - 90 * 86400, abs=60 ) @@ -194,6 +196,8 @@ def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys): def test_sessions_prune_source_matches_all_ages(monkeypatch, capsys): """--source alone suppresses the implicit 90-day cutoff (all ages).""" filters, _out = _run_prune(monkeypatch, capsys, ["--source", "cron"]) + assert filters["last_active_before"] is None + assert filters["last_active_after"] is None assert filters["started_before"] is None assert filters["started_after"] is None assert filters["source"] == "cron" @@ -206,7 +210,7 @@ def test_sessions_prune_source_with_explicit_time_respected(monkeypatch, capsys) filters, _out = _run_prune( monkeypatch, capsys, ["--source", "cron", "--older-than", "30"] ) - assert filters["started_before"] == pytest.approx( + assert filters["last_active_before"] == pytest.approx( _time.time() - 30 * 86400, abs=60 ) assert filters["source"] == "cron" @@ -218,5 +222,5 @@ def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys): _filters, out = _run_prune(monkeypatch, capsys, ["--source", "cron"]) assert "2 session(s) match" in out - assert f"oldest {format_epoch(1_600_000_000.0)}" in out - assert f"newest {format_epoch(1_700_000_000.0)}" in out + assert f"oldest activity {format_epoch(1_600_000_050.0)}" in out + assert f"newest activity {format_epoch(1_700_000_050.0)}" in out diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index 665da274c9c4..5ebf86c5802a 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -189,11 +189,11 @@ def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path, class FakeDB: def list_prune_candidates(self, **kwargs): # Export flows through the shared prune-filter machinery: - # --older-than 30 becomes a started_before epoch bound, source + # --older-than 30 becomes a last-active epoch bound, source # passes through, and archived is tri-state None (export includes # archived sessions). assert kwargs.get("source") == "cron" - assert kwargs.get("started_before") is not None + assert kwargs.get("last_active_before") is not None assert kwargs.get("archived") is None return [{"id": "s1", "source": "cron"}, {"id": "s2", "source": "cron"}] @@ -444,7 +444,7 @@ def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, class FakeDB: def list_prune_candidates(self, **kwargs): - assert kwargs.get("started_before") is not None + assert kwargs.get("last_active_before") is not None return [{"id": "s1", "source": "cli"}] def close(self): diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 6f3704fa3e23..e99785ae5643 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3008,6 +3008,31 @@ class TestPruneSessions: assert session is not None assert session["id"] == "new" + def test_age_preview_and_prune_use_last_activity(self, db): + old_ts = time.time() - 100 * 86400 + for sid in ("inactive", "recently-active"): + db.create_session(session_id=sid, source="telegram") + db._conn.execute( + "UPDATE sessions SET started_at = ? WHERE id = ?", + (old_ts, sid), + ) + db.end_session("inactive", end_reason="agent_close") + db.append_message( + "recently-active", + role="user", + content="A recent message in a long-lived conversation.", + ) + db.end_session("recently-active", end_reason="agent_close") + db._conn.commit() + + candidates = db.list_prune_candidates(older_than_days=90) + + assert [row["id"] for row in candidates] == ["inactive"] + assert candidates[0]["last_active"] == pytest.approx(old_ts) + assert db.prune_sessions(older_than_days=90) == 1 + assert db.get_session("inactive") is None + assert db.get_session("recently-active") is not None + def test_prune_skips_active_sessions(self, db): db.create_session(session_id="active", source="cli") # Backdate but don't end @@ -5548,6 +5573,39 @@ class TestAutoMaintenance: # Active session's transcript is untouched assert (sessions_dir / "new.jsonl").exists() + def test_auto_prune_preserves_old_session_with_recent_activity(self, db, tmp_path): + """Retention is based on activity, not when a conversation began.""" + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + + db.create_session(session_id="long-lived", source="telegram") + db._conn.execute( + "UPDATE sessions SET started_at = ? WHERE id = ?", + (time.time() - 100 * 86400, "long-lived"), + ) + db._conn.commit() + db.append_message( + "long-lived", + role="user", + content="This conversation was active today.", + ) + db.end_session("long-lived", end_reason="agent_close") + transcript = sessions_dir / "long-lived.jsonl" + transcript.write_text('{"role":"user","content":"recent"}\n') + + result = db.maybe_auto_prune_and_vacuum( + retention_days=90, + vacuum=False, + sessions_dir=sessions_dir, + ) + + assert result["pruned"] == 0 + assert db.get_session("long-lived") is not None + assert [m["content"] for m in db.get_messages("long-lived")] == [ + "This conversation was active today." + ] + assert transcript.exists() + def test_auto_prune_without_sessions_dir_preserves_files(self, db, tmp_path): """Backward-compat: no sessions_dir = DB-only cleanup (legacy behavior).""" sessions_dir = tmp_path / "sessions" diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 50e52363171a..e154209f6b89 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -419,7 +419,7 @@ If the title is already in use by another session, an error is shown. ### Prune Old Sessions ```bash -# Delete ended sessions older than 90 days (default) +# Delete ended sessions inactive for 90 days (default) hermes sessions prune # Custom age threshold — bare numbers are days @@ -461,8 +461,10 @@ hermes sessions prune --older-than 30 --yes Time values (`--older-than`, `--newer-than`, `--before`, `--after`) accept a duration (`5h`, `30m`, `2d`, `1w`), a bare number of days, or an ISO timestamp (`2026-07-05`, `2026-07-05 14:30`). `--older-than`/`--before` set -the upper bound; `--newer-than`/`--after` set the lower bound. Combine both -for a window. +the upper bound; `--newer-than`/`--after` set the lower bound. The +`--older-than`/`--newer-than` pair uses latest message activity (falling back +to session start for empty sessions); `--before`/`--after` explicitly uses +session start time. Combine either pair for a window. Attribute filters: `--source` (platform, exact), `--title` / `--model` / `--branch` (case-insensitive substring), `--provider` (billing provider, @@ -688,7 +690,7 @@ Key tables in `state.db`: - Gateway sessions auto-reset based on the configured reset policy - Before reset, the agent saves memories and skills from the expiring session -- Opt-in auto-pruning: when `sessions.auto_prune` is `true`, ended sessions older than `sessions.retention_days` (default 90) are pruned at CLI/gateway startup +- Opt-in auto-pruning: when `sessions.auto_prune` is `true`, ended sessions inactive for `sessions.retention_days` (default 90) are pruned at CLI/gateway startup - After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space (SQLite does not shrink the file on plain DELETE) - Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME` @@ -697,12 +699,14 @@ Default is **off** — session history is valuable for `session_search` recall, ```yaml sessions: auto_prune: true # opt in — default is false - retention_days: 90 # keep ended sessions this many days + retention_days: 90 # keep ended sessions active within this window vacuum_after_prune: true # reclaim disk space after a pruning sweep min_interval_hours: 24 # don't re-run the sweep more often than this ``` -Active sessions are never auto-pruned, regardless of age. +Active sessions are never auto-pruned, regardless of age. Ended sessions are +aged from their latest message, so a long-lived conversation used recently is +not deleted merely because it began before the retention window. ### Manual Cleanup