diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a25..14c5a4febe6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1620,7 +1620,7 @@ async def get_status(): from hermes_state import SessionDB db = SessionDB() try: - sessions = db.list_sessions_rich(limit=50) + sessions = db.list_sessions_rich(limit=50, compact_rows=True) now = time.time() active_sessions = sum( 1 for s in sessions @@ -2607,6 +2607,7 @@ async def get_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) total = db.session_count( source=source or None, @@ -2718,6 +2719,7 @@ async def get_profiles_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + compact_rows=True, ) profile_total = db.session_count( source=source_filter, diff --git a/hermes_state.py b/hermes_state.py index 8ffe8c25f68..04233578fe3 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1977,6 +1977,21 @@ class SessionDB: current = row["id"] return current + # All sessions columns except the large system_prompt blob, each prefixed + # with the "s" table alias used in list_sessions_rich/_get_session_rich_row + # queries. Used when compact_rows=True to avoid reading the blob for + # dashboard and picker callers that only need lightweight metadata. + _SESSION_COMPACT_COLS = ( + "s.id, s.source, s.user_id, s.model, s.model_config, " + "s.parent_session_id, s.started_at, s.ended_at, s.end_reason, " + "s.message_count, s.tool_call_count, s.input_tokens, s.output_tokens, " + "s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens, " + "s.cwd, s.billing_provider, s.billing_base_url, s.billing_mode, " + "s.estimated_cost_usd, s.actual_cost_usd, s.cost_status, s.cost_source, " + "s.pricing_version, s.title, s.api_call_count, s.handoff_state, " + "s.handoff_platform, s.handoff_error, s.rewind_count, s.archived" + ) + def list_sessions_rich( self, source: str = None, @@ -1990,6 +2005,7 @@ class SessionDB: include_archived: bool = False, archived_only: bool = False, id_query: str = None, + compact_rows: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -2017,6 +2033,12 @@ class SessionDB: surfaces in the correct slot. Ordering is computed at SQL level via a recursive CTE that walks compression-continuation edges, so LIMIT and OFFSET still apply efficiently. + + Pass ``compact_rows=True`` for dashboard and picker callers that only + need lightweight metadata. This omits the ``system_prompt`` blob from + the SELECT so SQLite never copies it out of the B-tree page — a + significant I/O saving on large databases where the blob routinely + runs to tens of kilobytes per row. """ where_clauses = [] params = [] @@ -2099,6 +2121,7 @@ class SessionDB: outer_where = ( f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}" ) + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -2120,7 +2143,7 @@ class SessionDB: FROM chain GROUP BY root_id ) - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -2143,8 +2166,9 @@ class SessionDB: # only applies to the outer select. params = params + params + id_params + [limit, offset] else: + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" query = f""" - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -2281,13 +2305,17 @@ class SessionDB: runs.append(s) return runs - def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]: + def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]: """Fetch a single session with the same enriched columns as ``list_sessions_rich`` (preview + last_active). Returns None if the session doesn't exist. + + Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see + ``list_sessions_rich`` for details). """ - query = """ - SELECT s.*, + _sel = self._SESSION_COMPACT_COLS if compact_rows else "s.*" + query = f""" + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f4258f2b915..926e81c0ae2 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4141,3 +4141,63 @@ class TestListCronJobRuns: detail = " ".join(row[-1] for row in plan) assert "USING INDEX" in detail or "USING COVERING INDEX" in detail, detail assert "idx_sessions_source" in detail, detail + + +# ========================================================================= +# compact_rows — lightweight column projection (issue #47414) +# ========================================================================= + +class TestCompactRows: + """list_sessions_rich and _get_session_rich_row with compact_rows=True + must omit system_prompt but return all other metadata fields.""" + + def _create(self, db, sid, *, system_prompt="big blob " * 500): + db.create_session(session_id=sid, source="cli", model="m") + db.update_system_prompt(sid, system_prompt) + return sid + + def test_compact_rows_omits_system_prompt(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + assert len(rows) == 1 + assert "system_prompt" not in rows[0] + + def test_full_rows_include_system_prompt(self, db): + self._create(db, "s1", system_prompt="keep me") + rows = db.list_sessions_rich(compact_rows=False) + assert rows[0]["system_prompt"] == "keep me" + + def test_compact_rows_preserves_metadata_fields(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + row = rows[0] + for field in ("id", "source", "model", "started_at", "message_count", + "input_tokens", "output_tokens", "title", "cwd", + "archived", "preview", "last_active"): + assert field in row, f"missing field: {field}" + + def test_compact_rows_order_by_last_active(self, db): + """compact_rows=True also works with the CTE / order_by_last_active path.""" + self._create(db, "s1") + self._create(db, "s2") + rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True) + assert len(rows) == 2 + assert all("system_prompt" not in r for r in rows) + + def test_get_session_rich_row_compact_omits_system_prompt(self, db): + self._create(db, "s1", system_prompt="should be gone") + row = db._get_session_rich_row("s1", compact_rows=True) + assert row is not None + assert "system_prompt" not in row + assert row["id"] == "s1" + + def test_get_session_rich_row_full_includes_system_prompt(self, db): + self._create(db, "s1", system_prompt="stay") + row = db._get_session_rich_row("s1", compact_rows=False) + assert row["system_prompt"] == "stay" + + def test_compact_rows_default_is_false(self, db): + """Default behaviour (compact_rows not passed) is unchanged — full rows.""" + self._create(db, "s1", system_prompt="present") + rows = db.list_sessions_rich() + assert "system_prompt" in rows[0] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4d12a1a417b..b1611b6636c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3987,7 +3987,7 @@ def _(rid, params: dict) -> dict: fetch_limit = max(limit * 2, 200) rows = [ s - for s in db.list_sessions_rich(source=None, limit=fetch_limit) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -4034,7 +4034,7 @@ def _(rid, params: dict) -> dict: # users (lots of recent ``tool`` rows) don't get a false # "no eligible session" answer. ``session.list`` uses a # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200) + rows = db.list_sessions_rich(source=None, limit=200, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -9478,7 +9478,7 @@ def _(rid, params: dict) -> dict: cutoff = time.time() - days * 86400 rows = [ s - for s in db.list_sessions_rich(limit=500) + for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff ] return _ok(