Refactor entry-side deletion refusal to execute in-transaction via `_write_guards_reject(conn, sid)` (#123583), per maintainer review: - Underlying `delete_session` and `delete_sessions` now accept an opt-in kwarg `exclude_active_write_guards=True` running inside `_do` write transaction, eliminating the race condition where a turn acquires the lease between check and delete. - Raises `SessionActiveWriteGuardError` when refusing single delete, leaving the row untouched; `delete_sessions` atomically skips active rows. - Checks both active turn leases and compression locks via the existing reclaim-aware `_write_guards_reject` helper. - Covers all user-facing delete sinks: * Web `DELETE /api/sessions/{id}` -> 409 Conflict * Web `POST /api/sessions/bulk-delete` -> skips active rows * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage parents of active conversations are not pruned * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1 * CLI browse picker -> refuses active delete * TUI Gateway `session.delete` -> 4023 error - Conforms to rubric with 2 targeted invariant tests in `tests/hermes_state/test_delete_session_write_guards.py`. - Updates user guide and web dashboard docs for 409 / exit 1. (cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
1238 lines
56 KiB
Python
1238 lines
56 KiB
Python
"""``hermes sessions`` command.
|
|
|
|
``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
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
from functools import partial
|
|
from pathlib import Path
|
|
|
|
from hermes_cli.cli_output import print_truncated
|
|
from hermes_cli.sessions_cmd_browse import _relative_time, _session_browse_picker
|
|
|
|
|
|
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:
|
|
"""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:
|
|
"""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"No session '{session_id}'. Run: hermes sessions list to find the id.")
|
|
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_truncated(len(candidates) - 100)
|
|
|
|
|
|
_FILTER_ARGS = (
|
|
"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",
|
|
)
|
|
|
|
|
|
def _any_filter_args(args) -> bool:
|
|
return any(getattr(args, a, None) is not None for a in _FILTER_ARGS)
|
|
|
|
|
|
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 _output_file_in_dir(output, default_name: str):
|
|
"""Single-file exports accept a directory too (``--help`` calls the positional a path, and md/qmd take
|
|
one): an existing directory, or one spelled with a trailing separator, means ``<dir>/<default_name>``."""
|
|
if not output or output == "-":
|
|
return output
|
|
if output.endswith(("/", os.sep)) or os.path.isdir(output):
|
|
os.makedirs(output, exist_ok=True)
|
|
return os.path.join(output, default_name)
|
|
return output
|
|
|
|
|
|
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 as db_path, SessionDB
|
|
from hermes_state_repair import _db_opens_cleanly, repair_state_db_schema
|
|
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 1
|
|
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:
|
|
with SessionDB() as _repair_db:
|
|
n = _repair_db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
|
print(f"✓ Repaired — {n} sessions recovered.")
|
|
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']}")
|
|
# Without this pointer the user is at a dead end; lead with --inspect-only before writing.
|
|
source_hint = report.get("backup_path") or db_path
|
|
print(
|
|
" Keep state.db and the backup; do not delete them.\n"
|
|
"\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):
|
|
"""Offline recovery: works on a disposable copy of the source; never touches the active database."""
|
|
import sqlite3
|
|
from hermes_cli.session_recovery import (
|
|
SessionRecoveryError, inspect_session_database, recover_session_database, write_recovery_report,
|
|
)
|
|
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)
|
|
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}\nThe supplied source database was not replaced or deleted.")
|
|
return 1
|
|
if report_path is None:
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
else:
|
|
try:
|
|
print(f"Recovery report: {write_recovery_report(report_path, report)}")
|
|
except (FileExistsError, OSError) as exc:
|
|
print(f"Error: could not write recovery report: {exc}")
|
|
return 1
|
|
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 `` <table>: n/total`` line per table."""
|
|
|
|
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}\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}\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.\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.\n"
|
|
" 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 -> 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 ----------------------------------
|
|
|
|
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_sessions import workspace_key as _ws_key
|
|
# LIMIT lives in the query, so probe one row past the cap: it is the only way to know the
|
|
# page was cut without a second COUNT query (``--limit 0`` is ``LIMIT 0``: no rows, no probe).
|
|
limit = args.limit
|
|
sessions = db.list_sessions_rich(
|
|
source=args.source, exclude_sources=_default_exclude(args), limit=limit + 1 if limit > 0 else limit,
|
|
)
|
|
truncated = limit > 0 and len(sessions) > limit
|
|
sessions = sessions[:limit] if truncated else sessions
|
|
|
|
# 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()
|
|
keyed = ((s, (_ws_key(s) or "").lower()) for s in sessions)
|
|
sessions = [
|
|
s for s, key in keyed if key and (_needle in key or _needle == os.path.basename(key.rstrip("/\\")))
|
|
]
|
|
if not sessions:
|
|
print("No sessions found.")
|
|
return
|
|
|
|
# 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)
|
|
|
|
def _ws(s): # repo/dir basename, "—" when unbound
|
|
key = _ws_key(s)
|
|
return ((os.path.basename(key.rstrip("/\\")) or key) if key else "—")[:16]
|
|
_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"), session_id=s["id"]) # noqa: E731
|
|
|
|
def _src(s): # current routing platform; "<created>→<current>" when provenance diverged (#56439)
|
|
created = s.get("created_source") or ""
|
|
return f"{created}→{s['source']}" if created and created != s["source"] else s["source"]
|
|
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':<16} {'ID'}", 110,
|
|
lambda s: f"{_preview(s, 36):<38} {_ws(s):<18} {_ago(s):<13} {_src(s):<16} {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':<16} {'ID'}", 105,
|
|
lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {_src(s):<16} {s['id']}"),
|
|
}
|
|
header, rule, fmt = layouts[(has_ws, has_titles)]
|
|
print(header + "\n" + "─" * rule)
|
|
for s in sessions:
|
|
print(fmt(s))
|
|
if truncated:
|
|
print_truncated(None, f"use --limit {limit * 2} to see more")
|
|
|
|
|
|
# -- export -----------------------------------------------------------------
|
|
|
|
def _cmd_export(db, args):
|
|
from hermes_cli.session_filters import build_prune_filters
|
|
filters = None
|
|
if _any_filter_args(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)
|
|
|
|
from hermes_cli.session_export import SAVE_TRANSCRIPT_FORMATS
|
|
# --only is a transcript view too (md/jsonl of what the user saw); md/qmd without --only go to _export_markdown.
|
|
shown = args.format in SAVE_TRANSCRIPT_FORMATS or bool(getattr(args, "only", None))
|
|
|
|
def _collect_sessions():
|
|
"""--session-id / filters / bare export -> redacted session dicts, or None after printing an error."""
|
|
def _one(session_id):
|
|
return _redact(db.export_session(session_id, include_compacted=shown))
|
|
if args.session_id:
|
|
resolved = db.resolve_session_id(args.session_id)
|
|
data = _one(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:
|
|
return _print_dry_run_preview(candidates, filters)
|
|
return [s for s in (_one(row["id"]) for row in candidates) if s]
|
|
if args.dry_run:
|
|
return print("--dry-run requires at least one filter.")
|
|
return [_redact(s) for s in db.export_all(source=None, include_compacted=shown)]
|
|
if getattr(args, "only", None):
|
|
return _export_flat("only", args, _collect_sessions)
|
|
if args.format == "trace":
|
|
return _export_trace(db, args, filters)
|
|
if args.format in _FLAT_EXPORTERS:
|
|
return _export_flat(args.format, args, _collect_sessions)
|
|
return _export_markdown(db, args, filters, _redact)
|
|
|
|
|
|
def _render_only(args, sessions):
|
|
"""--only user-prompts: one prompt record per line (jsonl) or headed sections (md)."""
|
|
from hermes_cli.session_export import export_record_count, render_sessions_export
|
|
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)
|
|
return rendered, f"Exported {count} {noun}{'' if count == 1 else 's'} to {args.output}"
|
|
|
|
|
|
def _render_html(args, sessions):
|
|
"""One self-contained file (single session, or multi-session with sidebar)."""
|
|
from hermes_cli.session_export_html import generate_html_export, generate_multi_session_html_export
|
|
single = len(sessions) == 1
|
|
content = generate_html_export(sessions[0]) if single else generate_multi_session_html_export(sessions)
|
|
return content, f"Exported {len(sessions)} {'session' if single else 'sessions'} to {args.output} (HTML)"
|
|
|
|
|
|
def _render_jsonl(args, sessions):
|
|
lines = "".join(json.dumps(s, ensure_ascii=False) + "\n" for s in sessions)
|
|
return lines, f"Exported {len(sessions)} {'session' if args.session_id else 'sessions'} to {args.output}"
|
|
|
|
|
|
#: kind -> (usage error when the --output/--format combination is unusable, renderer)
|
|
_FLAT_EXPORTERS = {
|
|
"only": (
|
|
lambda a: a.format not in ("jsonl", "md"), "--only user-prompts supports --format jsonl or md.", _render_only
|
|
),
|
|
"html": (lambda a: not a.output or a.output == "-", "HTML export requires an output file path.", _render_html),
|
|
"jsonl": (lambda a: not a.output, "JSONL export requires an output path (use - for stdout).", _render_jsonl),
|
|
}
|
|
|
|
|
|
def _export_flat(kind, args, collect):
|
|
"""Single-file export: validate the output target, collect sessions, render, write."""
|
|
unusable, message, render = _FLAT_EXPORTERS[kind]
|
|
if unusable(args):
|
|
print(message)
|
|
return
|
|
sessions = collect()
|
|
if sessions is not None:
|
|
from hermes_cli.session_export import default_save_filename
|
|
name = (default_save_filename(sessions[0].get("id", ""), args.format) if len(sessions) == 1
|
|
else f"hermes_sessions.{args.format}")
|
|
args.output = _output_file_in_dir(args.output, name)
|
|
_write_output(args.output, *render(args, sessions))
|
|
|
|
|
|
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: # "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:
|
|
return _print_dry_run_preview(candidates, filters)
|
|
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)
|
|
try:
|
|
if len(ids) == 1:
|
|
jsonl = _render_trace(ids[0])
|
|
if not jsonl:
|
|
print(f"No transcript to export for session '{ids[0]}'.")
|
|
return
|
|
args.output = _output_file_in_dir(args.output, f"{ids[0]}.trace.jsonl")
|
|
_write_output(args.output, jsonl, f"Exported 1 session trace to {args.output}")
|
|
else:
|
|
out_dir = _export_dir(args.output)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
exported = 0
|
|
for sid in ids:
|
|
jsonl = _render_trace(sid)
|
|
if jsonl:
|
|
(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 = _export_dir(args.output)
|
|
|
|
def _export_one(session_id: str, *, include_lineage: bool = False):
|
|
# The history the user sees, not only the live rows: in-place compaction archives earlier turns under
|
|
# the same id, and --delete-after-verified removes every row of it.
|
|
export = db.export_session_lineage if include_lineage else db.export_session
|
|
raw_data = export(session_id, include_compacted=True)
|
|
if not raw_data:
|
|
return None, None, None
|
|
snapshots = {
|
|
segment["id"]: segment.get("messages") or []
|
|
for segment in (raw_data.get("segments") or [raw_data]) if segment.get("id")
|
|
}
|
|
data = redact(raw_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, snapshots
|
|
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:
|
|
return _print_dry_run_preview(candidates, filters)
|
|
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 = (
|
|
db.get_session_delete_targets(resolved_session_id) if args.delete_after_verified else [resolved_session_id]
|
|
)
|
|
exported_items = []
|
|
for target_id in delete_target_ids:
|
|
try:
|
|
data, exported_path, snapshots = 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, snapshots))
|
|
message_count = sum(len(data.get("messages") or []) for data, _path, _ in exported_items)
|
|
n = len(exported_items)
|
|
print(f"Exported {n} session{'' if n == 1 else 's'} ({message_count} message{'' if message_count == 1 else 's'}) "
|
|
f"to {exported_items[0][1] if n == 1 else output_dir}")
|
|
if not args.delete_after_verified:
|
|
return
|
|
# verify_export_file proves file == dict; store == dict is decided inside delete_session's transaction
|
|
# (expected_display_messages), where no writer can slip between the check and the delete.
|
|
expected_messages = {}
|
|
for data, exported_path, snapshots 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
|
|
expected_messages.update(snapshots)
|
|
from hermes_state_errors import SessionActiveWriteGuardError
|
|
try:
|
|
if not db.delete_session(
|
|
resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids,
|
|
expected_display_messages=expected_messages, exclude_active_write_guards=True,
|
|
):
|
|
print(f"Exported, but session '{resolved_session_id}' was not deleted because its history or delegate set "
|
|
"changed after export.")
|
|
return
|
|
except SessionActiveWriteGuardError as exc:
|
|
print(f"Exported, but session '{resolved_session_id}' was not deleted because it is active: {exc}")
|
|
return
|
|
delegates = len(delete_target_ids) - 1
|
|
delegate_suffix = f" and {delegates} delegate session{'' if delegates == 1 else 's'}" if delegates else ""
|
|
print(f"Deleted exported session '{resolved_session_id}'{delegate_suffix}.")
|
|
|
|
|
|
# -- 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 delete is honored (explicit id), but a pin is a "keep" flag: say so instead of silently destroying it.
|
|
_pinned_note = " (this session is PINNED)" if (db.get_session(resolved_session_id) or {}).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}'.")
|
|
from hermes_state_errors import SessionActiveWriteGuardError
|
|
try:
|
|
if not db.delete_session(resolved_session_id, sessions_dir=_sessions_dir(), exclude_active_write_guards=True):
|
|
return _not_found(args.session_id)
|
|
except SessionActiveWriteGuardError as exc:
|
|
print(f"Cannot delete active session: {exc}")
|
|
return 1
|
|
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.
|
|
_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.
|
|
|
|
The population is dominated by escaped test fixtures (#82770), which the hermetic-isolation guard can
|
|
only stop from being *created* — rows already written to a developer's state.db need a sweep to leave.
|
|
"""
|
|
from hermes_cli.session_filters import format_epoch, parse_duration_seconds
|
|
older_than = getattr(args, "older_than", None)
|
|
days = _NEVER_ACTIVE_DEFAULT_DAYS
|
|
if older_than is not None:
|
|
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 {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} {(s.get('source') or '-'):<10} "
|
|
f"{s.get('session_key') or '-'}")
|
|
if len(candidates) > len(shown):
|
|
print_truncated(len(candidates) - len(shown))
|
|
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):
|
|
"""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"}
|
|
# Count matching pinned rows only: whole-lineage selection would also drop the unpinned
|
|
# ancestors a pinned tip spares, and report them as pinned.
|
|
with_pinned, without = (int(db.count_prune_matches(**_base, include_pinned=flag)) for flag in (True, False))
|
|
skipped = max(with_pinned - without, 0)
|
|
if not skipped:
|
|
return
|
|
suffix = "" if skipped == 1 else "s"
|
|
if action == "prune":
|
|
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 will NOT be {verb} "
|
|
f"(pin is a keep flag). {optin}")
|
|
|
|
|
|
def _cmd_prune_or_archive(db, args, action):
|
|
prune = action == "prune"
|
|
if 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` 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 prune and not _any_filter_args(args):
|
|
args.older_than = "90"
|
|
try:
|
|
filters = build_prune_filters(args)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
return 1
|
|
if not prune 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.
|
|
filters["archived"] = None if prune and getattr(args, "include_archived", False) else False
|
|
filters["include_pinned"] = getattr(args, "include_pinned", False)
|
|
# Archive flips a compression lineage as a unit, matched through its tip (an old ancestor alone
|
|
# never qualifies); the preview must show the same rows the archive will touch.
|
|
filters["lineage_tips_only"] = not prune
|
|
if not filters["include_pinned"]:
|
|
_note_pinned_skipped(db, filters, action)
|
|
# Prune deletes a compression lineage only as a unit; the preview must list the rows it deletes.
|
|
candidates = db.list_prune_candidates(**filters, whole_lineages=prune)
|
|
# Archive expands each matched tip to its compression lineage, so a direct-open count would
|
|
# misdescribe its effect.
|
|
skipped_open = db.count_open_prune_matches(**filters) if prune else 0
|
|
if skipped_open:
|
|
print(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.")
|
|
if not candidates:
|
|
print(f"No sessions match ({describe_filters(filters)}).")
|
|
return
|
|
# 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'))}"
|
|
)
|
|
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:
|
|
model = (s.get("model") or "-").split("/")[-1][:24]
|
|
print(f" {s['id']} {format_epoch(s.get('last_active')):<17} {s['source']:<10} {model:<24} "
|
|
f"{s['message_count']:>4} msgs {(s.get('title') or '')[:36]}")
|
|
if len(candidates) > len(shown):
|
|
print_truncated(len(candidates) - len(shown))
|
|
if args.dry_run:
|
|
print(f"Dry run — nothing {'deleted' if prune else 'archived'}.")
|
|
return
|
|
verb = "Delete" if prune else "Archive"
|
|
if not args.yes and not _confirm_prompt(f"{verb} these {len(candidates)} session(s) ({_span})? [y/N] "):
|
|
print("Cancelled.")
|
|
return
|
|
if prune:
|
|
print(f"Pruned {db.prune_sessions(sessions_dir=_sessions_dir(), exclude_active_write_guards=True, **filters)} session(s).")
|
|
else:
|
|
print(f"Archived {db.archive_sessions(**filters)} 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 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):
|
|
"""Durable "keep" flag (exempt from sessions.auto_archive, always listed); every surface shares the store."""
|
|
failures = 0
|
|
# Pinned sessions are exempt from the sessions.auto_archive stale sweep and always surface in listings;
|
|
# until now only the Desktop sidebar could write the flag. Inspired by Perplexity Computer's
|
|
# conversational session management (pin/archive from any surface): pin state is operational
|
|
# infrastructure, so every surface — GUI, TUI, CLI, scripts — needs read/write access to the same store.
|
|
# See #52955.
|
|
for raw_id in args.session_ids:
|
|
resolved = db.resolve_session_id(raw_id)
|
|
if resolved and db.set_session_pinned(resolved, pinning):
|
|
title = db.get_session_title(resolved)
|
|
print(f"{'Pinned' if pinning else 'Unpinned'} session '{resolved}'.{f' ({title})' if title else ''}")
|
|
else:
|
|
failures += _not_found(raw_id)
|
|
return 1 if failures else None
|
|
|
|
|
|
def _cmd_pinned(db, args):
|
|
# 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):
|
|
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>")
|
|
return
|
|
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]
|
|
print(f"{title:<32} {_relative_time(s.get('last_active'), session_id=s['id']):<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))
|
|
candidates = db.list_skill_scaffolded_sessions(limit=limit)
|
|
if not candidates:
|
|
print("No sessions were titled from a /skill invocation.")
|
|
return
|
|
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"]
|
|
typed = describe_skill_invocation(row["content"]) or ""
|
|
new_title = generate_title(typed)
|
|
if not new_title or new_title == row["title"]:
|
|
continue
|
|
if not new_title[0].isalnum():
|
|
# Non-title: an auxiliary model occasionally answers the prompt instead of titling it
|
|
# ('$ df -h /'). This is a REPAIR — never replace a serviceable title with that.
|
|
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
|
|
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
|
|
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 (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()
|
|
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:
|
|
n = db.vacuum() # merges FTS5 segments then VACUUMs; returns indexes merged
|
|
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):
|
|
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.")
|
|
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:
|
|
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}\n 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{' incl. VACUUM' if do_vacuum else ''})")
|
|
if free_bytes < need_bytes:
|
|
print("\n⚠ 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}
|
|
labels = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"}
|
|
|
|
def _progress(info):
|
|
phase = info.get("phase")
|
|
if phase == "backfill":
|
|
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"]:
|
|
print(f"\n {labels.get(phase, phase)}…", 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}\nNo 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']} (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 _repair_prompts_pin_names(row) -> list[str] | None:
|
|
"""Resolve legacy name-list and current versioned tools[] pins to tool names.
|
|
|
|
Any malformed or unknown shape is unverifiable and therefore never eligible for automatic
|
|
repair. Current pins store full tool definitions in a versioned object; older rows may contain
|
|
a JSON list of names directly.
|
|
"""
|
|
try:
|
|
pin = json.loads(row.get("tool_names") or "null")
|
|
except (TypeError, ValueError):
|
|
return None
|
|
tools = pin.get("tools") if isinstance(pin, dict) else pin
|
|
if not isinstance(tools, list):
|
|
return None
|
|
names: list[str] = []
|
|
for item in tools:
|
|
if isinstance(item, str):
|
|
name = item
|
|
elif isinstance(item, dict):
|
|
function = item.get("function")
|
|
name = function.get("name") if isinstance(function, dict) else None
|
|
else:
|
|
return None
|
|
if not isinstance(name, str) or not name:
|
|
return None
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def _repair_prompts_lacks_skill_safety(row) -> bool:
|
|
# Only the Skill Safety guidance is a reliable marker: <available_skills> is legitimately
|
|
# absent when no skills are installed, but the guidance is emitted whenever skill_manage is.
|
|
from agent.prompt_builder import SKILL_SAFETY_HEADING
|
|
prompt = (row.get("system_prompt") or "").strip()
|
|
return bool(prompt and SKILL_SAFETY_HEADING not in prompt)
|
|
|
|
|
|
def _cmd_repair_prompts(db, args):
|
|
"""Report (or clear) stored system prompts degraded to a reduced-toolset build (#122822).
|
|
|
|
Automatic repair requires positive tools[] evidence. Rows with missing/malformed pins are
|
|
reported as unverifiable and never changed by a scan. An explicit session_id remains the
|
|
operator escape hatch and clears that row regardless of detector evidence.
|
|
"""
|
|
findings = []
|
|
unverifiable = []
|
|
target = getattr(args, "session_id", None)
|
|
if target:
|
|
session_id = db.resolve_session_id(target)
|
|
if not session_id:
|
|
print(f"No session matches {target!r}.")
|
|
return 1
|
|
row = db.get_session(session_id)
|
|
if row and row.get("system_prompt"):
|
|
findings.append({
|
|
"id": row["id"], "reason": "targeted clear", "prompt_chars": len(row["system_prompt"]),
|
|
})
|
|
else:
|
|
seen = set()
|
|
offset = 0
|
|
while True:
|
|
batch = db.list_sessions_rich(
|
|
limit=200, offset=offset, include_children=True,
|
|
include_archived=True, include_hidden=True, compact_rows=True,
|
|
)
|
|
if not batch:
|
|
break
|
|
offset += len(batch)
|
|
for summary in batch:
|
|
# OFFSET paging can re-serve a row when sessions are inserted mid-scan.
|
|
if summary["id"] in seen:
|
|
continue
|
|
seen.add(summary["id"])
|
|
row = db.get_session(summary["id"])
|
|
if not row or not _repair_prompts_lacks_skill_safety(row):
|
|
continue
|
|
pin = _repair_prompts_pin_names(row)
|
|
entry = {"id": row["id"], "prompt_chars": len(row["system_prompt"])}
|
|
if "skill_manage" in (pin or ()):
|
|
findings.append({
|
|
**entry,
|
|
"reason": "Skill Safety guidance missing while the tools[] pin carries skill_manage",
|
|
})
|
|
elif pin is None:
|
|
unverifiable.append({
|
|
**entry,
|
|
"reason": "skills markers missing but the tools[] pin is unavailable or unreadable",
|
|
})
|
|
elif set(pin) == {"memory"}:
|
|
# Not proof: toolsets=[memory] is also a legitimate user config whose healthy
|
|
# prompt has no skills markers, so auto-clearing it would repeat every run.
|
|
unverifiable.append({
|
|
**entry,
|
|
"reason": "memory-only tools[] pin may be a user toolset; clear explicitly by SESSION_ID",
|
|
})
|
|
|
|
apply = bool(getattr(args, "apply", False))
|
|
as_json = bool(getattr(args, "json", False))
|
|
|
|
def _payload(cleared):
|
|
return {
|
|
"findings": findings,
|
|
"unverifiable": unverifiable,
|
|
"apply": apply,
|
|
"cleared": cleared,
|
|
}
|
|
|
|
if not findings:
|
|
if as_json:
|
|
print(json.dumps(_payload([]), indent=2))
|
|
elif target:
|
|
print(f"{session_id} already has no stored prompt; nothing to clear.")
|
|
else:
|
|
print("No degraded stored system prompts found.")
|
|
if unverifiable:
|
|
print(f"{len(unverifiable)} row(s) lacked enough tools[] evidence and were left unchanged.")
|
|
return 0
|
|
|
|
if not as_json:
|
|
for finding in findings:
|
|
print(f" {finding['id']} ({finding['prompt_chars']} chars) - {finding['reason']}")
|
|
if unverifiable:
|
|
print(f"\nSkipped {len(unverifiable)} unverifiable row(s) without enough tools[] evidence.")
|
|
|
|
if not apply:
|
|
if as_json:
|
|
print(json.dumps(_payload([]), indent=2))
|
|
else:
|
|
print(f"\n{len(findings)} stored prompt(s) can be repaired. Re-run with --apply to clear them; "
|
|
"the next turn rebuilds a healthy prompt (one prefix-cache break per session).")
|
|
return 0
|
|
|
|
# JSON mode is the non-interactive automation surface; --apply is the explicit mutation opt-in.
|
|
if not as_json and not _confirm_prompt(f"Clear {len(findings)} stored prompt(s)? [y/N] "):
|
|
print("Aborted - nothing was changed.")
|
|
return 0
|
|
|
|
cleared = []
|
|
for finding in findings:
|
|
db.update_system_prompt(finding["id"], None)
|
|
cleared.append(finding["id"])
|
|
|
|
if as_json:
|
|
print(json.dumps(_payload(cleared), indent=2))
|
|
else:
|
|
print(f"\nCleared {len(cleared)} stored prompt(s); the next turn for each rebuilds and persists "
|
|
"a healthy prompt.")
|
|
print("One 'Stored system prompt ... is null' warning per repaired session is expected on that rebuild.")
|
|
return 0
|
|
|
|
|
|
def _cmd_stats(db, args):
|
|
print(f"Total sessions: {db.session_count()}\nTotal messages: {db.message_count()}")
|
|
for src in ("cli", "telegram", "discord", "whatsapp", "slack"):
|
|
if (c := db.session_count(source=src)) > 0:
|
|
print(f" {src}: {c} sessions")
|
|
if db.db_path.exists():
|
|
print(f"Database size: {_size_mb(db.db_path):.1f} MB")
|
|
|
|
|
|
# -- dispatch -----------------------------------------------------------------
|
|
|
|
def _cmd_repair_profiles(args):
|
|
from hermes_cli.sessions_cmd_repair_profiles import cmd_repair_profiles
|
|
return cmd_repair_profiles(args)
|
|
|
|
|
|
def _cmd_set_journal_mode(args):
|
|
from hermes_cli.sessions_cmd_journal_mode import cmd_set_journal_mode
|
|
return cmd_set_journal_mode(args)
|
|
|
|
|
|
_PRE_DB_HANDLERS = {
|
|
"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import,
|
|
"repair-profiles": _cmd_repair_profiles, # opens every profile's store itself
|
|
"set-journal-mode": _cmd_set_journal_mode, # offline: must not open the store it converts
|
|
}
|
|
_OBSERVATIONAL_DB_ACTIONS = frozenset({"list", "stats", "pinned"})
|
|
_DB_HANDLERS = {
|
|
"list": _cmd_list, "export": _cmd_export, "delete": _cmd_delete, "rename": _cmd_rename, "pinned": _cmd_pinned,
|
|
"prune": partial(_cmd_prune_or_archive, action="prune"), "pin": partial(_cmd_pin, pinning=True),
|
|
"archive": partial(_cmd_prune_or_archive, action="archive"), "unpin": partial(_cmd_pin, pinning=False),
|
|
"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, "repair-prompts": _cmd_repair_prompts, "stats": _cmd_stats,
|
|
}
|
|
|
|
|
|
def _print_empty_store(action: str, args) -> None:
|
|
"""A profile that never created state.db: report empty instead of opening a writer that creates it."""
|
|
if action == "stats":
|
|
print("Total sessions: 0\nTotal messages: 0")
|
|
elif action == "pinned":
|
|
print("[]" if getattr(args, "json", False) else "No pinned sessions. Pin one with: hermes sessions pin <session_id>")
|
|
else:
|
|
print("No sessions found.")
|
|
|
|
|
|
# VACUUM, the FTS-layout rebuild and bulk deletes rewrite the store; underneath a live gateway/Desktop/cron
|
|
# writer that is the second-writer class behind the retired-WAL refusal (#110054). `--force` is the override.
|
|
_HELD_STORE_ACTIONS = frozenset({"optimize", "optimize-storage", "prune"})
|
|
|
|
|
|
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)
|
|
observational = action in _OBSERVATIONAL_DB_ACTIONS
|
|
from hermes_state import SessionDB, _default_db_path
|
|
try:
|
|
db = SessionDB(read_only=observational)
|
|
except Exception as e:
|
|
# mode=ro cannot create the store; a reader on a fresh profile reports empty rather than failing.
|
|
if observational and not _default_db_path().exists():
|
|
return _print_empty_store(action, args)
|
|
print("Could not open your session history database. "
|
|
"Run: hermes sessions repair to fix it (a backup is made first).")
|
|
print(f"Details: {e}")
|
|
return 1
|
|
try:
|
|
handler = _DB_HANDLERS.get(action)
|
|
if handler is None:
|
|
sessions_parser.print_help()
|
|
return
|
|
if action in _HELD_STORE_ACTIONS and not getattr(args, "dry_run", False) and not getattr(args, "force", False):
|
|
from hermes_state_holders import held_store_refusal
|
|
# Same resolver the SessionDB above opened, so the scan never depends on the db object.
|
|
refusal = held_store_refusal(_default_db_path(), command=action)
|
|
if refusal:
|
|
print(refusal)
|
|
return 1
|
|
try:
|
|
return handler(db, args)
|
|
except sqlite3.OperationalError as e:
|
|
from hermes_state_repair import _schema_not_built
|
|
|
|
if not observational or not _schema_not_built(e):
|
|
raise
|
|
# A read-only opener skips schema migration, so a store from an older release can lack a column.
|
|
print(f"Error: session database needs migration — run any writing hermes command first ({e})")
|
|
return 1
|
|
finally:
|
|
db.close()
|