Merge branch 'simp/r3-23-g2' into simp/integration3
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -114,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
|
||||
|
||||
|
||||
@@ -140,22 +136,16 @@ 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
|
||||
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 +157,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
|
||||
@@ -184,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
|
||||
|
||||
@@ -199,7 +187,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 +270,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:
|
||||
@@ -298,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)
|
||||
@@ -320,10 +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:
|
||||
@@ -348,7 +330,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)
|
||||
@@ -368,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:
|
||||
@@ -441,7 +419,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 +431,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 +457,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 +538,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 +585,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 +595,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 +603,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 +653,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 +663,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 +672,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 +699,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,36 +743,30 @@ 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",
|
||||
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))
|
||||
@@ -822,19 +781,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 +831,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 +856,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 +874,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 +890,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 +907,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 +930,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 +944,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 +954,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 +978,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,
|
||||
@@ -1055,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"],
|
||||
@@ -1083,11 +1022,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 +1043,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 +1050,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 +1067,6 @@ def recover_session_database(
|
||||
source_conn.close()
|
||||
if destination_conn is not None:
|
||||
destination_conn.close()
|
||||
|
||||
verification = _verify_recovered_database(
|
||||
output,
|
||||
expected_counts={
|
||||
|
||||
+52
-117
@@ -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:
|
||||
@@ -109,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.")
|
||||
@@ -136,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,
|
||||
)
|
||||
@@ -159,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:
|
||||
@@ -173,10 +169,8 @@ 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:
|
||||
written_report = write_recovery_report(report_path, report)
|
||||
@@ -186,7 +180,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)
|
||||
@@ -267,13 +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
|
||||
@@ -285,11 +275,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']}"),
|
||||
@@ -301,8 +289,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 +299,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:
|
||||
@@ -346,7 +333,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":
|
||||
@@ -410,10 +396,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).")
|
||||
@@ -422,14 +406,12 @@ 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:
|
||||
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):
|
||||
@@ -438,7 +420,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])
|
||||
@@ -452,10 +433,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.")
|
||||
@@ -470,14 +450,13 @@ 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)
|
||||
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
|
||||
@@ -485,20 +464,15 @@ 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)."
|
||||
)
|
||||
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:
|
||||
@@ -518,10 +492,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:
|
||||
@@ -535,7 +508,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)
|
||||
@@ -548,14 +520,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 -------------------------------------------------
|
||||
@@ -598,12 +570,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:")
|
||||
@@ -612,14 +582,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()
|
||||
)
|
||||
@@ -648,56 +616,49 @@ 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, _TIME_FILTER_ARGS + _NON_TIME_FILTER_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)
|
||||
|
||||
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 <id>` "
|
||||
"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}):")
|
||||
@@ -711,14 +672,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 "
|
||||
@@ -789,12 +749,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
|
||||
@@ -821,7 +779,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:
|
||||
@@ -837,7 +794,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:
|
||||
@@ -894,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)
|
||||
@@ -903,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:
|
||||
@@ -924,8 +877,8 @@ def _cmd_optimize_storage(db, args):
|
||||
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")
|
||||
@@ -933,16 +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')}")
|
||||
@@ -963,7 +913,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
|
||||
@@ -991,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")
|
||||
@@ -1003,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1027,15 +965,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:
|
||||
|
||||
@@ -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 "")
|
||||
@@ -98,13 +101,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:
|
||||
@@ -115,28 +116,24 @@ 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))
|
||||
except c.error:
|
||||
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 = ""
|
||||
@@ -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)
|
||||
@@ -197,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()
|
||||
@@ -222,15 +217,10 @@ 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:
|
||||
val = input(f"\n Select [1-{len(sessions)}]: ").strip()
|
||||
@@ -256,13 +246,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 +258,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
|
||||
|
||||
Reference in New Issue
Block a user