feat(sessions): full filter surface for prune + bulk archive subcommand (#59327)

* feat(sessions): full filter surface for prune + new bulk archive subcommand

hermes sessions prune previously only supported --older-than N (integer
days) and --source — no way to target a window like 'the last 5 hours'
(e.g. a batch of CI smoke-test sessions), and no non-destructive option.

- SessionDB.prune_sessions gains keyword filters that AND together:
  started_before/started_after epoch bounds, title_like, end_reason,
  cwd_prefix, min/max_messages, archived tri-state. Default call is
  byte-for-byte compatible (90-day cutoff, ended-only, source).
- New SessionDB.list_prune_candidates (backs --dry-run + confirmation
  previews) and SessionDB.archive_sessions (bulk soft-hide via the
  existing set_session_archived lineage-aware path; nothing deleted).
- CLI: prune gains --newer-than/--before/--after (durations like 5h/2d/1w,
  bare days, or ISO timestamps), --title, --end-reason, --cwd,
  --min/--max-messages, --include-archived, --dry-run. New
  'hermes sessions archive' takes the same filters, requires at least one,
  and is idempotent. Both show a preview before confirming.
- Dashboard /api/sessions/prune accepts the same filters + dry_run.
- Docs: sessions.md + cli-commands.md updated.

Filter parsing lives in hermes_cli/session_filters.py with unit tests;
DB filters covered in tests/test_hermes_state.py.

* feat(sessions): prune/archive filters for model, provider, user, chat, branch, tokens, cost, tool calls

Extends the prune/archive filter surface to everything identifiable in
the sessions table:

- --model (substring on model slug), --provider (exact on
  billing_provider, case-insensitive), --user, --chat-id, --chat-type
  (exact), --branch (substring on git_branch), --min/--max-tokens
  (input+output), --min/--max-cost (USD, actual_cost_usd falling back to
  estimated_cost_usd), --min/--max-tool-calls.
- SessionDB prune/archive/list_prune_candidates now share the filter
  kwargs via **filters into _prune_filter_where (unknown names raise
  TypeError); candidates listing + CLI preview now include the model.
- Any attribute filter (except legacy --source) suppresses the implicit
  90-day default so 'prune --model X' matches all ages.
- Dashboard /api/sessions/prune passes the new fields through.
- Docs + tests updated (7 new DB tests, 3 new parser tests).
This commit is contained in:
Teknium 2026-07-05 22:04:52 -07:00 committed by GitHub
parent 0f154e780e
commit 040a5e30dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1131 additions and 38 deletions

View file

@ -13422,16 +13422,126 @@ def main():
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions")
sessions_prune.add_argument(
"--older-than",
type=int,
default=90,
help="Delete sessions older than N days (default: 90)",
def _add_session_filter_args(p, default_older_help):
p.add_argument(
"--older-than",
metavar="AGE",
help=default_older_help,
)
p.add_argument(
"--newer-than",
metavar="AGE",
help="Only match sessions started within the last AGE "
"(e.g. '5h', '2d') or after an ISO timestamp",
)
p.add_argument(
"--before",
metavar="TIME",
help="Only match sessions started before TIME "
"(duration ago like '5h', or ISO timestamp like '2026-07-05 14:30')",
)
p.add_argument(
"--after",
metavar="TIME",
help="Only match sessions started at/after TIME "
"(duration ago like '5h', or ISO timestamp)",
)
p.add_argument("--source", help="Only match sessions from this source")
p.add_argument(
"--title", help="Only match sessions whose title contains this substring"
)
p.add_argument(
"--end-reason", help="Only match sessions with this end reason"
)
p.add_argument(
"--cwd", help="Only match sessions whose working directory is under this path"
)
p.add_argument(
"--min-messages", type=int, help="Only match sessions with >= N messages"
)
p.add_argument(
"--max-messages", type=int, help="Only match sessions with <= N messages"
)
p.add_argument(
"--model",
help="Only match sessions whose model name contains this substring "
"(e.g. 'sonnet', 'gpt-5', 'hermes')",
)
p.add_argument(
"--provider",
help="Only match sessions billed through this provider "
"(e.g. openrouter, anthropic, nous)",
)
p.add_argument(
"--user", help="Only match sessions from this user ID"
)
p.add_argument(
"--chat-id", help="Only match sessions from this chat/channel ID"
)
p.add_argument(
"--chat-type",
help="Only match sessions with this chat type (e.g. dm, group)",
)
p.add_argument(
"--branch",
help="Only match sessions whose git branch contains this substring",
)
p.add_argument(
"--min-tokens", type=int,
help="Only match sessions with >= N total tokens (input+output)",
)
p.add_argument(
"--max-tokens", type=int,
help="Only match sessions with <= N total tokens (input+output)",
)
p.add_argument(
"--min-cost", type=float,
help="Only match sessions costing >= N USD (actual or estimated)",
)
p.add_argument(
"--max-cost", type=float,
help="Only match sessions costing <= N USD (actual or estimated)",
)
p.add_argument(
"--min-tool-calls", type=int,
help="Only match sessions with >= N tool calls",
)
p.add_argument(
"--max-tool-calls", type=int,
help="Only match sessions with <= N tool calls",
)
p.add_argument(
"--dry-run",
action="store_true",
help="List matching sessions without changing anything",
)
p.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
)
sessions_prune = sessions_subparsers.add_parser(
"prune",
help="Delete old sessions (filterable by time window, source, title, ...)",
)
_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",
)
sessions_prune.add_argument("--source", help="Only prune sessions from this source")
sessions_prune.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation"
"--include-archived",
action="store_true",
help="Also delete archived sessions (excluded by default)",
)
sessions_archive = sessions_subparsers.add_parser(
"archive",
help="Bulk-archive (soft-hide) sessions matching filters — no deletion",
)
_add_session_filter_args(
sessions_archive,
"Only archive sessions older than AGE (duration like '5h'/'2d', "
"bare number of days, or ISO timestamp)",
)
sessions_subparsers.add_parser(
@ -13624,20 +13734,106 @@ def main():
else:
print(f"Session '{args.session_id}' not found.")
elif action == "prune":
days = args.older_than
source_msg = f" from '{args.source}'" if args.source else ""
elif action in ("prune", "archive"):
from hermes_cli.session_filters import (
build_prune_filters,
describe_filters,
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.
_non_time_filters = any(
getattr(args, a, None) is not None
for a in (
"title", "end_reason", "cwd",
"min_messages", "max_messages", "model", "provider",
"user", "chat_id", "chat_type", "branch",
"min_tokens", "max_tokens", "min_cost", "max_cost",
"min_tool_calls", "max_tool_calls",
)
)
if (
action == "prune"
and args.older_than is None
and args.newer_than is None
and args.before is None
and args.after is None
and not _non_time_filters
):
args.older_than = "90"
try:
filters = build_prune_filters(args)
except ValueError as e:
print(f"Error: {e}")
return
if action == "archive" and not any(
v for k, v in filters.items() if k != "older_than_days"
):
print(
"Refusing to archive every ended session: pass at least one "
"filter (e.g. --newer-than 5h, --source cli, --title codex)."
)
return
# Prune skips archived sessions unless --include-archived;
# archive only targets not-yet-archived rows (idempotent).
if action == "prune":
filters["archived"] = (
None if getattr(args, "include_archived", False) else False
)
else:
filters["archived"] = False
candidates = db.list_prune_candidates(**filters)
verb = "Delete" if action == "prune" else "Archive"
if not candidates:
print(f"No sessions match ({describe_filters(filters)}).")
return
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)}):"
)
for s in shown:
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['source']:<10} {model:<24} "
f"{s['message_count']:>4} msgs {title}"
)
if len(candidates) > len(shown):
print(f" … and {len(candidates) - len(shown)} more")
if args.dry_run:
print(f"Dry run — nothing {'deleted' if action == 'prune' else 'archived'}.")
return
if not args.yes:
if not _confirm_prompt(
f"Delete all ended sessions older than {days} days{source_msg}? [y/N] "
f"{verb} these {len(candidates)} session(s)? [y/N] "
):
print("Cancelled.")
return
sessions_dir = get_hermes_home() / "sessions"
count = db.prune_sessions(
older_than_days=days, source=args.source, sessions_dir=sessions_dir
)
print(f"Pruned {count} session(s).")
if action == "prune":
sessions_dir = get_hermes_home() / "sessions"
count = db.prune_sessions(sessions_dir=sessions_dir, **filters)
print(f"Pruned {count} session(s).")
else:
count = db.archive_sessions(**filters)
print(
f"Archived {count} session(s). They're hidden from listings "
"but fully recoverable (nothing was deleted)."
)
elif action == "rename":
resolved_session_id = db.resolve_session_id(args.session_id)

View file

@ -0,0 +1,208 @@
"""Shared time/filter parsing for `hermes sessions prune` / `archive`.
Turns user-friendly CLI values into the epoch bounds and filter kwargs
consumed by ``SessionDB.prune_sessions`` / ``archive_sessions`` /
``list_prune_candidates``.
Two value shapes are accepted anywhere a point in time is expected:
* Durations (relative to now): ``5h``, ``30m``, ``2d``, ``1w`` and, for
backward compatibility with the original ``--older-than N`` flag, a bare
integer which means **days**.
* Absolute timestamps: ``2026-07-05``, ``2026-07-05 14:30``,
``2026-07-05T14:30:00`` (any ISO-8601 form ``datetime.fromisoformat``
understands; naive values are interpreted in local time).
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
_DURATION_RE = re.compile(
r"^(\d+(?:\.\d+)?)\s*"
r"(s|sec|secs|second|seconds|"
r"m|min|mins|minute|minutes|"
r"h|hr|hrs|hour|hours|"
r"d|day|days|"
r"w|wk|wks|week|weeks)$"
)
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
def parse_duration_seconds(value: str) -> Optional[float]:
"""Parse ``5h`` / ``30m`` / ``2d`` / ``1w`` / ``90`` (bare = days) into
seconds. Returns None when the value doesn't look like a duration."""
s = str(value).strip().lower()
if not s:
return None
if re.fullmatch(r"\d+(?:\.\d+)?", s):
# Bare number = days (backward compatible with --older-than 90)
return float(s) * 86400
m = _DURATION_RE.match(s)
if not m:
return None
return float(m.group(1)) * _UNIT_SECONDS[m.group(2)[0]]
def parse_point_in_time(value: str, flag: str) -> float:
"""Parse a CLI time value into an epoch timestamp.
Durations are interpreted as "that long ago" (``5h`` now 5 hours).
Absolute ISO timestamps are returned as-is (naive = local time).
Raises ``ValueError`` with a user-facing message on unparseable input.
"""
s = str(value).strip()
dur = parse_duration_seconds(s)
if dur is not None:
return time.time() - dur
try:
dt = datetime.fromisoformat(s)
except ValueError:
raise ValueError(
f"Invalid value for {flag}: '{value}'. Use a duration like '5h', "
f"'30m', '2d', '1w', a bare number of days, or an ISO timestamp "
f"like '2026-07-05' or '2026-07-05 14:30'."
) from None
if dt.tzinfo is None:
return dt.timestamp()
return dt.astimezone(timezone.utc).timestamp()
def format_epoch(ts: Optional[float]) -> str:
"""Render an epoch timestamp as a short local-time string."""
if ts is None:
return "-"
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
def build_prune_filters(args: Any) -> Dict[str, Any]:
"""Translate argparse Namespace flags into SessionDB filter kwargs.
Understands: ``--older-than``, ``--newer-than``, ``--before``,
``--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.
Raises ``ValueError`` on unparseable values or an empty/inverted window.
"""
started_before: Optional[float] = None
started_after: Optional[float] = None
def _tighter(current: Optional[float], new: float, upper: bool) -> float:
if current is None:
return new
return min(current, new) if upper else max(current, new)
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
)
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(
started_after, parse_point_in_time(after, "--after"), False
)
if (
started_before is not None
and started_after is not None
and started_after >= started_before
):
raise ValueError(
"Empty time window: the --after/--newer-than bound "
f"({format_epoch(started_after)}) is not earlier than the "
f"--before/--older-than bound ({format_epoch(started_before)})."
)
filters: Dict[str, Any] = {
# older_than_days=None: the epoch bounds above are the whole story.
# Without this, prune_sessions' default 90-day cutoff would silently
# cap an --after/--newer-than-only window.
"older_than_days": None,
"started_before": started_before,
"started_after": started_after,
"source": getattr(args, "source", None),
"title_like": getattr(args, "title", None),
"end_reason": getattr(args, "end_reason", None),
"cwd_prefix": getattr(args, "cwd", None),
"min_messages": getattr(args, "min_messages", None),
"max_messages": getattr(args, "max_messages", None),
"model_like": getattr(args, "model", None),
"provider": getattr(args, "provider", None),
"user_id": getattr(args, "user", None),
"chat_id": getattr(args, "chat_id", None),
"chat_type": getattr(args, "chat_type", None),
"branch_like": getattr(args, "branch", None),
"min_tokens": getattr(args, "min_tokens", None),
"max_tokens": getattr(args, "max_tokens", None),
"min_cost": getattr(args, "min_cost", None),
"max_cost": getattr(args, "max_cost", None),
"min_tool_calls": getattr(args, "min_tool_calls", None),
"max_tool_calls": getattr(args, "max_tool_calls", None),
}
return filters
def describe_filters(filters: Dict[str, Any]) -> str:
"""Human-readable summary of active filters for confirmation prompts."""
parts = []
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:
parts.append(f"started after {format_epoch(filters['started_after'])}")
if filters.get("source"):
parts.append(f"source '{filters['source']}'")
if filters.get("title_like"):
parts.append(f"title contains '{filters['title_like']}'")
if filters.get("end_reason"):
parts.append(f"end reason '{filters['end_reason']}'")
if filters.get("cwd_prefix"):
parts.append(f"cwd under '{filters['cwd_prefix']}'")
if filters.get("min_messages") is not None:
parts.append(f">= {filters['min_messages']} messages")
if filters.get("max_messages") is not None:
parts.append(f"<= {filters['max_messages']} messages")
if filters.get("model_like"):
parts.append(f"model contains '{filters['model_like']}'")
if filters.get("provider"):
parts.append(f"provider '{filters['provider']}'")
if filters.get("user_id"):
parts.append(f"user '{filters['user_id']}'")
if filters.get("chat_id"):
parts.append(f"chat '{filters['chat_id']}'")
if filters.get("chat_type"):
parts.append(f"chat type '{filters['chat_type']}'")
if filters.get("branch_like"):
parts.append(f"git branch contains '{filters['branch_like']}'")
if filters.get("min_tokens") is not None:
parts.append(f">= {filters['min_tokens']} tokens")
if filters.get("max_tokens") is not None:
parts.append(f"<= {filters['max_tokens']} tokens")
if filters.get("min_cost") is not None:
parts.append(f">= ${filters['min_cost']}")
if filters.get("max_cost") is not None:
parts.append(f"<= ${filters['max_cost']}")
if filters.get("min_tool_calls") is not None:
parts.append(f">= {filters['min_tool_calls']} tool calls")
if filters.get("max_tool_calls") is not None:
parts.append(f"<= {filters['max_tool_calls']} tool calls")
return ", ".join(parts) if parts else "no filters (all ended sessions)"

View file

@ -8199,24 +8199,90 @@ async def export_session_endpoint(session_id: str, profile: Optional[str] = None
class SessionPrune(BaseModel):
older_than_days: int = 90
older_than_days: Optional[float] = 90
source: Optional[str] = None
profile: Optional[str] = None
# Extended filters (all optional, AND together — mirrors the CLI flags)
started_before: Optional[float] = None # epoch seconds
started_after: Optional[float] = None # epoch seconds
title_like: Optional[str] = None
end_reason: Optional[str] = None
cwd_prefix: Optional[str] = None
min_messages: Optional[int] = None
max_messages: Optional[int] = None
model_like: Optional[str] = None
provider: Optional[str] = None
user_id: Optional[str] = None
chat_id: Optional[str] = None
chat_type: Optional[str] = None
branch_like: Optional[str] = None
min_tokens: Optional[int] = None
max_tokens: Optional[int] = None
min_cost: Optional[float] = None
max_cost: Optional[float] = None
min_tool_calls: Optional[int] = None
max_tool_calls: Optional[int] = None
include_archived: bool = False
dry_run: bool = False
@app.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
if body.older_than_days < 1:
"""Delete ended sessions matching filters (mirrors `hermes sessions prune`)."""
has_window = (
body.started_before is not None or body.started_after is not None
)
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")
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,
source=(body.source or None),
started_before=body.started_before,
started_after=body.started_after,
title_like=(body.title_like or None),
end_reason=(body.end_reason or None),
cwd_prefix=(body.cwd_prefix or None),
min_messages=body.min_messages,
max_messages=body.max_messages,
model_like=(body.model_like or None),
provider=(body.provider or None),
user_id=(body.user_id or None),
chat_id=(body.chat_id or None),
chat_type=(body.chat_type or None),
branch_like=(body.branch_like or None),
min_tokens=body.min_tokens,
max_tokens=body.max_tokens,
min_cost=body.min_cost,
max_cost=body.max_cost,
min_tool_calls=body.min_tool_calls,
max_tool_calls=body.max_tool_calls,
archived=None if body.include_archived else False,
)
if body.dry_run:
rows = db.list_prune_candidates(**filters)
return {
"ok": True,
"removed": 0,
"matched": len(rows),
"sessions": [
{
"id": r["id"],
"source": r["source"],
"title": r.get("title"),
"model": r.get("model"),
"started_at": r["started_at"],
"message_count": r["message_count"],
}
for r in rows
],
}
sessions_dir = profile_home / "sessions"
removed = db.prune_sessions(
older_than_days=body.older_than_days,
source=(body.source or None),
sessions_dir=sessions_dir if sessions_dir.exists() else None,
**filters,
)
return {"ok": True, "removed": removed}
finally:

View file

@ -5266,13 +5266,211 @@ class SessionDB:
self._remove_session_files(sessions_dir, sid)
return count
@staticmethod
def _prune_filter_where(
*,
started_before: Optional[float] = None,
started_after: Optional[float] = None,
source: Optional[str] = None,
title_like: Optional[str] = None,
end_reason: Optional[str] = None,
cwd_prefix: Optional[str] = None,
min_messages: Optional[int] = None,
max_messages: Optional[int] = None,
archived: Optional[bool] = None,
model_like: Optional[str] = None,
provider: Optional[str] = None,
user_id: Optional[str] = None,
chat_id: Optional[str] = None,
chat_type: Optional[str] = None,
branch_like: Optional[str] = None,
min_tokens: Optional[int] = None,
max_tokens: Optional[int] = None,
min_cost: Optional[float] = None,
max_cost: Optional[float] = None,
min_tool_calls: Optional[int] = None,
max_tool_calls: Optional[int] = None,
) -> Tuple[str, list]:
"""Build the shared WHERE clause for bulk prune/archive selection.
All filters AND together. Only ended sessions are ever candidates
(``ended_at IS NOT NULL``) so a live session is never selected.
``archived`` is a tri-state: ``None`` = both, ``True`` = only
archived rows, ``False`` = only unarchived rows.
String matching conventions: ``model_like`` / ``branch_like`` /
``title_like`` are case-insensitive substring matches (model slugs
and branch names vary in prefix format); ``provider`` / ``user_id``
/ ``chat_id`` / ``chat_type`` / ``source`` / ``end_reason`` are
exact (case-insensitive for provider). Token bounds apply to
``input_tokens + output_tokens``; cost bounds apply to
``COALESCE(actual_cost_usd, estimated_cost_usd)``.
The clause references the ``s`` table alias callers must select
``FROM sessions s``.
"""
clauses = ["s.ended_at IS NOT NULL"]
params: list = []
if started_before is not None:
clauses.append("s.started_at < ?")
params.append(started_before)
if started_after is not None:
clauses.append("s.started_at >= ?")
params.append(started_after)
if source:
clauses.append("s.source = ?")
params.append(source)
if title_like:
clauses.append("LOWER(COALESCE(s.title, '')) LIKE ?")
params.append(f"%{title_like.lower()}%")
if end_reason:
clauses.append("s.end_reason = ?")
params.append(end_reason)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
clauses.append(clause)
params.extend(clause_params)
if min_messages is not None:
clauses.append("s.message_count >= ?")
params.append(min_messages)
if max_messages is not None:
clauses.append("s.message_count <= ?")
params.append(max_messages)
if model_like:
clauses.append("LOWER(COALESCE(s.model, '')) LIKE ?")
params.append(f"%{model_like.lower()}%")
if provider:
clauses.append("LOWER(COALESCE(s.billing_provider, '')) = ?")
params.append(provider.lower())
if user_id:
clauses.append("s.user_id = ?")
params.append(user_id)
if chat_id:
clauses.append("s.chat_id = ?")
params.append(chat_id)
if chat_type:
clauses.append("s.chat_type = ?")
params.append(chat_type)
if branch_like:
clauses.append("LOWER(COALESCE(s.git_branch, '')) LIKE ?")
params.append(f"%{branch_like.lower()}%")
if min_tokens is not None:
clauses.append(
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) >= ?"
)
params.append(min_tokens)
if max_tokens is not None:
clauses.append(
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) <= ?"
)
params.append(max_tokens)
if min_cost is not None:
clauses.append(
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) >= ?"
)
params.append(min_cost)
if max_cost is not None:
clauses.append(
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) <= ?"
)
params.append(max_cost)
if min_tool_calls is not None:
clauses.append("COALESCE(s.tool_call_count, 0) >= ?")
params.append(min_tool_calls)
if max_tool_calls is not None:
clauses.append("COALESCE(s.tool_call_count, 0) <= ?")
params.append(max_tool_calls)
if archived is True:
clauses.append("s.archived = 1")
elif archived is False:
clauses.append("s.archived = 0")
return " AND ".join(clauses), params
def list_prune_candidates(
self,
older_than_days: Optional[float] = None,
source: str = None,
**filters,
) -> List[Dict[str, Any]]:
"""Return the sessions a matching :meth:`prune_sessions` /
:meth:`archive_sessions` call would touch, without modifying anything.
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``.
"""
if filters.get("started_before") is None and older_than_days is not None:
filters["started_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,
s.ended_at, s.message_count, s.archived
FROM sessions s WHERE {where}
ORDER BY s.started_at ASC""",
params,
)
return [dict(row) for row in cursor.fetchall()]
def archive_sessions(
self,
older_than_days: Optional[float] = None,
source: str = None,
**filters,
) -> int:
"""Bulk-archive (soft-hide) every session matching the filters.
Same filter surface as :meth:`prune_sessions`, but instead of deleting
rows it flips ``archived = 1`` via :meth:`set_session_archived` so
each match's compression lineage is archived as a unit (an unarchived
compression root would otherwise resurrect the conversation in
Desktop's projected list). Nothing is deleted; messages and transcript
files are untouched. Returns the number of sessions matched.
``archived`` defaults to ``False`` here (only select rows not yet
archived) so repeat runs are idempotent no-ops.
"""
filters.setdefault("archived", False)
rows = self.list_prune_candidates(
older_than_days=older_than_days, source=source, **filters
)
for row in rows:
self.set_session_archived(row["id"], True)
return len(rows)
def prune_sessions(
self,
older_than_days: int = 90,
older_than_days: Optional[float] = 90,
source: str = None,
sessions_dir: Optional[Path] = None,
**filters,
) -> int:
"""Delete sessions older than N days. Returns count of deleted sessions.
"""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`:
* ``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``).
* ``title_like`` / ``model_like`` / ``branch_like``
case-insensitive substring matches.
* ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` /
``chat_type`` exact matches (provider case-insensitive, against
``billing_provider``).
* ``cwd_prefix`` session cwd equals or is under this path.
* ``min_messages`` / ``max_messages`` bounds on message_count.
* ``min_tokens`` / ``max_tokens`` bounds on input+output tokens.
* ``min_cost`` / ``max_cost`` bounds on USD cost
(actual, falling back to estimated).
* ``min_tool_calls`` / ``max_tool_calls`` bounds on tool_call_count.
* ``archived`` tri-state: None = both (default), True = only
archived, False = only unarchived.
Only prunes ended sessions (not active ones). Child sessions outside
the prune window are orphaned (parent_session_id set to NULL) rather
@ -5281,21 +5479,15 @@ class SessionDB:
``request_dump_*``) for every pruned session, outside the DB
transaction.
"""
cutoff = time.time() - (older_than_days * 86400)
if filters.get("started_before") is None and older_than_days is not None:
filters["started_before"] = time.time() - (older_than_days * 86400)
where, where_params = self._prune_filter_where(source=source, **filters)
removed_ids: list[str] = []
def _do(conn):
if source:
cursor = conn.execute(
"""SELECT id FROM sessions
WHERE started_at < ? AND ended_at IS NOT NULL AND source = ?""",
(cutoff, source),
)
else:
cursor = conn.execute(
"SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
(cutoff,),
)
cursor = conn.execute(
f"SELECT s.id FROM sessions s WHERE {where}", where_params
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:

View file

@ -0,0 +1,149 @@
"""Tests for hermes_cli.session_filters — CLI time/filter parsing for
`hermes sessions prune` / `hermes sessions archive`."""
import time
from argparse import Namespace
from datetime import datetime
import pytest
from hermes_cli.session_filters import (
build_prune_filters,
describe_filters,
parse_duration_seconds,
parse_point_in_time,
)
def _ns(**kwargs):
defaults = dict(
older_than=None, newer_than=None, before=None, after=None,
source=None, title=None, end_reason=None, cwd=None,
min_messages=None, max_messages=None,
model=None, provider=None, user=None, chat_id=None, chat_type=None,
branch=None, min_tokens=None, max_tokens=None, min_cost=None,
max_cost=None, min_tool_calls=None, max_tool_calls=None,
)
defaults.update(kwargs)
return Namespace(**defaults)
class TestParseDurationSeconds:
@pytest.mark.parametrize(
"value,expected",
[
("30m", 1800),
("5h", 18000),
("2d", 172800),
("1w", 604800),
("90", 90 * 86400), # bare number = days (back-compat)
("1.5h", 5400),
("10 min", 600),
("2 hours", 7200),
],
)
def test_valid(self, value, expected):
assert parse_duration_seconds(value) == pytest.approx(expected)
@pytest.mark.parametrize("value", ["", "abc", "5x", "2026-07-05", "h5"])
def test_invalid_returns_none(self, value):
assert parse_duration_seconds(value) is None
class TestParsePointInTime:
def test_duration_is_relative_to_now(self):
ts = parse_point_in_time("5h", "--before")
assert ts == pytest.approx(time.time() - 18000, abs=5)
def test_iso_date(self):
ts = parse_point_in_time("2026-07-05", "--before")
assert ts == datetime(2026, 7, 5).timestamp()
def test_iso_datetime(self):
ts = parse_point_in_time("2026-07-05 14:30", "--after")
assert ts == datetime(2026, 7, 5, 14, 30).timestamp()
def test_invalid_raises_with_flag_name(self):
with pytest.raises(ValueError, match="--older-than"):
parse_point_in_time("nonsense", "--older-than")
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["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(
time.time() - 90 * 86400, abs=5
)
assert f["started_after"] is None
def test_window_before_and_after(self):
f = build_prune_filters(_ns(after="10h", before="2h"))
assert f["started_after"] < f["started_before"]
def test_inverted_window_rejected(self):
with pytest.raises(ValueError, match="Empty 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.
f = build_prune_filters(_ns(older_than="1d", before="5h"))
assert f["started_before"] == pytest.approx(
time.time() - 86400, abs=5
)
def test_passthrough_filters(self):
f = build_prune_filters(
_ns(source="cli", title="smoke", end_reason="done",
cwd="/tmp/x", min_messages=1, max_messages=9)
)
assert f["source"] == "cli"
assert f["title_like"] == "smoke"
assert f["end_reason"] == "done"
assert f["cwd_prefix"] == "/tmp/x"
assert f["min_messages"] == 1
assert f["max_messages"] == 9
def test_passthrough_extended_filters(self):
f = build_prune_filters(
_ns(model="sonnet", provider="openrouter", user="alice",
chat_id="c-9", chat_type="group", branch="feature/x",
min_tokens=100, max_tokens=5000, min_cost=0.01,
max_cost=2.5, min_tool_calls=1, max_tool_calls=40)
)
assert f["model_like"] == "sonnet"
assert f["provider"] == "openrouter"
assert f["user_id"] == "alice"
assert f["chat_id"] == "c-9"
assert f["chat_type"] == "group"
assert f["branch_like"] == "feature/x"
assert f["min_tokens"] == 100
assert f["max_tokens"] == 5000
assert f["min_cost"] == 0.01
assert f["max_cost"] == 2.5
assert f["min_tool_calls"] == 1
assert f["max_tool_calls"] == 40
def test_describe_filters_extended(self):
f = build_prune_filters(_ns(model="gpt-5", provider="nous",
max_cost=0.5))
desc = describe_filters(f)
assert "model contains 'gpt-5'" in desc
assert "provider 'nous'" in desc
assert "<= $0.5" in desc
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 "source 'cli'" in desc
def test_describe_filters_empty(self):
f = build_prune_filters(_ns())
assert describe_filters(f) == "no filters (all ended sessions)"

View file

@ -98,6 +98,19 @@ def test_sessions_prune_handles_eoferror_on_confirm(monkeypatch, capsys):
import hermes_state
class FakeDB:
def list_prune_candidates(self, **kwargs):
return [
{
"id": "20260315_092437_c9a6ff",
"source": "cli",
"title": "old session",
"started_at": 0.0,
"ended_at": 1.0,
"message_count": 3,
"archived": 0,
}
]
def prune_sessions(self, **kwargs):
raise AssertionError("prune_sessions should not be called when cancelled")

View file

@ -1936,6 +1936,210 @@ class TestPruneSessions:
assert db.get_session(sid) is None
class TestPruneSessionFilters:
"""Extended filter surface shared by prune/archive/list_prune_candidates."""
@staticmethod
def _mk(db, sid, *, source="cli", age_seconds=0, title=None,
end_reason="done", message_count=0, cwd=None):
db.create_session(session_id=sid, source=source, cwd=cwd)
db.end_session(sid, end_reason=end_reason)
db._conn.execute(
"UPDATE sessions SET started_at = ?, message_count = ?, title = ? "
"WHERE id = ?",
(time.time() - age_seconds, message_count, title, sid),
)
db._conn.commit()
def test_started_after_window_prunes_only_recent(self, db):
self._mk(db, "recent1", age_seconds=3600) # 1h ago
self._mk(db, "recent2", age_seconds=2 * 3600) # 2h ago
self._mk(db, "old", age_seconds=10 * 3600) # 10h ago
cutoff = time.time() - 5 * 3600
pruned = db.prune_sessions(older_than_days=None, started_after=cutoff)
assert pruned == 2
assert db.get_session("old") is not None
assert db.get_session("recent1") is None
def test_before_after_window(self, db):
self._mk(db, "inside", age_seconds=5 * 3600)
self._mk(db, "too_new", age_seconds=1 * 3600)
self._mk(db, "too_old", age_seconds=20 * 3600)
now = time.time()
pruned = db.prune_sessions(
older_than_days=None,
started_after=now - 10 * 3600,
started_before=now - 2 * 3600,
)
assert pruned == 1
assert db.get_session("inside") is None
assert db.get_session("too_new") is not None
assert db.get_session("too_old") is not None
def test_title_and_message_count_filters(self, db):
self._mk(db, "smoke1", age_seconds=60, title="Codex Smoke Test 1",
message_count=2)
self._mk(db, "smoke2", age_seconds=60, title="codex smoke test 2",
message_count=8)
self._mk(db, "real", age_seconds=60, title="Debugging auth",
message_count=8)
rows = db.list_prune_candidates(title_like="smoke")
assert {r["id"] for r in rows} == {"smoke1", "smoke2"}
pruned = db.prune_sessions(
older_than_days=None, title_like="Smoke", max_messages=3
)
assert pruned == 1
assert db.get_session("smoke1") is None
assert db.get_session("smoke2") is not None
assert db.get_session("real") is not None
def test_end_reason_and_cwd_filters(self, db):
self._mk(db, "s1", age_seconds=60, end_reason="done",
cwd="/home/u/scratch/x")
self._mk(db, "s2", age_seconds=60, end_reason="error",
cwd="/home/u/scratch")
self._mk(db, "s3", age_seconds=60, end_reason="done",
cwd="/home/u/work")
rows = db.list_prune_candidates(cwd_prefix="/home/u/scratch")
assert {r["id"] for r in rows} == {"s1", "s2"}
pruned = db.prune_sessions(
older_than_days=None, end_reason="done",
cwd_prefix="/home/u/scratch",
)
assert pruned == 1
assert db.get_session("s1") is None
def test_prune_excludes_archived_when_requested(self, db):
self._mk(db, "arch", age_seconds=60)
self._mk(db, "plain", age_seconds=60)
db.set_session_archived("arch", True)
pruned = db.prune_sessions(older_than_days=None, started_after=0,
archived=False)
assert pruned == 1
assert db.get_session("arch") is not None
assert db.get_session("plain") is None
def test_archive_sessions_bulk(self, db):
self._mk(db, "a1", age_seconds=3600)
self._mk(db, "a2", age_seconds=2 * 3600)
self._mk(db, "keep", age_seconds=10 * 3600)
# Active session in the window must never be touched
db.create_session(session_id="live", source="cli")
cutoff = time.time() - 5 * 3600
count = db.archive_sessions(started_after=cutoff)
assert count == 2
assert db.get_session("a1")["archived"] == 1
assert db.get_session("a2")["archived"] == 1
assert db.get_session("keep")["archived"] == 0
assert db.get_session("live")["archived"] == 0
# Idempotent: already-archived rows aren't re-selected
assert db.archive_sessions(started_after=cutoff) == 0
def test_list_prune_candidates_matches_prune(self, db):
self._mk(db, "c1", age_seconds=3600, source="cli")
self._mk(db, "c2", age_seconds=3600, source="telegram")
rows = db.list_prune_candidates(started_after=0, source="cli")
assert [r["id"] for r in rows] == ["c1"]
pruned = db.prune_sessions(older_than_days=None, started_after=0,
source="cli")
assert pruned == 1
def test_default_signature_unchanged(self, db):
"""Legacy positional call keeps working with identical semantics."""
self._mk(db, "ancient", age_seconds=200 * 86400)
self._mk(db, "fresh", age_seconds=60)
assert db.prune_sessions(90) == 1
assert db.get_session("ancient") is None
assert db.get_session("fresh") is not None
@staticmethod
def _mk_rich(db, sid, **cols):
"""Create an ended session then set arbitrary sessions columns."""
db.create_session(session_id=sid, source=cols.pop("source", "cli"))
db.end_session(sid, end_reason=cols.pop("end_reason", "done"))
cols.setdefault("started_at", time.time() - 60)
sets = ", ".join(f"{k} = ?" for k in cols)
db._conn.execute(
f"UPDATE sessions SET {sets} WHERE id = ?", (*cols.values(), sid)
)
db._conn.commit()
def test_model_like_filter(self, db):
self._mk_rich(db, "m1", model="anthropic/claude-sonnet-4.6")
self._mk_rich(db, "m2", model="openai/gpt-5.4")
self._mk_rich(db, "m3", model=None)
rows = db.list_prune_candidates(model_like="Sonnet")
assert [r["id"] for r in rows] == ["m1"]
assert db.prune_sessions(older_than_days=None, model_like="gpt-5") == 1
assert db.get_session("m2") is None
assert db.get_session("m1") is not None
assert db.get_session("m3") is not None
def test_provider_filter(self, db):
self._mk_rich(db, "p1", billing_provider="openrouter")
self._mk_rich(db, "p2", billing_provider="Anthropic")
self._mk_rich(db, "p3", billing_provider=None)
assert db.prune_sessions(older_than_days=None, provider="anthropic") == 1
assert db.get_session("p2") is None
assert db.get_session("p1") is not None
assert db.get_session("p3") is not None
def test_user_chat_filters(self, db):
self._mk_rich(db, "u1", user_id="alice", chat_id="c-1", chat_type="dm")
self._mk_rich(db, "u2", user_id="bob", chat_id="c-2", chat_type="group")
assert db.prune_sessions(older_than_days=None, user_id="alice") == 1
assert db.get_session("u1") is None
assert db.prune_sessions(
older_than_days=None, chat_id="c-2", chat_type="group"
) == 1
assert db.get_session("u2") is None
def test_branch_like_filter(self, db):
self._mk_rich(db, "b1", git_branch="feature/old-experiment")
self._mk_rich(db, "b2", git_branch="main")
assert db.prune_sessions(older_than_days=None, branch_like="experiment") == 1
assert db.get_session("b1") is None
assert db.get_session("b2") is not None
def test_token_cost_toolcall_bounds(self, db):
self._mk_rich(db, "cheap", input_tokens=100, output_tokens=50,
actual_cost_usd=0.001, tool_call_count=0)
self._mk_rich(db, "mid", input_tokens=5000, output_tokens=2000,
actual_cost_usd=None, estimated_cost_usd=0.5,
tool_call_count=12)
self._mk_rich(db, "big", input_tokens=90000, output_tokens=30000,
actual_cost_usd=4.2, tool_call_count=80)
rows = db.list_prune_candidates(max_tokens=200)
assert [r["id"] for r in rows] == ["cheap"]
rows = db.list_prune_candidates(min_tokens=7000, max_tokens=10000)
assert [r["id"] for r in rows] == ["mid"]
# Cost falls back to estimated when actual is NULL
rows = db.list_prune_candidates(min_cost=0.4, max_cost=1.0)
assert [r["id"] for r in rows] == ["mid"]
rows = db.list_prune_candidates(min_tool_calls=50)
assert [r["id"] for r in rows] == ["big"]
assert db.prune_sessions(older_than_days=None, max_tool_calls=0) == 1
assert db.get_session("cheap") is None
def test_unknown_filter_rejected(self, db):
import pytest as _pytest
with _pytest.raises(TypeError):
db.prune_sessions(older_than_days=None, bogus_filter="x")
class TestDeleteSessionOrphansChildren:
def test_delete_orphans_children(self, db):
"""Deleting a parent session orphans its children."""

View file

@ -1364,7 +1364,8 @@ Subcommands:
| `browse` | Interactive session picker with search and resume. |
| `export <output> [--session-id ID]` | Export sessions to JSONL. |
| `delete <session-id>` | Delete one session. |
| `prune` | Delete old sessions. |
| `prune` | Delete sessions matching filters: time bounds `--older-than`/`--newer-than`/`--before`/`--after` (durations like `5h`/`2d`, bare days, or ISO timestamps); attributes `--source`, `--title`, `--model`, `--provider`, `--branch`, `--end-reason`, `--user`, `--chat-id`, `--chat-type`, `--cwd`; numeric bounds `--min/--max-messages`, `--min/--max-tokens`, `--min/--max-cost`, `--min/--max-tool-calls`; plus `--include-archived`, `--dry-run`, `--yes`. Default: older than 90 days. |
| `archive` | Bulk-archive (soft-hide, no deletion) sessions matching the same filters as `prune`. Requires at least one filter. |
| `stats` | Show session-store statistics. |
| `rename <session-id> <title>` | Set or change a session title. |

View file

@ -335,20 +335,84 @@ If the title is already in use by another session, an error is shown.
# Delete ended sessions older than 90 days (default)
hermes sessions prune
# Custom age threshold
# Custom age threshold — bare numbers are days
hermes sessions prune --older-than 30
# Durations work too: 5h, 30m, 2d, 1w
hermes sessions prune --older-than 12h
# Delete only a specific time window (e.g. a batch of test sessions
# created in the last 5 hours)
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
# More filters — all AND together
hermes sessions prune --newer-than 5h --title "smoke test" # title substring
hermes sessions prune --older-than 30 --max-messages 3 # tiny sessions
hermes sessions prune --cwd ~/scratch --end-reason done # by cwd / end reason
hermes sessions prune --model gpt-5 --older-than 1w # by model (substring)
hermes sessions prune --provider openrouter --older-than 60 # by billing provider
hermes sessions prune --branch feature/old-experiment # by git branch
hermes sessions prune --user 12345678 --chat-type group # by messaging origin
hermes sessions prune --max-tokens 500 --older-than 7 # by token usage
hermes sessions prune --max-cost 0.01 --max-tool-calls 0 # cheap, tool-less runs
# Preview what would be deleted, without deleting anything
hermes sessions prune --newer-than 5h --dry-run
# Skip confirmation
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.
Attribute filters: `--source` (platform, exact), `--title` / `--model` /
`--branch` (case-insensitive substring), `--provider` (billing provider,
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.
Archived sessions are skipped by default; pass `--include-archived` to
delete them too.
:::info
Pruning only deletes **ended** sessions (sessions that have been explicitly ended or auto-reset). Active sessions are never pruned.
:::
### Bulk-Archive Sessions
If you want sessions out of your listings without deleting anything,
`hermes sessions archive` takes the same filters as `prune` but soft-hides
matching sessions instead (sets the same archived flag as archiving a single
session from the Desktop/Dashboard UI — messages and search stay intact):
```bash
# Archive everything from the last 5 hours (e.g. 75 CI smoke-test sessions)
hermes sessions archive --newer-than 5h
# Archive by title substring, preview first
hermes sessions archive --title "dry run" --dry-run
hermes sessions archive --title "dry run" --yes
```
At least one filter is required — a bare `hermes sessions archive` refuses to
archive your entire history. Archived sessions are hidden from
`hermes sessions list` and `/resume` but remain in the database and can be
unarchived from the Desktop/Dashboard session list.
### Session Statistics
```bash