diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 30c475d6f97..a57d4d953de 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) diff --git a/hermes_cli/session_filters.py b/hermes_cli/session_filters.py new file mode 100644 index 00000000000..c05164695af --- /dev/null +++ b/hermes_cli/session_filters.py @@ -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)" diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 564c9d00f41..242983561b5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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: diff --git a/hermes_state.py b/hermes_state.py index 0a198430334..a2895b09c7a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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: diff --git a/tests/hermes_cli/test_session_filters.py b/tests/hermes_cli/test_session_filters.py new file mode 100644 index 00000000000..8eb721ae41e --- /dev/null +++ b/tests/hermes_cli/test_session_filters.py @@ -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)" diff --git a/tests/hermes_cli/test_sessions_delete.py b/tests/hermes_cli/test_sessions_delete.py index 7b3b8a9add2..5866d31102b 100644 --- a/tests/hermes_cli/test_sessions_delete.py +++ b/tests/hermes_cli/test_sessions_delete.py @@ -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") diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index e57a9286b6c..707272f1004 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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.""" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4717197cbd4..69e9d702253 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1364,7 +1364,8 @@ Subcommands: | `browse` | Interactive session picker with search and resume. | | `export [--session-id ID]` | Export sessions to JSONL. | | `delete ` | 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 ` | Set or change a session title. | diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index b1d6c903c4a..66631448400 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -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