# NousResearch/hermes-agent · Search desktop sessions by session ID feature

## Open now

- Nothing open on GitHub.

These members relate to adding the ability to search Hermes desktop sessions by session ID, with at least one implementation using SQL-bounded querying.

## TL;DR

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

- #37882: \[Feature\]: Search Hermes Desktop Sessions by Session ID

## Prior & competing PRs

- **#39062: feat(desktop): search sessions by id (SQL-bounded)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/app/chat/sidebar/index.tsx`, `apps/desktop/src/app/command-center/index.tsx`, `apps/desktop/src/lib/session-search.test.ts`, `apps/desktop/src/lib/session-search.ts`, `hermes_cli/web_server.py`, `hermes_state.py`, `tests/hermes_cli/test_web_server_session_search.py`, `tests/test_hermes_state.py`
  - fix hunks:
  - `apps/desktop/src/app/chat/sidebar/index.tsx`

    ```diff
    @@ -35,6 +35,7 @@ import {
     } from '@/components/ui/sidebar'
     import { Skeleton } from '@/components/ui/skeleton'
     import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
    +import { sessionMatchesSearch } from '@/lib/session-search'
     import { cn } from '@/lib/utils'
     import {
       $panesFlipped,
    ```

    ```diff
    @@ -330,11 +331,10 @@ export function ChatSidebar({
           return []
         }
     
    -    const needle = trimmedQuery.toLowerCase()
         const out = new Map<string, SessionInfo>()
     
         for (const s of sortedSessions) {
    -      if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
    +      if (sessionMatchesSearch(s, trimmedQuery)) {
             out.set(s.id, s)
           }
         }
    ```
  - `apps/desktop/src/app/command-center/index.tsx`

    ```diff
    @@ -168,7 +168,7 @@ export function CommandCenterView({
         }
     
         return sorted.filter(session => {
    -      const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()
    +      const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()
     
           return haystack.includes(needle)
         })
    ```
  - `apps/desktop/src/lib/session-search.test.ts`

    ```diff
    @@ -0,0 +1,58 @@
    +import { describe, expect, it } from 'vitest'
    +
    +import type { SessionInfo } from '@/types/hermes'
    +
    +import { sessionMatchesSearch } from './session-search'
    +
    +function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
    +  return {
    +    archived: false,
    +    cwd: '/home/user/projects/hermes-agent',
    +    ended_at: null,
    +    id: '20260603_090200_abcd12',
    +    input_tokens: 0,
    +    is_active: false,
    +    last_active: 1_000,
    +    message_count: 2,
    +    model: 'claude',
    +    output_tokens: 0,
    +    preview: 'Fix Desktop session search',
    +    source: 'cli',
    +    started_at: 1_000,
    +    title: 'Desktop Search Feature',
    +    tool_call_count: 0,
    +    ...overrides
    +  }
    +}
    +
    +describe('sessionMatchesSearch', () => {
    +  it('matches loaded sessions by full and partial session id', () => {
    +    const session = makeSession()
    +
    +    expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true)
    +    expect(sessionMatchesSearch(session, '090200')).toBe(true)
    +    expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true)
    +  })
    +
    +  it('matches projected compression sessions by lineage root id', () => {
    +    const session = makeSession({
    +      _lineage_root_id: '20260602_235959_root99',
    +      id: '20260603_010000_tip01'
    +    })
    +
    +    expect(sessionMatchesSearch(session, 'root99')).toBe(true)
    +    expect(sessionMatchesSearch(session, '20260602')).toBe(true)
    +  })
    +
    +  it('preserves title, preview, and workspace matching', () => {
    +    const session = makeSession()
    +
    +    expect(sessionMatchesSearch(session, 'desktop search')).toBe(true)
    +    expect(sessionMatchesSearch(session, 'session search')).toBe(true)
    +    expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)
    +  })
    +
    +  it('does not match unrelated queries', () => {
    +    expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)
    +  })
    +})
    ```
  - `apps/desktop/src/lib/session-search.ts`

    ```diff
    @@ -0,0 +1,19 @@
    +import type { SessionInfo } from '@/types/hermes'
    +
    +import { sessionTitle } from './chat-runtime'
    +
    +export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {
    +  const needle = query.trim().toLowerCase()
    +
    +  if (!needle) {
    +    return true
    +  }
    +
    +  return [
    +    session.id,
    +    session._lineage_root_id ?? '',
    +    sessionTitle(session),
    +    session.preview ?? '',
    +    session.cwd ?? ''
    +  ].some(value => value.toLowerCase().includes(needle))
    +}
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -1611,14 +1611,15 @@ async def get_sessions(
     
     @app.get("/api/sessions/search")
     async def search_sessions(q: str = "", limit: int = 20):
    -    """Full-text search across session message content using FTS5.
    -
    -    Results are deduped by compression lineage, not by raw ``session_id``.
    -    Auto-compression rotates a conversation onto a fresh session id (and leaves
    -    the old segment's messages in the FTS index), so one logical chat can own
    -    many ``sessions`` rows that all match the same query. Branches also use
    -    ``parent_session_id``, but they are real alternate conversations; don't
    -    collapse branch-specific hits back into the parent.
    +    """Search sessions by ID plus full-text message content using FTS5.
    +
    +    Direct session-id matches are surfaced first, then FTS message-content
    +    matches. Results are deduped by compression lineage, not by raw
    +    ``session_id``. Auto-compression rotates a conversation onto a fresh
    +    session id (and leaves the old segment's messages in the FTS index), so one
    +    logical chat can own many ``sessions`` rows that all match the same query.
    +    Branches also use ``parent_session_id``, but they are real alternate
    +    conversations; don't collapse branch-specific hits back into the parent.
         """
         if not q or not q.strip():
             return {"results": []}
    ```

    ```diff
    @@ -1626,21 +1627,7 @@ async def search_sessions(q: str = "", limit: int = 20):
             from hermes_state import SessionDB
             db = SessionDB()
             try:
    -            # Auto-add prefix wildcards so partial words match
    -            # e.g. "nimb" → "nimb*" matches "nimby"
    -            # Preserve quoted phrases and existing wildcards as-is
    -            import re
    -            terms = []
    -            for token in re.findall(r'"[^"]*"|\S+', q.strip()):
    -                if token.startswith('"') or token.endswith("*"):
    -                    terms.append(token)
    -                else:
    -                    terms.append(token + "*")
    -            prefix_query = " ".join(terms)
    -            # Over-fetch so lineage dedup can still surface `limit` distinct
    -            # conversations even when several hits collapse onto one root.
    -            fetch_limit = max(limit * 5, 50)
    -            matches = db.search_messages(query=prefix_query, limit=fetch_limit)
    +            safe_limit = max(1, min(int(limit or 20), 100))
     
                 # Walk parent_session_id to the compression root, memoized so a
                 # chain of compression segments only costs one walk. We deliberately
    ```

    ```diff
    @@ -1712,24 +1699,71 @@ def lineage_tip(root_id: str) -> str:
                     tip_cache[root_id] = tip
                     return tip
     
    -            # Keep the best (first / most relevant) hit per compression root.
    +            # Both ID matches and content matches share one keyspace, keyed by
    +            # compression lineage root, so an id-hit and a content-hit on the
    +            # same logical conversation collapse to a single result. The first
    +            # hit for a lineage wins; ID matches run first and take priority.
                 seen: dict = {}
    -            for m in matches:
    -                raw_sid = m["session_id"]
    +
    +            def add_lineage_result(raw_sid: str, payload: dict) -> None:
    +                if not raw_sid:
    +                    return
                     root = compression_root(raw_sid)
    -                if root in seen:
    -                    continue
    -                seen[root] = {
    -                    "session_id": lineage_tip(root),
    -                    "lineage_root": root,
    -                    "snippet": m.get("snippet", ""),
    -                    "role": m.get("role"),
    -                    "source": m.get("source"),
    -                    "model": m.get("model"),
    -                    "session_started": m.get("session_started"),
    -                }
    -                if len(seen) >= limit:
    +                if root in seen or len(seen) >= safe_limit:
    +                    return
    +                payload = dict(payload)
    +                payload["session_id"] = lineage_tip(root)
    +                payload["lineage_root"] = root
    +                seen[root] = payload
    +
    +            # Direct ID matches first: users often paste a session id from CLI,
    +            # logs, or another Hermes surface. FTS can't find those unless the
    +            # id happens to appear in message text. search_sessions_by_id is
    +            # SQL-bounded, so this stays cheap even with thousands of sessions.
    +            for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True):
    +                sid = row.get("id")
    +                preview = (row.get("preview") or "").strip()
    +                snippet = preview or f"Session ID: {sid}"
    +                add_lineage_result(
    +                    sid,
    +                    {
    +                        "snippet": snippet,
    +                        "role": None,
    +                        "source": row.get("source"),
    +                        "model": row.get("model"),
    +                        "session_started": row.get("started_at"),
    +                    },
    +                )
    +
    +            # Auto-add prefix wildcards so partial words match
    +            # e.g. "nimb" → "nimb*" matches "nimby"
    +            # Preserve quoted phrases and existing wildcards as-is
    +            import re
    +            terms = []
    +            for token in re.findall(r'"[^"]*"|\S+', q.strip()):
    +                if token.startswith('"') or token.endswith("*"):
    +                    terms.append(token)
    +                else:
    +                    terms.append(token + "*")
    +            prefix_query = " ".join(terms)
    +            # Over-fetch so lineage dedup can still surface `limit` distinct
    +            # conversations even when several hits collapse onto one root.
    +            fetch_limit = max(safe_limit * 5, 50)
    +            matches = db.search_messages(query=prefix_query, limit=fetch_limit)
    +
    +            for m in matches:
    +                if len(seen) >= safe_limit:
                         break
    +                add_lineage_result(
    +                    m["session_id"],
    +                    {
    +                        "snippet": m.get("snippet", ""),
    +                        "role": m.get("role"),
    +                        "source": m.get("source"),
    +                        "model": m.get("model"),
    +                        "session_started": m.get("session_started"),
    +                    },
    +                )
                 return {"results": list(seen.values())}
             finally:
                 db.close()
    ```
  - `hermes_state.py`

    ```diff
    @@ -1565,6 +1565,7 @@ def list_sessions_rich(
             order_by_last_active: bool = False,
             include_archived: bool = False,
             archived_only: bool = False,
    +        id_query: str = None,
         ) -> List[Dict[str, Any]]:
             """List sessions with preview (first user message) and last active timestamp.
     
    ```

    ```diff
    @@ -1626,6 +1627,16 @@ def list_sessions_rich(
                 where_clauses.append("s.archived = 0")
     
             where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
    +
    +        # Optional session-id filter, pushed into SQL so callers (Desktop
    +        # session-id search) don't have to fetch every row and filter in
    +        # Python. ``id_query`` is matched as a case-insensitive substring
    +        # against each surfaced row's id AND every id in its forward
    +        # compression chain — so searching a compression *root* id or a *tip*
    +        # id both resolve to the same projected conversation. Only used in the
    +        # order_by_last_active path (which builds the chain CTE); other callers
    +        # pass id_query=None.
    +        id_needle = (id_query or "").strip().lower()
             if order_by_last_active:
                 # Compute effective_last_active by walking each surfaced session's
                 # compression-continuation chain forward in SQL and taking the MAX
    ```

    ```diff
    @@ -1638,6 +1649,28 @@ def list_sessions_rich(
                 # compression-continuation edges using the same criteria as
                 # get_compression_tip (parent.end_reason='compression' AND
                 # child.started_at >= parent.ended_at).
    +            outer_where = where_sql
    +            id_params: List[Any] = []
    +            if id_needle:
    +                # Admit a surfaced row if its own id or any id in its forward
    +                # compression chain matches the needle. LIKE with a leading
    +                # wildcard can't use an index, but the chain membership and
    +                # the small result set keep this bounded — far cheaper than
    +                # fetching every session and scanning in Python.
    +                id_clause = (
    +                    "EXISTS (SELECT 1 FROM chain cq"
    +                    "        WHERE cq.root_id = s.id"
    +                    "          AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
    +                )
    +                like_pattern = (
    +                    "%"
    +                    + id_needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
    +                    + "%"
    +                )
    +                id_params = [like_pattern]
    +                outer_where = (
    +                    f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}"
    +                )
                 query = f"""
                     WITH RECURSIVE chain(root_id, cur_id) AS (
                         SELECT s.id, s.id FROM sessions s {where_sql}
    ```

    ```diff
    @@ -1674,12 +1707,13 @@ def list_sessions_rich(
                         COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
                     FROM sessions s
                     LEFT JOIN chain_max cm ON cm.root_id = s.id
    -                {where_sql}
    +                {outer_where}
                     ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
                     LIMIT ? OFFSET ?
                 """
    -            # WHERE params apply twice (CTE seed + outer select).
    -            params = params + params + [limit, offset]
    +            # WHERE params apply twice (CTE seed + outer select); the id filter
    +            # only applies to the outer select.
    +            params = params + params + id_params + [limit, offset]
             else:
                 query = f"""
                     SELECT s.*,
    ```

    ```diff
    @@ -3007,6 +3041,53 @@ def search_messages(
     
             return matches
     
    +    def search_sessions_by_id(
    +        self,
    +        query: str,
    +        limit: int = 20,
    +        include_archived: bool = True,
    +    ) -> List[Dict[str, Any]]:
    +        """Search surfaced sessions by exact/prefix/substring session id.
    +
    +        Desktop search uses this alongside FTS message search so users can paste
    +        a session id from logs, CLI output, or another Hermes surface and jump
    +        straight to that conversation.  Matching also checks ``_lineage_root_id``
    +        for projected compression-chain tips, so an old root id still resolves to
    +        the live continuation row.
    +        """
    +        needle = (query or "").strip().lower()
    +        if not needle or limit <= 0:
    +            return []
    +
    +        # SQL-bounded: list_sessions_rich pushes the id LIKE filter into the
    +        # query (matching the row's own id AND any id in its forward
    +        # compression chain), so we only materialize matching rows instead of
    +        # scanning every session. Fetch a small multiple of `limit` so the
    +        # in-Python exact/prefix/substring ranking below has enough candidates
    +        # to order, then truncate.
    +        candidates = self.list_sessions_rich(
    +            limit=max(limit * 4, limit),
    +            offset=0,
    +            include_archived=include_archived,
    +            order_by_last_active=True,
    +            id_query=needle,
    +        )
    +
    +        def score(row: Dict[str, Any]) -> int:
    +            ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")]
    +            normalized = [value.lower() for value in ids if value]
    +            if any(value == needle for value in normalized):
    +                return 0
    +            if any(value.startswith(needle) for value in normalized):
    +                return 1
    +            return 2
    +
    +        ranked = sorted(
    +            enumerate(candidates),
    +            key=lambda item: (score(item[1]), item[0]),
    +        )
    +        return [row for _, row in ranked[:limit]]
    +
         def search_sessions(
             self,
             source: str = None,
    ```
  - `tests/hermes_cli/test_web_server_session_search.py`

    ```diff
    @@ -0,0 +1,90 @@
    +import asyncio
    +
    +from hermes_cli import web_server
    +
    +
    +class _FakeSessionDB:
    +    """Fake backing the /api/sessions/search endpoint.
    +
    +    The endpoint surfaces direct session-id matches first, then FTS message
    +    matches, deduping both by compression lineage root. This fake has no
    +    compression chains (get_session returns no parent), so each session is its
    +    own lineage root.
    +    """
    +
    +    closed = False
    +
    +    def search_sessions_by_id(self, query, limit=20, include_archived=True):
    +        assert query == "20260603"
    +        assert include_archived is True
    +        return [
    +            {
    +                "id": "20260603_090200_exact",
    +                "preview": "ID match preview",
    +                "source": "cli",
    +                "model": "claude",
    +                "started_at": 100,
    +            }
    +        ]
    +
    +    def search_messages(self, query, limit=20):
    +        assert query == "20260603*"
    +        return [
    +            {
    +                "session_id": "20260603_090200_exact",
    +                "snippet": "duplicate content hit should not replace ID hit",
    +                "role": "user",
    +                "source": "cli",
    +                "model": "claude",
    +                "session_started": 100,
    +            },
    +            {
    +                "session_id": "content_session",
    +                "snippet": "content hit",
    +                "role": "assistant",
    +                "source": "desktop",
    +                "model": "gpt",
    +                "session_started": 200,
    +            },
    +        ]
    +
    +    def get_session(self, session_id):
    +        # No compression chains in this fixture — every session is its own root.
    +        return {"id": session_id, "parent_session_id": None}
    +
    +    def get_compression_tip(self, session_id):
    +        return session_id
    +
    +    def close(self):
    +        self.closed = True
    +
    +
    +def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch):
    +    monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB)
    +
    +    response = asyncio.run(web_server.search_sessions(q="20260603", limit=2))
    +
    +    # ID match surfaces first; the content hit on the SAME session is deduped
    +    # by lineage root (not double-listed); the unrelated content hit follows.
    +    assert response == {
    +        "results": [
    +            {
    +                "session_id": "20260603_090200_exact",
    +                "lineage_root": "20260603_090200_exact",
    +                "snippet": "ID match preview",
    +                "role": None,
    +                "source": "cli",
    +                "model": "claude",
    +                "session_started": 100,
    +            },
    +            {
    +                "session_id": "content_session",
    +                "lineage_root": "content_session",
    +                "snippet": "content hit",
    +                "role": "assistant",
    +                "source": "desktop",
    +                "model": "gpt",
    +                "session_started": 200,
    +            },
    +        ]
    +    }
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -3786,3 +3786,57 @@ def test_archived_only_and_include(self, db):
             both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
             assert both == {"live", "hidden"}
             assert db.session_count(include_archived=True) == 2
    +
    +
    +
    +class TestSessionIdSearch:
    +    """Session id search backs Desktop's Search Sessions UX."""
    +
    +    def _seed(self, db, sid, *, content="ordinary message", archived=False):
    +        db.create_session(session_id=sid, source="cli", model="test-model")
    +        db.append_message(session_id=sid, role="user", content=content)
    +        if archived:
    +            db.set_session_archived(sid, True)
    +
    +    def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db):
    +        self._seed(db, "20260603_090200_abcd12", content="content without id")
    +        self._seed(db, "20260602_111111_other99", content="other content")
    +
    +        assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [
    +            "20260603_090200_abcd12"
    +        ]
    +        assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"]
    +        assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"]
    +
    +    def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db):
    +        self._seed(db, "20260603_090200_abcd12")
    +        self._seed(db, "20260603_090200_abcd12_child")
    +        self._seed(db, "x_20260603_090200_abcd12")
    +
    +        ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)]
    +
    +        assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"]
    +
    +    def test_search_sessions_by_id_can_include_or_exclude_archived(self, db):
    +        self._seed(db, "20260603_090200_live")
    +        self._seed(db, "20260603_090200_archived", archived=True)
    +
    +        included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)}
    +        excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)}
    +
    +        assert included == {"20260603_090200_live", "20260603_090200_archived"}
    +        assert excluded == {"20260603_090200_live"}
    +
    +    def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db):
    +        root = "20260602_235959_root99"
    +        tip = "20260603_010000_tip01"
    +        db.create_session(session_id=root, source="cli")
    +        db.append_message(root, role="user", content="root conversation")
    +        db.end_session(root, "compression")
    +        db.create_session(session_id=tip, source="cli", parent_session_id=root)
    +        db.append_message(tip, role="user", content="continued conversation")
    +
    +        matches = db.search_sessions_by_id("root99")
    +
    +        assert [s["id"] for s in matches] == [tip]
    +        assert matches[0]["_lineage_root_id"] == root
    ```
- **#37883: feat(desktop): Search Hermes Desktop Sessions by Session ID**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#39062`

## Fix status

- Merged to main in PR #39062 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-04`.
