diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a57d4d953de..caca5e6a8a3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13525,8 +13525,9 @@ def main(): ) _add_session_filter_args( sessions_prune, - "Delete sessions older than AGE — days if bare number (default: 90), " - "or a duration like '5h'/'2d'/'1w', or an ISO timestamp", + "Delete sessions older than AGE — days if bare number, or a duration " + "like '5h'/'2d'/'1w', or an ISO timestamp (bare prune with no filters " + "defaults to 90 days; any filter matches all ages)", ) sessions_prune.add_argument( "--include-archived", @@ -13741,16 +13742,16 @@ def main(): format_epoch, ) - # Preserve the historical default: bare `hermes sessions prune` - # means "older than 90 days" — and --source keeps that default - # too (it predates the extended filters; scripts may rely on - # `prune --source X --yes` meaning >90d). Any NEW attribute - # filter suppresses the implicit cutoff so e.g. - # `prune --model sonnet` matches ALL sessions for that model. + # Preserve the historical default ONLY for a truly bare + # `hermes sessions prune`: no time window and no filters at all + # means "older than 90 days". ANY filter — including --source — + # suppresses the implicit cutoff, so `prune --source cron` + # matches ALL cron sessions regardless of age. The preview + + # confirmation below (count, oldest/newest) is the safety net. _non_time_filters = any( getattr(args, a, None) is not None for a in ( - "title", "end_reason", "cwd", + "source", "title", "end_reason", "cwd", "min_messages", "max_messages", "model", "provider", "user", "chat_id", "chat_type", "branch", "min_tokens", "max_tokens", "min_cost", "max_cost", @@ -13797,11 +13798,19 @@ 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") + _span = ( + f"oldest {format_epoch(_oldest)}, newest {format_epoch(_newest)}" + ) + if args.dry_run or not args.yes: shown = candidates if args.dry_run else candidates[:15] print( f"{len(candidates)} session(s) match " - f"({describe_filters(filters)}):" + f"({describe_filters(filters)}; {_span}):" ) for s in shown: title = (s.get("title") or "")[:36] @@ -13819,7 +13828,7 @@ def main(): if not args.yes: if not _confirm_prompt( - f"{verb} these {len(candidates)} session(s)? [y/N] " + f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] " ): print("Cancelled.") return diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 242983561b5..58cf4b33dbd 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -8234,11 +8234,28 @@ async def prune_sessions_endpoint(body: SessionPrune): ) if body.older_than_days is not None and body.older_than_days < 1 and not has_window: raise HTTPException(status_code=400, detail="older_than_days must be >= 1") + # Mirror the CLI: the implicit 90-day cutoff only applies to a truly bare + # prune. Any attribute filter (source, title, model, ...) suppresses it + # unless the caller explicitly sent older_than_days. + _attr_filters_set = any( + getattr(body, f) is not None + for f in ( + "source", "title_like", "end_reason", "cwd_prefix", + "min_messages", "max_messages", "model_like", "provider", + "user_id", "chat_id", "chat_type", "branch_like", + "min_tokens", "max_tokens", "min_cost", "max_cost", + "min_tool_calls", "max_tool_calls", + ) + ) + _older_than_explicit = "older_than_days" in body.model_fields_set + _effective_older_than = body.older_than_days + if has_window or (_attr_filters_set and not _older_than_explicit): + _effective_older_than = None profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home() db = _open_session_db_for_profile(body.profile) try: filters = dict( - older_than_days=None if has_window else body.older_than_days, + older_than_days=_effective_older_than, source=(body.source or None), started_before=body.started_before, started_after=body.started_after, @@ -8267,6 +8284,9 @@ async def prune_sessions_endpoint(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, "sessions": [ { "id": r["id"], diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 6650d055a42..e0e083c5d19 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -452,6 +452,36 @@ class TestSessionManagementEndpoints: "/api/sessions/prune", json={"older_than_days": 0} ).status_code == 400 + def test_prune_attr_filter_suppresses_default_cutoff(self): + # An attribute filter without an explicit older_than_days matches all + # ages (mirrors the CLI: any filter disables the implicit 90-day + # default). dry_run so nothing is deleted; the seeded session is + # recent + ended, so it would be invisible under a 90-day cutoff. + from hermes_state import SessionDB + + db = SessionDB() + db.create_session(session_id="sess-recent-ended", source="cli") + db.end_session("sess-recent-ended", "completed") + db.close() + + r = self.client.post( + "/api/sessions/prune", + json={"source": "cli", "dry_run": True}, + ) + assert r.status_code == 200 + body = r.json() + assert body["matched"] >= 1 + assert "oldest_started_at" in body and "newest_started_at" in body + + def test_prune_explicit_older_than_kept_with_attr_filter(self): + # Explicit older_than_days is honored even alongside attribute filters. + r = self.client.post( + "/api/sessions/prune", + json={"source": "cli", "older_than_days": 9999, "dry_run": True}, + ) + assert r.status_code == 200 + assert r.json()["matched"] == 0 + class TestSkillsHubSearchEndpoint: @pytest.fixture(autouse=True) diff --git a/tests/hermes_cli/test_sessions_delete.py b/tests/hermes_cli/test_sessions_delete.py index 5866d31102b..0c54da1cf15 100644 --- a/tests/hermes_cli/test_sessions_delete.py +++ b/tests/hermes_cli/test_sessions_delete.py @@ -1,5 +1,7 @@ import sys +import pytest + def test_sessions_delete_accepts_unique_id_prefix(monkeypatch, capsys): import hermes_cli.main as main_mod @@ -128,3 +130,93 @@ def test_sessions_prune_handles_eoferror_on_confirm(monkeypatch, capsys): output = capsys.readouterr().out assert "Cancelled" in output + + +def _run_prune(monkeypatch, capsys, argv_tail, candidates=None): + """Run `hermes sessions prune ` against a FakeDB, capturing + the filter kwargs passed to list_prune_candidates. Auto-confirms.""" + import hermes_cli.main as main_mod + import hermes_state + + seen = {} + rows = candidates if candidates is not None else [ + { + "id": "20260101_000000_aaaaaa", + "source": "cron", + "title": "oldest run", + "started_at": 1_600_000_000.0, + "ended_at": 1_600_000_100.0, + "message_count": 2, + "archived": 0, + }, + { + "id": "20260601_000000_bbbbbb", + "source": "cron", + "title": "newest run", + "started_at": 1_700_000_000.0, + "ended_at": 1_700_000_100.0, + "message_count": 4, + "archived": 0, + }, + ] + + class FakeDB: + def list_prune_candidates(self, **kwargs): + seen.update(kwargs) + return rows + + def prune_sessions(self, **kwargs): + return len(rows) + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, "argv", ["hermes", "sessions", "prune", *argv_tail] + ) + monkeypatch.setattr("builtins.input", lambda _prompt="": "y") + main_mod.main() + return seen, capsys.readouterr().out + + +def test_sessions_prune_bare_keeps_90_day_default(monkeypatch, capsys): + """A truly bare `hermes sessions prune` keeps the implicit 90-day cutoff.""" + import time as _time + + filters, _out = _run_prune(monkeypatch, capsys, []) + assert filters["started_before"] is not None + assert filters["started_before"] == pytest.approx( + _time.time() - 90 * 86400, abs=60 + ) + + +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["started_before"] is None + assert filters["started_after"] is None + assert filters["source"] == "cron" + + +def test_sessions_prune_source_with_explicit_time_respected(monkeypatch, capsys): + """--source + explicit --older-than keeps the user's bound.""" + import time as _time + + filters, _out = _run_prune( + monkeypatch, capsys, ["--source", "cron", "--older-than", "30"] + ) + assert filters["started_before"] == pytest.approx( + _time.time() - 30 * 86400, abs=60 + ) + assert filters["source"] == "cron" + + +def test_sessions_prune_preview_shows_oldest_newest(monkeypatch, capsys): + """Confirmation preview surfaces count + oldest/newest session times.""" + from hermes_cli.session_filters import format_epoch + + _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 diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 66631448400..2e1ec54640f 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -348,8 +348,10 @@ hermes sessions prune --newer-than 5h # Explicit window with absolute timestamps hermes sessions prune --after "2026-07-05 09:00" --before "2026-07-05 14:30" -# Only prune sessions from a specific platform -hermes sessions prune --source telegram --older-than 60 +# Only prune sessions from a specific platform (all ages — any filter +# disables the implicit 90-day default) +hermes sessions prune --source telegram +hermes sessions prune --source cron --older-than 60 # add a time flag to narrow # More filters — all AND together hermes sessions prune --newer-than 5h --title "smoke test" # title substring @@ -380,10 +382,12 @@ Attribute filters: `--source` (platform, exact), `--title` / `--model` / exact), `--end-reason`, `--user`, `--chat-id`, `--chat-type` (exact), `--cwd` (path prefix), plus numeric bounds `--min/--max-messages`, `--min/--max-tokens` (input+output), `--min/--max-cost` (USD, actual falling -back to estimated), and `--min/--max-tool-calls`. Using any attribute filter -(other than `--source`) disables the implicit 90-day default, so -`hermes sessions prune --model gpt-4o` matches all ages — add a time flag to -narrow it. +back to estimated), and `--min/--max-tool-calls`. Using any filter disables +the implicit 90-day default, so `hermes sessions prune --source cron` or +`--model gpt-4o` matches all ages — add a time flag to narrow it. Only a +completely bare `hermes sessions prune` keeps the 90-day cutoff. Every +non-`--yes` run shows the match count plus the oldest and newest matching +session before asking for confirmation. Archived sessions are skipped by default; pass `--include-archived` to delete them too.