refactor(hermes_cli): sessions_cmd second pass — merged printer calls, _export_dir helper, inverted guards

This commit is contained in:
Teknium
2026-09-02 22:19:58 -07:00
parent cec7daa07d
commit 9dfdbe7190

View File

@@ -71,6 +71,11 @@ def _any_filter_args(args, names) -> bool:
return any(getattr(args, a, None) is not None for a in names)
def _export_dir(output) -> Path:
"""``--output`` dir for multi-file exports; ``~/.hermes/session-exports`` when empty or ``-``."""
return Path(output).expanduser() if output and output != "-" else get_hermes_home() / "session-exports"
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 == "-":
@@ -116,15 +121,16 @@ def _cmd_repair(args):
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; lead with --inspect-only 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")
print(
"\n Next step — offline recovery (never modifies the source):\n"
f" hermes sessions recover --source {source_hint} \\\n"
" --inspect-only\n"
" If that reports the data is recoverable, rebuild it into\n"
" a NEW database (the active one is left untouched):\n"
f" hermes sessions recover --source {source_hint} \\\n"
" --output recovered-state.db"
)
def _cmd_recover(args):
@@ -134,8 +140,7 @@ def _cmd_recover(args):
from hermes_cli.session_recovery import (
SessionRecoveryError, inspect_session_database, recover_session_database, write_recovery_report,
)
source = args.source
output = getattr(args, "output", None)
source, output = args.source, 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)
@@ -209,30 +214,34 @@ class _RecoveryProgress:
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.")
print(
f"✓ Recovered database verified at: {output}\n"
" The active session database was not changed.\n"
" 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(
f"✓ BEST-EFFORT page-level salvage verified at: {output}\n"
" 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}")
sessions_n, messages_n = int(counts.get("sessions") or 0), int(counts.get("messages") or 0)
print(f" Recovered {sessions_n:,} sessions and {messages_n:,} messages.")
print(" The active session database was not changed.")
print(
f" Recovered {sessions_n:,} sessions and {messages_n:,} messages.\n"
" The active session database was not changed.\n"
" 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.")
print(
"✗ Recovery output did not pass every verification check.\n"
" Do not install it. Review the JSON report for partial data or errors."
)
return 1
@@ -277,14 +286,9 @@ def _cmd_list(db, args):
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"))
_title = lambda s, n: (s.get("title") or "—")[:n] # noqa: E731
_preview = lambda s, n: s.get("preview", "")[:n] # noqa: E731
_ago = lambda s: _relative_time(s.get("last_active")) # noqa: E731
layouts = { # (has_ws, has_titles): header, rule width, row formatter
(True, True): (f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}", 110,
@@ -345,12 +349,10 @@ def _cmd_export(db, args):
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)
if args.format in ("html", "jsonl"):
return (_export_html if args.format == "html" else _export_jsonl)(args, _collect_sessions)
return _export_markdown(db, args, filters, _redact)
@@ -379,9 +381,8 @@ def _export_html(args, collect):
return
single = len(sessions) == 1
content = generate_html_export(sessions[0]) if single else generate_multi_session_html_export(sessions)
with open(args.output, "w", encoding="utf-8") as f:
f.write(content)
print(f"Exported {len(sessions)} session{'' if single else 's'} to {args.output} (HTML)")
noun = "session" if single else "sessions"
_write_output(args.output, content, f"Exported {len(sessions)} {noun} to {args.output} (HTML)")
def _export_jsonl(args, collect):
@@ -438,7 +439,6 @@ def _export_trace(db, args, filters):
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])
@@ -447,7 +447,7 @@ def _export_trace(db, args, filters):
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 = _export_dir(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
exported = 0
for sid in ids:
@@ -467,7 +467,7 @@ def _export_markdown(db, args, filters, redact):
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"
output_dir = _export_dir(args.output)
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))
@@ -538,10 +538,9 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
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}")
n = len(exported_items)
where = exported_items[0][1] if n == 1 else output_dir
print(f"Exported {n} session{'' if n == 1 else 's'} ({message_count} message{suffix}) to {where}")
if not args.delete_after_verified:
return
for data, exported_path in exported_items:
@@ -569,18 +568,14 @@ def _cmd_delete(db, args):
_meta = db.get_session(resolved_session_id) or {}
_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] "
):
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:
if not db.delete_session(resolved_session_id, sessions_dir=_sessions_dir()):
return _not_found(args.session_id)
print(f"Deleted session '{resolved_session_id}'.")
#: Age floor for `prune --never-active`; generous: a young never-active row may be a chat nobody replied to yet.
@@ -593,9 +588,8 @@ def _prune_never_active_keyed(db, args):
NULL` — never-closed rows sit outside it by construction."""
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:
days = _NEVER_ACTIVE_DEFAULT_DAYS
if older_than is not None:
seconds = parse_duration_seconds(str(older_than))
if seconds is None:
print(
@@ -611,15 +605,11 @@ def _prune_never_active_keyed(db, args):
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:"
)
print(f"{len(candidates)} never-active keyed session(s) older than {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 '-'}"
)
print(f" {s['id']} {format_epoch(s.get('started_at')):<17} {(s.get('source') or '-'):<10} "
f"{s.get('session_key') or '-'}")
if len(candidates) > len(shown):
print(f" … {len(candidates) - len(shown)} more")
@@ -649,16 +639,12 @@ def _note_pinned_skipped(db, filters, action):
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 <id>`."
)
verb = "deleted"
optin = "Pass --include-pinned to delete them anyway, or unpin first with `hermes sessions unpin <id>`."
else:
verb, optin = "archived", "Unpin first with `hermes sessions unpin <id>` 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}"
)
print(f"Note: {skipped} pinned session{suffix} also match these filters but will NOT be {verb} "
f"(pin is a keep flag). {optin}")
def _cmd_prune_or_archive(db, args, action):
@@ -679,10 +665,8 @@ def _cmd_prune_or_archive(db, args, action):
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)."
)
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.
@@ -697,11 +681,9 @@ def _cmd_prune_or_archive(db, args, action):
# 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 <id>` "
f"Note: {skipped_open} open session{'' if skipped_open == 1 else 's'} also match these filters but "
"will be skipped because prune only deletes ended sessions. Use `hermes sessions delete <id>` "
"to remove one explicitly."
)
verb = "Delete" if action == "prune" else "Archive"
@@ -723,8 +705,7 @@ def _cmd_prune_or_archive(db, args, action):
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['id']} {format_epoch(s.get('last_active')):<17} {s['source']:<10} {model:<24} "
f"{s['message_count']:>4} msgs {title}"
)
if len(candidates) > len(shown):
@@ -740,10 +721,8 @@ def _cmd_prune_or_archive(db, args, action):
if action == "prune":
print(f"Pruned {db.prune_sessions(sessions_dir=_sessions_dir(), **filters)} session(s).")
else:
print(
f"Archived {db.archive_sessions(**filters)} session(s). They're hidden from listings "
"but fully recoverable (nothing was deleted)."
)
print(f"Archived {db.archive_sessions(**filters)} session(s). They're hidden from listings "
"but fully recoverable (nothing was deleted).")
# -- titles / pins -----------------------------------------------------------
@@ -761,13 +740,12 @@ def _cmd_rename(db, args):
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:
if not db.set_session_title(resolved_session_id, title):
return _not_found(args.session_id)
except ValueError as e:
print(f"Error: {e}")
return 1
print(f"Session '{resolved_session_id}' renamed to: {title}")
def _cmd_pin(db, args, pinning):
@@ -776,10 +754,8 @@ def _cmd_pin(db, args, pinning):
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}")
print(f"{'Pinned' if pinning else 'Unpinned'} session '{resolved}'.{f' ({title})' if title else ''}")
else:
failures += _not_found(raw_id)
if failures:
@@ -797,12 +773,10 @@ def _cmd_pinned(db, args):
if not pinned_rows:
print("No pinned sessions. Pin one with: hermes sessions pin <session_id>")
return
print(f"{'Title':<32} {'Last Active':<13} {'Src':<9} {'ID'}")
print("─" * 100)
print(f"{'Title':<32} {'Last Active':<13} {'Src':<9} {'ID'}\n" + "─" * 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']}")
print(f"{title:<32} {_relative_time(s.get('last_active')):<13} {(s.get('source') or '-'):<9} {s['id']}")
def _cmd_retitle_skills(db, args):
@@ -821,10 +795,8 @@ def _cmd_retitle_skills(db, args):
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)'}:"
)
mode = "" if apply_changes else " (dry run — pass --apply to write)"
print(f"{len(candidates)} session(s) opened with a /skill{mode}:")
changed = 0
for row in candidates:
session_id = row["id"]
@@ -920,7 +892,6 @@ def _cmd_optimize_storage(db, args):
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)
@@ -1000,8 +971,8 @@ def _cmd_repair_routing(db, args):
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.")
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] "):
@@ -1018,9 +989,8 @@ def _cmd_repair_routing(db, args):
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"]:
print(f"Total sessions: {db.session_count()}\nTotal 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")