mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
0f154e780e
commit
040a5e30dd
9 changed files with 1131 additions and 38 deletions
|
|
@ -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)
|
||||
|
|
|
|||
208
hermes_cli/session_filters.py
Normal file
208
hermes_cli/session_filters.py
Normal 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)"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
220
hermes_state.py
220
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:
|
||||
|
|
|
|||
149
tests/hermes_cli/test_session_filters.py
Normal file
149
tests/hermes_cli/test_session_filters.py
Normal 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)"
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue