"""``hermes sessions`` command. ``cmd_sessions`` routes ``args.sessions_action`` through ``_PRE_DB_HANDLERS`` (repair / recover / import — must run without opening ``SessionDB()``, which is exactly what a malformed schema prevents) and ``_DB_HANDLERS`` (everything else, sharing one ``SessionDB`` that is always closed afterwards). ``get_hermes_home`` is resolved through ``hermes_cli.main`` at call time so monkeypatches there keep working and imports stay one-way. Browse picker: :mod:`hermes_cli.sessions_cmd_browse`. """ import json import os import sys from functools import partial from pathlib import Path from hermes_cli.sessions_cmd_browse import ( # noqa: F401 (re-exports) _annotate_session_statuses, _relative_time, _session_browse_picker, _session_status_tag, ) def get_hermes_home(): from hermes_cli import main return main.get_hermes_home() def _sessions_dir() -> Path: return get_hermes_home() / "sessions" def _size_mb(path) -> float: return os.path.getsize(path) / (1024 * 1024) if path.exists() else 0.0 def _size_delta_label(saved_mb: float) -> str: """Size delta label. A negative delta means the file GREW (concurrent writes during a long optimize can outweigh what the rebuild freed); "reclaimed -163.0 MB" reads as data loss, so say "grew by" instead.""" if saved_mb >= 0: return f"reclaimed {saved_mb:.1f} MB" return f"grew by {-saved_mb:.1f} MB" def _confirm_prompt(prompt: str) -> bool: """Prompt for y/N confirmation, safe against non-TTY environments.""" try: return input(prompt).strip().lower() in {"y", "yes"} except (EOFError, KeyboardInterrupt): return False def _not_found(session_id) -> int: print(f"Session '{session_id}' not found.") return 1 def _print_dry_run_preview(candidates, filters) -> None: from hermes_cli.session_filters import describe_filters print(f"Would export {len(candidates)} session(s) ({describe_filters(filters)}).") for row in candidates[:100]: print(f" {row.get('id')} {row.get('source', '')}") if len(candidates) > 100: print(f" ... {len(candidates) - 100} more") _TIME_FILTER_ARGS = ("older_than", "newer_than", "before", "after") _NON_TIME_FILTER_ARGS = ( "source", "title", "end_reason", "cwd", "min_messages", "max_messages", "model", "provider", "user", "chat_id", "chat_type", "branch", "min_tokens", "max_tokens", "min_cost", "max_cost", "min_tool_calls", "max_tool_calls", ) def _any_filter_args(args, names) -> bool: return any(getattr(args, a, None) is not None for a in names) def _write_output(output, text, summary) -> None: """Write to stdout when *output* is empty or ``-``; else to the file + print *summary*.""" if not output or output == "-": sys.stdout.write(text) return with open(output, "w", encoding="utf-8") as f: f.write(text) print(summary) # --------------------------------------------------------------------------- # Handlers that must run BEFORE SessionDB() is opened # --------------------------------------------------------------------------- def _cmd_repair(args): from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema db_path = DEFAULT_DB_PATH if not db_path.exists(): print(f"No session database at {db_path} (nothing to repair).") return reason = _db_opens_cleanly(db_path) if reason is None: print(f"✓ {db_path} opens cleanly — no repair needed.") return print(f"✗ {db_path} does not open cleanly: {reason}") if getattr(args, "check_only", False): return print("Repairing (a backup copy is made first)…") report = repair_state_db_schema(db_path, backup=not getattr(args, "no_backup", False)) if report.get("repaired"): if report.get("backup_path"): print(f" backup: {report['backup_path']}") print(f" strategy: {report.get('strategy')}") try: from hermes_state import SessionDB _repair_db = SessionDB() try: n = _repair_db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] print(f"✓ Repaired — {n} sessions recovered.") finally: _repair_db.close() except Exception: print("✓ Repaired.") return print(f"✗ Repair failed: {report.get('error')}") if report.get("backup_path"): print(f" A backup is preserved at: {report['backup_path']}") print(" Keep state.db and the backup; do not delete them.") # Without this pointer the user is at a dead end after a failed in-place # repair. Lead with --inspect-only so they confirm readability before writing. print("") print(" Next step — offline recovery (never modifies the source):") source_hint = report.get("backup_path") or db_path print(f" hermes sessions recover --source {source_hint} \\") print(" --inspect-only") print(" If that reports the data is recoverable, rebuild it into") print(" a NEW database (the active one is left untouched):") print(f" hermes sessions recover --source {source_hint} \\") print(" --output recovered-state.db") def _cmd_recover(args): """Offline recovery: never opens the supplied source directly (works on a disposable copy) and never touches the active database.""" import sqlite3 from hermes_cli.session_recovery import ( SessionRecoveryError, inspect_session_database, recover_session_database, write_recovery_report, ) source = args.source output = getattr(args, "output", None) inspect_only = bool(getattr(args, "inspect_only", False)) allow_partial = bool(getattr(args, "allow_partial", False)) report_path = getattr(args, "report", None) if not inspect_only and output is not None and report_path is None: report_path = output.with_name(output.name + ".recovery.json") usage_errors = ( (inspect_only and output is not None, "--output cannot be used with --inspect-only."), (inspect_only and allow_partial, "--allow-partial cannot be used with --inspect-only."), (not inspect_only and output is None, "--output is required unless --inspect-only is used."), ( report_path is not None and os.path.lexists(report_path.expanduser()), f"refusing to overwrite existing report: {report_path}", ), ) for bad, msg in usage_errors: if bad: print(f"Error: {msg}") return 2 work_dir = getattr(args, "work_dir", None) try: if inspect_only: report = inspect_session_database(source, work_dir=work_dir) else: print("Recovering canonical session data into a new database…") progress = _RecoveryProgress() report = recover_session_database( source, output, work_dir=work_dir, chunk_size=getattr(args, "chunk_size", 1000), progress_cb=progress, allow_partial=allow_partial, ) progress.finish() except (SessionRecoveryError, OSError, sqlite3.DatabaseError) as exc: print(f"Error: session recovery failed: {exc}") print("The supplied source database was not replaced or deleted.") return 1 if report_path is not None: try: written_report = write_recovery_report(report_path, report) except (FileExistsError, OSError) as exc: print(f"Error: could not write recovery report: {exc}") return 1 print(f"Recovery report: {written_report}") else: print(json.dumps(report, indent=2, sort_keys=True)) if inspect_only: return 0 if report.get("recoverable") else 1 return _print_recovery_verdict(report, output, allow_partial) class _RecoveryProgress: """`recover` progress printer: one live-updating `` : n/total`` line per table.""" def __init__(self): self.table = None def __call__(self, info): table = info.get("table") if table != self.table: self.finish() print(f" {table}: ", end="", flush=True) self.table = table total = info.get("source_rows") suffix = f"/{int(total):,}" if total is not None else "" print(f"\r {table}: {int(info.get('copied_rows') or 0):,}{suffix}", end="", flush=True) def finish(self): if self.table is not None: print() def _print_recovery_verdict(report, output, allow_partial) -> int: if report.get("complete"): print(f"✓ Recovered database verified at: {output}") print(" The active session database was not changed.") print(" Review the JSON report before installing this database.") return 0 if allow_partial and report.get("verified"): counts = report.get("verification", {}).get("table_counts", {}) if report.get("best_effort"): print(f"✓ BEST-EFFORT page-level salvage verified at: {output}") print( " The source table schemas were unreadable; rows were rebuilt from raw pages " "via sqlite3 .recover and mapped heuristically." ) else: print(f"✓ Partial recovery output verified at: {output}") print( f" Recovered {int(counts.get('sessions') or 0):,} sessions and " f"{int(counts.get('messages') or 0):,} messages." ) print(" The active session database was not changed.") print( " This output is incomplete. Review every skipped range and orphan count in the " "JSON report before installing it." ) return 0 print("✗ Recovery output did not pass every verification check.") print(" Do not install it. Review the JSON report for partial data or errors.") return 1 def _cmd_import(args): from hermes_cli.foreign_sessions import run_sessions_import # Explicit path but nothing imported (bad path, unknown source, no turns) → # non-zero so scripts can detect it. Picker cancel (no path) → normal exit 0. if run_sessions_import(args) is None and getattr(args, "path", None): return 1 # --------------------------------------------------------------------------- # Handlers that receive an open SessionDB # --------------------------------------------------------------------------- def _default_exclude(args): """Hide third-party tool sessions by default, but honour explicit --source.""" return None if getattr(args, "source", None) else ["tool"] def _cmd_list(db, args): from hermes_state import workspace_key as _ws_key sessions = db.list_sessions_rich(source=args.source, exclude_sources=_default_exclude(args), limit=args.limit) # Workspace filter: workspace key (git repo root, else cwd) — path substring or exact basename. _ws_filter = (getattr(args, "workspace", None) or "").strip() if _ws_filter: _needle = _ws_filter.lower() def _in_workspace(s): key = (_ws_key(s) or "").lower() return bool(key) and (_needle in key or _needle == os.path.basename(key.rstrip("/\\"))) sessions = [s for s in sessions if _in_workspace(s)] if not sessions: print("No sessions found.") return # The Workspace column only appears once at least one session carries a # workspace key (or when filtering), so all-unbound listings read as before. has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions) has_titles = any(s.get("title") for s in sessions) def _ws(s): # repo/dir basename, "—" when unbound key = _ws_key(s) return ((os.path.basename(key.rstrip("/\\")) or key) if key else "—")[:16] def _title(s, n): return (s.get("title") or "—")[:n] def _preview(s, n): return s.get("preview", "")[:n] def _ago(s): return _relative_time(s.get("last_active")) layouts = { # (has_ws, has_titles): header, rule width, row formatter (True, True): (f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}", 110, lambda s: f"{_title(s, 26):<28} {_ws(s):<18} {_ago(s):<13} {s['id']}"), (True, False): (f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}", 100, lambda s: f"{_preview(s, 36):<38} {_ws(s):<18} {_ago(s):<13} {s['source']:<6} {s['id']}"), (False, True): (f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}", 110, lambda s: f"{_title(s, 30):<32} {_preview(s, 38):<40} {_ago(s):<13} {s['id']}"), (False, False): (f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}", 95, lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {s['source']:<6} {s['id']}"), } header, rule, fmt = layouts[(has_ws, has_titles)] print(header) print("─" * rule) for s in sessions: print(fmt(s)) # -- export ----------------------------------------------------------------- def _cmd_export(db, args): from hermes_cli.session_filters import build_prune_filters filters = None if _any_filter_args(args, _TIME_FILTER_ARGS + _NON_TIME_FILTER_ARGS): try: filters = build_prune_filters(args) except ValueError as e: print(f"Error: {e}") return # Unlike prune/archive, export includes archived sessions. filters["archived"] = None def _redact(data): if not args.redact or data is None: return data from hermes_cli.session_export_md import redact_session_data return redact_session_data(data) def _collect_sessions(): """Resolve --session-id / filters / bare export into a list of redacted session dicts, or None after printing an error.""" if args.session_id: resolved = db.resolve_session_id(args.session_id) data = _redact(db.export_session(resolved)) if resolved else None if not data: _not_found(args.session_id) return None return [data] if filters: candidates = db.list_prune_candidates(**filters) if args.dry_run: _print_dry_run_preview(candidates, filters) return None return [s for s in (_redact(db.export_session(row["id"])) for row in candidates) if s] if args.dry_run: print("--dry-run requires at least one filter.") return None return [_redact(s) for s in db.export_all(source=None)] if getattr(args, "only", None): return _export_only(args, _collect_sessions) if args.format == "html": return _export_html(args, _collect_sessions) if args.format == "trace": return _export_trace(db, args, filters) if args.format == "jsonl": return _export_jsonl(args, _collect_sessions) return _export_markdown(db, args, filters, _redact) def _export_only(args, collect): """--only user-prompts: one prompt record per line (jsonl) or headed sections (md).""" if args.format not in ("jsonl", "md"): print("--only user-prompts supports --format jsonl or md.") return from hermes_cli.session_export import export_record_count, render_sessions_export sessions = collect() if sessions is None: return rendered = render_sessions_export(sessions, fmt="markdown" if args.format == "md" else "jsonl", only=args.only) count, noun = export_record_count(sessions, only=args.only) _write_output(args.output, rendered, f"Exported {count} {noun}{'' if count == 1 else 's'} to {args.output}") def _export_html(args, collect): """One self-contained file (single session, or multi-session with sidebar).""" if not args.output or args.output == "-": print("HTML export requires an output file path.") return from hermes_cli.session_export_html import generate_html_export, generate_multi_session_html_export sessions = collect() if sessions is None: return if len(sessions) == 1: content = generate_html_export(sessions[0]) else: content = generate_multi_session_html_export(sessions) with open(args.output, "w", encoding="utf-8") as f: f.write(content) suffix = "" if len(sessions) == 1 else "s" print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)") def _export_jsonl(args, collect): if not args.output: print("JSONL export requires an output path (use - for stdout).") return sessions = collect() if sessions is None: return lines = "".join(json.dumps(s, ensure_ascii=False) + "\n" for s in sessions) noun = "session" if args.session_id else "sessions" _write_output(args.output, lines, f"Exported {len(sessions)} {noun} to {args.output}") def _export_trace(db, args, filters): """Claude Code JSONL trace export — local file or HF upload. Redaction is ON by default (traces leave the machine with --upload); --no-redact opts out.""" session_id = args.session_id if not session_id and not filters: # Match the shell's common intent: "the last thing I did". rows = db.list_sessions_rich(limit=1, order_by_last_active=True) session_id = rows[0].get("id") if rows else None if not session_id: print("No session found to export. Pass --session-id.") return if session_id and not db.resolve_session_id(session_id): _not_found(session_id) return from agent.trace_upload import TraceRedactionError, build_trace_jsonl, upload_session_trace redact_trace = not getattr(args, "no_redact", False) if getattr(args, "upload", False): if not session_id: print("--upload exports one session: pass --session-id (or drop filters to use the most recent).") return resolved = db.resolve_session_id(session_id) db.close() print(upload_session_trace(resolved, cwd="", redact=redact_trace, private=not getattr(args, "public", False))) return if session_id: ids = [db.resolve_session_id(session_id)] else: candidates = db.list_prune_candidates(**filters) if args.dry_run: _print_dry_run_preview(candidates, filters) return ids = [row["id"] for row in candidates] def _render_trace(sid): meta = db.get_session(sid) or {} messages = db.get_messages_as_conversation(sid) if not messages: return None return build_trace_jsonl(messages, session_id=sid, model=meta.get("model") or "", cwd="", redact=redact_trace) to_stdout = not args.output or args.output == "-" try: if len(ids) == 1: jsonl = _render_trace(ids[0]) if not jsonl: print(f"No transcript to export for session '{ids[0]}'.") return _write_output(args.output, jsonl, f"Exported 1 session trace to {args.output}") else: out_dir = (get_hermes_home() / "session-exports" if to_stdout else Path(args.output).expanduser()) out_dir.mkdir(parents=True, exist_ok=True) exported = 0 for sid in ids: jsonl = _render_trace(sid) if not jsonl: continue (out_dir / f"{sid}.trace.jsonl").write_text(jsonl, encoding="utf-8") exported += 1 print(f"Exported {exported} session trace(s) to {out_dir}") except TraceRedactionError: print("Redaction failed; refusing to export unredacted trace content.") def _export_markdown(db, args, filters, redact): """Markdown / QMD export: one file per session plus a manifest entry.""" from hermes_cli.session_export_md import append_manifest_entry, write_session_markdown if args.output == "-": print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.") return output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports" def _export_one(session_id: str, *, include_lineage: bool = False): data = (db.export_session_lineage(session_id) if include_lineage else db.export_session(session_id)) if not data: return None, None data = redact(data) path = write_session_markdown(data, output_dir, fmt=args.format, force=args.force) append_manifest_entry(output_dir, data, path, fmt=args.format) return data, path if args.delete_after_verified and not args.yes: print("--delete-after-verified requires --yes.") return if args.delete_after_verified and not args.session_id: print("--delete-after-verified is only supported with --session-id.") return lineage_is_logical = getattr(args, "lineage", "single") == "logical" if args.session_id: return _export_markdown_single(db, args, _export_one, output_dir, lineage_is_logical) if not filters: print( "Refusing bulk export without a filter. Pass --session-id or " "at least one filter (e.g. --older-than 90, --source telegram)." ) return candidates = db.list_prune_candidates(**filters) if args.dry_run: _print_dry_run_preview(candidates, filters) return exported = 0 for row in candidates: try: data, exported_path = _export_one(row["id"], include_lineage=lineage_is_logical) except FileExistsError as e: print(f"Skipping existing export: {e}. Pass --force to overwrite.") continue if data and exported_path: exported += 1 print(f"Exported {exported} session(s) to {output_dir}") def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical): """--session-id markdown export, optionally + verified delete of it and its delegates.""" from hermes_cli.session_export_md import verify_export_file resolved_session_id = db.resolve_session_id(args.session_id) if not resolved_session_id: _not_found(args.session_id) return delete_target_ids = [resolved_session_id] if args.delete_after_verified: delete_target_ids = db.get_session_delete_targets(resolved_session_id) exported_items = [] for target_id in delete_target_ids: try: data, exported_path = export_one( target_id, include_lineage=(target_id == resolved_session_id and lineage_is_logical), ) except FileExistsError as e: print(f"Export already exists: {e}. Pass --force to overwrite.") return if not data or not exported_path: print(f"Session '{target_id}' disappeared during export; nothing was deleted.") return exported_items.append((data, exported_path)) message_count = sum(len(data.get("messages") or []) for data, _path in exported_items) suffix = "" if message_count == 1 else "s" if len(exported_items) == 1: print(f"Exported 1 session ({message_count} message{suffix}) to {exported_items[0][1]}") else: print(f"Exported {len(exported_items)} sessions ({message_count} message{suffix}) to {output_dir}") if not args.delete_after_verified: return for data, exported_path in exported_items: ok, reason = verify_export_file(exported_path, data) if not ok: print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}") return if db.delete_session(resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids): delegate_count = len(delete_target_ids) - 1 delegate_suffix = ( "" if not delegate_count else f" and {delegate_count} delegate session{'' if delegate_count == 1 else 's'}" ) print(f"Deleted exported session '{resolved_session_id}'{delegate_suffix}.") else: print(f"Exported, but session '{resolved_session_id}' was not deleted because its delegate set changed.") # -- delete / prune / archive ------------------------------------------------- def _cmd_delete(db, args): resolved_session_id = db.resolve_session_id(args.session_id) if not resolved_session_id: return _not_found(args.session_id) # The user named this id directly so the delete is honored, but a pin is a # "keep" flag and silently destroying it is surprising — say so. _get_session = getattr(db, "get_session", None) _meta = (_get_session(resolved_session_id) or {}) if callable(_get_session) else {} _pinned_note = " (this session is PINNED)" if _meta.get("pinned") else "" if not args.yes: if not _confirm_prompt( f"Delete session '{resolved_session_id}'{_pinned_note} " "and all its messages? [y/N] " ): print("Cancelled.") return elif _pinned_note: print(f"Warning: deleting a pinned session '{resolved_session_id}'.") if db.delete_session(resolved_session_id, sessions_dir=_sessions_dir()): print(f"Deleted session '{resolved_session_id}'.") else: return _not_found(args.session_id) #: Age floor for `prune --never-active`. Generous on purpose: the rows are harmless, #: and a young never-active row may simply be a chat nobody has replied to yet. _NEVER_ACTIVE_DEFAULT_DAYS = 30.0 def _prune_never_active_keyed(db, args): """`prune --never-active`: drop keyed gateway rows opened and never used (mostly escaped test fixtures). Separate from the shared prune/archive selector, which is pinned to `ended_at IS NOT NULL`: never-closed rows sit outside it by construction and cannot be expressed as one more filter.""" from hermes_cli.session_filters import format_epoch, parse_duration_seconds older_than = getattr(args, "older_than", None) if older_than is None: days = _NEVER_ACTIVE_DEFAULT_DAYS else: seconds = parse_duration_seconds(str(older_than)) if seconds is None: print( f"Error: --older-than '{older_than}' is not a duration. " "Use a bare number of days or a form like '2d' / '1w'." ) return days = seconds / 86400.0 candidates = db.list_never_active_keyed_sessions(older_than_days=days) if not candidates: print(f"No never-active keyed sessions older than {days:g} day(s).") return shown = candidates if args.dry_run else candidates[:15] print( f"{len(candidates)} never-active keyed session(s) older than " f"{days:g} day(s) — no messages, tokens, tool calls or title:" ) for s in shown: print( f" {s['id']} {format_epoch(s.get('started_at')):<17} " f"{(s.get('source') or '-'):<10} {s.get('session_key') or '-'}" ) if len(candidates) > len(shown): print(f" … {len(candidates) - len(shown)} more") if args.dry_run: print("Dry run — nothing deleted.") return if not args.yes and not _confirm_prompt(f"Delete {len(candidates)} session(s)? [y/N] "): print("Aborted.") return deleted, routing_deleted = db.prune_never_active_keyed_sessions( older_than_days=days, sessions_dir=_sessions_dir() ) print(f"Deleted {deleted} never-active session(s) and {routing_deleted} stale routing entr(ies).") def _note_pinned_skipped(db, filters, action): """Pinned sessions are excluded by default from bulk prune/archive (pin = durable keep). `prune --include-pinned` opts in; archive has no such flag, so archive always spares pinned rows. Tell the user how many were spared.""" _count_matches = getattr(db, "count_prune_matches", None) if not callable(_count_matches): return _base = {k: v for k, v in filters.items() if k != "include_pinned"} try: skipped = max( int(_count_matches(**_base, include_pinned=True)) - int(_count_matches(**_base, include_pinned=False)), 0, ) except TypeError: return # a db double without include_pinned support — skip the note if not skipped: return suffix = "" if skipped == 1 else "s" if action == "prune": verb, optin = "deleted", ( "Pass --include-pinned to delete them anyway, or unpin " "first with `hermes sessions unpin `." ) else: verb, optin = "archived", "Unpin first with `hermes sessions unpin ` to include them." print( f"Note: {skipped} pinned session{suffix} also match these filters but " f"will NOT be {verb} (pin is a keep flag). {optin}" ) def _cmd_prune_or_archive(db, args, action): if action == "prune" and getattr(args, "never_active", False): return _prune_never_active_keyed(db, args) from hermes_cli.session_filters import build_prune_filters, describe_filters, format_epoch # Bare `prune` (no time window, no filters) keeps the historical "older # than 90 days" default. ANY filter — including --source — suppresses the # implicit cutoff (`prune --source cron` matches ALL cron sessions); the # preview + confirmation below is the safety net. if action == "prune" and not _any_filter_args(args, _TIME_FILTER_ARGS + _NON_TIME_FILTER_ARGS): args.older_than = "90" try: filters = build_prune_filters(args) except ValueError as e: print(f"Error: {e}") return 1 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 rows unless --include-archived; archive only targets # not-yet-archived rows (idempotent). filters["archived"] = None if action == "prune" and getattr(args, "include_archived", False) else False filters["include_pinned"] = getattr(args, "include_pinned", False) if not filters["include_pinned"]: _note_pinned_skipped(db, filters, action) candidates = db.list_prune_candidates(**filters) # Archive expands each row to its compression lineage (may include open # continuations), so a direct-open count would misdescribe its effect. skipped_open = db.count_open_prune_matches(**filters) if action == "prune" else 0 if skipped_open: suffix = "" if skipped_open == 1 else "s" print( f"Note: {skipped_open} open session{suffix} also match these " "filters but will be skipped because prune only deletes ended " "sessions. Use `hermes sessions delete ` " "to remove one explicitly." ) verb = "Delete" if action == "prune" else "Archive" if not candidates: print(f"No sessions match ({describe_filters(filters)}).") return # Candidates are ordered oldest-activity-first; show the span so a long-lived # but recently used conversation cannot look old merely by creation date. _span = ( f"oldest activity {format_epoch(candidates[0].get('last_active'))}, " f"newest activity {format_epoch(candidates[-1].get('last_active'))}" ) if args.dry_run or not args.yes: shown = candidates if args.dry_run else candidates[:15] print(f"{len(candidates)} session(s) match ({describe_filters(filters)}; {_span}):") 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.get('last_active')):<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 and not _confirm_prompt(f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "): print("Cancelled.") return if action == "prune": 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)." ) # -- titles / pins ----------------------------------------------------------- def _cmd_rename(db, args): resolved_session_id = db.resolve_session_id(args.session_id) if not resolved_session_id: return _not_found(args.session_id) title = " ".join(args.title) # Empty titles render as "—" and newlines corrupt the `list` table; length # is validated in set_session_title. if not title.strip(): print("Error: title cannot be empty or whitespace-only.") return 1 if "\n" in title or "\r" in title: print("Error: title cannot contain newlines.") return 1 try: if db.set_session_title(resolved_session_id, title): print(f"Session '{resolved_session_id}' renamed to: {title}") else: return _not_found(args.session_id) except ValueError as e: print(f"Error: {e}") return 1 def _cmd_pin(db, args, pinning): """Durable "keep" flag (exempt from sessions.auto_archive, always listed); every surface — GUI, TUI, CLI, scripts — reads/writes the same store.""" failures = 0 for raw_id in args.session_ids: resolved = db.resolve_session_id(raw_id) if resolved and db.set_session_pinned(resolved, pinning): verb = "Pinned" if pinning else "Unpinned" title = db.get_session_title(resolved) suffix = f" ({title})" if title else "" print(f"{verb} session '{resolved}'.{suffix}") else: failures += _not_found(raw_id) if failures: return 1 def _cmd_pinned(db, args): # limit=1 keeps the recency page minimal; include_pinned back-fills ALL # pinned rows the page missed, so old pins can't fall off a paging window. rows = db.list_sessions_rich(limit=1, include_pinned=True, exclude_sources=_default_exclude(args)) pinned_rows = [s for s in rows if s.get("pinned")] if getattr(args, "json", False): payload = [ { "id": s["id"], "title": s.get("title"), "source": s.get("source"), "last_active": s.get("last_active"), "message_count": s.get("message_count"), } for s in pinned_rows ] print(json.dumps(payload, indent=2)) return if not pinned_rows: print("No pinned sessions. Pin one with: hermes sessions pin ") return print(f"{'Title':<32} {'Last Active':<13} {'Src':<9} {'ID'}") print("─" * 100) for s in pinned_rows: title = (s.get("title") or s.get("preview", "") or "—")[:30] last_active = _relative_time(s.get("last_active")) print(f"{title:<32} {last_active:<13} {(s.get('source') or '-'):<9} {s['id']}") def _cmd_retitle_skills(db, args): from agent.skill_commands import describe_skill_invocation from agent.title_generator import generate_title limit = max(1, int(getattr(args, "limit", 200) or 200)) apply_changes = bool(getattr(args, "apply", False)) def _is_titlelike(candidate: str) -> bool: """Reject non-titles: an auxiliary model occasionally answers the prompt instead of titling it ('$ df -h /'). The live path takes what it gets, but this is a REPAIR — never replace a serviceable title with that.""" return bool(candidate) and candidate[0].isalnum() candidates = db.list_skill_scaffolded_sessions(limit=limit) if not candidates: print("No sessions were titled from a /skill invocation.") return print( f"{len(candidates)} session(s) opened with a /skill" f"{'' if apply_changes else ' (dry run — pass --apply to write)'}:" ) changed = 0 for row in candidates: session_id = row["id"] typed = describe_skill_invocation(row["content"]) or "" new_title = generate_title(typed) if not new_title or new_title == row["title"]: continue if not _is_titlelike(new_title): print(f" {session_id}\n kept {row['title']!r} — got {new_title!r}") continue print(f" {session_id}\n {row['title']!r}\n → {new_title!r}") changed += 1 if not apply_changes: continue try: db.set_session_title(session_id, new_title) except ValueError: # Unique-title collision: dedupe like the live auto-titler (base #2, #3, ...). deduped = db.get_next_title_in_lineage(new_title) try: db.set_session_title(session_id, deduped) print(f" (renamed to {deduped!r} — title was taken)") except ValueError as e: print(f" skipped: {e}") changed -= 1 if not changed: print(" every title already reflects the user's request.") elif apply_changes: print(f"✓ Re-titled {changed} session(s).") def _cmd_browse(db, args): limit = getattr(args, "limit", 500) or 500 sessions = db.list_sessions_rich( source=getattr(args, "source", None), exclude_sources=_default_exclude(args), limit=limit ) if not sessions: db.close() print("No sessions found.") return # Keep the DB open: the picker uses it for status tags and 'd' delete. try: selected_id = _session_browse_picker(sessions, session_db=db) finally: db.close() if not selected_id: print("Cancelled.") return # Launch hermes --resume by replacing the current process print(f"Resuming session: {selected_id}") from hermes_cli.relaunch import relaunch relaunch(["--resume", selected_id]) # won't return after execvp # -- storage maintenance ----------------------------------------------------- def _print_size_change(db, before_mb, prefix=""): """Report before/after size, preferring SQLite's page accounting over stat(): in WAL mode a VACUUM's rewrite sits in the -wal file until a checkpoint, which is refused while a live gateway holds a read-mark, so the main file lags and stat() can even go negative. page_count * page_size is correct now.""" after_mb = _size_mb(db.db_path) logical_after = db.logical_size_bytes() if logical_after is not None: after_mb = logical_after / (1024 * 1024) print( f"{prefix}Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB " f"({_size_delta_label(before_mb - after_mb)})" ) def _cmd_optimize(db, args): before_mb = _size_mb(db.db_path) print("Optimizing session store (FTS merge + VACUUM)…") try: # vacuum() merges FTS5 segments then VACUUMs; returns indexes merged. n = db.vacuum() except Exception as e: print(f"Error: optimization failed: {e}") return print(f"Optimized {n} FTS index(es).") _print_size_change(db, before_mb) def _cmd_clean_markers(db, args): if args.dry_run: print("Dry run — scanning for stale tool-call marker rows (#78148)…") else: print("Scanning for stale tool-call marker rows (#78148)…") report = db.purge_stale_tool_call_markers(dry_run=args.dry_run, backup=not args.no_backup) if report["rows_affected"] == 0: print("✓ No affected rows found — nothing to clean.") elif args.dry_run: print(f"Would clear {report['rows_affected']} row(s): ids {report['row_ids']}") else: if report["backup_path"]: print(f" backup: {report['backup_path']}") print(f"✓ Cleared {report['rows_affected']} row(s).") def _cmd_optimize_storage(db, args): db_path = db.db_path if not db.fts_optimize_available(): print("Search index is already on the compact layout — nothing to do.") return before_bytes = os.path.getsize(db_path) if db_path.exists() else 0 before_mb = before_bytes / (1024 * 1024) # Disk preflight: the new index is built before the old is torn down, and # VACUUM needs a full second copy — require headroom ≈ current file size. do_vacuum = not getattr(args, "no_vacuum", False) try: import shutil free_bytes = shutil.disk_usage(db_path.parent).free except Exception: free_bytes = None need_bytes = before_bytes if do_vacuum else int(before_bytes * 0.3) print(f"Search-index optimization for {db_path}") print(f" Current database size: {before_mb:.1f} MB") if free_bytes is not None: print(f" Free disk: {free_bytes / (1024*1024):.0f} MB " f"(need ~{need_bytes / (1024*1024):.0f} MB to complete" f"{' incl. VACUUM' if do_vacuum else ''})") if free_bytes < need_bytes: print() print("⚠ Not enough free disk to complete safely. Free up " "space, or run with --no-vacuum (rebuilds the index " "but doesn't reclaim space until a later VACUUM).") return if before_mb > 500: print(" This may take a while on a large database. It runs in " "the foreground with progress below; safe to Ctrl-C and " "re-run (it resumes).") if not getattr(args, "yes", False): try: resp = input("Proceed? [y/N] ").strip().lower() except EOFError: resp = "" if resp not in ("y", "yes"): print("Cancelled.") return _last = {"phase": None} def _progress(info): phase = info.get("phase") pct = info.get("percent", 0) if phase == "backfill": print(f"\r Rebuilding index: {pct:3d}% " f"({info.get('indexed',0):,}/{info.get('total',0):,})", end="", flush=True) elif phase != _last["phase"]: label = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"}.get(phase, phase) print(f"\n {label}…", flush=True) _last["phase"] = phase print("Optimizing search-index storage…") try: result = db.optimize_fts_storage(progress_cb=_progress, vacuum=do_vacuum) except Exception as e: print(f"\nError: optimization failed: {e}") print("No data was lost. Re-run to resume.") return if not result.get("ok"): print(f"\nCould not optimize: {result.get('reason', 'unknown')}") return print("\n✓ Search index optimized.") _print_size_change(db, before_mb, prefix=" ") if result.get("vacuumed") is False: print(" (VACUUM was skipped or failed — run `hermes sessions optimize` later to reclaim freed space.)") def _cmd_repair_routing(db, args): records = db.find_orphaned_gateway_sessions(max_gap_s=getattr(args, "max_gap_seconds", None)) adoptable = [r for r in records if r["adoptable"]] for record in records: print(f"{record['orphan_id']} ({record['source']}, {record['message_count']} messages)") if record["adoptable"]: print(f" → adopt into {record['session_key']} " f"(from {record['donor_id']}, " f"evidence: {record['evidence']})") else: print(f" ✗ not repairable — {record['reason']}") if not records: print("✓ No gateway sessions are missing their routing identity.") return if not adoptable: print(f"\n{len(records)} orphaned session(s) found, none unambiguously repairable. Nothing to do.") return if not getattr(args, "apply", False): print(f"\n{len(adoptable)} of {len(records)} orphaned session(s) " "can be repaired. Re-run with --apply to perform them.") return print("\nStop the gateway before applying — a running gateway still holds the old routing mapping in memory.") if not _confirm_prompt(f"Adopt {len(adoptable)} orphaned session(s)? [y/N] "): print("Aborted — nothing was changed.") return repaired = 0 for record in adoptable: if db.adopt_orphaned_gateway_session(record["orphan_id"], record["donor_id"]): repaired += 1 print(f"✓ {record['orphan_id']} now owns {record['session_key']}") else: print(f"✗ {record['orphan_id']} was not adopted (the row changed since it was reported)") print(f"\nRepaired {repaired} of {len(adoptable)} session(s).") def _cmd_stats(db, args): print(f"Total sessions: {db.session_count()}") print(f"Total messages: {db.message_count()}") for src in ["cli", "telegram", "discord", "whatsapp", "slack"]: c = db.session_count(source=src) if c > 0: print(f" {src}: {c} sessions") if db.db_path.exists(): print(f"Database size: {_size_mb(db.db_path):.1f} MB") # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- _PRE_DB_HANDLERS = {"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import} _DB_HANDLERS = { "list": _cmd_list, "export": _cmd_export, "delete": _cmd_delete, "prune": partial(_cmd_prune_or_archive, action="prune"), "archive": partial(_cmd_prune_or_archive, action="archive"), "rename": _cmd_rename, "pin": partial(_cmd_pin, pinning=True), "unpin": partial(_cmd_pin, pinning=False), "pinned": _cmd_pinned, "retitle-skills": _cmd_retitle_skills, "browse": _cmd_browse, "optimize": _cmd_optimize, "clean-markers": _cmd_clean_markers, "optimize-storage": _cmd_optimize_storage, "repair-routing": _cmd_repair_routing, "stats": _cmd_stats, } def cmd_sessions(args, sessions_parser=None): action = args.sessions_action pre = _PRE_DB_HANDLERS.get(action) if pre is not None: return pre(args) try: from hermes_state import SessionDB db = SessionDB() except Exception as e: print(f"Error: Could not open session database: {e}") return 1 try: handler = _DB_HANDLERS.get(action) if handler is None: sessions_parser.print_help() return return handler(db, args) finally: db.close()