# NousResearch/hermes-agent · Session transcript files accumulating unbounded on disk

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #13861. Verify it addresses your case.
- **Recommended:** read PR #13861 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-16286`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #3015: Session File Leak: JSON sessions never deleted, causing unbounded disk growth and cost explosion

## Prior & competing PRs

- **#13861: feat(state): auto-prune old sessions + VACUUM state.db at startup**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -914,6 +914,32 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
         print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
     
     
    +def _run_state_db_auto_maintenance(session_db) -> None:
    +    """Call ``SessionDB.maybe_auto_prune_and_vacuum`` using current config.
    +
    +    Reads the ``sessions:`` section from config.yaml via
    +    :func:`hermes_cli.config.load_config` (the authoritative loader that
    +    deep-merges DEFAULT_CONFIG, so unmigrated configs still get default
    +    values). Honours ``auto_prune`` / ``retention_days`` /
    +    ``vacuum_after_prune`` / ``min_interval_hours``, and delegates to the
    +    DB. Never raises — maintenance must never block interactive startup.
    +    """
    +    if session_db is None:
    +        return
    +    try:
    +        from hermes_cli.config import load_config as _load_full_config
    +        cfg = (_load_full_config().get("sessions") or {})
    +        if not cfg.get("auto_prune", False):
    +            return
    +        session_db.maybe_auto_prune_and_vacuum(
    +            retention_days=int(cfg.get("retention_days", 90)),
    +            min_interval_hours=int(cfg.get("min_interval_hours", 24)),
    +            vacuum=bool(cfg.get("vacuum_after_prune", True)),
    +        )
    +    except Exception as exc:
    +        logger.debug("state.db auto-maintenance skipped: %s", exc)
    +
    +
     def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
         """Remove stale worktrees and orphaned branches on startup.
     
    ```

    ```diff
    @@ -1961,7 +1987,13 @@ def __init__(
                 self._session_db = SessionDB()
             except Exception as e:
                 logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
    -        
    +
    +        # Opportunistic state.db maintenance — runs at most once per
    +        # min_interval_hours, tracked via state_meta in state.db itself so
    +        # it's shared across all Hermes processes for this HERMES_HOME.
    +        # Never blocks startup on failure.
    +        _run_state_db_auto_maintenance(self._session_db)
    +
             # Deferred title: stored in memory until the session is created in the DB
             self._pending_title: Optional[str] = None
             
    ```
  - `gateway/run.py`

    ```diff
    @@ -710,7 +710,26 @@ def __init__(self, config: Optional[GatewayConfig] = None):
                 self._session_db = SessionDB()
             except Exception as e:
                 logger.debug("SQLite session store not available: %s", e)
    -        
    +
    +        # Opportunistic state.db maintenance: prune ended sessions older
    +        # than sessions.retention_days + optional VACUUM. Tracks last-run
    +        # in state_meta so it only actually executes once per
    +        # sessions.min_interval_hours.  Gateway is long-lived so blocking
    +        # a few seconds once per day is acceptable; failures are logged
    +        # but never raised.
    +        if self._session_db is not None:
    +            try:
    +                from hermes_cli.config import load_config as _load_full_config
    +                _sess_cfg = (_load_full_config().get("sessions") or {})
    +                if _sess_cfg.get("auto_prune", False):
    +                    self._session_db.maybe_auto_prune_and_vacuum(
    +                        retention_days=int(_sess_cfg.get("retention_days", 90)),
    +                        min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
    +                        vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
    +                    )
    +            except Exception as exc:
    +                logger.debug("state.db auto-maintenance skipped: %s", exc)
    +
             # DM pairing store for code-based user authorization
             from gateway.pairing import PairingStore
             self.pairing_store = PairingStore()
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -893,6 +893,34 @@ def _ensure_hermes_home_managed(home: Path):
             "force_ipv4": False,
         },
     
    +    # Session storage — controls automatic cleanup of ~/.hermes/state.db.
    +    # state.db accumulates every session, message, tool call, and FTS5 index
    +    # entry forever.  Without auto-pruning, a heavy user (gateway + cron)
    +    # reports 384MB+ databases with 68K+ messages, which slows down FTS5
    +    # inserts, /resume listing, and insights queries.
    +    "sessions": {
    +        # When true, prune ended sessions older than retention_days once
    +        # per (roughly) min_interval_hours at CLI/gateway/cron startup.
    +        # Only touches ended sessions — active sessions are always preserved.
    +        # Default false: session history is valuable for search recall, and
    +        # silently deleting it could surprise users.  Opt in explicitly.
    +        "auto_prune": False,
    +        # How many days of ended-session history to keep.  Matches the
    +        # default of ``hermes sessions prune``.
    +        "retention_days": 90,
    +        # VACUUM after a prune that actually deleted rows.  SQLite does not
    +        # reclaim disk space on DELETE — freed pages are just reused on
    +        # subsequent INSERTs — so without VACUUM the file stays bloated
    +        # even after pruning.  VACUUM blocks writes for a few seconds per
    +        # 100MB, so it only runs at startup, and only when prune deleted
    +        # ≥1 session.
    +        "vacuum_after_prune": True,
    +        # Minimum hours between auto-maintenance runs (avoids repeating
    +        # the sweep on every CLI invocation).  Tracked via state_meta in
    +        # state.db itself, so it's shared across all processes.
    +        "min_interval_hours": 24,
    +    },
    +
         # Config schema version - bump this when adding new required fields
         "_config_version": 22,
     }
    ```

    ```diff
    @@ -2102,6 +2130,7 @@ def check_config_version() -> Tuple[int, int]:
         "fallback_providers", "credential_pool_strategies", "toolsets",
         "agent", "terminal", "display", "compression", "delegation",
         "auxiliary", "custom_providers", "context", "memory", "gateway",
    +    "sessions",
     }
     
     # Valid fields inside a custom_providers list entry
    ```
  - `hermes_state.py`

    ```diff
    @@ -84,6 +84,11 @@
         codex_reasoning_items TEXT
     );
     
    +CREATE TABLE IF NOT EXISTS state_meta (
    +    key TEXT PRIMARY KEY,
    +    value TEXT
    +);
    +
     CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
     CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
     CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
    ```

    ```diff
    @@ -1441,3 +1446,116 @@ def _do(conn):
                 return len(session_ids)
     
             return self._execute_write(_do)
    +
    +    # ── Meta key/value (for scheduler bookkeeping) ──
    +
    +    def get_meta(self, key: str) -> Optional[str]:
    +        """Read a value from the state_meta key/value store."""
    +        with self._lock:
    +            row = self._conn.execute(
    +                "SELECT value FROM state_meta WHERE key = ?", (key,)
    +            ).fetchone()
    +        if row is None:
    +            return None
    +        return row["value"] if isinstance(row, sqlite3.Row) else row[0]
    +
    +    def set_meta(self, key: str, value: str) -> None:
    +        """Write a value to the state_meta key/value store."""
    +        def _do(conn):
    +            conn.execute(
    +                "INSERT INTO state_meta (key, value) VALUES (?, ?) "
    +                "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
    +                (key, value),
    +            )
    +        self._execute_write(_do)
    +
    +    # ── Space reclamation ──
    +
    +    def vacuum(self) -> None:
    +        """Run VACUUM to reclaim disk space after large deletes.
    +
    +        SQLite does not shrink the database file when rows are deleted —
    +        freed pages just get reused on the next insert. After a prune that
    +        removed hundreds of sessions, the file stays bloated unless we
    +        explicitly VACUUM.
    +
    +        VACUUM rewrites the entire DB, so it's expensive (seconds per
    +        100MB) and cannot run inside a transaction. It also acquires an
    +        exclusive lock, so callers must ensure no other writers are
    +        active. Safe to call at startup before the gateway/CLI starts
    +        serving traffic.
    +        """
    +        # VACUUM cannot be executed inside a transaction.
    +        with self._lock:
    +            # Best-effort WAL checkpoint first, then VACUUM.
    +            try:
    +                self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
    +            except Exception:
    +                pass
    +            self._conn.execute("VACUUM")
    +
    +    def maybe_auto_prune_and_vacuum(
    +        self,
    +        retention_days: int = 90,
    +        min_interval_hours: int = 24,
    +        vacuum: bool = True,
    +    ) -> Dict[str, Any]:
    +        """Idempotent auto-maintenance: prune old sessions + optional VACUUM.
    +
    +        Records the last run timestamp in state_meta so subsequent calls
    +        within ``min_interval_hours`` no-op. Designed to be called once at
    +        startup from long-lived entrypoints (CLI, gateway, cron scheduler).
    +
    +        Never raises. On any failure, logs a warning and returns a dict
    +        with ``"error"`` set.
    +
    +        Returns a dict with keys:
    +          - ``"skipped"`` (bool) — true if within min_interval_hours of last run
    +          - ``"pruned"`` (int)   — number of sessions deleted
    +          - ``"vacuumed"`` (bool) — true if VACUUM ran
    +          - ``"error"`` (str, optional) — present only on failure
    +        """
    +        result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
    +        try:
    +            # Skip if another process/call did maintenance recently.
    +            last_raw = self.get_meta("last_auto_prune")
    +            now = time.time()
    +            if last_raw:
    +                try:
    +                    last_ts = float(last_raw)
    +                    if now - last_ts < min_interval_hours * 3600:
    +                        result["skipped"] = True
    +                        return result
    +                except (TypeError, ValueError):
    +                    pass  # corrupt meta; treat as no prior run
    +
    +            pruned = self.prune_sessions(older_than_days=retention_days)
    +            result["pruned"] = pruned
    +
    +            # Only VACUUM if we actually freed rows — VACUUM on a tight DB
    +            # is wasted I/O. Threshold keeps small DBs from paying the cost.
    +            if vacuum and pruned > 0:
    +                try:
    +                    self.vacuum()
    +                    result["vacuumed"] = True
    +                except Exception as exc:
    +                    logger.warning("state.db VACUUM failed: %s", exc)
    +
    +            # Record the attempt even if pruned == 0, so we don't retry
    +            # every startup within the min_interval_hours window.
    +            self.set_meta("last_auto_prune", str(now))
    +
    +            if pruned > 0:
    +                logger.info(
    +                    "state.db auto-maintenance: pruned %d session(s) older than %d days%s",
    +                    pruned,
    +                    retention_days,
    +                    " + VACUUM" if result["vacuumed"] else "",
    +                )
    +        except Exception as exc:
    +            # Maintenance must never block startup. Log and return error marker.
    +            logger.warning("state.db auto-maintenance failed: %s", exc)
    +            result["error"] = str(exc)
    +
    +        return result
    +
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -1732,3 +1732,124 @@ def test_sqlite_timeout_is_at_least_30s(self, db):
             assert "30" in src, (
                 "SQLite timeout should be at least 30s to handle CLI/gateway lock contention"
             )
    +
    +
    +# =========================================================================
    +# Auto-maintenance: state_meta + vacuum + maybe_auto_prune_and_vacuum
    +# =========================================================================
    +
    +class TestStateMeta:
    +    def test_get_meta_missing_returns_none(self, db):
    +        assert db.get_meta("nonexistent") is None
    +
    +    def test_set_then_get_meta(self, db):
    +        db.set_meta("foo", "bar")
    +        assert db.get_meta("foo") == "bar"
    +
    +    def test_set_meta_upsert(self, db):
    +        """set_meta overwrites existing value (ON CONFLICT DO UPDATE)."""
    +        db.set_meta("key", "v1")
    +        db.set_meta("key", "v2")
    +        assert db.get_meta("key") == "v2"
    +
    +
    +class TestVacuum:
    +    def test_vacuum_runs_without_error(self, db):
    +        """VACUUM must succeed on a fresh DB (no rows to reclaim)."""
    +        db.create_session(session_id="s1", source="cli")
    +        db.append_message(session_id="s1", role="user", content="hi")
    +        # Should not raise, even though there's nothing significant to reclaim.
    +        db.vacuum()
    +
    +
    +class TestAutoMaintenance:
    +    def _make_old_ended(self, db, sid: str, days_old: int = 100):
    +        """Create a session that is ended and was started `days_old` days ago."""
    +        db.create_session(session_id=sid, source="cli")
    +        db.end_session(sid, end_reason="done")
    +        db._conn.execute(
    +            "UPDATE sessions SET started_at = ? WHERE id = ?",
    +            (time.time() - days_old * 86400, sid),
    +        )
    +        db._conn.commit()
    +
    +    def test_first_run_prunes_and_vacuums(self, db):
    +        self._make_old_ended(db, "old1", days_old=100)
    +        self._make_old_ended(db, "old2", days_old=100)
    +        db.create_session(session_id="new", source="cli")  # active, must survive
    +
    +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
    +        assert result["skipped"] is False
    +        assert result["pruned"] == 2
    +        assert result["vacuumed"] is True
    +        assert result.get("error") is None
    +        assert db.get_session("old1") is None
    +        assert db.get_session("old2") is None
    +        assert db.get_session("new") is not None
    +
    +    def test_second_call_within_interval_skips(self, db):
    +        self._make_old_ended(db, "old", days_old=100)
    +        first = db.maybe_auto_prune_and_vacuum(
    +            retention_days=90, min_interval_hours=24
    +        )
    +        assert first["skipped"] is False
    +        assert first["pruned"] == 1
    +
    +        # Create another prunable session; a second call within
    +        # min_interval_hours should still skip without touching it.
    +        self._make_old_ended(db, "old2", days_old=100)
    +        second = db.maybe_auto_prune_and_vacuum(
    +            retention_days=90, min_interval_hours=24
    +        )
    +        assert second["skipped"] is True
    +        assert second["pruned"] == 0
    +        assert db.get_session("old2") is not None  # untouched
    +
    +    def test_second_call_after_interval_runs_again(self, db):
    +        self._make_old_ended(db, "old", days_old=100)
    +        db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=24)
    +
    +        # Backdate the last-run marker to force another run.
    +        db.set_meta("last_auto_prune", str(time.time() - 48 * 3600))
    +
    +        self._make_old_ended(db, "old2", days_old=100)
    +        result = db.maybe_auto_prune_and_vacuum(
    +            retention_days=90, min_interval_hours=24
    +        )
    +        assert result["skipped"] is False
    +        assert result["pruned"] == 1
    +        assert db.get_session("old2") is None
    +
    +    def test_no_prunable_sessions_no_vacuum(self, db):
    +        """When prune deletes 0 rows, VACUUM is skipped (wasted I/O)."""
    +        db.create_session(session_id="fresh", source="cli")  # too recent
    +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
    +        assert result["skipped"] is False
    +        assert result["pruned"] == 0
    +        assert result["vacuumed"] is False
    +        # But last-run is still recorded so we don't retry immediately.
    +        assert db.get_meta("last_auto_prune") is not None
    +
    +    def test_vacuum_disabled_via_flag(self, db):
    +        self._make_old_ended(db, "old", days_old=100)
    +        result = db.maybe_auto_prune_and_vacuum(retention_days=90, vacuum=False)
    +        assert result["pruned"] == 1
    +        assert result["vacuumed"] is False
    +
    +    def test_corrupt_last_run_marker_treated_as_no_prior_run(self, db):
    +        """A non-numeric marker must not break maintenance."""
    +        db.set_meta("last_auto_prune", "not-a-timestamp")
    +        self._make_old_ended(db, "old", days_old=100)
    +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
    +        assert result["skipped"] is False
    +        assert result["pruned"] == 1
    +
    +    def test_state_meta_survives_vacuum(self, db):
    +        """Marker written just before VACUUM must still be readable after."""
    +        self._make_old_ended(db, "old", days_old=100)
    +        db.maybe_auto_prune_and_vacuum(retention_days=90)
    +        marker = db.get_meta("last_auto_prune")
    +        assert marker is not None
    +        # Should parse as a float timestamp close to now.
    +        assert abs(float(marker) - time.time()) < 60
    +
    ```
  - `website/docs/user-guide/sessions.md`

    ```diff
    @@ -386,7 +386,21 @@ Key tables in `state.db`:
     
     - Gateway sessions auto-reset based on the configured reset policy
     - Before reset, the agent saves memories and skills from the expiring session
    -- Ended sessions remain in the database until pruned
    +- 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
    +- 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)
    +- 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`
    +
    +Default is **off** — session history is valuable for `session_search` recall, and silently deleting it could surprise users. Enable in `~/.hermes/config.yaml`:
    +
    +```yaml
    +sessions:
    +  auto_prune: true          # opt in — default is false
    +  retention_days: 90        # keep ended sessions this many days
    +  vacuum_after_prune: true  # reclaim disk space after a pruning sweep
    +  min_interval_hours: 24    # don't re-run the sweep more often than this
    +```
    +
    +Active sessions are never auto-pruned, regardless of age.
     
     ### Manual Cleanup
     
    ```

    ```diff
    @@ -403,5 +417,5 @@ hermes sessions prune --older-than 30 --yes
     ```
     
     :::tip
    -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.
    +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.
     :::
    ```
- **#16286: fix(sessions): delete transcript files during auto-prune (salvage #6613, closes #3015)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -974,6 +974,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
             return
         try:
             from hermes_cli.config import load_config as _load_full_config
    +        from hermes_constants import get_hermes_home as _get_hermes_home
             cfg = (_load_full_config().get("sessions") or {})
             if not cfg.get("auto_prune", False):
                 return
    ```

    ```diff
    @@ -981,6 +982,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
                 retention_days=int(cfg.get("retention_days", 90)),
                 min_interval_hours=int(cfg.get("min_interval_hours", 24)),
                 vacuum=bool(cfg.get("vacuum_after_prune", True)),
    +            sessions_dir=_get_hermes_home() / "sessions",
             )
         except Exception as exc:
             logger.debug("state.db auto-maintenance skipped: %s", exc)
    ```
  - `gateway/run.py`

    ```diff
    @@ -763,6 +763,7 @@ def __init__(self, config: Optional[GatewayConfig] = None):
                             retention_days=int(_sess_cfg.get("retention_days", 90)),
                             min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
                             vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
    +                        sessions_dir=self.config.sessions_dir,
                         )
                 except Exception as exc:
                     logger.debug("state.db auto-maintenance skipped: %s", exc)
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -9230,7 +9230,8 @@ def cmd_sessions(args):
                     ):
                         print("Cancelled.")
                         return
    -            if db.delete_session(resolved_session_id):
    +            sessions_dir = get_hermes_home() / "sessions"
    +            if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
                     print(f"Deleted session '{resolved_session_id}'.")
                 else:
                     print(f"Session '{args.session_id}' not found.")
    ```

    ```diff
    @@ -9244,7 +9245,9 @@ def cmd_sessions(args):
                     ):
                         print("Cancelled.")
                         return
    -            count = db.prune_sessions(older_than_days=days, source=args.source)
    +            sessions_dir = get_hermes_home() / "sessions"
    +            count = db.prune_sessions(older_than_days=days, source=args.source,
    +                                      sessions_dir=sessions_dir)
                 print(f"Pruned {count} session(s).")
     
             elif action == "rename":
    ```
  - `hermes_state.py`

    ```diff
    @@ -1512,12 +1512,45 @@ def _do(conn):
                 )
             self._execute_write(_do)
     
    -    def delete_session(self, session_id: str) -> bool:
    +    @staticmethod
    +    def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
    +        """Remove on-disk transcript files for a session.
    +
    +        Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
    +        ``request_dump_{session_id}_*.json`` files left by the gateway.
    +        Silently skips files that don't exist and swallows OSError so a
    +        filesystem hiccup never blocks a DB operation.
    +        """
    +        if sessions_dir is None:
    +            return
    +        for suffix in (".json", ".jsonl"):
    +            p = sessions_dir / f"{session_id}{suffix}"
    +            try:
    +                p.unlink(missing_ok=True)
    +            except OSError:
    +                pass
    +        # request_dump files use session_id as a prefix component
    +        try:
    +            for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
    +                try:
    +                    p.unlink(missing_ok=True)
    +                except OSError:
    +                    pass
    +        except OSError:
    +            pass
    +
    +    def delete_session(
    +        self,
    +        session_id: str,
    +        sessions_dir: Optional[Path] = None,
    +    ) -> bool:
             """Delete a session and all its messages.
     
             Child sessions are orphaned (parent_session_id set to NULL) rather
             than cascade-deleted, so they remain accessible independently.
    -        Returns True if the session was found and deleted.
    +        When *sessions_dir* is provided, also removes on-disk transcript
    +        files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted
    +        session. Returns True if the session was found and deleted.
             """
             def _do(conn):
                 cursor = conn.execute(
    ```

    ```diff
    @@ -1534,16 +1567,29 @@ def _do(conn):
                 conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
                 conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
                 return True
    -        return self._execute_write(_do)
     
    -    def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
    +        deleted = self._execute_write(_do)
    +        if deleted:
    +            self._remove_session_files(sessions_dir, session_id)
    +        return deleted
    +
    +    def prune_sessions(
    +        self,
    +        older_than_days: int = 90,
    +        source: str = None,
    +        sessions_dir: Optional[Path] = None,
    +    ) -> int:
             """Delete sessions older than N days. Returns count of deleted sessions.
     
             Only prunes ended sessions (not active ones).  Child sessions outside
             the prune window are orphaned (parent_session_id set to NULL) rather
    -        than cascade-deleted.
    +        than cascade-deleted.  When *sessions_dir* is provided, also removes
    +        on-disk transcript files (``.json`` / ``.jsonl`` /
    +        ``request_dump_*``) for every pruned session, outside the DB
    +        transaction.
             """
             cutoff = time.time() - (older_than_days * 86400)
    +        removed_ids: list[str] = []
     
             def _do(conn):
                 if source:
    ```

    ```diff
    @@ -1573,9 +1619,14 @@ def _do(conn):
                 for sid in session_ids:
                     conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
                     conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
    +                removed_ids.append(sid)
                 return len(session_ids)
     
    -        return self._execute_write(_do)
    +        count = self._execute_write(_do)
    +        # Clean up on-disk files outside the DB transaction
    +        for sid in removed_ids:
    +            self._remove_session_files(sessions_dir, sid)
    +        return count
     
         # ── Meta key/value (for scheduler bookkeeping) ──
     
    ```

    ```diff
    @@ -1629,6 +1680,7 @@ def maybe_auto_prune_and_vacuum(
             retention_days: int = 90,
             min_interval_hours: int = 24,
             vacuum: bool = True,
    +        sessions_dir: Optional[Path] = None,
         ) -> Dict[str, Any]:
             """Idempotent auto-maintenance: prune old sessions + optional VACUUM.
     
    ```

    ```diff
    @@ -1636,6 +1688,10 @@ def maybe_auto_prune_and_vacuum(
             within ``min_interval_hours`` no-op. Designed to be called once at
             startup from long-lived entrypoints (CLI, gateway, cron scheduler).
     
    +        When *sessions_dir* is provided, on-disk transcript files
    +        (``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
    +        are removed as part of the same sweep (issue #3015).
    +
             Never raises. On any failure, logs a warning and returns a dict
             with ``"error"`` set.
     
    ```

    ```diff
    @@ -1659,7 +1715,10 @@ def maybe_auto_prune_and_vacuum(
                     except (TypeError, ValueError):
                         pass  # corrupt meta; treat as no prior run
     
    -            pruned = self.prune_sessions(older_than_days=retention_days)
    +            pruned = self.prune_sessions(
    +                older_than_days=retention_days,
    +                sessions_dir=sessions_dir,
    +            )
                 result["pruned"] = pruned
     
                 # Only VACUUM if we actually freed rows — VACUUM on a tight DB
    ```
  - `tests/hermes_cli/test_sessions_delete.py`

    ```diff
    @@ -12,7 +12,7 @@ def resolve_session_id(self, session_id):
                 captured["resolved_from"] = session_id
                 return "20260315_092437_c9a6ff"
     
    -        def delete_session(self, session_id):
    +        def delete_session(self, session_id, **kwargs):
                 captured["deleted"] = session_id
                 return True
     
    ```

    ```diff
    @@ -45,7 +45,7 @@ class FakeDB:
             def resolve_session_id(self, session_id):
                 return None
     
    -        def delete_session(self, session_id):
    +        def delete_session(self, session_id, **kwargs):
                 raise AssertionError("delete_session should not be called when resolution fails")
     
             def close(self):
    ```

    ```diff
    @@ -73,7 +73,7 @@ class FakeDB:
             def resolve_session_id(self, session_id):
                 return "20260315_092437_c9a6ff"
     
    -        def delete_session(self, session_id):
    +        def delete_session(self, session_id, **kwargs):
                 raise AssertionError("delete_session should not be called when cancelled")
     
             def close(self):
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -1981,3 +1981,58 @@ def test_state_meta_survives_vacuum(self, db):
             # Should parse as a float timestamp close to now.
             assert abs(float(marker) - time.time()) < 60
     
    +    def test_auto_prune_deletes_transcript_files(self, db, tmp_path):
    +        """Issue #3015: auto-prune must also delete on-disk transcript files."""
    +        sessions_dir = tmp_path / "sessions"
    +        sessions_dir.mkdir()
    +
    +        self._make_old_ended(db, "old1", days_old=100)
    +        self._make_old_ended(db, "old2", days_old=100)
    +        db.create_session(session_id="new", source="cli")  # active
    +
    +        # Transcript files mimicking real gateway/CLI layout
    +        (sessions_dir / "old1.json").write_text("{}")
    +        (sessions_dir / "old1.jsonl").write_text("{}\n")
    +        (sessions_dir / "old2.jsonl").write_text("{}\n")
    +        (sessions_dir / "request_dump_old1_001.json").write_text("{}")
    +        (sessions_dir / "new.jsonl").write_text("{}\n")  # active, must survive
    +
    +        result = db.maybe_auto_prune_and_vacuum(
    +            retention_days=90, sessions_dir=sessions_dir
    +        )
    +        assert result["pruned"] == 2
    +
    +        # Pruned transcript files are gone
    +        assert not (sessions_dir / "old1.json").exists()
    +        assert not (sessions_dir / "old1.jsonl").exists()
    +        assert not (sessions_dir / "old2.jsonl").exists()
    +        assert not (sessions_dir / "request_dump_old1_001.json").exists()
    +        # Active session's transcript is untouched
    +        assert (sessions_dir / "new.jsonl").exists()
    +
    +    def test_auto_prune_without_sessions_dir_preserves_files(self, db, tmp_path):
    +        """Backward-compat: no sessions_dir = DB-only cleanup (legacy behavior)."""
    +        sessions_dir = tmp_path / "sessions"
    +        sessions_dir.mkdir()
    +        self._make_old_ended(db, "old", days_old=100)
    +        (sessions_dir / "old.jsonl").write_text("{}\n")
    +
    +        result = db.maybe_auto_prune_and_vacuum(retention_days=90)
    +        assert result["pruned"] == 1
    +        # File stays — caller didn't opt in
    +        assert (sessions_dir / "old.jsonl").exists()
    +
    +    def test_prune_sessions_deletes_files_for_pruned_only(self, db, tmp_path):
    +        """Active-session transcripts must never be deleted by prune."""
    +        sessions_dir = tmp_path / "sessions"
    +        sessions_dir.mkdir()
    +        self._make_old_ended(db, "old", days_old=100)
    +        db.create_session(session_id="active", source="cli")  # not ended
    +        (sessions_dir / "old.jsonl").write_text("{}\n")
    +        (sessions_dir / "active.jsonl").write_text("{}\n")
    +
    +        count = db.prune_sessions(older_than_days=90, sessions_dir=sessions_dir)
    +        assert count == 1
    +        assert not (sessions_dir / "old.jsonl").exists()
    +        assert (sessions_dir / "active.jsonl").exists()
    +
    ```
- **#3028: fix(gateway): delete legacy session transcript files on reset (#3015)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#6613: fix(sessions): delete on-disk transcript files during prune and delete**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#16286`, `#13861`

## Fix status

- Merged to main in PR #13861 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-27`.
- Subsystem: `gateway`
