{"repo":"NousResearch/hermes-agent","public_id":"3015","display_label":"Session transcript files accumulating unbounded on disk","summary":"These issues and PRs address a leak where on-disk session transcript files are never deleted during pruning or reset, causing unbounded disk growth. Fixes involve deleting transcript files during auto-prune, session deletion, and gateway reset operations.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13861},"resolution":{"state":"resolved","by":"pr-16286"},"freshness":{"state":"verified","last_verified_at":"2026-04-27T01:31:08+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":3015,"pr_number":13861,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3015,"pr_number":16286,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3015,"title":"Session File Leak: JSON sessions never deleted, causing unbounded disk growth and cost explosion","url":null,"state":"closed"},{"kind":"pr","number":3028,"title":"fix(gateway): delete legacy session transcript files on reset (#3015)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":6613,"title":"fix(sessions): delete on-disk transcript files during prune and delete","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16286","number":16286},{"kind":"pr","value":"#13861","number":13861}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13861,"title":"feat(state): auto-prune old sessions + VACUUM state.db at startup","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/config.py","hermes_state.py","tests/test_hermes_state.py","website/docs/user-guide/sessions.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -914,6 +914,32 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:\n     print(f\"\\033[32m✓ Worktree cleaned up: {wt_path}\\033[0m\")\n \n \n+def _run_state_db_auto_maintenance(session_db) -> None:\n+    \"\"\"Call ``SessionDB.maybe_auto_prune_and_vacuum`` using current config.\n+\n+    Reads the ``sessions:`` section from config.yaml via\n+    :func:`hermes_cli.config.load_config` (the authoritative loader that\n+    deep-merges DEFAULT_CONFIG, so unmigrated configs still get default\n+    values). Honours ``auto_prune`` / ``retention_days`` /\n+    ``vacuum_after_prune`` / ``min_interval_hours``, and delegates to the\n+    DB. Never raises — maintenance must never block interactive startup.\n+    \"\"\"\n+    if session_db is None:\n+        return\n+    try:\n+        from hermes_cli.config import load_config as _load_full_config\n+        cfg = (_load_full_config().get(\"sessions\") or {})\n+        if not cfg.get(\"auto_prune\", False):\n+            return\n+        session_db.maybe_auto_prune_and_vacuum(\n+            retention_days=int(cfg.get(\"retention_days\", 90)),\n+            min_interval_hours=int(cfg.get(\"min_interval_hours\", 24)),\n+            vacuum=bool(cfg.get(\"vacuum_after_prune\", True)),\n+        )\n+    except Exception as exc:\n+        logger.debug(\"state.db auto-maintenance skipped: %s\", exc)\n+\n+\n def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:\n     \"\"\"Remove stale worktrees and orphaned branches on startup.\n ","@@ -1961,7 +1987,13 @@ def __init__(\n             self._session_db = SessionDB()\n         except Exception as e:\n             logger.warning(\"Failed to initialize SessionDB — session will NOT be indexed for search: %s\", e)\n-        \n+\n+        # Opportunistic state.db maintenance — runs at most once per\n+        # min_interval_hours, tracked via state_meta in state.db itself so\n+        # it's shared across all Hermes processes for this HERMES_HOME.\n+        # Never blocks startup on failure.\n+        _run_state_db_auto_maintenance(self._session_db)\n+\n         # Deferred title: stored in memory until the session is created in the DB\n         self._pending_title: Optional[str] = None\n         "]},{"file":"gateway/run.py","hunks":["@@ -710,7 +710,26 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n             self._session_db = SessionDB()\n         except Exception as e:\n             logger.debug(\"SQLite session store not available: %s\", e)\n-        \n+\n+        # Opportunistic state.db maintenance: prune ended sessions older\n+        # than sessions.retention_days + optional VACUUM. Tracks last-run\n+        # in state_meta so it only actually executes once per\n+        # sessions.min_interval_hours.  Gateway is long-lived so blocking\n+        # a few seconds once per day is acceptable; failures are logged\n+        # but never raised.\n+        if self._session_db is not None:\n+            try:\n+                from hermes_cli.config import load_config as _load_full_config\n+                _sess_cfg = (_load_full_config().get(\"sessions\") or {})\n+                if _sess_cfg.get(\"auto_prune\", False):\n+                    self._session_db.maybe_auto_prune_and_vacuum(\n+                        retention_days=int(_sess_cfg.get(\"retention_days\", 90)),\n+                        min_interval_hours=int(_sess_cfg.get(\"min_interval_hours\", 24)),\n+                        vacuum=bool(_sess_cfg.get(\"vacuum_after_prune\", True)),\n+                    )\n+            except Exception as exc:\n+                logger.debug(\"state.db auto-maintenance skipped: %s\", exc)\n+\n         # DM pairing store for code-based user authorization\n         from gateway.pairing import PairingStore\n         self.pairing_store = PairingStore()"]},{"file":"hermes_cli/config.py","hunks":["@@ -893,6 +893,34 @@ def _ensure_hermes_home_managed(home: Path):\n         \"force_ipv4\": False,\n     },\n \n+    # Session storage — controls automatic cleanup of ~/.hermes/state.db.\n+    # state.db accumulates every session, message, tool call, and FTS5 index\n+    # entry forever.  Without auto-pruning, a heavy user (gateway + cron)\n+    # reports 384MB+ databases with 68K+ messages, which slows down FTS5\n+    # inserts, /resume listing, and insights queries.\n+    \"sessions\": {\n+        # When true, prune ended sessions older than retention_days once\n+        # per (roughly) min_interval_hours at CLI/gateway/cron startup.\n+        # Only touches ended sessions — active sessions are always preserved.\n+        # Default false: session history is valuable for search recall, and\n+        # silently deleting it could surprise users.  Opt in explicitly.\n+        \"auto_prune\": False,\n+        # How many days of ended-session history to keep.  Matches the\n+        # default of ``hermes sessions prune``.\n+        \"retention_days\": 90,\n+        # VACUUM after a prune that actually deleted rows.  SQLite does not\n+        # reclaim disk space on DELETE — freed pages are just reused on\n+        # subsequent INSERTs — so without VACUUM the file stays bloated\n+        # even after pruning.  VACUUM blocks writes for a few seconds per\n+        # 100MB, so it only runs at startup, and only when prune deleted\n+        # ≥1 session.\n+        \"vacuum_after_prune\": True,\n+        # Minimum hours between auto-maintenance runs (avoids repeating\n+        # the sweep on every CLI invocation).  Tracked via state_meta in\n+        # state.db itself, so it's shared across all processes.\n+        \"min_interval_hours\": 24,\n+    },\n+\n     # Config schema version - bump this when adding new required fields\n     \"_config_version\": 22,\n }","@@ -2102,6 +2130,7 @@ def check_config_version() -> Tuple[int, int]:\n     \"fallback_providers\", \"credential_pool_strategies\", \"toolsets\",\n     \"agent\", \"terminal\", \"display\", \"compression\", \"delegation\",\n     \"auxiliary\", \"custom_providers\", \"context\", \"memory\", \"gateway\",\n+    \"sessions\",\n }\n \n # Valid fields inside a custom_providers list entry"]},{"file":"hermes_state.py","hunks":["@@ -84,6 +84,11 @@\n     codex_reasoning_items TEXT\n );\n \n+CREATE TABLE IF NOT EXISTS state_meta (\n+    key TEXT PRIMARY KEY,\n+    value TEXT\n+);\n+\n CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);\n CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);\n CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);","@@ -1441,3 +1446,116 @@ def _do(conn):\n             return len(session_ids)\n \n         return self._execute_write(_do)\n+\n+    # ── Meta key/value (for scheduler bookkeeping) ──\n+\n+    def get_meta(self, key: str) -> Optional[str]:\n+        \"\"\"Read a value from the state_meta key/value store.\"\"\"\n+        with self._lock:\n+            row = self._conn.execute(\n+                \"SELECT value FROM state_meta WHERE key = ?\", (key,)\n+            ).fetchone()\n+        if row is None:\n+            return None\n+        return row[\"value\"] if isinstance(row, sqlite3.Row) else row[0]\n+\n+    def set_meta(self, key: str, value: str) -> None:\n+        \"\"\"Write a value to the state_meta key/value store.\"\"\"\n+        def _do(conn):\n+            conn.execute(\n+                \"INSERT INTO state_meta (key, value) VALUES (?, ?) \"\n+                \"ON CONFLICT(key) DO UPDATE SET value = excluded.value\",\n+                (key, value),\n+            )\n+        self._execute_write(_do)\n+\n+    # ── Space reclamation ──\n+\n+    def vacuum(self) -> None:\n+        \"\"\"Run VACUUM to reclaim disk space after large deletes.\n+\n+        SQLite does not shrink the database file when rows are deleted —\n+        freed pages just get reused on the next insert. After a prune that\n+        removed hundreds of sessions, the file stays bloated unless we\n+        explicitly VACUUM.\n+\n+        VACUUM rewrites the entire DB, so it's expensive (seconds per\n+        100MB) and cannot run inside a transaction. It also acquires an\n+        exclusive lock, so callers must ensure no other writers are\n+        active. Safe to call at startup before the gateway/CLI starts\n+        serving traffic.\n+        \"\"\"\n+        # VACUUM cannot be executed inside a transaction.\n+        with self._lock:\n+            # Best-effort WAL checkpoint first, then VACUUM.\n+            try:\n+                self._conn.execute(\"PRAGMA wal_checkpoint(TRUNCATE)\")\n+            except Exception:\n+                pass\n+            self._conn.execute(\"VACUUM\")\n+\n+    def maybe_auto_prune_and_vacuum(\n+        self,\n+        retention_days: int = 90,\n+        min_interval_hours: int = 24,\n+        vacuum: bool = True,\n+    ) -> Dict[str, Any]:\n+        \"\"\"Idempotent auto-maintenance: prune old sessions + optional VACUUM.\n+\n+        Records the last run timestamp in state_meta so subsequent calls\n+        within ``min_interval_hours`` no-op. Designed to be called once at\n+        startup from long-lived entrypoints (CLI, gateway, cron scheduler).\n+\n+        Never raises. On any failure, logs a warning and returns a dict\n+        with ``\"error\"`` set.\n+\n+        Returns a dict with keys:\n+          - ``\"skipped\"`` (bool) — true if within min_interval_hours of last run\n+          - ``\"pruned\"`` (int)   — number of sessions deleted\n+          - ``\"vacuumed\"`` (bool) — true if VACUUM ran\n+          - ``\"error\"`` (str, optional) — present only on failure\n+        \"\"\"\n+        result: Dict[str, Any] = {\"skipped\": False, \"pruned\": 0, \"vacuumed\": False}\n+        try:\n+            # Skip if another process/call did maintenance recently.\n+            last_raw = self.get_meta(\"last_auto_prune\")\n+            now = time.time()\n+            if last_raw:\n+                try:\n+                    last_ts = float(last_raw)\n+                    if now - last_ts < min_interval_hours * 3600:\n+                        result[\"skipped\"] = True\n+                        return result\n+                except (TypeError, ValueError):\n+                    pass  # corrupt meta; treat as no prior run\n+\n+            pruned = self.prune_sessions(older_than_days=retention_days)\n+            result[\"pruned\"] = pruned\n+\n+            # Only VACUUM if we actually freed rows — VACUUM on a tight DB\n+            # is wasted I/O. Threshold keeps small DBs from paying the cost.\n+            if vacuum and pruned > 0:\n+                try:\n+                    self.vacuum()\n+                    result[\"vacuumed\"] = True\n+                except Exception as exc:\n+                    logger.warning(\"state.db VACUUM failed: %s\", exc)\n+\n+            # Record the attempt even if pruned == 0, so we don't retry\n+            # every startup within the min_interval_hours window.\n+            self.set_meta(\"last_auto_prune\", str(now))\n+\n+            if pruned > 0:\n+                logger.info(\n+                    \"state.db auto-maintenance: pruned %d session(s) older than %d days%s\",\n+                    pruned,\n+                    retention_days,\n+                    \" + VACUUM\" if result[\"vacuumed\"] else \"\",\n+                )\n+        except Exception as exc:\n+            # Maintenance must never block startup. Log and return error marker.\n+            logger.warning(\"state.db auto-maintenance failed: %s\", exc)\n+            result[\"error\"] = str(exc)\n+\n+        return result\n+"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -1732,3 +1732,124 @@ def test_sqlite_timeout_is_at_least_30s(self, db):\n         assert \"30\" in src, (\n             \"SQLite timeout should be at least 30s to handle CLI/gateway lock contention\"\n         )\n+\n+\n+# =========================================================================\n+# Auto-maintenance: state_meta + vacuum + maybe_auto_prune_and_vacuum\n+# =========================================================================\n+\n+class TestStateMeta:\n+    def test_get_meta_missing_returns_none(self, db):\n+        assert db.get_meta(\"nonexistent\") is None\n+\n+    def test_set_then_get_meta(self, db):\n+        db.set_meta(\"foo\", \"bar\")\n+        assert db.get_meta(\"foo\") == \"bar\"\n+\n+    def test_set_meta_upsert(self, db):\n+        \"\"\"set_meta overwrites existing value (ON CONFLICT DO UPDATE).\"\"\"\n+        db.set_meta(\"key\", \"v1\")\n+        db.set_meta(\"key\", \"v2\")\n+        assert db.get_meta(\"key\") == \"v2\"\n+\n+\n+class TestVacuum:\n+    def test_vacuum_runs_without_error(self, db):\n+        \"\"\"VACUUM must succeed on a fresh DB (no rows to reclaim).\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        db.append_message(session_id=\"s1\", role=\"user\", content=\"hi\")\n+        # Should not raise, even though there's nothing significant to reclaim.\n+        db.vacuum()\n+\n+\n+class TestAutoMaintenance:\n+    def _make_old_ended(self, db, sid: str, days_old: int = 100):\n+        \"\"\"Create a session that is ended and was started `days_old` days ago.\"\"\"\n+        db.create_session(session_id=sid, source=\"cli\")\n+        db.end_session(sid, end_reason=\"done\")\n+        db._conn.execute(\n+            \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+            (time.time() - days_old * 86400, sid),\n+        )\n+        db._conn.commit()\n+\n+    def test_first_run_prunes_and_vacuums(self, db):\n+        self._make_old_ended(db, \"old1\", days_old=100)\n+        self._make_old_ended(db, \"old2\", days_old=100)\n+        db.create_session(session_id=\"new\", source=\"cli\")  # active, must survive\n+\n+        result = db.maybe_auto_prune_and_vacuum(retention_days=90)\n+        assert result[\"skipped\"] is False\n+        assert result[\"pruned\"] == 2\n+        assert result[\"vacuumed\"] is True\n+        assert result.get(\"error\") is None\n+        assert db.get_session(\"old1\") is None\n+        assert db.get_session(\"old2\") is None\n+        assert db.get_session(\"new\") is not None\n+\n+    def test_second_call_within_interval_skips(self, db):\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        first = db.maybe_auto_prune_and_vacuum(\n+            retention_days=90, min_interval_hours=24\n+        )\n+        assert first[\"skipped\"] is False\n+        assert first[\"pruned\"] == 1\n+\n+        # Create another prunable session; a second call within\n+        # min_interval_hours should still skip without touching it.\n+        self._make_old_ended(db, \"old2\", days_old=100)\n+        second = db.maybe_auto_prune_and_vacuum(\n+            retention_days=90, min_interval_hours=24\n+        )\n+        assert second[\"skipped\"] is True\n+        assert second[\"pruned\"] == 0\n+        assert db.get_session(\"old2\") is not None  # untouched\n+\n+    def test_second_call_after_interval_runs_again(self, db):\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=24)\n+\n+        # Backdate the last-run marker to force another run.\n+        db.set_meta(\"last_auto_prune\", str(time.time() - 48 * 3600))\n+\n+        self._make_old_ended(db, \"old2\", days_old=100)\n+        result = db.maybe_auto_prune_and_vacuum(\n+            retention_days=90, min_interval_hours=24\n+        )\n+        assert result[\"skipped\"] is False\n+        assert result[\"pruned\"] == 1\n+        assert db.get_session(\"old2\") is None\n+\n+    def test_no_prunable_sessions_no_vacuum(self, db):\n+        \"\"\"When prune deletes 0 rows, VACUUM is skipped (wasted I/O).\"\"\"\n+        db.create_session(session_id=\"fresh\", source=\"cli\")  # too recent\n+        result = db.maybe_auto_prune_and_vacuum(retention_days=90)\n+        assert result[\"skipped\"] is False\n+        assert result[\"pruned\"] == 0\n+        assert result[\"vacuumed\"] is False\n+        # But last-run is still recorded so we don't retry immediately.\n+        assert db.get_meta(\"last_auto_prune\") is not None\n+\n+    def test_vacuum_disabled_via_flag(self, db):\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        result = db.maybe_auto_prune_and_vacuum(retention_days=90, vacuum=False)\n+        assert result[\"pruned\"] == 1\n+        assert result[\"vacuumed\"] is False\n+\n+    def test_corrupt_last_run_marker_treated_as_no_prior_run(self, db):\n+        \"\"\"A non-numeric marker must not break maintenance.\"\"\"\n+        db.set_meta(\"last_auto_prune\", \"not-a-timestamp\")\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        result = db.maybe_auto_prune_and_vacuum(retention_days=90)\n+        assert result[\"skipped\"] is False\n+        assert result[\"pruned\"] == 1\n+\n+    def test_state_meta_survives_vacuum(self, db):\n+        \"\"\"Marker written just before VACUUM must still be readable after.\"\"\"\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        db.maybe_auto_prune_and_vacuum(retention_days=90)\n+        marker = db.get_meta(\"last_auto_prune\")\n+        assert marker is not None\n+        # Should parse as a float timestamp close to now.\n+        assert abs(float(marker) - time.time()) < 60\n+"]},{"file":"website/docs/user-guide/sessions.md","hunks":["@@ -386,7 +386,21 @@ Key tables in `state.db`:\n \n - Gateway sessions auto-reset based on the configured reset policy\n - Before reset, the agent saves memories and skills from the expiring session\n-- Ended sessions remain in the database until pruned\n+- Opt-in auto-pruning: when `sessions.auto_prune` is `true`, ended sessions older than `sessions.retention_days` (default 90) are pruned at CLI/gateway startup\n+- After a prune that actually removed rows, `state.db` is `VACUUM`ed to reclaim disk space (SQLite does not shrink the file on plain DELETE)\n+- Pruning runs at most once per `sessions.min_interval_hours` (default 24); the last-run timestamp is tracked inside `state.db` itself so it's shared across every Hermes process in the same `HERMES_HOME`\n+\n+Default is **off** — session history is valuable for `session_search` recall, and silently deleting it could surprise users. Enable in `~/.hermes/config.yaml`:\n+\n+```yaml\n+sessions:\n+  auto_prune: true          # opt in — default is false\n+  retention_days: 90        # keep ended sessions this many days\n+  vacuum_after_prune: true  # reclaim disk space after a pruning sweep\n+  min_interval_hours: 24    # don't re-run the sweep more often than this\n+```\n+\n+Active sessions are never auto-pruned, regardless of age.\n \n ### Manual Cleanup\n ","@@ -403,5 +417,5 @@ hermes sessions prune --older-than 30 --yes\n ```\n \n :::tip\n-The database grows slowly (typical: 10-15 MB for hundreds of sessions). Pruning is mainly useful for removing old conversations you no longer need for search recall.\n+The database grows slowly (typical: 10-15 MB for hundreds of sessions) and session history powers `session_search` recall across past conversations, so auto-prune ships disabled. Enable it if you're running a heavy gateway/cron workload where `state.db` is meaningfully affecting performance (observed failure mode: 384 MB state.db with ~1000 sessions slowing down FTS5 inserts and `/resume` listing). Use `hermes sessions prune` for one-off cleanup without turning on the automatic sweep.\n :::\n"]}]},{"kind":"pr","number":16286,"title":"fix(sessions): delete transcript files during auto-prune (salvage #6613, closes #3015)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/main.py","hermes_state.py","tests/hermes_cli/test_sessions_delete.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -974,6 +974,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:\n         return\n     try:\n         from hermes_cli.config import load_config as _load_full_config\n+        from hermes_constants import get_hermes_home as _get_hermes_home\n         cfg = (_load_full_config().get(\"sessions\") or {})\n         if not cfg.get(\"auto_prune\", False):\n             return","@@ -981,6 +982,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:\n             retention_days=int(cfg.get(\"retention_days\", 90)),\n             min_interval_hours=int(cfg.get(\"min_interval_hours\", 24)),\n             vacuum=bool(cfg.get(\"vacuum_after_prune\", True)),\n+            sessions_dir=_get_hermes_home() / \"sessions\",\n         )\n     except Exception as exc:\n         logger.debug(\"state.db auto-maintenance skipped: %s\", exc)"]},{"file":"gateway/run.py","hunks":["@@ -763,6 +763,7 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n                         retention_days=int(_sess_cfg.get(\"retention_days\", 90)),\n                         min_interval_hours=int(_sess_cfg.get(\"min_interval_hours\", 24)),\n                         vacuum=bool(_sess_cfg.get(\"vacuum_after_prune\", True)),\n+                        sessions_dir=self.config.sessions_dir,\n                     )\n             except Exception as exc:\n                 logger.debug(\"state.db auto-maintenance skipped: %s\", exc)"]},{"file":"hermes_cli/main.py","hunks":["@@ -9230,7 +9230,8 @@ def cmd_sessions(args):\n                 ):\n                     print(\"Cancelled.\")\n                     return\n-            if db.delete_session(resolved_session_id):\n+            sessions_dir = get_hermes_home() / \"sessions\"\n+            if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):\n                 print(f\"Deleted session '{resolved_session_id}'.\")\n             else:\n                 print(f\"Session '{args.session_id}' not found.\")","@@ -9244,7 +9245,9 @@ def cmd_sessions(args):\n                 ):\n                     print(\"Cancelled.\")\n                     return\n-            count = db.prune_sessions(older_than_days=days, source=args.source)\n+            sessions_dir = get_hermes_home() / \"sessions\"\n+            count = db.prune_sessions(older_than_days=days, source=args.source,\n+                                      sessions_dir=sessions_dir)\n             print(f\"Pruned {count} session(s).\")\n \n         elif action == \"rename\":"]},{"file":"hermes_state.py","hunks":["@@ -1512,12 +1512,45 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n-    def delete_session(self, session_id: str) -> bool:\n+    @staticmethod\n+    def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:\n+        \"\"\"Remove on-disk transcript files for a session.\n+\n+        Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any\n+        ``request_dump_{session_id}_*.json`` files left by the gateway.\n+        Silently skips files that don't exist and swallows OSError so a\n+        filesystem hiccup never blocks a DB operation.\n+        \"\"\"\n+        if sessions_dir is None:\n+            return\n+        for suffix in (\".json\", \".jsonl\"):\n+            p = sessions_dir / f\"{session_id}{suffix}\"\n+            try:\n+                p.unlink(missing_ok=True)\n+            except OSError:\n+                pass\n+        # request_dump files use session_id as a prefix component\n+        try:\n+            for p in sessions_dir.glob(f\"request_dump_{session_id}_*.json\"):\n+                try:\n+                    p.unlink(missing_ok=True)\n+                except OSError:\n+                    pass\n+        except OSError:\n+            pass\n+\n+    def delete_session(\n+        self,\n+        session_id: str,\n+        sessions_dir: Optional[Path] = None,\n+    ) -> bool:\n         \"\"\"Delete a session and all its messages.\n \n         Child sessions are orphaned (parent_session_id set to NULL) rather\n         than cascade-deleted, so they remain accessible independently.\n-        Returns True if the session was found and deleted.\n+        When *sessions_dir* is provided, also removes on-disk transcript\n+        files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted\n+        session. Returns True if the session was found and deleted.\n         \"\"\"\n         def _do(conn):\n             cursor = conn.execute(","@@ -1534,16 +1567,29 @@ def _do(conn):\n             conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (session_id,))\n             conn.execute(\"DELETE FROM sessions WHERE id = ?\", (session_id,))\n             return True\n-        return self._execute_write(_do)\n \n-    def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:\n+        deleted = self._execute_write(_do)\n+        if deleted:\n+            self._remove_session_files(sessions_dir, session_id)\n+        return deleted\n+\n+    def prune_sessions(\n+        self,\n+        older_than_days: int = 90,\n+        source: str = None,\n+        sessions_dir: Optional[Path] = None,\n+    ) -> int:\n         \"\"\"Delete sessions older than N days. Returns count of deleted sessions.\n \n         Only prunes ended sessions (not active ones).  Child sessions outside\n         the prune window are orphaned (parent_session_id set to NULL) rather\n-        than cascade-deleted.\n+        than cascade-deleted.  When *sessions_dir* is provided, also removes\n+        on-disk transcript files (``.json`` / ``.jsonl`` /\n+        ``request_dump_*``) for every pruned session, outside the DB\n+        transaction.\n         \"\"\"\n         cutoff = time.time() - (older_than_days * 86400)\n+        removed_ids: list[str] = []\n \n         def _do(conn):\n             if source:","@@ -1573,9 +1619,14 @@ def _do(conn):\n             for sid in session_ids:\n                 conn.execute(\"DELETE FROM messages WHERE session_id = ?\", (sid,))\n                 conn.execute(\"DELETE FROM sessions WHERE id = ?\", (sid,))\n+                removed_ids.append(sid)\n             return len(session_ids)\n \n-        return self._execute_write(_do)\n+        count = self._execute_write(_do)\n+        # Clean up on-disk files outside the DB transaction\n+        for sid in removed_ids:\n+            self._remove_session_files(sessions_dir, sid)\n+        return count\n \n     # ── Meta key/value (for scheduler bookkeeping) ──\n ","@@ -1629,6 +1680,7 @@ def maybe_auto_prune_and_vacuum(\n         retention_days: int = 90,\n         min_interval_hours: int = 24,\n         vacuum: bool = True,\n+        sessions_dir: Optional[Path] = None,\n     ) -> Dict[str, Any]:\n         \"\"\"Idempotent auto-maintenance: prune old sessions + optional VACUUM.\n ","@@ -1636,6 +1688,10 @@ def maybe_auto_prune_and_vacuum(\n         within ``min_interval_hours`` no-op. Designed to be called once at\n         startup from long-lived entrypoints (CLI, gateway, cron scheduler).\n \n+        When *sessions_dir* is provided, on-disk transcript files\n+        (``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions\n+        are removed as part of the same sweep (issue #3015).\n+\n         Never raises. On any failure, logs a warning and returns a dict\n         with ``\"error\"`` set.\n ","@@ -1659,7 +1715,10 @@ def maybe_auto_prune_and_vacuum(\n                 except (TypeError, ValueError):\n                     pass  # corrupt meta; treat as no prior run\n \n-            pruned = self.prune_sessions(older_than_days=retention_days)\n+            pruned = self.prune_sessions(\n+                older_than_days=retention_days,\n+                sessions_dir=sessions_dir,\n+            )\n             result[\"pruned\"] = pruned\n \n             # Only VACUUM if we actually freed rows — VACUUM on a tight DB"]},{"file":"tests/hermes_cli/test_sessions_delete.py","hunks":["@@ -12,7 +12,7 @@ def resolve_session_id(self, session_id):\n             captured[\"resolved_from\"] = session_id\n             return \"20260315_092437_c9a6ff\"\n \n-        def delete_session(self, session_id):\n+        def delete_session(self, session_id, **kwargs):\n             captured[\"deleted\"] = session_id\n             return True\n ","@@ -45,7 +45,7 @@ class FakeDB:\n         def resolve_session_id(self, session_id):\n             return None\n \n-        def delete_session(self, session_id):\n+        def delete_session(self, session_id, **kwargs):\n             raise AssertionError(\"delete_session should not be called when resolution fails\")\n \n         def close(self):","@@ -73,7 +73,7 @@ class FakeDB:\n         def resolve_session_id(self, session_id):\n             return \"20260315_092437_c9a6ff\"\n \n-        def delete_session(self, session_id):\n+        def delete_session(self, session_id, **kwargs):\n             raise AssertionError(\"delete_session should not be called when cancelled\")\n \n         def close(self):"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -1981,3 +1981,58 @@ def test_state_meta_survives_vacuum(self, db):\n         # Should parse as a float timestamp close to now.\n         assert abs(float(marker) - time.time()) < 60\n \n+    def test_auto_prune_deletes_transcript_files(self, db, tmp_path):\n+        \"\"\"Issue #3015: auto-prune must also delete on-disk transcript files.\"\"\"\n+        sessions_dir = tmp_path / \"sessions\"\n+        sessions_dir.mkdir()\n+\n+        self._make_old_ended(db, \"old1\", days_old=100)\n+        self._make_old_ended(db, \"old2\", days_old=100)\n+        db.create_session(session_id=\"new\", source=\"cli\")  # active\n+\n+        # Transcript files mimicking real gateway/CLI layout\n+        (sessions_dir / \"old1.json\").write_text(\"{}\")\n+        (sessions_dir / \"old1.jsonl\").write_text(\"{}\\n\")\n+        (sessions_dir / \"old2.jsonl\").write_text(\"{}\\n\")\n+        (sessions_dir / \"request_dump_old1_001.json\").write_text(\"{}\")\n+        (sessions_dir / \"new.jsonl\").write_text(\"{}\\n\")  # active, must survive\n+\n+        result = db.maybe_auto_prune_and_vacuum(\n+            retention_days=90, sessions_dir=sessions_dir\n+        )\n+        assert result[\"pruned\"] == 2\n+\n+        # Pruned transcript files are gone\n+        assert not (sessions_dir / \"old1.json\").exists()\n+        assert not (sessions_dir / \"old1.jsonl\").exists()\n+        assert not (sessions_dir / \"old2.jsonl\").exists()\n+        assert not (sessions_dir / \"request_dump_old1_001.json\").exists()\n+        # Active session's transcript is untouched\n+        assert (sessions_dir / \"new.jsonl\").exists()\n+\n+    def test_auto_prune_without_sessions_dir_preserves_files(self, db, tmp_path):\n+        \"\"\"Backward-compat: no sessions_dir = DB-only cleanup (legacy behavior).\"\"\"\n+        sessions_dir = tmp_path / \"sessions\"\n+        sessions_dir.mkdir()\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        (sessions_dir / \"old.jsonl\").write_text(\"{}\\n\")\n+\n+        result = db.maybe_auto_prune_and_vacuum(retention_days=90)\n+        assert result[\"pruned\"] == 1\n+        # File stays — caller didn't opt in\n+        assert (sessions_dir / \"old.jsonl\").exists()\n+\n+    def test_prune_sessions_deletes_files_for_pruned_only(self, db, tmp_path):\n+        \"\"\"Active-session transcripts must never be deleted by prune.\"\"\"\n+        sessions_dir = tmp_path / \"sessions\"\n+        sessions_dir.mkdir()\n+        self._make_old_ended(db, \"old\", days_old=100)\n+        db.create_session(session_id=\"active\", source=\"cli\")  # not ended\n+        (sessions_dir / \"old.jsonl\").write_text(\"{}\\n\")\n+        (sessions_dir / \"active.jsonl\").write_text(\"{}\\n\")\n+\n+        count = db.prune_sessions(older_than_days=90, sessions_dir=sessions_dir)\n+        assert count == 1\n+        assert not (sessions_dir / \"old.jsonl\").exists()\n+        assert (sessions_dir / \"active.jsonl\").exists()\n+\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}