From 9dfdbe71906902104a2a78773b73b3c1973e7ca4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:19:58 -0700 Subject: [PATCH] =?UTF-8?q?refactor(hermes=5Fcli):=20sessions=5Fcmd=20seco?= =?UTF-8?q?nd=20pass=20=E2=80=94=20merged=20printer=20calls,=20=5Fexport?= =?UTF-8?q?=5Fdir=20helper,=20inverted=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/sessions_cmd.py | 170 +++++++++++++++---------------------- 1 file changed, 70 insertions(+), 100 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index f2de9f4325..c8c26e8e35 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -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 `." - ) + verb = "deleted" + optin = "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}" - ) + 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 ` " + 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 ` " "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 ") 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")