mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
1159 lines
46 KiB
Python
1159 lines
46 KiB
Python
"""``hermes sessions`` command — extracted from ``hermes_cli/main.py``.
|
|
|
|
Mechanical move (main.py decomposition): ``cmd_sessions`` was a ``def`` nested
|
|
inside ``main()``'s body; its dispatch on ``args.sessions_action`` is lifted
|
|
byte-identical. A symtable/AST closure check found exactly two free variables:
|
|
|
|
* ``_confirm_prompt`` — a sibling nested def with zero captures of its own;
|
|
moved here to module level, byte-identical.
|
|
* ``sessions_parser`` — a ``main()``-local (the argparse subparser, used only
|
|
for ``sessions_parser.print_help()`` in the fallthrough branch). It is
|
|
threaded as a keyword parameter via ``functools.partial`` at the
|
|
``set_defaults(func=...)`` wiring site in ``main()``.
|
|
|
|
Helpers that stay in ``hermes_cli.main`` (``get_hermes_home``,
|
|
``_relative_time``, ``_session_browse_picker``, ``_size_delta_label``) are
|
|
delegated through call-time wrappers below so existing test monkeypatches on
|
|
``hermes_cli.main.<name>`` keep reaching this code path, and so imports stay
|
|
one-way (main.py imports this module; the reverse happens only lazily at call
|
|
time — no import cycle).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _m():
|
|
"""Lazy ``hermes_cli.main`` reference (call-time, keeps patches working)."""
|
|
from hermes_cli import main
|
|
|
|
return main
|
|
|
|
|
|
def get_hermes_home():
|
|
return _m().get_hermes_home()
|
|
|
|
|
|
def _relative_time(ts):
|
|
return _m()._relative_time(ts)
|
|
|
|
|
|
def _session_browse_picker(sessions):
|
|
return _m()._session_browse_picker(sessions)
|
|
|
|
|
|
def _size_delta_label(saved_mb):
|
|
return _m()._size_delta_label(saved_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 cmd_sessions(args, sessions_parser=None):
|
|
import json as _json
|
|
|
|
action = args.sessions_action
|
|
|
|
# 'repair' and 'recover' must run BEFORE opening SessionDB(): a
|
|
# malformed schema is exactly the case where SessionDB() can't open.
|
|
# Recovery additionally promises never to open the supplied source
|
|
# directly, so it operates through its own disposable source copy.
|
|
if action == "repair":
|
|
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
|
|
|
|
n = SessionDB()._conn.execute(
|
|
"SELECT COUNT(*) FROM sessions"
|
|
).fetchone()[0]
|
|
print(f"✓ Repaired — {n} sessions recovered.")
|
|
except Exception:
|
|
print("✓ Repaired.")
|
|
else:
|
|
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: in-place
|
|
# repair has failed and nothing tells them the non-destructive
|
|
# offline recovery path exists. Lead with --inspect-only so
|
|
# they confirm the data is readable before writing anything.
|
|
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")
|
|
return
|
|
|
|
if action == "recover":
|
|
import sqlite3 as _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 inspect_only and output is not None:
|
|
print("Error: --output cannot be used with --inspect-only.")
|
|
return 2
|
|
if inspect_only and allow_partial:
|
|
print("Error: --allow-partial cannot be used with --inspect-only.")
|
|
return 2
|
|
if not inspect_only and output is None:
|
|
print("Error: --output is required unless --inspect-only is used.")
|
|
return 2
|
|
if not inspect_only and report_path is None:
|
|
report_path = output.with_name(output.name + ".recovery.json")
|
|
if (
|
|
report_path is not None
|
|
and os.path.lexists(report_path.expanduser())
|
|
):
|
|
print(f"Error: refusing to overwrite existing report: {report_path}")
|
|
return 2
|
|
|
|
try:
|
|
if inspect_only:
|
|
report = inspect_session_database(
|
|
source,
|
|
work_dir=getattr(args, "work_dir", None),
|
|
)
|
|
else:
|
|
last_progress = {"table": None}
|
|
|
|
def _recovery_progress(info):
|
|
table = info.get("table")
|
|
copied = int(info.get("copied_rows") or 0)
|
|
total = info.get("source_rows")
|
|
if table != last_progress["table"]:
|
|
if last_progress["table"] is not None:
|
|
print()
|
|
print(f" {table}: ", end="", flush=True)
|
|
last_progress["table"] = table
|
|
suffix = f"/{int(total):,}" if total is not None else ""
|
|
print(f"\r {table}: {copied:,}{suffix}", end="", flush=True)
|
|
|
|
print("Recovering canonical session data into a new database…")
|
|
report = recover_session_database(
|
|
source,
|
|
output,
|
|
work_dir=getattr(args, "work_dir", None),
|
|
chunk_size=getattr(args, "chunk_size", 1000),
|
|
progress_cb=_recovery_progress,
|
|
allow_partial=allow_partial,
|
|
)
|
|
if last_progress["table"] is not None:
|
|
print()
|
|
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
|
|
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", {})
|
|
print(f"✓ Partial recovery output verified at: {output}")
|
|
print(
|
|
" Recovered "
|
|
f"{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
|
|
|
|
try:
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB()
|
|
except Exception as e:
|
|
print(f"Error: Could not open session database: {e}")
|
|
return
|
|
|
|
# Hide third-party tool sessions by default, but honour explicit --source
|
|
_source = getattr(args, "source", None)
|
|
_exclude = None if _source else ["tool"]
|
|
|
|
if action == "list":
|
|
from hermes_state import workspace_key as _ws_key
|
|
|
|
sessions = db.list_sessions_rich(
|
|
source=args.source, exclude_sources=_exclude, limit=args.limit
|
|
)
|
|
|
|
# Workspace filter: match a session by its 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
|
|
|
|
# Short workspace label: the repo/dir basename, "—" when unbound. The
|
|
# Workspace column only appears once at least one session carries one
|
|
# (or when filtering), so all-unbound listings read as before.
|
|
def _ws_label(s):
|
|
key = _ws_key(s)
|
|
return (os.path.basename(key.rstrip("/\\")) or key) if key else "—"
|
|
|
|
has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions)
|
|
has_titles = any(s.get("title") for s in sessions)
|
|
|
|
if has_ws:
|
|
if has_titles:
|
|
print(f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}")
|
|
print("─" * 110)
|
|
else:
|
|
print(f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}")
|
|
print("─" * 100)
|
|
for s in sessions:
|
|
last_active = _relative_time(s.get("last_active"))
|
|
ws = _ws_label(s)[:16]
|
|
if has_titles:
|
|
title = (s.get("title") or "—")[:26]
|
|
print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}")
|
|
else:
|
|
preview = s.get("preview", "")[:36]
|
|
print(f"{preview:<38} {ws:<18} {last_active:<13} {s['source']:<6} {s['id']}")
|
|
return
|
|
|
|
if has_titles:
|
|
print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
|
print("─" * 110)
|
|
else:
|
|
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
|
|
print("─" * 95)
|
|
for s in sessions:
|
|
last_active = _relative_time(s.get("last_active"))
|
|
preview = (
|
|
s.get("preview", "")[:38]
|
|
if has_titles
|
|
else s.get("preview", "")[:48]
|
|
)
|
|
if has_titles:
|
|
title = (s.get("title") or "—")[:30]
|
|
sid = s["id"]
|
|
print(f"{title:<32} {preview:<40} {last_active:<13} {sid}")
|
|
else:
|
|
sid = s["id"]
|
|
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
|
|
|
|
elif action == "export":
|
|
from hermes_cli.session_filters import (
|
|
build_prune_filters,
|
|
describe_filters,
|
|
)
|
|
|
|
_filter_arg_names = (
|
|
"older_than", "newer_than", "before", "after",
|
|
"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",
|
|
)
|
|
_any_filters = any(
|
|
getattr(args, a, None) is not None for a in _filter_arg_names
|
|
)
|
|
filters = None
|
|
if _any_filters:
|
|
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:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
return None
|
|
return [data]
|
|
if filters:
|
|
candidates = db.list_prune_candidates(**filters)
|
|
if args.dry_run:
|
|
print(
|
|
f"Would export {len(candidates)} session(s) "
|
|
f"({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")
|
|
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)]
|
|
|
|
# Prompt-only export (--only user-prompts): one prompt record per
|
|
# line (jsonl) or headed sections (md). Delegates rendering to
|
|
# hermes_cli.session_export.
|
|
if getattr(args, "only", None):
|
|
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_sessions()
|
|
if sessions is None:
|
|
db.close()
|
|
return
|
|
rendered = render_sessions_export(
|
|
sessions,
|
|
fmt="markdown" if args.format == "md" else "jsonl",
|
|
only=args.only,
|
|
)
|
|
if not args.output or args.output == "-":
|
|
sys.stdout.write(rendered)
|
|
db.close()
|
|
return
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(rendered)
|
|
count, noun = export_record_count(sessions, only=args.only)
|
|
suffix = "" if count == 1 else "s"
|
|
print(f"Exported {count} {noun}{suffix} to {args.output}")
|
|
db.close()
|
|
return
|
|
|
|
# Standalone HTML export: one self-contained file (single session
|
|
# or multi-session with sidebar navigation).
|
|
if args.format == "html":
|
|
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_sessions()
|
|
if sessions is None:
|
|
db.close()
|
|
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)")
|
|
db.close()
|
|
return
|
|
|
|
# Claude Code JSONL trace export — local file or HF upload.
|
|
# Redaction is ON by default for traces (they leave the machine
|
|
# when --upload is used); --no-redact opts out after review.
|
|
if args.format == "trace":
|
|
if getattr(args, "only", None):
|
|
print("--only user-prompts supports --format jsonl or md.")
|
|
db.close()
|
|
return
|
|
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.")
|
|
db.close()
|
|
return
|
|
if session_id and not db.resolve_session_id(session_id):
|
|
print(f"Session '{session_id}' not found.")
|
|
db.close()
|
|
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).")
|
|
db.close()
|
|
return
|
|
resolved = db.resolve_session_id(session_id)
|
|
db.close()
|
|
status = upload_session_trace(
|
|
resolved,
|
|
cwd="",
|
|
redact=redact_trace,
|
|
private=not getattr(args, "public", False),
|
|
)
|
|
print(status)
|
|
return
|
|
|
|
# Local trace file(s)
|
|
def _trace_ids():
|
|
if session_id:
|
|
return [db.resolve_session_id(session_id)]
|
|
candidates = db.list_prune_candidates(**filters)
|
|
if args.dry_run:
|
|
print(
|
|
f"Would export {len(candidates)} session(s) "
|
|
f"({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")
|
|
return None
|
|
return [row["id"] for row in candidates]
|
|
|
|
ids = _trace_ids()
|
|
if ids is None:
|
|
db.close()
|
|
return
|
|
|
|
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,
|
|
)
|
|
|
|
try:
|
|
if len(ids) == 1:
|
|
jsonl = _render_trace(ids[0])
|
|
if not jsonl:
|
|
print(f"No transcript to export for session '{ids[0]}'.")
|
|
db.close()
|
|
return
|
|
if not args.output or args.output == "-":
|
|
sys.stdout.write(jsonl)
|
|
else:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(jsonl)
|
|
print(f"Exported 1 session trace to {args.output}")
|
|
else:
|
|
out_dir = (
|
|
Path(args.output).expanduser()
|
|
if args.output and args.output != "-"
|
|
else get_hermes_home() / "session-exports"
|
|
)
|
|
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.")
|
|
db.close()
|
|
return
|
|
|
|
if args.format == "jsonl":
|
|
if not args.output:
|
|
print("JSONL export requires an output path (use - for stdout).")
|
|
return
|
|
if args.session_id:
|
|
resolved_session_id = db.resolve_session_id(args.session_id)
|
|
if not resolved_session_id:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
return
|
|
data = _redact(db.export_session(resolved_session_id))
|
|
if not data:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
return
|
|
line = _json.dumps(data, ensure_ascii=False) + "\n"
|
|
if args.output == "-":
|
|
|
|
sys.stdout.write(line)
|
|
else:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(line)
|
|
print(f"Exported 1 session to {args.output}")
|
|
else:
|
|
if filters:
|
|
candidates = db.list_prune_candidates(**filters)
|
|
if args.dry_run:
|
|
print(
|
|
f"Would export {len(candidates)} session(s) "
|
|
f"({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")
|
|
return
|
|
sessions = [
|
|
s
|
|
for s in (
|
|
db.export_session(row["id"]) for row in candidates
|
|
)
|
|
if s
|
|
]
|
|
else:
|
|
if args.dry_run:
|
|
print("--dry-run requires at least one filter.")
|
|
return
|
|
sessions = db.export_all(source=None)
|
|
if args.output == "-":
|
|
|
|
for s in sessions:
|
|
sys.stdout.write(
|
|
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
|
|
)
|
|
else:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
for s in sessions:
|
|
f.write(
|
|
_json.dumps(_redact(s), ensure_ascii=False) + "\n"
|
|
)
|
|
print(f"Exported {len(sessions)} sessions to {args.output}")
|
|
return
|
|
|
|
# Markdown / QMD export
|
|
from hermes_cli.session_export_md import (
|
|
append_manifest_entry,
|
|
verify_export_file,
|
|
write_session_markdown,
|
|
)
|
|
|
|
if args.output == "-":
|
|
print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.")
|
|
db.close()
|
|
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.")
|
|
db.close()
|
|
return
|
|
if args.delete_after_verified and not args.session_id:
|
|
print("--delete-after-verified is only supported with --session-id.")
|
|
db.close()
|
|
return
|
|
|
|
lineage_is_logical = getattr(args, "lineage", "single") == "logical"
|
|
|
|
if args.session_id:
|
|
resolved_session_id = db.resolve_session_id(args.session_id)
|
|
if not resolved_session_id:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
db.close()
|
|
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."
|
|
)
|
|
db.close()
|
|
return
|
|
if not data or not exported_path:
|
|
print(
|
|
f"Session '{target_id}' disappeared during export; "
|
|
"nothing was deleted."
|
|
)
|
|
db.close()
|
|
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}) "
|
|
f"to {exported_items[0][1]}"
|
|
)
|
|
else:
|
|
print(
|
|
f"Exported {len(exported_items)} sessions "
|
|
f"({message_count} message{suffix}) to {output_dir}"
|
|
)
|
|
if args.delete_after_verified:
|
|
for data, exported_path in exported_items:
|
|
ok, reason = verify_export_file(exported_path, data)
|
|
if not ok:
|
|
print(
|
|
"Export verification failed; not deleting "
|
|
f"session '{data.get('id')}': {reason}"
|
|
)
|
|
db.close()
|
|
return
|
|
sessions_dir = get_hermes_home() / "sessions"
|
|
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"
|
|
f"{'' if delegate_count == 1 else 's'}"
|
|
)
|
|
print(
|
|
f"Deleted exported session '{resolved_session_id}'"
|
|
f"{delegate_suffix}."
|
|
)
|
|
else:
|
|
print(
|
|
f"Exported, but session '{resolved_session_id}' was "
|
|
"not deleted because its delegate set changed."
|
|
)
|
|
db.close()
|
|
return
|
|
|
|
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)."
|
|
)
|
|
db.close()
|
|
return
|
|
candidates = db.list_prune_candidates(**filters)
|
|
if args.dry_run:
|
|
print(
|
|
f"Would export {len(candidates)} session(s) "
|
|
f"({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")
|
|
db.close()
|
|
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}")
|
|
|
|
elif action == "delete":
|
|
resolved_session_id = db.resolve_session_id(args.session_id)
|
|
if not resolved_session_id:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
return
|
|
if not args.yes:
|
|
if not _confirm_prompt(
|
|
f"Delete session '{resolved_session_id}' and all its messages? [y/N] "
|
|
):
|
|
print("Cancelled.")
|
|
return
|
|
sessions_dir = get_hermes_home() / "sessions"
|
|
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
|
|
print(f"Deleted session '{resolved_session_id}'.")
|
|
else:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
|
|
elif action in ("prune", "archive"):
|
|
from hermes_cli.session_filters import (
|
|
build_prune_filters,
|
|
describe_filters,
|
|
format_epoch,
|
|
)
|
|
|
|
# Preserve the historical default ONLY for a truly bare
|
|
# `hermes sessions prune`: no time window and no filters at all
|
|
# means "older than 90 days". ANY filter — including --source —
|
|
# suppresses the implicit cutoff, so `prune --source cron`
|
|
# matches ALL cron sessions regardless of age. The preview +
|
|
# confirmation below (count, oldest/newest) is the safety net.
|
|
_non_time_filters = any(
|
|
getattr(args, a, None) is not None
|
|
for a in (
|
|
"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",
|
|
)
|
|
)
|
|
if (
|
|
action == "prune"
|
|
and args.older_than is None
|
|
and args.newer_than is None
|
|
and args.before is None
|
|
and args.after is None
|
|
and not _non_time_filters
|
|
):
|
|
args.older_than = "90"
|
|
|
|
try:
|
|
filters = build_prune_filters(args)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
return
|
|
|
|
if action == "archive" and not any(
|
|
v for k, v in filters.items() if k != "older_than_days"
|
|
):
|
|
print(
|
|
"Refusing to archive every ended session: pass at least one "
|
|
"filter (e.g. --newer-than 5h, --source cli, --title codex)."
|
|
)
|
|
return
|
|
|
|
# Prune skips archived sessions unless --include-archived;
|
|
# archive only targets not-yet-archived rows (idempotent).
|
|
if action == "prune":
|
|
filters["archived"] = (
|
|
None if getattr(args, "include_archived", False) else False
|
|
)
|
|
else:
|
|
filters["archived"] = False
|
|
|
|
candidates = db.list_prune_candidates(**filters)
|
|
verb = "Delete" if action == "prune" else "Archive"
|
|
if not candidates:
|
|
print(f"No sessions match ({describe_filters(filters)}).")
|
|
return
|
|
|
|
# Candidates are ordered by activity oldest-first. Surface that
|
|
# span so a long-lived but recently used conversation cannot look
|
|
# old merely because of its creation date.
|
|
_oldest = candidates[0].get("last_active")
|
|
_newest = candidates[-1].get("last_active")
|
|
_span = (
|
|
f"oldest activity {format_epoch(_oldest)}, "
|
|
f"newest activity {format_epoch(_newest)}"
|
|
)
|
|
|
|
if args.dry_run or not args.yes:
|
|
shown = candidates if args.dry_run else candidates[:15]
|
|
print(
|
|
f"{len(candidates)} session(s) match "
|
|
f"({describe_filters(filters)}; {_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:
|
|
if not _confirm_prompt(
|
|
f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "
|
|
):
|
|
print("Cancelled.")
|
|
return
|
|
|
|
if action == "prune":
|
|
sessions_dir = get_hermes_home() / "sessions"
|
|
count = db.prune_sessions(sessions_dir=sessions_dir, **filters)
|
|
print(f"Pruned {count} session(s).")
|
|
else:
|
|
count = db.archive_sessions(**filters)
|
|
print(
|
|
f"Archived {count} session(s). They're hidden from listings "
|
|
"but fully recoverable (nothing was deleted)."
|
|
)
|
|
|
|
elif action == "rename":
|
|
resolved_session_id = db.resolve_session_id(args.session_id)
|
|
if not resolved_session_id:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
return
|
|
title = " ".join(args.title)
|
|
try:
|
|
if db.set_session_title(resolved_session_id, title):
|
|
print(f"Session '{resolved_session_id}' renamed to: {title}")
|
|
else:
|
|
print(f"Session '{args.session_id}' not found.")
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
|
|
elif action == "retitle-skills":
|
|
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 a candidate that isn't a title at all.
|
|
|
|
An auxiliary model occasionally answers the prompt instead of
|
|
titling it and echoes the assistant's output ('$ df -h /'). The
|
|
live path has no alternative and takes what it gets, but this is
|
|
a REPAIR — replacing a serviceable title with command output
|
|
would make things worse, so keep the old one.
|
|
"""
|
|
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 ""
|
|
first_reply = db.get_first_assistant_text(session_id) or ""
|
|
new_title = generate_title(typed, first_reply)
|
|
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 the same way the live
|
|
# auto-titler does (base #2, base #3, ...) rather than
|
|
# leaving the leaked title in place.
|
|
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).")
|
|
|
|
elif action == "browse":
|
|
limit = getattr(args, "limit", 500) or 500
|
|
source = getattr(args, "source", None)
|
|
_browse_exclude = None if source else ["tool"]
|
|
sessions = db.list_sessions_rich(
|
|
source=source, exclude_sources=_browse_exclude, limit=limit
|
|
)
|
|
db.close()
|
|
if not sessions:
|
|
print("No sessions found.")
|
|
return
|
|
|
|
selected_id = _session_browse_picker(sessions)
|
|
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])
|
|
return # won't reach here after execvp
|
|
|
|
elif action == "optimize":
|
|
db_path = db.db_path
|
|
before_mb = (
|
|
os.path.getsize(db_path) / (1024 * 1024)
|
|
if db_path.exists()
|
|
else 0.0
|
|
)
|
|
print("Optimizing session store (FTS merge + VACUUM)…")
|
|
try:
|
|
# vacuum() merges FTS5 segments (optimize_fts) then VACUUMs,
|
|
# and returns the number of indexes it merged.
|
|
n = db.vacuum()
|
|
except Exception as e:
|
|
print(f"Error: optimization failed: {e}")
|
|
db.close()
|
|
return
|
|
after_mb = (
|
|
os.path.getsize(db_path) / (1024 * 1024)
|
|
if db_path.exists()
|
|
else 0.0
|
|
)
|
|
# Same WAL caveat as optimize-storage: after a VACUUM the main file
|
|
# on disk lags until the WAL is checkpointed back (refused while a
|
|
# live gateway holds a read-mark), so stat() understates the win and
|
|
# can go negative. SQLite's page accounting is correct immediately.
|
|
logical_after = db.logical_size_bytes()
|
|
if logical_after is not None:
|
|
after_mb = logical_after / (1024 * 1024)
|
|
saved = before_mb - after_mb
|
|
print(f"Optimized {n} FTS index(es).")
|
|
print(
|
|
f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
|
|
f"({_size_delta_label(saved)})"
|
|
)
|
|
|
|
elif action == "optimize-storage":
|
|
db_path = db.db_path
|
|
if not db.fts_optimize_available():
|
|
print("Search index is already on the compact layout — nothing to do.")
|
|
db.close()
|
|
return
|
|
|
|
before_bytes = os.path.getsize(db_path) if db_path.exists() else 0
|
|
before_mb = before_bytes / (1024 * 1024)
|
|
|
|
# Disk preflight: the rebuild adds the new index before the old is
|
|
# torn down, and the final VACUUM needs a full second copy of the
|
|
# file. Require headroom ≈ current file size to finish cleanly.
|
|
do_vacuum = not getattr(args, "no_vacuum", False)
|
|
try:
|
|
import shutil as _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).")
|
|
db.close()
|
|
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.")
|
|
db.close()
|
|
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.")
|
|
db.close()
|
|
return
|
|
if not result.get("ok"):
|
|
print(f"\nCould not optimize: {result.get('reason', 'unknown')}")
|
|
db.close()
|
|
return
|
|
after_mb = (
|
|
os.path.getsize(db_path) / (1024 * 1024) if db_path.exists() else 0.0
|
|
)
|
|
# Prefer SQLite's own page accounting over stat(). In WAL mode a
|
|
# VACUUM's rewrite sits in the -wal file until a checkpoint folds it
|
|
# back, and that checkpoint is refused while another connection (a
|
|
# live gateway) holds a read-mark — so the main file on disk still
|
|
# reads at its pre-VACUUM size and keeps growing. stat()ing it here
|
|
# reported "reclaimed -3820.1 MB" on a DB that had actually shrunk
|
|
# 60%. page_count * page_size is correct immediately.
|
|
logical_after = db.logical_size_bytes()
|
|
if logical_after is not None:
|
|
after_mb = logical_after / (1024 * 1024)
|
|
saved = before_mb - after_mb
|
|
print(f"\n✓ Search index optimized.")
|
|
print(
|
|
f" Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
|
|
f"({_size_delta_label(saved)})"
|
|
)
|
|
if result.get("vacuumed") is False:
|
|
print(" (VACUUM was skipped or failed — run "
|
|
"`hermes sessions optimize` later to reclaim freed space.)")
|
|
|
|
elif action == "stats":
|
|
total = db.session_count()
|
|
msgs = db.message_count()
|
|
print(f"Total sessions: {total}")
|
|
print(f"Total messages: {msgs}")
|
|
for src in ["cli", "telegram", "discord", "whatsapp", "slack"]:
|
|
c = db.session_count(source=src)
|
|
if c > 0:
|
|
print(f" {src}: {c} sessions")
|
|
db_path = db.db_path
|
|
if db_path.exists():
|
|
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
|
print(f"Database size: {size_mb:.1f} MB")
|
|
|
|
else:
|
|
sessions_parser.print_help()
|
|
|
|
db.close()
|