NousResearch/hermes-agent

Search desktop sessions by session ID feature

Cluster 37882. Data as of

Nothing open on GitHub. Fix merged in #39062 on 4 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #39062. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #39062. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #39062 feat(desktop): search sessions by id (SQL-bounded)

    merged to main

    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

    Show the diff
    • apps/desktop/src/app/chat/sidebar/index.tsx
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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": []}
      @@ -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
      @@ -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
      @@ -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.
       
      @@ -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
      @@ -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}
      @@ -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.*,
      @@ -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
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #37883 feat(desktop): Search Hermes Desktop Sessions by Session ID · merged via another change → #39062
Closed issues · 1
  • Closed#37882[Feature]: Search Hermes Desktop Sessions by Session ID
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#39062
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
4 Jun 2026

For your agent

The same facts, typed.