From cfcafb5b234c57a0107e4ddf098eb4de7a4faf7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:42:47 -0700 Subject: [PATCH 1/8] refactor(hermes_cli): merge sessions_cmd filter-arg tables into one _FILTER_ARGS predicate --- hermes_cli/sessions_cmd.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index c8c26e8e35..d20c667ab0 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -59,16 +59,15 @@ def _print_dry_run_preview(candidates, filters) -> None: print(f" ... {len(candidates) - 100} more") -_TIME_FILTER_ARGS = ("older_than", "newer_than", "before", "after") -_NON_TIME_FILTER_ARGS = ( - "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", +_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, names) -> bool: - return any(getattr(args, a, None) is not None for a in names) +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: @@ -301,8 +300,7 @@ def _cmd_list(db, args): lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {s['source']:<6} {s['id']}"), } header, rule, fmt = layouts[(has_ws, has_titles)] - print(header) - print("─" * rule) + print(header + "\n" + "─" * rule) for s in sessions: print(fmt(s)) @@ -312,7 +310,7 @@ def _cmd_list(db, args): def _cmd_export(db, args): from hermes_cli.session_filters import build_prune_filters filters = None - if _any_filter_args(args, _TIME_FILTER_ARGS + _NON_TIME_FILTER_ARGS): + if _any_filter_args(args): try: filters = build_prune_filters(args) except ValueError as e: @@ -655,7 +653,7 @@ def _cmd_prune_or_archive(db, args, action): # Bare `prune` keeps the historical "older than 90 days" default. ANY filter — including --source — # suppresses the implicit cutoff (`prune --source cron` matches ALL cron sessions); the preview + # confirmation below is the safety net. - if action == "prune" and not _any_filter_args(args, _TIME_FILTER_ARGS + _NON_TIME_FILTER_ARGS): + if action == "prune" and not _any_filter_args(args): args.older_than = "90" try: From df39de873890f9ac883d62906328dfd8f862983c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:48:19 -0700 Subject: [PATCH 2/8] refactor(hermes_cli): sessions_cmd export/prune flow tightening --- hermes_cli/sessions_cmd.py | 67 +++++++++++++++----------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index d20c667ab0..f594fb7617 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -426,8 +426,7 @@ def _export_trace(db, args, filters): else: candidates = db.list_prune_candidates(**filters) if args.dry_run: - _print_dry_run_preview(candidates, filters) - return + return _print_dry_run_preview(candidates, filters) ids = [row["id"] for row in candidates] def _render_trace(sid): @@ -450,10 +449,9 @@ def _export_trace(db, args, filters): 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 + 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.") @@ -468,7 +466,7 @@ def _export_markdown(db, args, filters, redact): output_dir = _export_dir(args.output) def _export_one(session_id: str, *, include_lineage: bool = False): - data = (db.export_session_lineage(session_id) if include_lineage else db.export_session(session_id)) + 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) @@ -488,15 +486,12 @@ def _export_markdown(db, args, filters, redact): 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)." - ) + 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: - _print_dry_run_preview(candidates, filters) - return + return _print_dry_run_preview(candidates, filters) exported = 0 for row in candidates: try: @@ -516,10 +511,9 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical if not resolved_session_id: _not_found(args.session_id) return - delete_target_ids = [resolved_session_id] - if args.delete_after_verified: - delete_target_ids = db.get_session_delete_targets(resolved_session_id) - + 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: @@ -546,14 +540,14 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical if not ok: print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}") return - 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 = ( - f" and {delegate_count} delegate session{'' if delegate_count == 1 else 's'}" if delegate_count else "" - ) - print(f"Deleted exported session '{resolved_session_id}'{delegate_suffix}.") - else: + if not db.delete_session( + resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids + ): print(f"Exported, but session '{resolved_session_id}' was not deleted because its delegate set changed.") + 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 ------------------------------------------------- @@ -646,30 +640,27 @@ def _note_pinned_skipped(db, filters, action): def _cmd_prune_or_archive(db, args, action): - if action == "prune" and getattr(args, "never_active", False): + 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 action == "prune" and not _any_filter_args(args): + 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 action == "archive" and not any(v for k, v in filters.items() if k != "older_than_days"): + 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 action == "prune" and getattr(args, "include_archived", False) else False - + filters["archived"] = None if prune and getattr(args, "include_archived", False) else False filters["include_pinned"] = getattr(args, "include_pinned", False) if not filters["include_pinned"]: _note_pinned_skipped(db, filters, action) @@ -677,25 +668,22 @@ def _cmd_prune_or_archive(db, args, action): candidates = db.list_prune_candidates(**filters) # Archive expands each row to its compression lineage (may include open continuations), so a # direct-open count would misdescribe its effect. - skipped_open = db.count_open_prune_matches(**filters) if action == "prune" else 0 + 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 ` " "to remove one explicitly." ) - verb = "Delete" if action == "prune" else "Archive" 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}):") @@ -709,14 +697,13 @@ def _cmd_prune_or_archive(db, args, action): 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'}.") + 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 action == "prune": + if prune: print(f"Pruned {db.prune_sessions(sessions_dir=_sessions_dir(), **filters)} session(s).") else: print(f"Archived {db.archive_sessions(**filters)} session(s). They're hidden from listings " From 679b303ba1f9f7b15e1d0863886eac9fd0551d2c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:55:35 -0700 Subject: [PATCH 3/8] refactor(hermes_cli): drop intra-function blank separators in session modules (AST-identical) --- hermes_cli/session_lost_and_found.py | 6 ---- hermes_cli/session_recap.py | 8 ----- hermes_cli/session_recovery.py | 46 ---------------------------- hermes_cli/sessions_cmd.py | 33 -------------------- hermes_cli/sessions_cmd_browse.py | 9 ------ 5 files changed, 102 deletions(-) diff --git a/hermes_cli/session_lost_and_found.py b/hermes_cli/session_lost_and_found.py index 7a5a3c525a..95f727e80d 100644 --- a/hermes_cli/session_lost_and_found.py +++ b/hermes_cli/session_lost_and_found.py @@ -112,7 +112,6 @@ def run_cli_lost_and_found_recover( attempt["usable"] = _lost_and_found_db_usable(lf_path) if attempt["usable"]: return {"binary": sqlite3_bin, "attempts": attempts} - raise LostAndFoundError( "sqlite3 .recover did not produce a usable lost_and_found database: " + "; ".join( @@ -178,7 +177,6 @@ def _looks_like_source(value: Any) -> bool: def classify_lost_and_found_row(nfield: int, cells: tuple[Any, ...]) -> Optional[str]: """Classify one lost_and_found record by field count + sentinel values.""" - if len(cells) >= 3 and cells[0] is None: # Rowid-alias tables store their INTEGER PRIMARY KEY as NULL; messages is the only canonical # table shaped like that with a session id second and a role third. @@ -262,7 +260,6 @@ def map_lost_and_found_rows(lf_conn: sqlite3.Connection, dest: sqlite3.Connectio for index in protected: defaults.pop(index, None) targets[kind_name] = (_table_columns(dest, kind_name), defaults) - lf_tables = [ str(row[0]) for row in lf_conn.execute( @@ -270,7 +267,6 @@ def map_lost_and_found_rows(lf_conn: sqlite3.Connection, dest: sqlite3.Connectio ) ] report["lost_and_found_tables"] = lf_tables - for lf_table in lf_tables: if _table_columns(lf_conn, lf_table)[:3] != ["rootpgno", "pgno", "nfield"]: continue @@ -338,7 +334,6 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]: "EXISTS (SELECT 1 FROM sessions WHERE sessions.id = u.session_id)" ): orphan_ids.setdefault(str(session_id), {"started_at": 0.0, "message_count": 0}) - titles = _placeholder_titles(dest, "best-effort recovered") for session_id, info in sorted(orphan_ids.items()): title = next(titles) @@ -348,7 +343,6 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]: ) result["sessions_stubbed"] += 1 result["messages_retained"] += info["message_count"] - result["usage_rows_retained"] = int(dest.execute("SELECT COUNT(*) FROM session_model_usage").fetchone()[0]) # Repair dangling intra-sessions references without deleting rows. diff --git a/hermes_cli/session_recap.py b/hermes_cli/session_recap.py index 35c550e961..6358acb7fd 100644 --- a/hermes_cli/session_recap.py +++ b/hermes_cli/session_recap.py @@ -161,15 +161,12 @@ def build_recap( lines[0] += f" — {session_title}" elif session_id: lines[0] += f" — {session_id[:8]}" - if not messages: lines.append(" (nothing to recap — no messages yet)") return "\n".join(lines) - users, assistants, tool_msgs = _count_visible_turns(messages) window = _recent_window(messages) win_users, win_assistants, _ = _count_visible_turns(window) - scope = ( f"{win_users} user turn{'s' if win_users != 1 else ''} / " f"{win_assistants} assistant repl{'ies' if win_assistants != 1 else 'y'}" @@ -177,7 +174,6 @@ def build_recap( if (users, assistants) != (win_users, win_assistants): scope += f" (of {users}/{assistants} total)" lines.append(f" Recent: {scope}, {tool_msgs} tool result{'s' if tool_msgs != 1 else ''}") - tool_calls = list(_iter_assistant_tool_calls(window)) tool_counts, files = _summarise_tool_activity(tool_calls) if tool_counts: @@ -185,18 +181,14 @@ def build_recap( lines.append(f" Tools used: {top}") if files: lines.append(f" Files touched: {_join_capped(files, _MAX_FILES_LISTED)}") - latest_user = _latest_text(window, "user") if latest_user: lines.append(f" Last ask: {_truncate(latest_user, _PROMPT_PREVIEW_CHARS)}") - latest_reply = _latest_text(window, "assistant") if latest_reply: lines.append(f" Last reply: {_truncate(latest_reply, _ASSISTANT_PREVIEW_CHARS)}") - if len(lines) == 2: # only header + scope line: nothing substantive to show lines.append(" (no assistant activity yet in this window)") - return "\n".join(lines) diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index 4fa8700caf..9acf3b09f7 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -31,7 +31,6 @@ _TOPIC_TABLES = ("telegram_dm_topic_mode", "telegram_dm_topic_bindings") def _init_delivery_ledger_schema(conn: sqlite3.Connection) -> None: from gateway.delivery_ledger import _initialize_schema - _initialize_schema(conn) @@ -86,7 +85,6 @@ def _validate_paths( source = source_path.expanduser().resolve(strict=True) if not source.is_file(): raise SessionRecoverySafetyError(f"Source is not a file: {source}") - output: Optional[Path] = None if output_path is not None: output = _resolved_output_path(output_path) @@ -99,7 +97,6 @@ def _validate_paths( candidate = _sidecar_path(output, suffix) if os.path.lexists(candidate): raise SessionRecoverySafetyError(f"Refusing to overwrite existing recovery output: {candidate}") - work_root = ( work_dir.expanduser().resolve(strict=True) if work_dir is not None @@ -149,13 +146,11 @@ def _disk_space_preflight(source: Path, work_root: Path, output_parent: Optional # with the complete bundle avoids betting the user's disk on that. output_allowance = bundle_bytes if output_parent is not None else 0 headroom = max(_MINIMUM_SPACE_HEADROOM, int((bundle_bytes + output_allowance) * 0.05)) - work_free = int(shutil.disk_usage(work_root).free) report: dict[str, Any] = { "source_bundle_bytes": bundle_bytes, "estimated_output_bytes": output_allowance, "headroom_bytes": headroom, "work_dir": str(work_root), "work_dir_free_bytes": work_free, } - if output_parent is None or _same_filesystem(work_root, output_parent): required = bundle_bytes + output_allowance + headroom report.update(shared_filesystem=True, work_dir_required_bytes=required) @@ -167,7 +162,6 @@ def _disk_space_preflight(source: Path, work_root: Path, output_parent: Optional "Use --work-dir or --output on a filesystem with more free space." ) return report - output_free = int(shutil.disk_usage(output_parent).free) work_required = bundle_bytes + headroom output_required = output_allowance + headroom @@ -199,7 +193,6 @@ def _copy_source_bundle(source: Path, snapshot_dir: Path) -> tuple[Path, list[st guard keeps this path consistent with ``hermes_state._backup_db_file``. """ from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access - snapshot_source = snapshot_dir / source.name copied: list[str] = [] try: @@ -283,7 +276,6 @@ def _copy_rows( result["status"] = "partial" if result["copied_rows"] else "failed" result["error"] = str(exc) return result - if expected_rows is None or result["copied_rows"] == expected_rows: result["status"] = "complete" else: @@ -320,10 +312,8 @@ def _inspect_connection(conn: sqlite3.Connection) -> dict[str, Any]: report["journal_mode"] = None # Journal metadata is context, not canonical data: a damaged pragma must not block readable rows. report["warnings"].append(f"journal mode: {exc}") - for table in _INVENTORY_TABLES: report["tables"][table] = _table_inventory(conn, table) - for required in ("sessions", "messages"): table_report = report["tables"][required] if not table_report.get("available") or table_report.get("rows") is None: @@ -348,7 +338,6 @@ def _snapshot_and_inspect( "stopped. Run the recovery from a fresh shell with no `hermes` session open, or point --source at an " "immutable snapshot copy of the database." ) - conn = _connect(snapshot_source) try: inspection = _inspect_connection(conn) @@ -441,7 +430,6 @@ def _salvage_rowid_bounds(source: sqlite3.Connection, table: str) -> dict[str, A rows[edge] = int(row[0]) except sqlite3.DatabaseError as exc: result["errors"].append(f"{edge} rowid: {exc}") - if rows["low"] is None and rows["high"] is None: result["empty" if not result["errors"] else "unavailable"] = True return result @@ -454,7 +442,6 @@ def _salvage_rowid_bounds(source: sqlite3.Connection, table: str) -> dict[str, A if rows["high"] is None: rows["high"] = _MAX_SQLITE_ROWID result["fallback_edges"].append("high") - result.update(rows) # Bisecting the whole synthetic domain tail used to exhaust the range-query budget before readable # tail rows were copied (#80205); gallop outward from the surviving edge for a finite bound first. @@ -481,7 +468,6 @@ def _probe_populated_edge(source: sqlite3.Connection, table: str, *, edge: str, ) domain_limit = _MAX_SQLITE_ROWID if ascending else _MIN_SQLITE_ROWID result: dict[str, Any] = {"edge": edge, "probes": 0, "capped": False} - position = anchor span = 1 while True: @@ -563,7 +549,6 @@ class _RowidRangeSalvage: self.stopped_at_query_limit = True self._skip(low, high, "salvage range query limit reached") return - result["range_queries"] += 1 last_committed_rowid: Optional[int] = None try: @@ -611,7 +596,6 @@ def _copy_table_salvage( columns = _compatible_columns(source, destination, table, result) if columns is None: return result - bounds = _salvage_rowid_bounds(source, table) result["rowid_bounds"] = bounds if bounds.get("empty"): @@ -622,7 +606,6 @@ def _copy_table_salvage( result["status"] = "failed" result["error"] = "could not determine a rowid range for salvage" + (f": {details}" if details else "") return result - salvage = _RowidRangeSalvage( source, destination, table, columns, chunk_size=chunk_size, progress_cb=progress_cb, source_rows=source_rows, insert_prefix=insert_prefix, row_filter=row_filter, result=result, @@ -631,7 +614,6 @@ def _copy_table_salvage( skipped_ranges = result["skipped_rowid_ranges"] result["skipped_rowid_span"] = sum(item["high"] - item["low"] + 1 for item in skipped_ranges) result["query_limit_reached"] = salvage.stopped_at_query_limit - if skipped_ranges: result["status"] = "partial" if result["copied_rows"] else "failed" result["error"] = f"{len(skipped_ranges)} rowid range(s) skipped" @@ -682,11 +664,9 @@ def _copy_state_meta( problem = _state_meta_precheck(source, destination, source_rows, salvage=salvage) if problem is not None: return problem - if salvage: def keep_user_meta(row: tuple[Any, ...], columns: tuple[str, ...]) -> bool: return str(row[columns.index("key")]) not in _GENERATED_META_KEYS - result = _copy_table_salvage( source, destination, "state_meta", chunk_size=chunk_size, progress_cb=progress_cb, source_rows=source_rows, insert_prefix="INSERT OR REPLACE", row_filter=keep_user_meta, @@ -694,7 +674,6 @@ def _copy_state_meta( result["source_meta_rows"] = result.pop("source_rows") result["excluded_keys"] = sorted(_GENERATED_META_KEYS) return result - placeholders = ", ".join("?" for _ in _GENERATED_META_KEYS) params = tuple(_GENERATED_META_KEYS) filtered_source_rows: Optional[int] = None @@ -704,7 +683,6 @@ def _copy_state_meta( ) except sqlite3.DatabaseError: pass # the copy loop below will return the concrete read error - return _copy_rows( source, destination, f"SELECT key, value FROM state_meta WHERE key NOT IN ({placeholders})", params, "INSERT OR REPLACE INTO state_meta(key, value) VALUES (?, ?)", table="state_meta", chunk_size=chunk_size, @@ -732,14 +710,12 @@ def _reconstruct_missing_sessions(destination: sqlite3.Connection) -> dict[str, result: dict[str, Any] = {"sessions_reconstructed": 0, "messages_retained": 0} if not (_table_columns(destination, "sessions") and _table_columns(destination, "messages")): return result - orphaned = destination.execute( "SELECT m.session_id, MIN(m.timestamp), COUNT(*) FROM messages AS m WHERE m.session_id IS NOT NULL AND NOT " "EXISTS (SELECT 1 FROM sessions WHERE sessions.id = m.session_id) GROUP BY m.session_id" ).fetchall() if not orphaned: return result - titles = _placeholder_titles(destination, "recovered") for session_id, first_timestamp, message_count in orphaned: started_at = float(first_timestamp) if first_timestamp is not None else 0.0 @@ -778,7 +754,6 @@ def _cleanup_partial_orphans(destination: sqlite3.Connection) -> dict[str, Any]: with _immediate_transaction(destination): # Rebuild owners BEFORE any orphan deletion. result.update(_reconstruct_missing_sessions(destination)) - result["sessions_parent_cleared"] = _reconcile( destination, "sessions", @@ -822,19 +797,15 @@ def _verify_structure(conn: sqlite3.Connection, verification: dict[str, Any]) -> verification["integrity_check"] = integrity_rows if integrity_rows != ["ok"]: errors.append("PRAGMA integrity_check did not return exactly 'ok'") - foreign_key_rows = [list(row) for row in conn.execute("PRAGMA foreign_key_check").fetchall()] verification["foreign_key_check"] = foreign_key_rows if foreign_key_rows: errors.append("foreign key violations remain") - verification["journal_mode"] = _journal_mode(conn) - schema_row = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone() verification["schema_version"] = int(schema_row[0]) if schema_row else None if verification["schema_version"] != SCHEMA_VERSION: errors.append(f"schema version is {verification['schema_version']}, expected {SCHEMA_VERSION}") - meta = dict(conn.execute("SELECT key, value FROM state_meta WHERE key LIKE 'fts_%'").fetchall()) meta = {str(key): value for key, value in meta.items()} verification["fts_meta"] = meta @@ -876,15 +847,12 @@ def _verify_row_counts( verification["loss_detected"] = True else: verification["errors"].append(message) - counts = {table: _count_rows(conn, table) for table in _INVENTORY_TABLES if _table_columns(conn, table)} verification["table_counts"] = counts - for table in ("sessions", "messages", *_AUXILIARY_TABLES): expected = expected_counts.get(table) if expected is not None and counts.get(table) != expected: flag(f"{table} count is {counts.get(table)}, expected {expected}", soft=allow_partial) - cleanup = orphan_cleanup or {} rebuilt_sessions = int(cleanup.get("sessions_reconstructed") or 0) retained_messages = int(cleanup.get("messages_retained") or 0) @@ -904,7 +872,6 @@ def _verify_row_counts( or (table == "sessions" and sessions_fully_reconstructed) ) flag(f"{table} copy status is {status}", soft=allow_partial and tolerable) - if orphan_cleanup: orphan_count = int(orphan_cleanup.get("total_removed_or_relinked") or 0) if orphan_count: @@ -923,12 +890,10 @@ def _verify_recovered_database( allow_partial: bool = False, orphan_cleanup: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: verification: dict[str, Any] = {"errors": [], "warnings": [], "loss_detected": False} - open_error = _db_opens_cleanly(output) verification["opens_cleanly"] = open_error is None if open_error is not None: verification["errors"].append(f"database health probe: {open_error}") - conn = sqlite3.connect(str(output), isolation_level=None) try: _verify_structure(conn, verification) @@ -941,7 +906,6 @@ def _verify_recovered_database( verification["errors"].append(f"verification query failed: {exc}") finally: conn.close() - verification["healthy"] = not verification["errors"] verification["complete"] = bool(verification["healthy"] and not verification["loss_detected"]) return verification @@ -959,7 +923,6 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any if fts_tables != {"messages_fts", "messages_fts_trigram"}: result["error"] = "fresh destination is missing required FTS tables" return result - fts_keys = tuple(key for key in _GENERATED_META_KEYS if key.startswith("fts_")) placeholders = ", ".join("?" for _ in fts_keys) with _immediate_transaction(destination): @@ -983,7 +946,6 @@ def _recover_via_lost_and_found( SQLITE3_CLI_GUIDANCE, LostAndFoundError, find_sqlite3_cli, map_lost_and_found_rows, rebuild_fts_indexes, run_cli_lost_and_found_recover, stub_missing_parent_sessions, ) - missing = ", ".join(missing_required) sqlite3_bin = find_sqlite3_cli() if sqlite3_bin is None: @@ -998,7 +960,6 @@ def _recover_via_lost_and_found( f"Partial recovery could not read the table schemas for: {missing}, " f"and page-level .recover salvage failed: {exc}" ) from exc - lf_conn = sqlite3.connect(str(lf_path), isolation_level=None) destination_conn = _fresh_destination(output) try: @@ -1009,7 +970,6 @@ def _recover_via_lost_and_found( finally: lf_conn.close() destination_conn.close() - copy_report: dict[str, dict[str, Any]] = { table: { "mode": "lost_and_found_salvage", @@ -1034,7 +994,6 @@ def _recover_via_lost_and_found( "pages and mapped heuristically. Review every count before trusting this output." ) verification["complete"] = False - return _recovery_report( source, output, inspection, disk_space, verification, on_source_change="healthy", allow_partial=True, mode="lost_and_found_salvage", best_effort=True, unreadable_schemas=missing_required, @@ -1083,11 +1042,9 @@ def recover_session_database( copied before SQLite opens anything; ``output_path`` must not exist and is never swapped into place.""" if chunk_size <= 0: raise SessionRecoverySafetyError("chunk_size must be greater than zero") - source, output, work_root = _validate_paths(source_path, output_path=output_path, work_dir=work_dir) assert output is not None disk_space = _disk_space_preflight(source, work_root, output.parent) - temp_dir, snapshot_source, inspection = _snapshot_and_inspect(source, work_root) try: if not inspection.get("recoverable") and not allow_partial: @@ -1106,7 +1063,6 @@ def recover_session_database( source=source, snapshot_source=snapshot_source, snapshot_dir=Path(temp_dir.name), output=output, inspection=inspection, disk_space=disk_space, missing_required=missing_required, ) - source_conn = _connect(snapshot_source) source_conn.execute("PRAGMA writable_schema=ON") destination_conn: Optional[sqlite3.Connection] = None @@ -1114,7 +1070,6 @@ def recover_session_database( destination_conn = _fresh_destination( output, topic_tables=any(inspection["tables"][table].get("available") for table in _TOPIC_TABLES), ) - copy_report: dict[str, dict[str, Any]] = {} for table in (*_CANONICAL_TABLES, "state_meta", *_TOPIC_TABLES, *_AUXILIARY_TABLES): if table not in _CANONICAL_TABLES and not inspection["tables"][table].get("available"): @@ -1132,7 +1087,6 @@ def recover_session_database( source_conn.close() if destination_conn is not None: destination_conn.close() - verification = _verify_recovered_database( output, expected_counts={ diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index f594fb7617..ec436bd5af 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -108,7 +108,6 @@ def _cmd_repair(args): print(f" strategy: {report.get('strategy')}") try: from hermes_state import SessionDB - with SessionDB() as _repair_db: n = _repair_db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] print(f"✓ Repaired — {n} sessions recovered.") @@ -135,7 +134,6 @@ def _cmd_repair(args): 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, ) @@ -158,7 +156,6 @@ def _cmd_recover(args): if bad: print(f"Error: {msg}") return 2 - work_dir = getattr(args, "work_dir", None) try: if inspect_only: @@ -175,7 +172,6 @@ def _cmd_recover(args): 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) @@ -185,7 +181,6 @@ def _cmd_recover(args): 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 return _print_recovery_verdict(report, output, allow_partial) @@ -270,9 +265,7 @@ def _cmd_list(db, args): 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 @@ -284,11 +277,9 @@ def _cmd_list(db, args): 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")) # noqa: E731 - 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']}"), @@ -344,7 +335,6 @@ def _cmd_export(db, args): print("--dry-run requires at least one filter.") return None return [_redact(s) for s in db.export_all(source=None)] - if getattr(args, "only", None): return _export_only(args, _collect_sessions) if args.format == "trace": @@ -408,10 +398,8 @@ def _export_trace(db, args, filters): 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).") @@ -420,7 +408,6 @@ def _export_trace(db, args, filters): 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: @@ -435,7 +422,6 @@ def _export_trace(db, args, filters): 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]) @@ -473,7 +459,6 @@ def _export_markdown(db, args, filters, redact): 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.") return @@ -481,10 +466,8 @@ def _export_markdown(db, args, filters, redact): 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).") @@ -527,7 +510,6 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical print(f"Session '{target_id}' disappeared during export; nothing was deleted.") 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" n = len(exported_items) @@ -590,12 +572,10 @@ def _prune_never_active_keyed(db, args): ) 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:") @@ -604,14 +584,12 @@ def _prune_never_active_keyed(db, args): f"{s.get('session_key') or '-'}") if len(candidates) > len(shown): print(f" … {len(candidates) - len(shown)} more") - 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() ) @@ -664,7 +642,6 @@ def _cmd_prune_or_archive(db, args, action): filters["include_pinned"] = getattr(args, "include_pinned", False) if not filters["include_pinned"]: _note_pinned_skipped(db, filters, action) - candidates = db.list_prune_candidates(**filters) # Archive expands each row to its compression lineage (may include open continuations), so a # direct-open count would misdescribe its effect. @@ -774,12 +751,10 @@ def _cmd_retitle_skills(db, args): """Reject non-titles: an auxiliary model occasionally answers the prompt instead of titling it ('$ df -h /'). This is a REPAIR — never replace a serviceable title with that.""" return bool(candidate) and candidate[0].isalnum() - candidates = db.list_skill_scaffolded_sessions(limit=limit) 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 @@ -806,7 +781,6 @@ def _cmd_retitle_skills(db, args): 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: @@ -822,7 +796,6 @@ def _cmd_browse(db, args): 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: @@ -909,7 +882,6 @@ def _cmd_optimize_storage(db, args): if resp not in ("y", "yes"): print("Cancelled.") return - _last = {"phase": None} def _progress(info): @@ -921,7 +893,6 @@ def _cmd_optimize_storage(db, args): label = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"} print(f"\n {label.get(phase, phase)}…", flush=True) _last["phase"] = phase - print("Optimizing search-index storage…") try: result = db.optimize_fts_storage(progress_cb=_progress, vacuum=do_vacuum) @@ -948,7 +919,6 @@ def _cmd_repair_routing(db, args): f"evidence: {record['evidence']})") else: print(f" ✗ not repairable — {record['reason']}") - if not records: print("✓ No gateway sessions are missing their routing identity.") return @@ -1012,15 +982,12 @@ def cmd_sessions(args, sessions_parser=None): pre = _PRE_DB_HANDLERS.get(action) if pre is not None: return pre(args) - try: from hermes_state import SessionDB - db = SessionDB() except Exception as e: print(f"Error: Could not open session database: {e}") return 1 - with db: handler = _DB_HANDLERS.get(action) if handler is None: diff --git a/hermes_cli/sessions_cmd_browse.py b/hermes_cli/sessions_cmd_browse.py index c34c31715f..af092177df 100644 --- a/hermes_cli/sessions_cmd_browse.py +++ b/hermes_cli/sessions_cmd_browse.py @@ -98,13 +98,11 @@ class _CursesBrowser: header = " Browse sessions — ↑↓ navigate Enter select Type to filter Esc quit" header_attr = c.A_BOLD | self._pair(2) self._put(stdscr, 0, 0, header, max_x - 1, header_attr) - name_width = max(20, max_x - _FIXED_COLS) col_header = ( f" {'Title / Preview':<{name_width}} {'Stat':<5} {'Msgs':>5} {'Active':<10} {'Src':<5} {'ID'}" ) self._put(stdscr, 1, 0, col_header, max_x - 1, self._pair(4, c.A_DIM)) - visible_rows = max(max_y - 4, 1) # header + col header + blank + footer filtered = self.filtered if not filtered: @@ -130,7 +128,6 @@ class _CursesBrowser: stdscr.addnstr(y, tag_x, f"{_session_status_tag(status):<5}", 5, self._status_attr(status)) except c.error: pass - footer_attr = self._pair(4, c.A_DIM) if self.confirm_delete is not None: label = _label(self.confirm_delete) @@ -163,7 +160,6 @@ class _CursesBrowser: else: self.flash = "Delete failed." return False - if key in (c.KEY_UP, c.KEY_DOWN): if self.filtered: self.cursor = (self.cursor + (1 if key == c.KEY_DOWN else -1)) % len(self.filtered) @@ -230,7 +226,6 @@ def _fallback_picker(sessions: list) -> Optional[str]: f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} " f"{s.get('source', '')[:6]}" ) - while True: try: val = input(f"\n Select [1-{len(sessions)}]: ").strip() @@ -256,13 +251,11 @@ def _session_browse_picker(sessions: list, session_db=None) -> Optional[str]: if not sessions: print("No sessions found.") return None - _annotate_session_statuses(sessions, session_db) def _delete_session(session_id: str) -> bool: try: from hermes_cli.sessions_cmd import get_hermes_home - sessions_dir = get_hermes_home() / "sessions" except Exception: sessions_dir = None @@ -270,10 +263,8 @@ def _session_browse_picker(sessions: list, session_db=None) -> Optional[str]: return bool(session_db.delete_session(session_id, sessions_dir=sessions_dir)) except Exception: return False - try: # curses first; any failure (no curses module, odd terminal) falls back import curses - browser = _CursesBrowser(curses, sessions, _delete_session if session_db is not None else None) curses.wrapper(browser.run) return browser.result From 4dacaa265d649fffd85da4f616aa622762656946 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:59:16 -0700 Subject: [PATCH 4/8] refactor(hermes_cli): sessions_cmd_browse _clip helper, hoisted invariants --- hermes_cli/sessions_cmd_browse.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/hermes_cli/sessions_cmd_browse.py b/hermes_cli/sessions_cmd_browse.py index af092177df..688d39dc42 100644 --- a/hermes_cli/sessions_cmd_browse.py +++ b/hermes_cli/sessions_cmd_browse.py @@ -28,6 +28,10 @@ def _label(s: dict) -> str: return (s.get("title") or "").strip() or (s.get("preview") or "").strip() or s["id"] +def _clip(text: str, n: int) -> str: + return text if len(text) <= n else text[: n - 3] + "..." + + def _msgs_str(s: dict) -> str: msgs = s.get("message_count") return str(msgs) if isinstance(msgs, int) else "-" @@ -72,8 +76,7 @@ class _CursesBrowser: self.filtered = list(sessions) def _pair(self, n, fallback=0): - c = self.curses - return c.color_pair(n) if c.has_colors() else fallback + return self.curses.color_pair(n) if self.curses.has_colors() else fallback def _status_attr(self, status): pair = {"complete": 1, "interrupted": 2, "error": 5, "empty": 4}.get(status or "") @@ -113,16 +116,15 @@ class _CursesBrowser: self.scroll = self.cursor elif self.cursor >= self.scroll + visible_rows: self.scroll = self.cursor - visible_rows + 1 + tag_x = 3 + max(20, (max_x - 3) - _FIXED_COLS) + 2 for draw_i, i in enumerate(range(self.scroll, min(len(filtered), self.scroll + visible_rows))): y = draw_i + 3 if y >= max_y - 1: break - s = filtered[i] - selected = i == self.cursor + s, selected = filtered[i], i == self.cursor row = (" → " if selected else " ") + _format_row(s, max_x - 3) try: stdscr.addnstr(y, 0, row, max_x - 1, c.A_BOLD | self._pair(1) if selected else c.A_NORMAL) - tag_x = 3 + max(20, (max_x - 3) - _FIXED_COLS) + 2 if not selected and tag_x + 5 < max_x - 1: # recolor the status tag in place status = s.get("_status") stdscr.addnstr(y, tag_x, f"{_session_status_tag(status):<5}", 5, self._status_attr(status)) @@ -130,10 +132,8 @@ class _CursesBrowser: pass footer_attr = self._pair(4, c.A_DIM) if self.confirm_delete is not None: - label = _label(self.confirm_delete) - if len(label) > 40: - label = label[:37] + "..." - footer, footer_attr = f" Delete session '{label}'? [y/N]", c.A_BOLD | self._pair(5) + footer = f" Delete session '{_clip(_label(self.confirm_delete), 40)}'? [y/N]" + footer_attr = c.A_BOLD | self._pair(5) elif self.flash: footer = f" {self.flash}" self.flash = "" @@ -193,9 +193,8 @@ class _CursesBrowser: c.start_color() c.use_default_colors() # 1 selected, 2 header, 3 search, 4 dim, 5 error/delete - for n, color in enumerate( - (c.COLOR_GREEN, c.COLOR_YELLOW, c.COLOR_CYAN, 8 if c.COLORS > 8 else c.COLOR_WHITE, c.COLOR_RED), 1 - ): + palette = (c.COLOR_GREEN, c.COLOR_YELLOW, c.COLOR_CYAN, 8 if c.COLORS > 8 else c.COLOR_WHITE, c.COLOR_RED) + for n, color in enumerate(palette, 1): c.init_pair(n, color, -1) while True: stdscr.clear() @@ -218,13 +217,9 @@ def _fallback_picker(sessions: list) -> Optional[str]: """Numbered list (Windows without curses, etc.). Same columns, no delete.""" print("\n Browse sessions (enter number to resume, q to cancel)\n") for i, s in enumerate(sessions): - label = _label(s) - if len(label) > 50: - label = label[:47] + "..." print( - f" {i + 1:>3}. {label:<50} {_session_status_tag(s.get('_status')):<5} " - f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} " - f"{s.get('source', '')[:6]}" + f" {i + 1:>3}. {_clip(_label(s), 50):<50} {_session_status_tag(s.get('_status')):<5} " + f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} {s.get('source', '')[:6]}" ) while True: try: From 68d746df2a0ef87cf465847c06018dc8b371e80e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:02:46 -0700 Subject: [PATCH 5/8] refactor(hermes_cli): sessions_cmd list/recover minor tightening --- hermes_cli/sessions_cmd.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index ec436bd5af..e1937d5751 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -169,8 +169,7 @@ def _cmd_recover(args): ) progress.finish() except (SessionRecoveryError, OSError, sqlite3.DatabaseError) as exc: - print(f"Error: session recovery failed: {exc}") - print("The supplied source database was not replaced or deleted.") + print(f"Error: session recovery failed: {exc}\nThe supplied source database was not replaced or deleted.") return 1 if report_path is not None: try: @@ -261,11 +260,10 @@ def _cmd_list(db, args): _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)] + 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 From b6974fc75ed1cb348ae011e68e9a7b7a7ce8fd23 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:06:28 -0700 Subject: [PATCH 6/8] refactor(hermes_cli): session_recovery preflight reuses _source_fingerprint sizes --- hermes_cli/session_recovery.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index 9acf3b09f7..1626cf6ba5 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -111,10 +111,9 @@ def _source_fingerprint(source: Path) -> dict[str, dict[str, int]]: fingerprint: dict[str, dict[str, int]] = {} for suffix in _SIDECAR_SUFFIXES: path = _sidecar_path(source, suffix) - if not path.exists(): - continue - stat = path.stat() - fingerprint[suffix or "main"] = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns} + if path.exists(): + stat = path.stat() + fingerprint[suffix or "main"] = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns} return fingerprint @@ -137,11 +136,7 @@ def _same_filesystem(left: Path, right: Path) -> bool: def _disk_space_preflight(source: Path, work_root: Path, output_parent: Optional[Path]) -> dict[str, Any]: """Require space for the disposable bundle, output, and safety headroom.""" - bundle_bytes = sum( - _sidecar_path(source, suffix).stat().st_size - for suffix in _SIDECAR_SUFFIXES - if _sidecar_path(source, suffix).exists() - ) + bundle_bytes = sum(info["size"] for info in _source_fingerprint(source).values()) # The v23 external-content rebuild is usually much smaller than a legacy database, but estimating # with the complete bundle avoids betting the user's disk on that. output_allowance = bundle_bytes if output_parent is not None else 0 @@ -178,9 +173,8 @@ def _disk_space_preflight(source: Path, work_root: Path, output_parent: Optional ] if shortages: raise SessionRecoverySafetyError( - "Not enough free disk space for safe recovery: " - + "; ".join(shortages) - + ". Choose work/output filesystems with more free space." + f"Not enough free disk space for safe recovery: {'; '.join(shortages)}. " + "Choose work/output filesystems with more free space." ) return report @@ -290,8 +284,7 @@ def _table_inventory(conn: sqlite3.Connection, table: str) -> dict[str, Any]: columns = _table_columns(conn, table) if not columns: return result - result["available"] = True - result["columns"] = columns + result.update(available=True, columns=columns) result["rows"] = _count_rows(conn, table) except sqlite3.DatabaseError as exc: result["error"] = str(exc) From 7d675b91325dce1df035b33fb4e7f55818cb9c43 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:10:33 -0700 Subject: [PATCH 7/8] refactor(hermes_cli): sessions_cmd storage/dispatch tail compaction --- hermes_cli/sessions_cmd.py | 39 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index e1937d5751..c7c838fa7b 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -850,7 +850,6 @@ def _cmd_optimize_storage(db, args): 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) @@ -859,14 +858,12 @@ def _cmd_optimize_storage(db, args): 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") + 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() - print("⚠ Not enough free disk to complete safely. Free up space, or run with --no-vacuum " + 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: @@ -881,6 +878,7 @@ def _cmd_optimize_storage(db, args): print("Cancelled.") return _last = {"phase": None} + labels = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"} def _progress(info): phase = info.get("phase") @@ -888,15 +886,13 @@ def _cmd_optimize_storage(db, args): print(f"\r Rebuilding index: {info.get('percent', 0):3d}% " f"({info.get('indexed',0):,}/{info.get('total',0):,})", end="", flush=True) elif phase != _last["phase"]: - label = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"} - print(f"\n {label.get(phase, phase)}…", flush=True) + 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}") - print("No data was lost. Re-run to resume.") + 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')}") @@ -944,8 +940,7 @@ def _cmd_repair_routing(db, args): 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"): - c = db.session_count(source=src) - if c > 0: + 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") @@ -956,22 +951,12 @@ def _cmd_stats(db, args): _PRE_DB_HANDLERS = {"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import} _DB_HANDLERS = { - "list": _cmd_list, - "export": _cmd_export, - "delete": _cmd_delete, - "prune": partial(_cmd_prune_or_archive, action="prune"), - "archive": partial(_cmd_prune_or_archive, action="archive"), - "rename": _cmd_rename, - "pin": partial(_cmd_pin, pinning=True), - "unpin": partial(_cmd_pin, pinning=False), - "pinned": _cmd_pinned, - "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, - "stats": _cmd_stats, + "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, "stats": _cmd_stats, } From c177f33095c05e2f262e0bfb0009a0d3980d7581 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:12:59 -0700 Subject: [PATCH 8/8] refactor(hermes_cli): session_recovery report literals and reconcile calls compacted --- hermes_cli/session_recovery.py | 41 ++++++++++++---------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index 1626cf6ba5..3fd4c9a011 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -305,8 +305,7 @@ def _inspect_connection(conn: sqlite3.Connection) -> dict[str, Any]: report["journal_mode"] = None # Journal metadata is context, not canonical data: a damaged pragma must not block readable rows. report["warnings"].append(f"journal mode: {exc}") - for table in _INVENTORY_TABLES: - report["tables"][table] = _table_inventory(conn, table) + report["tables"] = {table: _table_inventory(conn, table) for table in _INVENTORY_TABLES} for required in ("sessions", "messages"): table_report = report["tables"][required] if not table_report.get("available") or table_report.get("rows") is None: @@ -350,10 +349,7 @@ def inspect_session_database(source_path: Path, *, work_dir: Optional[Path] = No temp_dir, _, inspection = _snapshot_and_inspect(source, work_root) try: return { - "operation": "inspect", - "source": str(source), - "disk_space": disk_space, - **inspection, + "operation": "inspect", "source": str(source), "disk_space": disk_space, **inspection, "source_unchanged": _source_fingerprint(source) == inspection["source_fingerprint"], } finally: @@ -748,34 +744,29 @@ def _cleanup_partial_orphans(destination: sqlite3.Connection) -> dict[str, Any]: # Rebuild owners BEFORE any orphan deletion. result.update(_reconstruct_missing_sessions(destination)) result["sessions_parent_cleared"] = _reconcile( - destination, - "sessions", + destination, "sessions", "parent_session_id IS NOT NULL AND NOT EXISTS (" "SELECT 1 FROM sessions AS parent WHERE parent.id = sessions.parent_session_id)", "UPDATE sessions SET parent_session_id = NULL", ) result["session_prompt_refs_cleared"] = _reconcile( - destination, - "sessions", + destination, "sessions", "system_prompt_hash IS NOT NULL AND NOT EXISTS (" "SELECT 1 FROM system_prompts WHERE system_prompts.hash = sessions.system_prompt_hash)", "UPDATE sessions SET system_prompt_hash = NULL", ) result["system_prompts_removed"] = _reconcile( - destination, - "system_prompts", + destination, "system_prompts", "NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash)", "DELETE FROM system_prompts", ) for table in _DEPENDENT_TABLES: - if not _table_columns(destination, table): - continue - result[f"{table}_removed"] = _reconcile( - destination, - table, - f'NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = "{table}".session_id)', - f'DELETE FROM "{table}"', - ) + if _table_columns(destination, table): + result[f"{table}_removed"] = _reconcile( + destination, table, + f'NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = "{table}".session_id)', + f'DELETE FROM "{table}"', + ) # Only destructive/relinking actions count: reconstruction counters describe RETAINED data. result["total_removed_or_relinked"] = sum( int(result[key]) for key in (*_RELINK_COUNTERS, *(f"{table}_removed" for table in _DEPENDENT_TABLES)) @@ -1007,13 +998,9 @@ def _recovery_report( verification["errors"].append("the source database bundle changed during recovery") verification[on_source_change] = False return { - "operation": "recover", - "source": str(source), - "output": str(output), - "source_bundle": inspection["source_bundle"], - "source_fingerprint": inspection["source_fingerprint"], - "source_unchanged": source_unchanged, - "disk_space": disk_space, + "operation": "recover", "source": str(source), "output": str(output), + "source_bundle": inspection["source_bundle"], "source_fingerprint": inspection["source_fingerprint"], + "source_unchanged": source_unchanged, "disk_space": disk_space, "inspection": { "journal_mode": inspection.get("journal_mode"), "tables": inspection["tables"], "errors": inspection["errors"], "warnings": inspection["warnings"],