Merge branch 'simp/r3-23-g2' into simp/integration3
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
"""Last-resort page-level salvage for an unreadable session database schema.
|
||||
|
||||
The SQLite command-line shell ships a page-level ``.recover`` command that walks raw pages and
|
||||
rebuilds rows it cannot attribute to a schema into ``lost_and_found`` tables of the shape::
|
||||
"""
|
||||
"""Last-resort page-level salvage for an unreadable session database schema, via the sqlite3 shell's
|
||||
``.recover`` (rows it cannot attribute to a schema land in ``lost_and_found`` tables:
|
||||
``rootpgno, pgno, nfield, id, c0..cN``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,8 +12,12 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# Hermes session ids are timestamps: 20260812_135332_ab12cd. This is the
|
||||
# strongest sentinel available for classifying schema-less rows.
|
||||
from hermes_cli.session_recovery import (
|
||||
_AUXILIARY_TABLE_SCHEMAS, _AUXILIARY_TABLES, _CANONICAL_TABLES, _immediate_transaction, _quoted_columns,
|
||||
_table_columns,
|
||||
)
|
||||
|
||||
# Hermes session ids are timestamps (20260812_135332_ab12cd): the strongest sentinel for schema-less rows.
|
||||
SESSION_ID_PATTERN = re.compile(r"^\d{8}_\d{6}_")
|
||||
|
||||
MESSAGE_ROLES = frozenset({"user", "assistant", "tool", "system"})
|
||||
@@ -27,9 +29,7 @@ KNOWN_SOURCES = frozenset({
|
||||
"tool", "subagent", "cron", "recovered", "imported", "acp",
|
||||
})
|
||||
|
||||
# Historical physical layouts of the sessions table. Columns are only ever
|
||||
# appended (ALTER TABLE ADD COLUMN), so an older record is a strict prefix of
|
||||
# the current column order.
|
||||
# Historical sessions layouts. Columns are only ever appended, so an older record is a strict prefix.
|
||||
SESSIONS_LAYOUT_NFIELDS = frozenset({55, 54, 52})
|
||||
SESSIONS_LEGACY_MINIMAL_NFIELD = 14
|
||||
SESSION_MODEL_USAGE_NFIELD = 18
|
||||
@@ -56,22 +56,14 @@ class LostAndFoundError(RuntimeError):
|
||||
|
||||
|
||||
def find_sqlite3_cli() -> Optional[str]:
|
||||
"""Return a ``.recover``-capable sqlite3 CLI path, or None.
|
||||
|
||||
PATH presence is not enough: distro builds can ship a shell without the ``sqlite_dbpage``
|
||||
virtual table ``.recover`` needs, failing every recovery. Probe on a scratch DB once instead
|
||||
of discovering it mid-recovery.
|
||||
"""
|
||||
|
||||
"""A ``.recover``-capable sqlite3 CLI path, or None. PATH presence is not enough: distro builds can
|
||||
lack the ``sqlite_dbpage`` virtual table ``.recover`` needs, so probe on a scratch DB once."""
|
||||
binary = shutil.which("sqlite3")
|
||||
if binary is None:
|
||||
return None
|
||||
return binary if _cli_supports_recover(binary) else None
|
||||
return binary if binary is not None and _cli_supports_recover(binary) else None
|
||||
|
||||
|
||||
def _cli_supports_recover(binary: str) -> bool:
|
||||
"""True when ``binary`` can run ``.recover`` (has sqlite_dbpage)."""
|
||||
|
||||
scratch_dir = tempfile.mkdtemp(prefix="hermes-recover-probe-")
|
||||
scratch = Path(scratch_dir) / "probe.db"
|
||||
try:
|
||||
@@ -82,14 +74,8 @@ def _cli_supports_recover(binary: str) -> bool:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
probe = subprocess.run(
|
||||
[binary, "-readonly", str(scratch), ".recover"],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
return False
|
||||
return b"sqlite_dbpage" not in probe.stderr
|
||||
probe = subprocess.run([binary, "-readonly", str(scratch), ".recover"], capture_output=True, timeout=30)
|
||||
return probe.returncode == 0 and b"sqlite_dbpage" not in probe.stderr
|
||||
except (OSError, subprocess.SubprocessError, sqlite3.Error):
|
||||
return False
|
||||
finally:
|
||||
@@ -104,21 +90,15 @@ def run_cli_lost_and_found_recover(
|
||||
timeout: float = 3600.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Run ``sqlite3 <source> .recover`` streamed into a fresh scratch DB."""
|
||||
|
||||
attempts: list[dict[str, Any]] = []
|
||||
for command in (".recover --ignore-freelist", ".recover"):
|
||||
if lf_path.exists():
|
||||
lf_path.unlink()
|
||||
dump = subprocess.Popen(
|
||||
[sqlite3_bin, "-readonly", str(source), command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
[sqlite3_bin, "-readonly", str(source), command], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
load = subprocess.Popen(
|
||||
[sqlite3_bin, str(lf_path)],
|
||||
stdin=dump.stdout,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
[sqlite3_bin, str(lf_path)], stdin=dump.stdout, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
||||
)
|
||||
assert dump.stdout is not None
|
||||
dump.stdout.close() # let dump receive SIGPIPE if load dies
|
||||
@@ -129,21 +109,16 @@ def run_cli_lost_and_found_recover(
|
||||
except subprocess.TimeoutExpired:
|
||||
dump.kill()
|
||||
load.kill()
|
||||
raise LostAndFoundError(
|
||||
f"sqlite3 .recover timed out after {timeout:.0f}s"
|
||||
)
|
||||
raise LostAndFoundError(f"sqlite3 .recover timed out after {timeout:.0f}s")
|
||||
attempt = {
|
||||
"command": command,
|
||||
"dump_returncode": dump.returncode,
|
||||
"load_returncode": load.returncode,
|
||||
"command": command, "dump_returncode": dump.returncode, "load_returncode": load.returncode,
|
||||
"dump_stderr_tail": dump_err.decode("utf-8", "replace")[-2000:],
|
||||
"load_stderr_tail": load_err.decode("utf-8", "replace")[-2000:],
|
||||
}
|
||||
attempts.append(attempt)
|
||||
if _lost_and_found_db_usable(lf_path):
|
||||
attempt["usable"] = True
|
||||
attempt["usable"] = _lost_and_found_db_usable(lf_path)
|
||||
if attempt["usable"]:
|
||||
return {"binary": sqlite3_bin, "attempts": attempts}
|
||||
attempt["usable"] = False
|
||||
|
||||
raise LostAndFoundError(
|
||||
"sqlite3 .recover did not produce a usable lost_and_found database: "
|
||||
@@ -162,27 +137,17 @@ def _lost_and_found_db_usable(lf_path: Path) -> bool:
|
||||
try:
|
||||
conn = sqlite3.connect(str(lf_path))
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1"
|
||||
).fetchone() is not None
|
||||
return conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1").fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
|
||||
|
||||
def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
return [str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")')]
|
||||
|
||||
|
||||
def _notnull_defaults(conn: sqlite3.Connection, table: str) -> dict[int, Any]:
|
||||
"""Map column index -> substitute value for NOT NULL columns.
|
||||
|
||||
Page-level salvage can return NULLs where the live schema says NOT NULL (torn cells, old rows).
|
||||
Dropping a whole row over one damaged optional counter would defeat the lane, so such NULLs
|
||||
get the schema default (or '' / 0 when none is declared).
|
||||
"""
|
||||
|
||||
"""Column index -> substitute for NOT NULL columns. Salvage can return NULLs where the schema says
|
||||
NOT NULL (torn cells, old rows); dropping a row over one damaged counter would defeat the lane, so
|
||||
such NULLs get the schema default (or '' / 0 when none is declared)."""
|
||||
substitutes: dict[int, Any] = {}
|
||||
for index, row in enumerate(conn.execute(f'PRAGMA table_info("{table}")')):
|
||||
if not row[3]: # notnull flag
|
||||
@@ -218,40 +183,23 @@ def _looks_like_source(value: Any) -> bool:
|
||||
return value in KNOWN_SOURCES or bool(re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", value))
|
||||
|
||||
|
||||
def classify_lost_and_found_row(
|
||||
nfield: int,
|
||||
cells: tuple[Any, ...],
|
||||
) -> Optional[str]:
|
||||
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 in the
|
||||
# record; messages is the only canonical table shaped like that with
|
||||
# a session id second and a role third.
|
||||
if (
|
||||
isinstance(cells[1], str)
|
||||
and cells[1]
|
||||
and isinstance(cells[2], str)
|
||||
and cells[2] in MESSAGE_ROLES
|
||||
):
|
||||
# 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.
|
||||
if isinstance(cells[1], str) and cells[1] and isinstance(cells[2], str) and cells[2] in MESSAGE_ROLES:
|
||||
return "messages"
|
||||
return None
|
||||
|
||||
if not _is_session_id(cells[0] if cells else None):
|
||||
return None
|
||||
|
||||
second = cells[1] if len(cells) > 1 else None
|
||||
if nfield == SESSION_MODEL_USAGE_NFIELD:
|
||||
# 18 fields, session id first, model string second.
|
||||
if nfield == SESSION_MODEL_USAGE_NFIELD: # session id first, model string second
|
||||
return "session_model_usage" if isinstance(second, str) and second else None
|
||||
|
||||
# Known sessions layouts, or an unknown historical one (>= 30 fields): a
|
||||
# session-id first cell plus a recognizable source string is still strong
|
||||
# enough for a prefix map.
|
||||
# Known sessions layouts, or an unknown historical one (>= 30 fields): session id + source is enough.
|
||||
if (
|
||||
nfield in SESSIONS_LAYOUT_NFIELDS
|
||||
or nfield == SESSIONS_LEGACY_MINIMAL_NFIELD
|
||||
or nfield >= 30
|
||||
nfield in SESSIONS_LAYOUT_NFIELDS or nfield == SESSIONS_LEGACY_MINIMAL_NFIELD or nfield >= 30
|
||||
) and _looks_like_source(second):
|
||||
return "sessions"
|
||||
return None
|
||||
@@ -273,94 +221,53 @@ def _insert_prefix_row(
|
||||
) -> bool:
|
||||
if notnull_substitutes:
|
||||
values = [
|
||||
notnull_substitutes[index]
|
||||
if value is None and index in notnull_substitutes
|
||||
else value
|
||||
notnull_substitutes[index] if value is None and index in notnull_substitutes else value
|
||||
for index, value in enumerate(values)
|
||||
]
|
||||
columns = dest_columns[: len(values)]
|
||||
quoted = ", ".join(f'"{column}"' for column in columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
cursor = dest.execute(
|
||||
f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})',
|
||||
values,
|
||||
)
|
||||
quoted, placeholders = _quoted_columns(dest_columns[: len(values)])
|
||||
cursor = dest.execute(f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})', values)
|
||||
return cursor.rowcount == 1
|
||||
|
||||
|
||||
def _copy_direct_tables(
|
||||
lf_conn: sqlite3.Connection,
|
||||
dest: sqlite3.Connection,
|
||||
) -> dict[str, int]:
|
||||
def _copy_direct_tables(lf_conn: sqlite3.Connection, dest: sqlite3.Connection) -> dict[str, int]:
|
||||
"""Copy rows .recover managed to attribute to real canonical tables."""
|
||||
|
||||
# Lazy import: session_recovery imports this module inside a function, so
|
||||
# a module-level import here would be circular.
|
||||
from hermes_cli.session_recovery import (
|
||||
_AUXILIARY_TABLE_SCHEMAS,
|
||||
_AUXILIARY_TABLES,
|
||||
_CANONICAL_TABLES,
|
||||
)
|
||||
|
||||
copied: dict[str, int] = {}
|
||||
for table in (*_CANONICAL_TABLES, *_AUXILIARY_TABLES):
|
||||
source_columns = _table_columns(lf_conn, table)
|
||||
if not source_columns:
|
||||
continue
|
||||
dest_columns = _table_columns(dest, table)
|
||||
if not dest_columns and table in _AUXILIARY_TABLE_SCHEMAS:
|
||||
# Lazily-created gateway table: base SessionDB never made it on
|
||||
# the fresh destination, so create it before copying.
|
||||
if not dest_columns and table in _AUXILIARY_TABLE_SCHEMAS: # lazily-created gateway table
|
||||
_AUXILIARY_TABLE_SCHEMAS[table](dest)
|
||||
dest_columns = _table_columns(dest, table)
|
||||
columns = [c for c in dest_columns if c in source_columns]
|
||||
if not columns:
|
||||
continue
|
||||
quoted = ", ".join(f'"{c}"' for c in columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
quoted, placeholders = _quoted_columns(columns)
|
||||
rows = lf_conn.execute(f'SELECT {quoted} FROM "{table}"').fetchall()
|
||||
if not rows:
|
||||
copied[table] = 0
|
||||
continue
|
||||
before = int(dest.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
|
||||
dest.executemany(
|
||||
f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})',
|
||||
rows,
|
||||
)
|
||||
dest.executemany(f'INSERT OR IGNORE INTO "{table}" ({quoted}) VALUES ({placeholders})', rows)
|
||||
after = int(dest.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0])
|
||||
copied[table] = after - before
|
||||
return copied
|
||||
|
||||
|
||||
def map_lost_and_found_rows(
|
||||
lf_conn: sqlite3.Connection,
|
||||
dest: sqlite3.Connection,
|
||||
) -> dict[str, Any]:
|
||||
def map_lost_and_found_rows(lf_conn: sqlite3.Connection, dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Best-effort mapping of a .recover output DB into a fresh SessionDB."""
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"direct_table_rows": {},
|
||||
"mapped": {"sessions": 0, "messages": 0, "session_model_usage": 0},
|
||||
"legacy_minimal_sessions": 0,
|
||||
"unmapped_rows": 0,
|
||||
"insert_conflicts": 0,
|
||||
"lost_and_found_tables": [],
|
||||
"direct_table_rows": {}, "mapped": {"sessions": 0, "messages": 0, "session_model_usage": 0},
|
||||
"legacy_minimal_sessions": 0, "unmapped_rows": 0, "insert_conflicts": 0, "lost_and_found_tables": [],
|
||||
}
|
||||
|
||||
dest.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
with _immediate_transaction(dest):
|
||||
report["direct_table_rows"] = _copy_direct_tables(lf_conn, dest)
|
||||
|
||||
# Per-kind destination columns + NOT NULL substitutes. Never fabricate
|
||||
# identity fields: a row whose session id / role / source cell is
|
||||
# genuinely NULL was already rejected by classify_lost_and_found_row,
|
||||
# so the substitutions only fill NOT NULL bookkeeping counters/flags.
|
||||
# Per-kind destination columns + NOT NULL substitutes. Identity fields are never fabricated:
|
||||
# rows with a NULL session id / role / source were already rejected by classify_lost_and_found_row.
|
||||
targets: dict[str, tuple[list[str], dict[int, Any]]] = {}
|
||||
for kind_name, protected in (
|
||||
("sessions", (0, 1)),
|
||||
("messages", (1, 2)),
|
||||
("session_model_usage", (0, 1)),
|
||||
):
|
||||
for kind_name, protected in (("sessions", (0, 1)), ("messages", (1, 2)), ("session_model_usage", (0, 1))):
|
||||
defaults = _notnull_defaults(dest, kind_name)
|
||||
for index in protected:
|
||||
defaults.pop(index, None)
|
||||
@@ -376,8 +283,7 @@ def map_lost_and_found_rows(
|
||||
report["lost_and_found_tables"] = lf_tables
|
||||
|
||||
for lf_table in lf_tables:
|
||||
lf_columns = _table_columns(lf_conn, lf_table)
|
||||
if lf_columns[:3] != ["rootpgno", "pgno", "nfield"]:
|
||||
if _table_columns(lf_conn, lf_table)[:3] != ["rootpgno", "pgno", "nfield"]:
|
||||
continue
|
||||
for row in lf_conn.execute(f'SELECT * FROM "{lf_table}"'):
|
||||
try:
|
||||
@@ -393,37 +299,28 @@ def map_lost_and_found_rows(
|
||||
continue
|
||||
columns, defaults = targets[kind]
|
||||
try:
|
||||
if kind == "messages":
|
||||
# Rowid-alias PK is NULL in the record; use the lost_and_found rowid.
|
||||
values = [lf_rowid, *cells[1 : min(nfield, len(columns))]]
|
||||
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
|
||||
elif kind == "session_model_usage":
|
||||
values = list(cells[: len(columns)])
|
||||
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
|
||||
elif nfield == SESSIONS_LEGACY_MINIMAL_NFIELD:
|
||||
# A pre-modern layout whose column order is unknown:
|
||||
# salvage identity + timing rather than guessing 14
|
||||
# positional meanings.
|
||||
inserted = bool(
|
||||
if kind == "sessions" and nfield == SESSIONS_LEGACY_MINIMAL_NFIELD:
|
||||
# Pre-modern layout with unknown column order: salvage identity + timing only.
|
||||
inserted = (
|
||||
dest.execute(
|
||||
"INSERT OR IGNORE INTO sessions "
|
||||
"(id, source, started_at, title) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
cells[0],
|
||||
cells[1] if _looks_like_source(cells[1])
|
||||
else "recovered",
|
||||
cells[1] if _looks_like_source(cells[1]) else "recovered",
|
||||
_heuristic_started_at(cells),
|
||||
"[best-effort recovered] legacy session "
|
||||
"row (layout unknown)",
|
||||
"[best-effort recovered] legacy session row (layout unknown)",
|
||||
),
|
||||
).rowcount
|
||||
== 1
|
||||
)
|
||||
if inserted:
|
||||
report["legacy_minimal_sessions"] += 1
|
||||
report["legacy_minimal_sessions"] += int(inserted)
|
||||
else:
|
||||
values = list(cells[: min(nfield, len(columns))])
|
||||
# messages: the rowid-alias PK is NULL in the record; use the lost_and_found rowid.
|
||||
values = list(cells[:len(columns)])
|
||||
if kind == "messages":
|
||||
values[0] = lf_rowid
|
||||
inserted = _insert_prefix_row(dest, kind, columns, values, defaults)
|
||||
except sqlite3.DatabaseError:
|
||||
report["unmapped_rows"] += 1
|
||||
@@ -432,28 +329,14 @@ def map_lost_and_found_rows(
|
||||
report["mapped"][kind] += 1
|
||||
else:
|
||||
report["insert_conflicts"] += 1
|
||||
dest.execute("COMMIT")
|
||||
except BaseException:
|
||||
dest.execute("ROLLBACK")
|
||||
raise
|
||||
return report
|
||||
|
||||
|
||||
def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Fabricate placeholder parents for salvaged child rows.
|
||||
|
||||
Salvaged children (messages, model-usage rows) are NEVER deleted for foreign-key cleanup — a
|
||||
fabricated parent is cheaper than losing the only surviving copy of the user's data. Stubs are
|
||||
clearly marked.
|
||||
"""
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"sessions_stubbed": 0,
|
||||
"messages_retained": 0,
|
||||
"usage_rows_retained": 0,
|
||||
}
|
||||
dest.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
"""Fabricate clearly-marked placeholder parents for salvaged child rows: children (messages,
|
||||
model-usage rows) are NEVER deleted for FK cleanup — a stub parent beats losing the only copy."""
|
||||
result: dict[str, Any] = {"sessions_stubbed": 0, "messages_retained": 0, "usage_rows_retained": 0}
|
||||
with _immediate_transaction(dest):
|
||||
orphan_ids: dict[str, dict[str, Any]] = {}
|
||||
for session_id, first_ts, count in dest.execute(
|
||||
"SELECT m.session_id, MIN(m.timestamp), COUNT(*) FROM messages AS m "
|
||||
@@ -470,42 +353,24 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
"WHERE u.session_id IS NOT NULL AND NOT EXISTS "
|
||||
"(SELECT 1 FROM sessions WHERE sessions.id = u.session_id)"
|
||||
):
|
||||
orphan_ids.setdefault(
|
||||
str(session_id), {"started_at": 0.0, "message_count": 0}
|
||||
)
|
||||
orphan_ids.setdefault(str(session_id), {"started_at": 0.0, "message_count": 0})
|
||||
|
||||
sequence = 1
|
||||
for session_id, info in sorted(orphan_ids.items()):
|
||||
while True:
|
||||
title = (
|
||||
f"[best-effort recovered {sequence}] session metadata "
|
||||
"was unreadable"
|
||||
)
|
||||
title = f"[best-effort recovered {sequence}] session metadata was unreadable"
|
||||
sequence += 1
|
||||
if (
|
||||
dest.execute(
|
||||
"SELECT 1 FROM sessions WHERE title = ? LIMIT 1",
|
||||
(title,),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
if dest.execute("SELECT 1 FROM sessions WHERE title = ? LIMIT 1", (title,)).fetchone() is None:
|
||||
break
|
||||
dest.execute(
|
||||
"INSERT INTO sessions (id, source, started_at, title, "
|
||||
"message_count) VALUES (?, 'recovered', ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
info["started_at"],
|
||||
title,
|
||||
info["message_count"],
|
||||
),
|
||||
(session_id, info["started_at"], title, info["message_count"]),
|
||||
)
|
||||
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]
|
||||
)
|
||||
result["usage_rows_retained"] = int(dest.execute("SELECT COUNT(*) FROM session_model_usage").fetchone()[0])
|
||||
|
||||
# Repair dangling intra-sessions references without deleting rows.
|
||||
dest.execute(
|
||||
@@ -519,16 +384,11 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
|
||||
"(SELECT 1 FROM system_prompts "
|
||||
"WHERE system_prompts.hash = sessions.system_prompt_hash)"
|
||||
)
|
||||
dest.execute("COMMIT")
|
||||
except BaseException:
|
||||
dest.execute("ROLLBACK")
|
||||
raise
|
||||
return result
|
||||
|
||||
|
||||
def rebuild_fts_indexes(dest: sqlite3.Connection) -> dict[str, str]:
|
||||
"""Rebuild derived FTS indexes from the salvaged canonical rows."""
|
||||
|
||||
results: dict[str, str] = {}
|
||||
for table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
|
||||
if not _table_columns(dest, table):
|
||||
|
||||
+27
-80
@@ -1,8 +1,5 @@
|
||||
"""Session recap — summarize what's happened in the current session.
|
||||
|
||||
Differences from Claude Code: - Pure local computation from the in-memory conversation history. No
|
||||
LLM call, no auxiliary model, no prompt-cache invalidation. A recap should be instant and free.
|
||||
"""
|
||||
"""Session recap — summarize the current session from in-memory history. Pure local computation: no
|
||||
LLM call, no auxiliary model, no prompt-cache invalidation. A recap should be instant and free."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -12,35 +9,20 @@ from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from tools.ansi_strip import sanitize_display_text
|
||||
|
||||
# How many recent user/assistant turns we consider "recent activity".
|
||||
_RECENT_TURN_WINDOW = 20
|
||||
|
||||
# How many characters of the latest user prompt to show.
|
||||
_RECENT_TURN_WINDOW = 20 # user/assistant turns considered "recent activity"
|
||||
_PROMPT_PREVIEW_CHARS = 140
|
||||
|
||||
# How many characters of the latest assistant text to show.
|
||||
_ASSISTANT_PREVIEW_CHARS = 200
|
||||
|
||||
# How many recently-touched files to list.
|
||||
_MAX_FILES_LISTED = 5
|
||||
|
||||
# Tool names that identify a file-editing action and the argument key that
|
||||
# holds the path.
|
||||
# File-touching tool name -> argument key holding the path.
|
||||
_FILE_EDIT_TOOLS: Mapping[str, str] = {
|
||||
"write_file": "path",
|
||||
"patch": "path",
|
||||
"read_file": "path",
|
||||
"skill_manage": "file_path",
|
||||
"skill_view": "file_path",
|
||||
"write_file": "path", "patch": "path", "read_file": "path",
|
||||
"skill_manage": "file_path", "skill_view": "file_path",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_text(value: Any) -> str:
|
||||
"""Flatten assistant/user ``content`` into a plain string.
|
||||
|
||||
Content may be a string or a list of blocks (multimodal/reasoning models); text-like blocks
|
||||
are concatenated and the rest ignored.
|
||||
"""
|
||||
"""Flatten ``content`` (string, or list of blocks whose text-like parts are joined) into a string."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
@@ -59,14 +41,8 @@ def _coerce_text(value: Any) -> str:
|
||||
|
||||
|
||||
def _tool_call_name_and_args(tool_call: Any) -> Tuple[str, Mapping[str, Any]]:
|
||||
"""Extract ``(name, arguments_dict)`` from a tool_call entry.
|
||||
|
||||
``arguments`` may be a JSON string or a dict depending on provider. Return an empty dict if it
|
||||
cannot be parsed.
|
||||
"""
|
||||
if not isinstance(tool_call, Mapping):
|
||||
return "", {}
|
||||
fn = tool_call.get("function") or {}
|
||||
"""``(name, arguments_dict)`` from a tool_call; ``arguments`` may be a JSON string or dict ({} if unparsable)."""
|
||||
fn = tool_call.get("function") if isinstance(tool_call, Mapping) else None
|
||||
if not isinstance(fn, Mapping):
|
||||
return "", {}
|
||||
name = str(fn.get("name") or "")
|
||||
@@ -79,13 +55,9 @@ def _tool_call_name_and_args(tool_call: Any) -> Tuple[str, Mapping[str, Any]]:
|
||||
return name, raw_args if isinstance(raw_args, Mapping) else {}
|
||||
|
||||
|
||||
def _iter_assistant_tool_calls(
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
) -> Iterable[Tuple[str, Mapping[str, Any]]]:
|
||||
def _iter_assistant_tool_calls(messages: Sequence[Mapping[str, Any]]) -> Iterable[Tuple[str, Mapping[str, Any]]]:
|
||||
for msg in messages:
|
||||
if not isinstance(msg, Mapping):
|
||||
continue
|
||||
if msg.get("role") != "assistant":
|
||||
if not isinstance(msg, Mapping) or msg.get("role") != "assistant":
|
||||
continue
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if not isinstance(tool_calls, list):
|
||||
@@ -96,9 +68,7 @@ def _iter_assistant_tool_calls(
|
||||
yield name, args
|
||||
|
||||
|
||||
def _count_visible_turns(
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
) -> Tuple[int, int, int]:
|
||||
def _count_visible_turns(messages: Sequence[Mapping[str, Any]]) -> Tuple[int, int, int]:
|
||||
"""Return ``(user_turn_count, assistant_turn_count, tool_message_count)``."""
|
||||
roles = Counter(msg.get("role") for msg in messages if isinstance(msg, Mapping))
|
||||
return roles["user"], roles["assistant"], roles["tool"]
|
||||
@@ -117,21 +87,15 @@ def _latest_text(messages: Sequence[Mapping[str, Any]], role: str) -> Optional[s
|
||||
def _recent_window(
|
||||
messages: Sequence[Mapping[str, Any]], window: int = _RECENT_TURN_WINDOW
|
||||
) -> List[Mapping[str, Any]]:
|
||||
"""Return the tail slice of ``messages`` covering at most ``window`` user+assistant turns (tool
|
||||
messages ride along inside the window).
|
||||
"""
|
||||
"""Tail slice covering at most ``window`` user+assistant turns (tool messages ride along)."""
|
||||
count = 0
|
||||
cut = 0
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[i]
|
||||
if isinstance(msg, Mapping) and msg.get("role") in {"user", "assistant"}:
|
||||
count += 1
|
||||
if count >= window:
|
||||
cut = i
|
||||
break
|
||||
else:
|
||||
return list(messages)
|
||||
return list(messages[cut:])
|
||||
return list(messages[i:])
|
||||
return list(messages)
|
||||
|
||||
|
||||
def _shortened_path(path: str) -> str:
|
||||
@@ -156,16 +120,11 @@ def _shortened_path(path: str) -> str:
|
||||
def _summarise_tool_activity(
|
||||
tool_calls: Sequence[Tuple[str, Mapping[str, Any]]],
|
||||
) -> Tuple[List[Tuple[str, int]], List[str]]:
|
||||
"""Return ``(tool_counts_sorted, recently_edited_files)``.
|
||||
|
||||
Counts are descending and kept in full so callers truncate for display; files are distinct
|
||||
paths, most recent first, from file-editing tools.
|
||||
"""
|
||||
"""``(tool_counts_sorted_desc, recently_edited_files)`` — files are distinct paths, newest first."""
|
||||
counter: Counter[str] = Counter()
|
||||
files_seen: List[str] = []
|
||||
files_set: set[str] = set()
|
||||
# Walk in reverse so files_seen comes out newest→oldest (Counter ignores order).
|
||||
for name, args in reversed(list(tool_calls)):
|
||||
for name, args in reversed(list(tool_calls)): # reversed so files_seen comes out newest -> oldest
|
||||
counter[name] += 1
|
||||
arg_key = _FILE_EDIT_TOOLS.get(name)
|
||||
if arg_key:
|
||||
@@ -185,14 +144,10 @@ def _join_capped(items: List[str], limit: int) -> str:
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
# Stored history is untrusted for display — remove escape sequences and
|
||||
# control chars so a recap line can't clear the screen / retitle the
|
||||
# window when echoed to a terminal (openai/codex#31494 bug class).
|
||||
text = sanitize_display_text(text)
|
||||
text = " ".join(text.split()) # collapse newlines for a compact one-liner
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
# Stored history is untrusted for display: strip escapes/control chars so a recap line can't clear
|
||||
# the screen or retitle the window when echoed to a terminal.
|
||||
text = " ".join(sanitize_display_text(text).split()) # collapse newlines for a compact one-liner
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def build_recap(
|
||||
@@ -202,20 +157,13 @@ def build_recap(
|
||||
session_id: Optional[str] = None,
|
||||
platform: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build a multi-line recap of recent activity from chat-completion-style ``messages``.
|
||||
|
||||
``platform`` is accepted for forward compat but does not change behavior. Output is plain
|
||||
text that renders well both in an 80-col terminal and in a gateway message bubble.
|
||||
"""
|
||||
_ = platform # reserved for future use
|
||||
lines: List[str] = []
|
||||
|
||||
header_bits: List[str] = ["Session recap"]
|
||||
"""Multi-line plain-text recap of recent activity (80-col terminal / gateway bubble friendly).
|
||||
``platform`` is accepted for forward compat and does not change behavior."""
|
||||
lines: List[str] = ["Session recap"]
|
||||
if session_title:
|
||||
header_bits.append(f"— {session_title}")
|
||||
lines[0] += f" — {session_title}"
|
||||
elif session_id:
|
||||
header_bits.append(f"— {session_id[:8]}")
|
||||
lines.append(" ".join(header_bits))
|
||||
lines[0] += f" — {session_id[:8]}"
|
||||
|
||||
if not messages:
|
||||
lines.append(" (nothing to recap — no messages yet)")
|
||||
@@ -249,8 +197,7 @@ def build_recap(
|
||||
if latest_reply:
|
||||
lines.append(f" Last reply: {_truncate(latest_reply, _ASSISTANT_PREVIEW_CHARS)}")
|
||||
|
||||
if len(lines) == 2:
|
||||
# Only the header + scope line — nothing substantive to show.
|
||||
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)
|
||||
|
||||
+402
-601
File diff suppressed because it is too large
Load Diff
+81
-162
@@ -1,15 +1,14 @@
|
||||
"""``hermes sessions`` command.
|
||||
|
||||
``cmd_sessions`` routes ``args.sessions_action`` through ``_PRE_DB_HANDLERS``
|
||||
(repair / recover / import — must run without opening ``SessionDB()``, which is
|
||||
exactly what a malformed schema prevents) and ``_DB_HANDLERS`` (everything else,
|
||||
sharing one ``SessionDB`` that is always closed afterwards). ``get_hermes_home``
|
||||
is resolved through ``hermes_cli.main`` at call time so monkeypatches there keep
|
||||
working and imports stay one-way. Browse picker: :mod:`hermes_cli.sessions_cmd_browse`.
|
||||
``cmd_sessions`` routes ``args.sessions_action`` through ``_PRE_DB_HANDLERS`` (repair / recover /
|
||||
import — must run without opening ``SessionDB()``, which a malformed schema prevents) and
|
||||
``_DB_HANDLERS`` (everything else, sharing one ``SessionDB``). ``get_hermes_home`` is resolved through
|
||||
``hermes_cli.main`` at call time so monkeypatches keep working. Picker: :mod:`hermes_cli.sessions_cmd_browse`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -33,12 +32,9 @@ def _size_mb(path) -> float:
|
||||
|
||||
|
||||
def _size_delta_label(saved_mb: float) -> str:
|
||||
"""Size delta label. A negative delta means the file GREW (concurrent writes
|
||||
during a long optimize can outweigh what the rebuild freed); "reclaimed
|
||||
-163.0 MB" reads as data loss, so say "grew by" instead."""
|
||||
if saved_mb >= 0:
|
||||
return f"reclaimed {saved_mb:.1f} MB"
|
||||
return f"grew by {-saved_mb:.1f} MB"
|
||||
"""A negative delta means the file GREW (concurrent writes during a long optimize); "reclaimed
|
||||
-163.0 MB" reads as data loss, so say "grew by"."""
|
||||
return f"reclaimed {saved_mb:.1f} MB" if saved_mb >= 0 else f"grew by {-saved_mb:.1f} MB"
|
||||
|
||||
|
||||
def _confirm_prompt(prompt: str) -> bool:
|
||||
@@ -85,9 +81,7 @@ def _write_output(output, text, summary) -> None:
|
||||
print(summary)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers that must run BEFORE SessionDB() is opened
|
||||
# ---------------------------------------------------------------------------
|
||||
# -- handlers that must run BEFORE SessionDB() is opened ----------------------
|
||||
|
||||
def _cmd_repair(args):
|
||||
from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema
|
||||
@@ -111,12 +105,9 @@ def _cmd_repair(args):
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
_repair_db = SessionDB()
|
||||
try:
|
||||
with SessionDB() as _repair_db:
|
||||
n = _repair_db._conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
|
||||
print(f"✓ Repaired — {n} sessions recovered.")
|
||||
finally:
|
||||
_repair_db.close()
|
||||
print(f"✓ Repaired — {n} sessions recovered.")
|
||||
except Exception:
|
||||
print("✓ Repaired.")
|
||||
return
|
||||
@@ -124,8 +115,7 @@ def _cmd_repair(args):
|
||||
if report.get("backup_path"):
|
||||
print(f" A backup is preserved at: {report['backup_path']}")
|
||||
print(" Keep state.db and the backup; do not delete them.")
|
||||
# Without this pointer the user is at a dead end after a failed in-place
|
||||
# repair. Lead with --inspect-only so they confirm readability before writing.
|
||||
# Without this pointer the user is at a dead end; lead with --inspect-only before writing.
|
||||
print("")
|
||||
print(" Next step — offline recovery (never modifies the source):")
|
||||
source_hint = report.get("backup_path") or db_path
|
||||
@@ -138,8 +128,7 @@ def _cmd_repair(args):
|
||||
|
||||
|
||||
def _cmd_recover(args):
|
||||
"""Offline recovery: never opens the supplied source directly (works on a
|
||||
disposable copy) and never touches the active database."""
|
||||
"""Offline recovery: works on a disposable copy of the source; never touches the active database."""
|
||||
import sqlite3
|
||||
|
||||
from hermes_cli.session_recovery import (
|
||||
@@ -201,8 +190,7 @@ def _cmd_recover(args):
|
||||
class _RecoveryProgress:
|
||||
"""`recover` progress printer: one live-updating `` <table>: n/total`` line per table."""
|
||||
|
||||
def __init__(self):
|
||||
self.table = None
|
||||
table = None
|
||||
|
||||
def __call__(self, info):
|
||||
table = info.get("table")
|
||||
@@ -235,10 +223,8 @@ def _print_recovery_verdict(report, output, allow_partial) -> int:
|
||||
)
|
||||
else:
|
||||
print(f"✓ Partial recovery output verified at: {output}")
|
||||
print(
|
||||
f" Recovered {int(counts.get('sessions') or 0):,} sessions and "
|
||||
f"{int(counts.get('messages') or 0):,} messages."
|
||||
)
|
||||
sessions_n, messages_n = int(counts.get("sessions") or 0), int(counts.get("messages") or 0)
|
||||
print(f" Recovered {sessions_n:,} sessions and {messages_n:,} messages.")
|
||||
print(" The active session database was not changed.")
|
||||
print(
|
||||
" This output is incomplete. Review every skipped range and orphan count in the "
|
||||
@@ -252,15 +238,12 @@ def _print_recovery_verdict(report, output, allow_partial) -> int:
|
||||
|
||||
def _cmd_import(args):
|
||||
from hermes_cli.foreign_sessions import run_sessions_import
|
||||
# Explicit path but nothing imported (bad path, unknown source, no turns) →
|
||||
# non-zero so scripts can detect it. Picker cancel (no path) → normal exit 0.
|
||||
# Explicit path but nothing imported -> non-zero for scripts. Picker cancel (no path) -> exit 0.
|
||||
if run_sessions_import(args) is None and getattr(args, "path", None):
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers that receive an open SessionDB
|
||||
# ---------------------------------------------------------------------------
|
||||
# -- handlers that receive an open SessionDB ----------------------------------
|
||||
|
||||
def _default_exclude(args):
|
||||
"""Hide third-party tool sessions by default, but honour explicit --source."""
|
||||
@@ -286,8 +269,7 @@ def _cmd_list(db, args):
|
||||
print("No sessions found.")
|
||||
return
|
||||
|
||||
# The Workspace column only appears once at least one session carries a
|
||||
# workspace key (or when filtering), so all-unbound listings read as before.
|
||||
# Workspace column only when some session carries a key (or when filtering): unbound listings read as before.
|
||||
has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions)
|
||||
has_titles = any(s.get("title") for s in sessions)
|
||||
|
||||
@@ -342,8 +324,7 @@ def _cmd_export(db, args):
|
||||
return redact_session_data(data)
|
||||
|
||||
def _collect_sessions():
|
||||
"""Resolve --session-id / filters / bare export into a list of
|
||||
redacted session dicts, or None after printing an error."""
|
||||
"""--session-id / filters / bare export -> redacted session dicts, or None after printing an error."""
|
||||
if args.session_id:
|
||||
resolved = db.resolve_session_id(args.session_id)
|
||||
data = _redact(db.export_session(resolved)) if resolved else None
|
||||
@@ -396,14 +377,11 @@ def _export_html(args, collect):
|
||||
sessions = collect()
|
||||
if sessions is None:
|
||||
return
|
||||
if len(sessions) == 1:
|
||||
content = generate_html_export(sessions[0])
|
||||
else:
|
||||
content = generate_multi_session_html_export(sessions)
|
||||
single = len(sessions) == 1
|
||||
content = generate_html_export(sessions[0]) if single else generate_multi_session_html_export(sessions)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
suffix = "" if len(sessions) == 1 else "s"
|
||||
print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)")
|
||||
print(f"Exported {len(sessions)} session{'' if single else 's'} to {args.output} (HTML)")
|
||||
|
||||
|
||||
def _export_jsonl(args, collect):
|
||||
@@ -419,11 +397,10 @@ def _export_jsonl(args, collect):
|
||||
|
||||
|
||||
def _export_trace(db, args, filters):
|
||||
"""Claude Code JSONL trace export — local file or HF upload. Redaction is ON
|
||||
by default (traces leave the machine with --upload); --no-redact opts out."""
|
||||
"""Claude Code JSONL trace export — local file or HF upload. Redaction is ON by default (traces
|
||||
leave the machine with --upload); --no-redact opts out."""
|
||||
session_id = args.session_id
|
||||
if not session_id and not filters:
|
||||
# Match the shell's common intent: "the last thing I did".
|
||||
if not session_id and not filters: # "the last thing I did"
|
||||
rows = db.list_sessions_rich(limit=1, order_by_last_active=True)
|
||||
session_id = rows[0].get("id") if rows else None
|
||||
if not session_id:
|
||||
@@ -507,7 +484,6 @@ def _export_markdown(db, args, filters, redact):
|
||||
if args.delete_after_verified and not args.session_id:
|
||||
print("--delete-after-verified is only supported with --session-id.")
|
||||
return
|
||||
|
||||
lineage_is_logical = getattr(args, "lineage", "single") == "logical"
|
||||
|
||||
if args.session_id:
|
||||
@@ -577,9 +553,7 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
|
||||
if db.delete_session(resolved_session_id, sessions_dir=_sessions_dir(), expected_delete_ids=delete_target_ids):
|
||||
delegate_count = len(delete_target_ids) - 1
|
||||
delegate_suffix = (
|
||||
""
|
||||
if not delegate_count
|
||||
else f" and {delegate_count} delegate session{'' if delegate_count == 1 else 's'}"
|
||||
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:
|
||||
@@ -592,10 +566,8 @@ def _cmd_delete(db, args):
|
||||
resolved_session_id = db.resolve_session_id(args.session_id)
|
||||
if not resolved_session_id:
|
||||
return _not_found(args.session_id)
|
||||
# The user named this id directly so the delete is honored, but a pin is a
|
||||
# "keep" flag and silently destroying it is surprising — say so.
|
||||
_get_session = getattr(db, "get_session", None)
|
||||
_meta = (_get_session(resolved_session_id) or {}) if callable(_get_session) else {}
|
||||
# The delete is honored (explicit id), but a pin is a "keep" flag: say so instead of silently destroying it.
|
||||
_meta = db.get_session(resolved_session_id) or {}
|
||||
_pinned_note = " (this session is PINNED)" if _meta.get("pinned") else ""
|
||||
if not args.yes:
|
||||
if not _confirm_prompt(
|
||||
@@ -612,16 +584,14 @@ def _cmd_delete(db, args):
|
||||
return _not_found(args.session_id)
|
||||
|
||||
|
||||
#: Age floor for `prune --never-active`. Generous on purpose: the rows are harmless,
|
||||
#: and a young never-active row may simply be a chat nobody has replied to yet.
|
||||
#: Age floor for `prune --never-active`; generous: a young never-active row may be a chat nobody replied to yet.
|
||||
_NEVER_ACTIVE_DEFAULT_DAYS = 30.0
|
||||
|
||||
|
||||
def _prune_never_active_keyed(db, args):
|
||||
"""`prune --never-active`: drop keyed gateway rows opened and never used
|
||||
(mostly escaped test fixtures). Separate from the shared prune/archive
|
||||
selector, which is pinned to `ended_at IS NOT NULL`: never-closed rows sit
|
||||
outside it by construction and cannot be expressed as one more filter."""
|
||||
"""`prune --never-active`: drop keyed gateway rows opened and never used (mostly escaped test
|
||||
fixtures). Separate from the shared prune/archive selector, which is pinned to `ended_at IS NOT
|
||||
NULL` — never-closed rows sit outside it by construction."""
|
||||
from hermes_cli.session_filters import format_epoch, parse_duration_seconds
|
||||
older_than = getattr(args, "older_than", None)
|
||||
if older_than is None:
|
||||
@@ -668,21 +638,14 @@ def _prune_never_active_keyed(db, args):
|
||||
|
||||
|
||||
def _note_pinned_skipped(db, filters, action):
|
||||
"""Pinned sessions are excluded by default from bulk prune/archive (pin =
|
||||
durable keep). `prune --include-pinned` opts in; archive has no such flag,
|
||||
so archive always spares pinned rows. Tell the user how many were spared."""
|
||||
_count_matches = getattr(db, "count_prune_matches", None)
|
||||
if not callable(_count_matches):
|
||||
return
|
||||
"""Tell the user how many pinned rows bulk prune/archive spared (pin = durable keep; only
|
||||
`prune --include-pinned` opts in, archive always spares them)."""
|
||||
_base = {k: v for k, v in filters.items() if k != "include_pinned"}
|
||||
try:
|
||||
skipped = max(
|
||||
int(_count_matches(**_base, include_pinned=True))
|
||||
- int(_count_matches(**_base, include_pinned=False)),
|
||||
0,
|
||||
)
|
||||
except TypeError:
|
||||
return # a db double without include_pinned support — skip the note
|
||||
skipped = max(
|
||||
int(db.count_prune_matches(**_base, include_pinned=True))
|
||||
- int(db.count_prune_matches(**_base, include_pinned=False)),
|
||||
0,
|
||||
)
|
||||
if not skipped:
|
||||
return
|
||||
suffix = "" if skipped == 1 else "s"
|
||||
@@ -704,10 +667,9 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
return _prune_never_active_keyed(db, args)
|
||||
|
||||
from hermes_cli.session_filters import build_prune_filters, describe_filters, format_epoch
|
||||
# Bare `prune` (no time window, no filters) keeps the historical "older
|
||||
# than 90 days" default. ANY filter — including --source — suppresses the
|
||||
# implicit cutoff (`prune --source cron` matches ALL cron sessions); the
|
||||
# preview + confirmation below is the safety net.
|
||||
# Bare `prune` keeps the historical "older than 90 days" default. ANY filter — including --source —
|
||||
# suppresses the implicit cutoff (`prune --source cron` matches ALL cron sessions); the preview +
|
||||
# confirmation below is the safety net.
|
||||
if action == "prune" and not _any_filter_args(args, _TIME_FILTER_ARGS + _NON_TIME_FILTER_ARGS):
|
||||
args.older_than = "90"
|
||||
|
||||
@@ -724,8 +686,7 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
)
|
||||
return
|
||||
|
||||
# Prune skips archived rows unless --include-archived; archive only targets
|
||||
# not-yet-archived rows (idempotent).
|
||||
# Prune skips archived rows unless --include-archived; archive only targets not-yet-archived rows.
|
||||
filters["archived"] = None if action == "prune" and getattr(args, "include_archived", False) else False
|
||||
|
||||
filters["include_pinned"] = getattr(args, "include_pinned", False)
|
||||
@@ -733,8 +694,8 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
_note_pinned_skipped(db, filters, action)
|
||||
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
# Archive expands each row to its compression lineage (may include open
|
||||
# continuations), so a direct-open count would misdescribe its effect.
|
||||
# Archive expands each row to its compression lineage (may include open continuations), so a
|
||||
# direct-open count would misdescribe its effect.
|
||||
skipped_open = db.count_open_prune_matches(**filters) if action == "prune" else 0
|
||||
if skipped_open:
|
||||
suffix = "" if skipped_open == 1 else "s"
|
||||
@@ -749,8 +710,8 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
print(f"No sessions match ({describe_filters(filters)}).")
|
||||
return
|
||||
|
||||
# Candidates are ordered oldest-activity-first; show the span so a long-lived
|
||||
# but recently used conversation cannot look old merely by creation date.
|
||||
# Candidates are oldest-activity-first; show the span so a long-lived but recently used
|
||||
# conversation cannot look old merely by creation date.
|
||||
_span = (
|
||||
f"oldest activity {format_epoch(candidates[0].get('last_active'))}, "
|
||||
f"newest activity {format_epoch(candidates[-1].get('last_active'))}"
|
||||
@@ -778,12 +739,10 @@ def _cmd_prune_or_archive(db, args, action):
|
||||
return
|
||||
|
||||
if action == "prune":
|
||||
count = db.prune_sessions(sessions_dir=_sessions_dir(), **filters)
|
||||
print(f"Pruned {count} session(s).")
|
||||
print(f"Pruned {db.prune_sessions(sessions_dir=_sessions_dir(), **filters)} session(s).")
|
||||
else:
|
||||
count = db.archive_sessions(**filters)
|
||||
print(
|
||||
f"Archived {count} session(s). They're hidden from listings "
|
||||
f"Archived {db.archive_sessions(**filters)} session(s). They're hidden from listings "
|
||||
"but fully recoverable (nothing was deleted)."
|
||||
)
|
||||
|
||||
@@ -795,8 +754,7 @@ def _cmd_rename(db, args):
|
||||
if not resolved_session_id:
|
||||
return _not_found(args.session_id)
|
||||
title = " ".join(args.title)
|
||||
# Empty titles render as "—" and newlines corrupt the `list` table; length
|
||||
# is validated in set_session_title.
|
||||
# Empty titles render as "—" and newlines corrupt the `list` table; length is validated in set_session_title.
|
||||
if not title.strip():
|
||||
print("Error: title cannot be empty or whitespace-only.")
|
||||
return 1
|
||||
@@ -814,8 +772,7 @@ def _cmd_rename(db, args):
|
||||
|
||||
|
||||
def _cmd_pin(db, args, pinning):
|
||||
"""Durable "keep" flag (exempt from sessions.auto_archive, always listed);
|
||||
every surface — GUI, TUI, CLI, scripts — reads/writes the same store."""
|
||||
"""Durable "keep" flag (exempt from sessions.auto_archive, always listed); every surface shares the store."""
|
||||
failures = 0
|
||||
for raw_id in args.session_ids:
|
||||
resolved = db.resolve_session_id(raw_id)
|
||||
@@ -831,22 +788,12 @@ def _cmd_pin(db, args, pinning):
|
||||
|
||||
|
||||
def _cmd_pinned(db, args):
|
||||
# limit=1 keeps the recency page minimal; include_pinned back-fills ALL
|
||||
# pinned rows the page missed, so old pins can't fall off a paging window.
|
||||
# limit=1 keeps the recency page minimal; include_pinned back-fills ALL pinned rows the page missed.
|
||||
rows = db.list_sessions_rich(limit=1, include_pinned=True, exclude_sources=_default_exclude(args))
|
||||
pinned_rows = [s for s in rows if s.get("pinned")]
|
||||
if getattr(args, "json", False):
|
||||
payload = [
|
||||
{
|
||||
"id": s["id"],
|
||||
"title": s.get("title"),
|
||||
"source": s.get("source"),
|
||||
"last_active": s.get("last_active"),
|
||||
"message_count": s.get("message_count"),
|
||||
}
|
||||
for s in pinned_rows
|
||||
]
|
||||
print(json.dumps(payload, indent=2))
|
||||
keys = ("title", "source", "last_active", "message_count")
|
||||
print(json.dumps([{"id": s["id"], **{k: s.get(k) for k in keys}} for s in pinned_rows], indent=2))
|
||||
return
|
||||
if not pinned_rows:
|
||||
print("No pinned sessions. Pin one with: hermes sessions pin <session_id>")
|
||||
@@ -866,9 +813,8 @@ def _cmd_retitle_skills(db, args):
|
||||
apply_changes = bool(getattr(args, "apply", False))
|
||||
|
||||
def _is_titlelike(candidate: str) -> bool:
|
||||
"""Reject non-titles: an auxiliary model occasionally answers the prompt
|
||||
instead of titling it ('$ df -h /'). The live path takes what it gets,
|
||||
but this is a REPAIR — never replace a serviceable title with that."""
|
||||
"""Reject non-titles: an auxiliary model occasionally answers the prompt instead of titling it
|
||||
('$ df -h /'). This is a REPAIR — never replace a serviceable title with that."""
|
||||
return bool(candidate) and candidate[0].isalnum()
|
||||
|
||||
candidates = db.list_skill_scaffolded_sessions(limit=limit)
|
||||
@@ -896,8 +842,7 @@ def _cmd_retitle_skills(db, args):
|
||||
continue
|
||||
try:
|
||||
db.set_session_title(session_id, new_title)
|
||||
except ValueError:
|
||||
# Unique-title collision: dedupe like the live auto-titler (base #2, #3, ...).
|
||||
except ValueError: # unique-title collision: dedupe like the live auto-titler (base #2, #3, ...)
|
||||
deduped = db.get_next_title_in_lineage(new_title)
|
||||
try:
|
||||
db.set_session_title(session_id, deduped)
|
||||
@@ -922,16 +867,13 @@ def _cmd_browse(db, args):
|
||||
print("No sessions found.")
|
||||
return
|
||||
|
||||
# Keep the DB open: the picker uses it for status tags and 'd' delete.
|
||||
try:
|
||||
try: # keep the DB open: the picker uses it for status tags and 'd' delete
|
||||
selected_id = _session_browse_picker(sessions, session_db=db)
|
||||
finally:
|
||||
db.close()
|
||||
if not selected_id:
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
# Launch hermes --resume <id> by replacing the current process
|
||||
print(f"Resuming session: {selected_id}")
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["--resume", selected_id]) # won't return after execvp
|
||||
@@ -940,26 +882,20 @@ def _cmd_browse(db, args):
|
||||
# -- storage maintenance -----------------------------------------------------
|
||||
|
||||
def _print_size_change(db, before_mb, prefix=""):
|
||||
"""Report before/after size, preferring SQLite's page accounting over stat():
|
||||
in WAL mode a VACUUM's rewrite sits in the -wal file until a checkpoint,
|
||||
which is refused while a live gateway holds a read-mark, so the main file
|
||||
lags and stat() can even go negative. page_count * page_size is correct now."""
|
||||
after_mb = _size_mb(db.db_path)
|
||||
"""Report before/after size, preferring SQLite's page accounting over stat(): in WAL mode a VACUUM's
|
||||
rewrite sits in the -wal file until a checkpoint (refused while a live gateway holds a read-mark),
|
||||
so the main file lags and stat() can even go negative."""
|
||||
logical_after = db.logical_size_bytes()
|
||||
if logical_after is not None:
|
||||
after_mb = logical_after / (1024 * 1024)
|
||||
print(
|
||||
f"{prefix}Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
|
||||
f"({_size_delta_label(before_mb - after_mb)})"
|
||||
)
|
||||
after_mb = logical_after / (1024 * 1024) if logical_after is not None else _size_mb(db.db_path)
|
||||
delta = _size_delta_label(before_mb - after_mb)
|
||||
print(f"{prefix}Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB ({delta})")
|
||||
|
||||
|
||||
def _cmd_optimize(db, args):
|
||||
before_mb = _size_mb(db.db_path)
|
||||
print("Optimizing session store (FTS merge + VACUUM)…")
|
||||
try:
|
||||
# vacuum() merges FTS5 segments then VACUUMs; returns indexes merged.
|
||||
n = db.vacuum()
|
||||
n = db.vacuum() # merges FTS5 segments then VACUUMs; returns indexes merged
|
||||
except Exception as e:
|
||||
print(f"Error: optimization failed: {e}")
|
||||
return
|
||||
@@ -968,10 +904,7 @@ def _cmd_optimize(db, args):
|
||||
|
||||
|
||||
def _cmd_clean_markers(db, args):
|
||||
if args.dry_run:
|
||||
print("Dry run — scanning for stale tool-call marker rows (#78148)…")
|
||||
else:
|
||||
print("Scanning for stale tool-call marker rows (#78148)…")
|
||||
print(f"{'Dry run — scanning' if args.dry_run else 'Scanning'} for stale tool-call marker rows (#78148)…")
|
||||
report = db.purge_stale_tool_call_markers(dry_run=args.dry_run, backup=not args.no_backup)
|
||||
if report["rows_affected"] == 0:
|
||||
print("✓ No affected rows found — nothing to clean.")
|
||||
@@ -992,12 +925,10 @@ def _cmd_optimize_storage(db, args):
|
||||
before_bytes = os.path.getsize(db_path) if db_path.exists() else 0
|
||||
before_mb = before_bytes / (1024 * 1024)
|
||||
|
||||
# Disk preflight: the new index is built before the old is torn down, and
|
||||
# VACUUM needs a full second copy — require headroom ≈ current file size.
|
||||
# Disk preflight: the new index is built before the old is torn down, and VACUUM needs a full
|
||||
# second copy — require headroom ≈ current file size.
|
||||
do_vacuum = not getattr(args, "no_vacuum", False)
|
||||
try:
|
||||
import shutil
|
||||
|
||||
free_bytes = shutil.disk_usage(db_path.parent).free
|
||||
except Exception:
|
||||
free_bytes = None
|
||||
@@ -1006,18 +937,15 @@ def _cmd_optimize_storage(db, args):
|
||||
print(f" Current database size: {before_mb:.1f} MB")
|
||||
if free_bytes is not None:
|
||||
print(f" Free disk: {free_bytes / (1024*1024):.0f} MB "
|
||||
f"(need ~{need_bytes / (1024*1024):.0f} MB to complete"
|
||||
f"{' incl. VACUUM' if do_vacuum else ''})")
|
||||
f"(need ~{need_bytes / (1024*1024):.0f} MB to complete{' incl. VACUUM' if do_vacuum else ''})")
|
||||
if free_bytes < need_bytes:
|
||||
print()
|
||||
print("⚠ Not enough free disk to complete safely. Free up "
|
||||
"space, or run with --no-vacuum (rebuilds the index "
|
||||
"but doesn't reclaim space until a later VACUUM).")
|
||||
print("⚠ Not enough free disk to complete safely. Free up space, or run with --no-vacuum "
|
||||
"(rebuilds the index but doesn't reclaim space until a later VACUUM).")
|
||||
return
|
||||
if before_mb > 500:
|
||||
print(" This may take a while on a large database. It runs in "
|
||||
"the foreground with progress below; safe to Ctrl-C and "
|
||||
"re-run (it resumes).")
|
||||
print(" This may take a while on a large database. It runs in the foreground with progress below; "
|
||||
"safe to Ctrl-C and re-run (it resumes).")
|
||||
if not getattr(args, "yes", False):
|
||||
try:
|
||||
resp = input("Proceed? [y/N] ").strip().lower()
|
||||
@@ -1031,16 +959,12 @@ def _cmd_optimize_storage(db, args):
|
||||
|
||||
def _progress(info):
|
||||
phase = info.get("phase")
|
||||
pct = info.get("percent", 0)
|
||||
if phase == "backfill":
|
||||
print(f"\r Rebuilding index: {pct:3d}% "
|
||||
f"({info.get('indexed',0):,}/{info.get('total',0):,})",
|
||||
end="", flush=True)
|
||||
print(f"\r Rebuilding index: {info.get('percent', 0):3d}% "
|
||||
f"({info.get('indexed',0):,}/{info.get('total',0):,})", end="", flush=True)
|
||||
elif phase != _last["phase"]:
|
||||
label = {"teardown": "Reclaiming old index",
|
||||
"vacuum": "Compacting database (VACUUM)",
|
||||
"done": "Done"}.get(phase, phase)
|
||||
print(f"\n {label}…", flush=True)
|
||||
label = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"}
|
||||
print(f"\n {label.get(phase, phase)}…", flush=True)
|
||||
_last["phase"] = phase
|
||||
|
||||
print("Optimizing search-index storage…")
|
||||
@@ -1065,8 +989,7 @@ def _cmd_repair_routing(db, args):
|
||||
for record in records:
|
||||
print(f"{record['orphan_id']} ({record['source']}, {record['message_count']} messages)")
|
||||
if record["adoptable"]:
|
||||
print(f" → adopt into {record['session_key']} "
|
||||
f"(from {record['donor_id']}, "
|
||||
print(f" → adopt into {record['session_key']} (from {record['donor_id']}, "
|
||||
f"evidence: {record['evidence']})")
|
||||
else:
|
||||
print(f" ✗ not repairable — {record['reason']}")
|
||||
@@ -1106,9 +1029,7 @@ def _cmd_stats(db, args):
|
||||
print(f"Database size: {_size_mb(db.db_path):.1f} MB")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
# -- dispatch -----------------------------------------------------------------
|
||||
|
||||
_PRE_DB_HANDLERS = {"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import}
|
||||
|
||||
@@ -1146,11 +1067,9 @@ def cmd_sessions(args, sessions_parser=None):
|
||||
print(f"Error: Could not open session database: {e}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
with db:
|
||||
handler = _DB_HANDLERS.get(action)
|
||||
if handler is None:
|
||||
sessions_parser.print_help()
|
||||
return
|
||||
return handler(db, args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Interactive picker for ``hermes sessions browse`` (extracted from sessions_cmd).
|
||||
|
||||
Curses UI with live search filtering and ``d`` delete-with-confirmation; a
|
||||
numbered-list fallback when curses is unavailable (Windows, etc.).
|
||||
"""
|
||||
"""Interactive picker for ``hermes sessions browse``: curses UI with live search filtering and ``d``
|
||||
delete-with-confirmation; numbered-list fallback when curses is unavailable (Windows, etc.)."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -15,8 +12,7 @@ def _session_status_tag(status: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _annotate_session_statuses(sessions: list, session_db) -> None:
|
||||
"""Attach a ``_status`` key to each row via one indexed lookup per session
|
||||
(never a transcript scan). On failure rows stay untagged and render '-'."""
|
||||
"""Attach ``_status`` per row via one indexed lookup (never a transcript scan); on failure rows render '-'."""
|
||||
if session_db is None or not sessions:
|
||||
return
|
||||
try:
|
||||
@@ -29,7 +25,7 @@ def _annotate_session_statuses(sessions: list, session_db) -> None:
|
||||
|
||||
def _label(s: dict) -> str:
|
||||
"""Title, else preview, else id."""
|
||||
return ((s.get("title") or "").strip() or (s.get("preview") or "").strip() or s["id"])
|
||||
return (s.get("title") or "").strip() or (s.get("preview") or "").strip() or s["id"]
|
||||
|
||||
|
||||
def _msgs_str(s: dict) -> str:
|
||||
@@ -48,18 +44,14 @@ def _match(s: dict, query: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# Layout: [arrow 3] [title/preview flexible] [status 5] [msgs 5]
|
||||
# [active 12] [src 6] [id 18]
|
||||
# Layout: [arrow 3] [title/preview flexible] [status 5] [msgs 5] [active 12] [src 6] [id 18]
|
||||
_FIXED_COLS = 3 + 5 + 2 + 5 + 2 + 12 + 6 + 18 + 6
|
||||
|
||||
|
||||
def _format_row(s: dict, max_x: int) -> str:
|
||||
"""Format a session row for display."""
|
||||
name_width = max(20, max_x - _FIXED_COLS)
|
||||
title = (s.get("title") or "").strip()
|
||||
preview = (s.get("preview") or "").strip()
|
||||
sid = s["id"][:18]
|
||||
name = (title or preview)[:name_width] or sid
|
||||
name = ((s.get("title") or "").strip() or (s.get("preview") or "").strip())[:name_width] or sid
|
||||
return (
|
||||
f"{name:<{name_width}} {_session_status_tag(s.get('_status')):<5} "
|
||||
f"{_msgs_str(s):>5} {_relative_time(s.get('last_active')):<10} "
|
||||
@@ -71,12 +63,9 @@ class _CursesBrowser:
|
||||
"""State + render loop for the curses picker. ``run`` is the wrapper target."""
|
||||
|
||||
def __init__(self, curses, sessions, delete_fn):
|
||||
self.curses = curses
|
||||
self.sessions = sessions
|
||||
self.delete_fn = delete_fn # None => no delete support
|
||||
self.curses, self.sessions, self.delete_fn = curses, sessions, delete_fn # delete_fn None => no delete
|
||||
self.result = None
|
||||
self.cursor = 0
|
||||
self.scroll = 0
|
||||
self.cursor = self.scroll = 0
|
||||
self.search = ""
|
||||
self.confirm_delete = None # session dict pending y/n confirmation
|
||||
self.flash = "" # one-frame notice (e.g. "Deleted.")
|
||||
@@ -97,10 +86,9 @@ class _CursesBrowser:
|
||||
pass
|
||||
|
||||
def _refilter(self, reset_cursor=True):
|
||||
self.filtered = ([s for s in self.sessions if _match(s, self.search)] if self.search else list(self.sessions))
|
||||
self.filtered = [s for s in self.sessions if _match(s, self.search)] if self.search else list(self.sessions)
|
||||
if reset_cursor:
|
||||
self.cursor = 0
|
||||
self.scroll = 0
|
||||
self.cursor = self.scroll = 0
|
||||
|
||||
def _draw(self, stdscr, max_y, max_x):
|
||||
c = self.curses
|
||||
@@ -163,8 +151,7 @@ class _CursesBrowser:
|
||||
def _handle_key(self, key) -> bool:
|
||||
"""Apply one keypress; return True when the picker should exit."""
|
||||
c = self.curses
|
||||
if self.confirm_delete is not None:
|
||||
# y/n confirmation mode — only an explicit 'y' deletes.
|
||||
if self.confirm_delete is not None: # y/n confirmation mode — only an explicit 'y' deletes
|
||||
target, self.confirm_delete = self.confirm_delete, None
|
||||
if key in {ord("y"), ord("Y")}:
|
||||
if self.delete_fn(target["id"]):
|
||||
@@ -261,14 +248,10 @@ def _fallback_picker(sessions: list) -> Optional[str]:
|
||||
|
||||
|
||||
def _session_browse_picker(sessions: list, session_db=None) -> Optional[str]:
|
||||
"""Interactive curses-based session browser with live search filtering.
|
||||
"""Curses session browser with live search; returns the selected session ID, or None if cancelled.
|
||||
|
||||
Shows lifecycle status (done / intr / err / empty) and message count per
|
||||
session when *session_db* is provided. With a live *session_db*, pressing
|
||||
``d`` on a row (while the search filter is empty) prompts y/n and deletes
|
||||
the session via ``SessionDB.delete_session``.
|
||||
|
||||
Returns the selected session ID, or None if cancelled.
|
||||
With *session_db*: shows lifecycle status / message count per row, and ``d`` (while the filter is
|
||||
empty) prompts y/n and deletes via ``SessionDB.delete_session``.
|
||||
"""
|
||||
if not sessions:
|
||||
print("No sessions found.")
|
||||
|
||||
Reference in New Issue
Block a user