# NousResearch/hermes-agent · Long-session continuity and compression handling improvements

## Open now

- 1 issue, 0 PRs open on GitHub.
- #23307: Improve long-session continuity across compactions

These items address how sessions behave during and after context compression, including persisting compressed context to the gateway and surfacing compression information in session lists and resume lookups.

## TL;DR

- 4 member(s): 1 open issue(s), 0 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #119888. Verify it addresses your case.
- **Recommended:** read PR #119888 (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: `unresolved`. 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.50`: 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.

## Open related issues

- #23307: Improve long-session continuity across compactions

## Prior & competing PRs

- **#12960: fix(sessions): surface compression tips in session lists and resume lookups**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/main.py`, `hermes_state.py`, `tests/test_hermes_state.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -693,6 +693,10 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
         - If it looks like a session ID (contains underscore + hex), try direct lookup first.
         - Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
         - Falls back to the other method if the first doesn't match.
    +    - If the resolved session is a compression root, follow the chain forward
    +      to the latest continuation. Users who remember the old root ID (e.g.
    +      from an exit summary printed before the bug fix, or from notes) get
    +      resumed at the live tip instead of a stale parent with no messages.
         """
         try:
             from hermes_state import SessionDB
    ```

    ```diff
    @@ -701,14 +705,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
     
             # Try as exact session ID first
             session = db.get_session(name_or_id)
    +        resolved_id: Optional[str] = None
             if session:
    -            db.close()
    -            return session["id"]
    +            resolved_id = session["id"]
    +        else:
    +            # Try as title (with auto-latest for lineage)
    +            resolved_id = db.resolve_session_by_title(name_or_id)
    +
    +        if resolved_id:
    +            # Project forward through compression chain so resumes land on
    +            # the live tip instead of a dead compressed parent.
    +            try:
    +                resolved_id = db.get_compression_tip(resolved_id) or resolved_id
    +            except Exception:
    +                pass
     
    -        # Try as title (with auto-latest for lineage)
    -        session_id = db.resolve_session_by_title(name_or_id)
             db.close()
    -        return session_id
    +        return resolved_id
         except Exception:
             pass
         return None
    ```
  - `hermes_state.py`

    ```diff
    @@ -723,6 +723,42 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
     
             return f"{base} #{max_num + 1}"
     
    +    def get_compression_tip(self, session_id: str) -> Optional[str]:
    +        """Walk the compression-continuation chain forward and return the tip.
    +
    +        A compression continuation is a child session where:
    +        1. The parent's ``end_reason = 'compression'``
    +        2. The child was created AFTER the parent was ended (started_at >= ended_at)
    +
    +        The second condition distinguishes compression continuations from
    +        delegate subagents or branch children, which can also have a
    +        ``parent_session_id`` but were created while the parent was still live.
    +
    +        Returns the session_id of the latest continuation in the chain, or the
    +        input ``session_id`` if it isn't part of a compression chain (or if the
    +        input itself doesn't exist).
    +        """
    +        current = session_id
    +        # Bound the walk defensively — compression chains this deep are
    +        # pathological and shouldn't happen in practice. 100 = plenty.
    +        for _ in range(100):
    +            with self._lock:
    +                cursor = self._conn.execute(
    +                    "SELECT id FROM sessions "
    +                    "WHERE parent_session_id = ? "
    +                    "  AND started_at >= ("
    +                    "      SELECT ended_at FROM sessions "
    +                    "      WHERE id = ? AND end_reason = 'compression'"
    +                    "  ) "
    +                    "ORDER BY started_at DESC LIMIT 1",
    +                    (current, current),
    +                )
    +                row = cursor.fetchone()
    +            if row is None:
    +                return current
    +            current = row["id"]
    +        return current
    +
         def list_sessions_rich(
             self,
             source: str = None,
    ```

    ```diff
    @@ -730,6 +766,7 @@ def list_sessions_rich(
             limit: int = 20,
             offset: int = 0,
             include_children: bool = False,
    +        project_compression_tips: bool = True,
         ) -> List[Dict[str, Any]]:
             """List sessions with preview (first user message) and last active timestamp.
     
    ```

    ```diff
    @@ -741,6 +778,14 @@ def list_sessions_rich(
     
             By default, child sessions (subagent runs, compression continuations)
             are excluded.  Pass ``include_children=True`` to include them.
    +
    +        With ``project_compression_tips=True`` (default), sessions that are
    +        roots of compression chains are projected forward to their latest
    +        continuation — one logical conversation = one list entry, showing the
    +        live continuation's id/message_count/title/last_active. This prevents
    +        compressed continuations from being invisible to users while keeping
    +        delegate subagents and branches hidden. Pass ``False`` to return the
    +        raw root rows (useful for admin/debug UIs).
             """
             where_clauses = []
             params = []
    ```

    ```diff
    @@ -791,8 +836,77 @@ def list_sessions_rich(
                     s["preview"] = ""
                 sessions.append(s)
     
    +        # Project compression roots forward to their tips. Each row whose
    +        # end_reason is 'compression' has a continuation child; replace the
    +        # surfaced fields (id, message_count, title, last_active, ended_at,
    +        # end_reason, preview) with the tip's values so the list entry acts
    +        # as the live conversation. Keep the root's started_at to preserve
    +        # chronological ordering by original conversation start.
    +        if project_compression_tips and not include_children:
    +            projected = []
    +            for s in sessions:
    +                if s.get("end_reason") != "compression":
    +                    projected.append(s)
    +                    continue
    +                tip_id = self.get_compression_tip(s["id"])
    +                if tip_id == s["id"]:
    +                    projected.append(s)
    +                    continue
    +                tip_row = self._get_session_rich_row(tip_id)
    +                if not tip_row:
    +                    projected.append(s)
    +                    continue
    +                # Preserve the root's started_at for stable sort order, but
    +                # surface the tip's identity and activity data.
    +                merged = dict(s)
    +                for key in (
    +                    "id", "ended_at", "end_reason", "message_count",
    +                    "tool_call_count", "title", "last_active", "preview",
    +                    "model", "system_prompt",
    +                ):
    +                    if key in tip_row:
    +                        merged[key] = tip_row[key]
    +                merged["_lineage_root_id"] = s["id"]
    +                projected.append(merged)
    +            sessions = projected
    +
             return sessions
     
    +    def _get_session_rich_row(self, session_id: str) -> 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.
    +        """
    +        query = """
    +            SELECT s.*,
    +                COALESCE(
    +                    (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
    +                     FROM messages m
    +                     WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
    +                     ORDER BY m.timestamp, m.id LIMIT 1),
    +                    ''
    +                ) AS _preview_raw,
    +                COALESCE(
    +                    (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
    +                    s.started_at
    +                ) AS last_active
    +            FROM sessions s
    +            WHERE s.id = ?
    +        """
    +        with self._lock:
    +            cursor = self._conn.execute(query, (session_id,))
    +            row = cursor.fetchone()
    +        if not row:
    +            return None
    +        s = dict(row)
    +        raw = s.pop("_preview_raw", "").strip()
    +        if raw:
    +            text = raw[:60]
    +            s["preview"] = text + ("..." if len(raw) > 60 else "")
    +        else:
    +            s["preview"] = ""
    +        return s
    +
         # =========================================================================
         # Message storage
         # =========================================================================
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -1381,6 +1381,178 @@ def test_preview_newlines_collapsed(self, db):
             assert "Line one Line two" in sessions[0]["preview"]
     
     
    +class TestCompressionChainProjection:
    +    """Tests for lineage-aware list_sessions_rich — compressed conversations
    +    surface as their live continuation tip, not the dead parent root.
    +    """
    +
    +    def _build_compression_chain(self, db, t0: float):
    +        """Helper: builds root -> delegate -> compression-child -> tip chain.
    +
    +        Returns (root_id, delegate_id, mid_id, tip_id).
    +        """
    +        import time as _time
    +        # Root that gets compressed
    +        db.create_session("root1", "cli")
    +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1"))
    +        db.append_message("root1", "user", "help me refactor auth")
    +
    +        # Delegate subagent spawned while root1 was live (before it ended)
    +        db.create_session("delegate1", "cli", parent_session_id="root1")
    +        db._conn.execute(
    +            "UPDATE sessions SET started_at=?, ended_at=? WHERE id=?",
    +            (t0 + 600, t0 + 650, "delegate1"),
    +        )
    +        db.append_message("delegate1", "user", "delegate task")
    +
    +        # root1 compressed at t0+1800
    +        t_compress_root = t0 + 1800
    +        db._conn.execute(
    +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
    +            (t_compress_root, "compression", "root1"),
    +        )
    +
    +        # Continuation mid created 1s after parent ended
    +        db.create_session("mid1", "cli", parent_session_id="root1")
    +        db._conn.execute(
    +            "UPDATE sessions SET started_at=? WHERE id=?",
    +            (t_compress_root + 1, "mid1"),
    +        )
    +        db.append_message("mid1", "user", "continuing")
    +
    +        # mid1 also compressed
    +        t_compress_mid = t_compress_root + 1800
    +        db._conn.execute(
    +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
    +            (t_compress_mid, "compression", "mid1"),
    +        )
    +
    +        # Tip — latest continuation
    +        db.create_session("tip1", "cli", parent_session_id="mid1")
    +        db._conn.execute(
    +            "UPDATE sessions SET started_at=? WHERE id=?",
    +            (t_compress_mid + 1, "tip1"),
    +        )
    +        db.append_message("tip1", "user", "latest message")
    +
    +        db._conn.commit()
    +        return ("root1", "delegate1", "mid1", "tip1")
    +
    +    def test_get_compression_tip_walks_full_chain(self, db):
    +        import time as _time
    +        self._build_compression_chain(db, _time.time() - 3600)
    +        assert db.get_compression_tip("root1") == "tip1"
    +        assert db.get_compression_tip("mid1") == "tip1"
    +        assert db.get_compression_tip("tip1") == "tip1"
    +
    +    def test_get_compression_tip_returns_self_for_uncompressed(self, db):
    +        db.create_session("solo", "cli")
    +        assert db.get_compression_tip("solo") == "solo"
    +
    +    def test_get_compression_tip_skips_delegate_children(self, db):
    +        """Delegate subagents have parent_session_id set but were created
    +        BEFORE the parent ended. They must not be followed as compression
    +        continuations — the started_at >= ended_at guard handles this.
    +        """
    +        import time as _time
    +        self._build_compression_chain(db, _time.time() - 3600)
    +        # delegate1 is a child of root1 but NOT a compression continuation.
    +        # root1's tip must be tip1 (via mid1), not delegate1.
    +        assert db.get_compression_tip("root1") == "tip1"
    +
    +    def test_list_surfaces_tip_for_compressed_root(self, db):
    +        """The list must show the tip's id/message_count/preview in place of
    +        the root row, so users can see and resume the live conversation.
    +        """
    +        import time as _time
    +        self._build_compression_chain(db, _time.time() - 3600)
    +        # Add an uncompressed root for comparison.
    +        db.create_session("solo", "cli")
    +        db.append_message("solo", "user", "standalone")
    +        db._conn.commit()
    +
    +        sessions = db.list_sessions_rich(source="cli", limit=20)
    +        ids = [s["id"] for s in sessions]
    +        # Only top-level conversations appear: tip1 (projected from root1) + solo.
    +        # Delegate children, mid1, and the dead root1 must NOT be in the list.
    +        assert "tip1" in ids
    +        assert "solo" in ids
    +        assert "root1" not in ids
    +        assert "mid1" not in ids
    +        assert "delegate1" not in ids
    +
    +        tip_row = next(s for s in sessions if s["id"] == "tip1")
    +        # The row surfaces the tip's identity but preserves the root's start
    +        # timestamp for stable ordering and lineage tracking.
    +        assert tip_row["_lineage_root_id"] == "root1"
    +        assert tip_row["preview"].startswith("latest message")
    +        assert tip_row["ended_at"] is None  # tip is still live
    +        assert tip_row["end_reason"] is None
    +
    +    def test_list_without_projection_returns_raw_root(self, db):
    +        """project_compression_tips=False returns the raw parent-NULL root
    +        rows — useful for admin/debug UIs.
    +        """
    +        import time as _time
    +        self._build_compression_chain(db, _time.time() - 3600)
    +        sessions = db.list_sessions_rich(
    +            source="cli", limit=20, project_compression_tips=False
    +        )
    +        ids = [s["id"] for s in sessions]
    +        assert "root1" in ids
    +        assert "tip1" not in ids
    +
    +        root_row = next(s for s in sessions if s["id"] == "root1")
    +        assert root_row["end_reason"] == "compression"
    +        assert "_lineage_root_id" not in root_row
    +
    +    def test_list_preserves_sort_by_started_at(self, db):
    +        """Chronological ordering uses the ROOT's started_at (conversation
    +        start), not the tip's. This keeps lineage entries stable in the list
    +        even as new compressions push the tip forward in time.
    +        """
    +        import time as _time
    +        t0 = _time.time() - 3600
    +        self._build_compression_chain(db, t0)
    +
    +        # Create a newer standalone session that should sort above the lineage
    +        # if we used tip.started_at, but below if we correctly use root.started_at.
    +        t_between = t0 + 120  # between root1 and its compression
    +        db.create_session("newer", "cli")
    +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t_between, "newer"))
    +        db.append_message("newer", "user", "newer session started after root1")
    +        db._conn.commit()
    +
    +        sessions = db.list_sessions_rich(source="cli", limit=20)
    +        ids_in_order = [s["id"] for s in sessions]
    +        # 'newer' started AFTER root1 but BEFORE tip1's actual started_at.
    +        # Correct ordering (by root started_at): newer > tip1's lineage entry.
    +        assert ids_in_order.index("newer") < ids_in_order.index("tip1")
    +
    +    def test_list_handles_broken_chain_gracefully(self, db):
    +        """A compression root with no child (e.g. DB corruption or a partial
    +        end_session call that didn't finish creating the child) must not
    +        crash the list — it should fall back to surfacing the root as-is.
    +        """
    +        import time as _time
    +        t0 = _time.time() - 100
    +        db.create_session("orphan", "cli")
    +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "orphan"))
    +        db._conn.execute(
    +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
    +            (t0 + 10, "compression", "orphan"),
    +        )
    +        db._conn.commit()
    +
    +        sessions = db.list_sessions_rich(source="cli", limit=10)
    +        ids = [s["id"] for s in sessions]
    +        assert "orphan" in ids
    +        row = next(s for s in sessions if s["id"] == "orphan")
    +        # No tip means no projection — row stays raw.
    +        assert "_lineage_root_id" not in row
    +        assert row["end_reason"] == "compression"
    +
    +
     # =========================================================================
     # Session source exclusion (--source flag for third-party isolation)
     # =========================================================================
    ```
- **#119888: hindsight moves to the plugin catalog (auto-migrated on first start / hermes update)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `.github/workflows/tests-os.yml`, `.github/workflows/tests.yml`, `COMPAT_MANIFEST.md`, `CONTRIBUTING.es.md`, `CONTRIBUTING.md`, `Dockerfile`, `compat_manifest.json`, `hermes_cli/config_defaults.py`, `hermes_cli/memory_setup.py`, `hermes_cli/subcommands/memory.py`, `hermes_cli/tips.py`, `hermes_cli/update_cmd.py`, `hermes_cli/web_server_memory.py`, `nix/checks.nix`, `nix/hermes-agent.nix`, `nix/moduleCommon.nix`, `nix/packages.nix`, `plugins/AGENTS.md`, `plugins/memory/__init__.py`, `plugins/memory/hindsight/README.md` (+30 more)
- **#23308: feat: improve long-session continuity across compactions**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9d799e0531c`, `4cbf862abe4`, `dc75038866a3966b5bf5c82ff57c7a758bb4070a`, `22efc81cd7f`, `49596b70cb2`, `ad8c533cc78`, `924bc67eee3`, `192ef93ad5e`, `#119888`, `#12960`

## Fix status

- Merged to main in PR #119888 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-23`.
- Subsystem: `agent`
