diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index de3657812e9..8b820901c13 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2175,7 +2175,6 @@ def _git_path(path: str) -> str: class GitPathBody(BaseModel): path: str - class GitFileBody(BaseModel): path: str file: Optional[str] = None @@ -3914,6 +3913,7 @@ 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, @@ -4032,6 +4032,7 @@ 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 876b0ef3780..16998fb0669 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2947,6 +2947,21 @@ class SessionDB: current = child_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 distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]: """Distinct non-empty session cwds with usage stats, for repo discovery. @@ -2988,6 +3003,7 @@ class SessionDB: archived_only: bool = False, id_query: str = None, search_query: str = None, + compact_rows: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -3021,6 +3037,12 @@ class SessionDB: its forward compression chain). A punctuation-stripped variant is also matched so e.g. ``an94`` finds ``AN-94``. Only honored in the ``order_by_last_active`` path. + + 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 = [] @@ -3138,6 +3160,7 @@ class SessionDB: outer_where = ( f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}" ) + _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} @@ -3161,7 +3184,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 @@ -3184,8 +3207,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 @@ -3322,13 +3346,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 4ad888442af..56fa0d85d8e 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -5362,3 +5362,63 @@ def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(d monkeypatch.setattr(hermes_state.time, "time", lambda: 1016.0) assert db.try_acquire_compression_lock("s1", "holder-b", ttl_seconds=10.0) is True + + +# ========================================================================= +# 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 8357de83dae..8e825a22669 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5314,7 +5314,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, order_by_last_active=True) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -5361,7 +5361,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, order_by_last_active=True) + rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -13409,7 +13409,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(