refactor(hermes_cli): compact sessions_cmd docstrings, comments and printer bodies

This commit is contained in:
Teknium
2026-09-02 21:39:50 -07:00
parent f0b339e196
commit 542819c468

View File

@@ -1,11 +1,9 @@
"""``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`.
``cmd_sessions`` routes ``args.sessions_action`` through ``_PRE_DB_HANDLERS`` (repair / recover /
import — must run without opening ``SessionDB()``, which a malformed schema prevents) and
``_DB_HANDLERS`` (everything else, sharing one ``SessionDB``). ``get_hermes_home`` is resolved through
``hermes_cli.main`` at call time so monkeypatches keep working. Picker: :mod:`hermes_cli.sessions_cmd_browse`.
"""
import json
@@ -34,12 +32,9 @@ def _size_mb(path) -> float:
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"
"""A negative delta means the file GREW (concurrent writes during a long optimize); "reclaimed
-163.0 MB" reads as data loss, so say "grew by"."""
return f"reclaimed {saved_mb:.1f} MB" if saved_mb >= 0 else f"grew by {-saved_mb:.1f} MB"
def _confirm_prompt(prompt: str) -> bool:
@@ -86,9 +81,7 @@ def _write_output(output, text, summary) -> None:
print(summary)
# ---------------------------------------------------------------------------
# Handlers that must run BEFORE SessionDB() is opened
# ---------------------------------------------------------------------------
# -- 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
@@ -122,8 +115,7 @@ def _cmd_repair(args):
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.
# 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
@@ -136,8 +128,7 @@ def _cmd_repair(args):
def _cmd_recover(args):
"""Offline recovery: never opens the supplied source directly (works on a
disposable copy) and never touches the active database."""
"""Offline recovery: works on a disposable copy of the source; never touches the active database."""
import sqlite3
from hermes_cli.session_recovery import (
@@ -232,10 +223,8 @@ def _print_recovery_verdict(report, output, allow_partial) -> int:
)
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."
)
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(
" This output is incomplete. Review every skipped range and orphan count in the "
@@ -249,15 +238,12 @@ def _print_recovery_verdict(report, output, allow_partial) -> int:
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.
# Explicit path but nothing imported -> non-zero for scripts. Picker cancel (no path) -> exit 0.
if run_sessions_import(args) is None and getattr(args, "path", None):
return 1
# ---------------------------------------------------------------------------
# Handlers that receive an open SessionDB
# ---------------------------------------------------------------------------
# -- handlers that receive an open SessionDB ----------------------------------
def _default_exclude(args):
"""Hide third-party tool sessions by default, but honour explicit --source."""
@@ -283,8 +269,7 @@ def _cmd_list(db, args):
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.
# Workspace column only when some session carries a key (or when filtering): 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)
@@ -339,8 +324,7 @@ def _cmd_export(db, args):
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."""
"""--session-id / filters / bare export -> 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
@@ -413,11 +397,10 @@ def _export_jsonl(args, collect):
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."""
"""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".
if not session_id and not filters: # "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:
@@ -501,7 +484,6 @@ def _export_markdown(db, args, filters, redact):
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:
@@ -584,8 +566,7 @@ 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.
# The delete is honored (explicit id), but a pin is a "keep" flag: say so instead of silently destroying it.
_meta = db.get_session(resolved_session_id) or {}
_pinned_note = " (this session is PINNED)" if _meta.get("pinned") else ""
if not args.yes:
@@ -603,16 +584,14 @@ def _cmd_delete(db, args):
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.
#: Age floor for `prune --never-active`; generous: a young never-active row may be a chat nobody 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."""
"""`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."""
from hermes_cli.session_filters import format_epoch, parse_duration_seconds
older_than = getattr(args, "older_than", None)
if older_than is None:
@@ -659,9 +638,8 @@ def _prune_never_active_keyed(db, args):
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."""
"""Tell the user how many pinned rows bulk prune/archive spared (pin = durable keep; only
`prune --include-pinned` opts in, archive always spares them)."""
_base = {k: v for k, v in filters.items() if k != "include_pinned"}
skipped = max(
int(db.count_prune_matches(**_base, include_pinned=True))
@@ -689,10 +667,9 @@ def _cmd_prune_or_archive(db, args, action):
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.
# Bare `prune` 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"
@@ -709,8 +686,7 @@ def _cmd_prune_or_archive(db, args, action):
)
return
# Prune skips archived rows unless --include-archived; archive only targets
# not-yet-archived rows (idempotent).
# Prune skips archived rows unless --include-archived; archive only targets not-yet-archived rows.
filters["archived"] = None if action == "prune" and getattr(args, "include_archived", False) else False
filters["include_pinned"] = getattr(args, "include_pinned", False)
@@ -718,8 +694,8 @@ def _cmd_prune_or_archive(db, args, action):
_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.
# 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"
@@ -734,8 +710,8 @@ def _cmd_prune_or_archive(db, args, action):
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.
# Candidates are 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'))}"
@@ -778,8 +754,7 @@ def _cmd_rename(db, args):
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.
# 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
@@ -797,8 +772,7 @@ def _cmd_rename(db, args):
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."""
"""Durable "keep" flag (exempt from sessions.auto_archive, always listed); every surface shares the store."""
failures = 0
for raw_id in args.session_ids:
resolved = db.resolve_session_id(raw_id)
@@ -814,22 +788,12 @@ def _cmd_pin(db, args, pinning):
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.
# limit=1 keeps the recency page minimal; include_pinned back-fills ALL pinned rows the page missed.
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))
keys = ("title", "source", "last_active", "message_count")
print(json.dumps([{"id": s["id"], **{k: s.get(k) for k in keys}} for s in pinned_rows], indent=2))
return
if not pinned_rows:
print("No pinned sessions. Pin one with: hermes sessions pin <session_id>")
@@ -849,9 +813,8 @@ def _cmd_retitle_skills(db, args):
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."""
"""Reject non-titles: an auxiliary model occasionally answers the prompt instead of titling it
('$ df -h /'). 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)
@@ -879,8 +842,7 @@ def _cmd_retitle_skills(db, args):
continue
try:
db.set_session_title(session_id, new_title)
except ValueError:
# Unique-title collision: dedupe like the live auto-titler (base #2, #3, ...).
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)
@@ -905,16 +867,13 @@ def _cmd_browse(db, args):
print("No sessions found.")
return
# Keep the DB open: the picker uses it for status tags and 'd' delete.
try:
try: # keep the DB open: the picker uses it for status tags and 'd' delete
selected_id = _session_browse_picker(sessions, session_db=db)
finally:
db.close()
if not selected_id:
print("Cancelled.")
return
# Launch hermes --resume <id> 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
@@ -923,26 +882,20 @@ def _cmd_browse(db, args):
# -- 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)
"""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 (refused while a live gateway holds a read-mark),
so the main file lags and stat() can even go negative."""
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)})"
)
after_mb = logical_after / (1024 * 1024) if logical_after is not None else _size_mb(db.db_path)
delta = _size_delta_label(before_mb - after_mb)
print(f"{prefix}Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB ({delta})")
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()
n = db.vacuum() # merges FTS5 segments then VACUUMs; returns indexes merged
except Exception as e:
print(f"Error: optimization failed: {e}")
return
@@ -951,10 +904,7 @@ def _cmd_optimize(db, args):
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)…")
print(f"{'Dry run — scanning' if args.dry_run else '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.")
@@ -975,8 +925,8 @@ def _cmd_optimize_storage(db, args):
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.
# 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:
free_bytes = shutil.disk_usage(db_path.parent).free
@@ -987,18 +937,15 @@ def _cmd_optimize_storage(db, args):
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 ''})")
f"(need ~{need_bytes / (1024*1024):.0f} MB to complete{' 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).")
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).")
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()
@@ -1012,16 +959,12 @@ def _cmd_optimize_storage(db, args):
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)
print(f"\r Rebuilding index: {info.get('percent', 0):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)
label = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"}
print(f"\n {label.get(phase, phase)}…", flush=True)
_last["phase"] = phase
print("Optimizing search-index storage…")
@@ -1046,8 +989,7 @@ def _cmd_repair_routing(db, args):
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']}, "
print(f" → adopt into {record['session_key']} (from {record['donor_id']}, "
f"evidence: {record['evidence']})")
else:
print(f" ✗ not repairable — {record['reason']}")
@@ -1087,9 +1029,7 @@ def _cmd_stats(db, args):
print(f"Database size: {_size_mb(db.db_path):.1f} MB")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
# -- dispatch -----------------------------------------------------------------
_PRE_DB_HANDLERS = {"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import}