{"repo":"NousResearch/hermes-agent","public_id":"37882","display_label":"Search desktop sessions by session ID feature","summary":"These members relate to adding the ability to search Hermes desktop sessions by session ID, with at least one implementation using SQL-bounded querying.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39062,"merged_at":"2026-06-04T14:49:35+00:00"},"resolution":{"state":"resolved","by":"pr-39062"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T14:49:35+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":37882,"pr_number":39062,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-04T14:49:35+00:00"}],"members":[{"kind":"issue","number":37882,"title":"[Feature]: Search Hermes Desktop Sessions by Session ID","url":null,"state":"closed"},{"kind":"pr","number":37883,"title":"feat(desktop): Search Hermes Desktop Sessions by Session ID","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39062","number":39062}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39062,"title":"feat(desktop): search sessions by id (SQL-bounded)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/desktop/src/app/chat/sidebar/index.tsx","hunks":["@@ -35,6 +35,7 @@ import {\n } from '@/components/ui/sidebar'\n import { Skeleton } from '@/components/ui/skeleton'\n import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'\n+import { sessionMatchesSearch } from '@/lib/session-search'\n import { cn } from '@/lib/utils'\n import {\n   $panesFlipped,","@@ -330,11 +331,10 @@ export function ChatSidebar({\n       return []\n     }\n \n-    const needle = trimmedQuery.toLowerCase()\n     const out = new Map<string, SessionInfo>()\n \n     for (const s of sortedSessions) {\n-      if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {\n+      if (sessionMatchesSearch(s, trimmedQuery)) {\n         out.set(s.id, s)\n       }\n     }"]},{"file":"apps/desktop/src/app/command-center/index.tsx","hunks":["@@ -168,7 +168,7 @@ export function CommandCenterView({\n     }\n \n     return sorted.filter(session => {\n-      const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()\n+      const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()\n \n       return haystack.includes(needle)\n     })"]},{"file":"apps/desktop/src/lib/session-search.test.ts","hunks":["@@ -0,0 +1,58 @@\n+import { describe, expect, it } from 'vitest'\n+\n+import type { SessionInfo } from '@/types/hermes'\n+\n+import { sessionMatchesSearch } from './session-search'\n+\n+function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {\n+  return {\n+    archived: false,\n+    cwd: '/home/user/projects/hermes-agent',\n+    ended_at: null,\n+    id: '20260603_090200_abcd12',\n+    input_tokens: 0,\n+    is_active: false,\n+    last_active: 1_000,\n+    message_count: 2,\n+    model: 'claude',\n+    output_tokens: 0,\n+    preview: 'Fix Desktop session search',\n+    source: 'cli',\n+    started_at: 1_000,\n+    title: 'Desktop Search Feature',\n+    tool_call_count: 0,\n+    ...overrides\n+  }\n+}\n+\n+describe('sessionMatchesSearch', () => {\n+  it('matches loaded sessions by full and partial session id', () => {\n+    const session = makeSession()\n+\n+    expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true)\n+    expect(sessionMatchesSearch(session, '090200')).toBe(true)\n+    expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true)\n+  })\n+\n+  it('matches projected compression sessions by lineage root id', () => {\n+    const session = makeSession({\n+      _lineage_root_id: '20260602_235959_root99',\n+      id: '20260603_010000_tip01'\n+    })\n+\n+    expect(sessionMatchesSearch(session, 'root99')).toBe(true)\n+    expect(sessionMatchesSearch(session, '20260602')).toBe(true)\n+  })\n+\n+  it('preserves title, preview, and workspace matching', () => {\n+    const session = makeSession()\n+\n+    expect(sessionMatchesSearch(session, 'desktop search')).toBe(true)\n+    expect(sessionMatchesSearch(session, 'session search')).toBe(true)\n+    expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)\n+  })\n+\n+  it('does not match unrelated queries', () => {\n+    expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)\n+  })\n+})"]},{"file":"apps/desktop/src/lib/session-search.ts","hunks":["@@ -0,0 +1,19 @@\n+import type { SessionInfo } from '@/types/hermes'\n+\n+import { sessionTitle } from './chat-runtime'\n+\n+export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {\n+  const needle = query.trim().toLowerCase()\n+\n+  if (!needle) {\n+    return true\n+  }\n+\n+  return [\n+    session.id,\n+    session._lineage_root_id ?? '',\n+    sessionTitle(session),\n+    session.preview ?? '',\n+    session.cwd ?? ''\n+  ].some(value => value.toLowerCase().includes(needle))\n+}"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -1611,14 +1611,15 @@ async def get_sessions(\n \n @app.get(\"/api/sessions/search\")\n async def search_sessions(q: str = \"\", limit: int = 20):\n-    \"\"\"Full-text search across session message content using FTS5.\n-\n-    Results are deduped by compression lineage, not by raw ``session_id``.\n-    Auto-compression rotates a conversation onto a fresh session id (and leaves\n-    the old segment's messages in the FTS index), so one logical chat can own\n-    many ``sessions`` rows that all match the same query. Branches also use\n-    ``parent_session_id``, but they are real alternate conversations; don't\n-    collapse branch-specific hits back into the parent.\n+    \"\"\"Search sessions by ID plus full-text message content using FTS5.\n+\n+    Direct session-id matches are surfaced first, then FTS message-content\n+    matches. Results are deduped by compression lineage, not by raw\n+    ``session_id``. Auto-compression rotates a conversation onto a fresh\n+    session id (and leaves the old segment's messages in the FTS index), so one\n+    logical chat can own many ``sessions`` rows that all match the same query.\n+    Branches also use ``parent_session_id``, but they are real alternate\n+    conversations; don't collapse branch-specific hits back into the parent.\n     \"\"\"\n     if not q or not q.strip():\n         return {\"results\": []}","@@ -1626,21 +1627,7 @@ async def search_sessions(q: str = \"\", limit: int = 20):\n         from hermes_state import SessionDB\n         db = SessionDB()\n         try:\n-            # Auto-add prefix wildcards so partial words match\n-            # e.g. \"nimb\" → \"nimb*\" matches \"nimby\"\n-            # Preserve quoted phrases and existing wildcards as-is\n-            import re\n-            terms = []\n-            for token in re.findall(r'\"[^\"]*\"|\\S+', q.strip()):\n-                if token.startswith('\"') or token.endswith(\"*\"):\n-                    terms.append(token)\n-                else:\n-                    terms.append(token + \"*\")\n-            prefix_query = \" \".join(terms)\n-            # Over-fetch so lineage dedup can still surface `limit` distinct\n-            # conversations even when several hits collapse onto one root.\n-            fetch_limit = max(limit * 5, 50)\n-            matches = db.search_messages(query=prefix_query, limit=fetch_limit)\n+            safe_limit = max(1, min(int(limit or 20), 100))\n \n             # Walk parent_session_id to the compression root, memoized so a\n             # chain of compression segments only costs one walk. We deliberately","@@ -1712,24 +1699,71 @@ def lineage_tip(root_id: str) -> str:\n                 tip_cache[root_id] = tip\n                 return tip\n \n-            # Keep the best (first / most relevant) hit per compression root.\n+            # Both ID matches and content matches share one keyspace, keyed by\n+            # compression lineage root, so an id-hit and a content-hit on the\n+            # same logical conversation collapse to a single result. The first\n+            # hit for a lineage wins; ID matches run first and take priority.\n             seen: dict = {}\n-            for m in matches:\n-                raw_sid = m[\"session_id\"]\n+\n+            def add_lineage_result(raw_sid: str, payload: dict) -> None:\n+                if not raw_sid:\n+                    return\n                 root = compression_root(raw_sid)\n-                if root in seen:\n-                    continue\n-                seen[root] = {\n-                    \"session_id\": lineage_tip(root),\n-                    \"lineage_root\": root,\n-                    \"snippet\": m.get(\"snippet\", \"\"),\n-                    \"role\": m.get(\"role\"),\n-                    \"source\": m.get(\"source\"),\n-                    \"model\": m.get(\"model\"),\n-                    \"session_started\": m.get(\"session_started\"),\n-                }\n-                if len(seen) >= limit:\n+                if root in seen or len(seen) >= safe_limit:\n+                    return\n+                payload = dict(payload)\n+                payload[\"session_id\"] = lineage_tip(root)\n+                payload[\"lineage_root\"] = root\n+                seen[root] = payload\n+\n+            # Direct ID matches first: users often paste a session id from CLI,\n+            # logs, or another Hermes surface. FTS can't find those unless the\n+            # id happens to appear in message text. search_sessions_by_id is\n+            # SQL-bounded, so this stays cheap even with thousands of sessions.\n+            for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True):\n+                sid = row.get(\"id\")\n+                preview = (row.get(\"preview\") or \"\").strip()\n+                snippet = preview or f\"Session ID: {sid}\"\n+                add_lineage_result(\n+                    sid,\n+                    {\n+                        \"snippet\": snippet,\n+                        \"role\": None,\n+                        \"source\": row.get(\"source\"),\n+                        \"model\": row.get(\"model\"),\n+                        \"session_started\": row.get(\"started_at\"),\n+                    },\n+                )\n+\n+            # Auto-add prefix wildcards so partial words match\n+            # e.g. \"nimb\" → \"nimb*\" matches \"nimby\"\n+            # Preserve quoted phrases and existing wildcards as-is\n+            import re\n+            terms = []\n+            for token in re.findall(r'\"[^\"]*\"|\\S+', q.strip()):\n+                if token.startswith('\"') or token.endswith(\"*\"):\n+                    terms.append(token)\n+                else:\n+                    terms.append(token + \"*\")\n+            prefix_query = \" \".join(terms)\n+            # Over-fetch so lineage dedup can still surface `limit` distinct\n+            # conversations even when several hits collapse onto one root.\n+            fetch_limit = max(safe_limit * 5, 50)\n+            matches = db.search_messages(query=prefix_query, limit=fetch_limit)\n+\n+            for m in matches:\n+                if len(seen) >= safe_limit:\n                     break\n+                add_lineage_result(\n+                    m[\"session_id\"],\n+                    {\n+                        \"snippet\": m.get(\"snippet\", \"\"),\n+                        \"role\": m.get(\"role\"),\n+                        \"source\": m.get(\"source\"),\n+                        \"model\": m.get(\"model\"),\n+                        \"session_started\": m.get(\"session_started\"),\n+                    },\n+                )\n             return {\"results\": list(seen.values())}\n         finally:\n             db.close()"]},{"file":"hermes_state.py","hunks":["@@ -1565,6 +1565,7 @@ def list_sessions_rich(\n         order_by_last_active: bool = False,\n         include_archived: bool = False,\n         archived_only: bool = False,\n+        id_query: str = None,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"List sessions with preview (first user message) and last active timestamp.\n ","@@ -1626,6 +1627,16 @@ def list_sessions_rich(\n             where_clauses.append(\"s.archived = 0\")\n \n         where_sql = f\"WHERE {' AND '.join(where_clauses)}\" if where_clauses else \"\"\n+\n+        # Optional session-id filter, pushed into SQL so callers (Desktop\n+        # session-id search) don't have to fetch every row and filter in\n+        # Python. ``id_query`` is matched as a case-insensitive substring\n+        # against each surfaced row's id AND every id in its forward\n+        # compression chain — so searching a compression *root* id or a *tip*\n+        # id both resolve to the same projected conversation. Only used in the\n+        # order_by_last_active path (which builds the chain CTE); other callers\n+        # pass id_query=None.\n+        id_needle = (id_query or \"\").strip().lower()\n         if order_by_last_active:\n             # Compute effective_last_active by walking each surfaced session's\n             # compression-continuation chain forward in SQL and taking the MAX","@@ -1638,6 +1649,28 @@ def list_sessions_rich(\n             # compression-continuation edges using the same criteria as\n             # get_compression_tip (parent.end_reason='compression' AND\n             # child.started_at >= parent.ended_at).\n+            outer_where = where_sql\n+            id_params: List[Any] = []\n+            if id_needle:\n+                # Admit a surfaced row if its own id or any id in its forward\n+                # compression chain matches the needle. LIKE with a leading\n+                # wildcard can't use an index, but the chain membership and\n+                # the small result set keep this bounded — far cheaper than\n+                # fetching every session and scanning in Python.\n+                id_clause = (\n+                    \"EXISTS (SELECT 1 FROM chain cq\"\n+                    \"        WHERE cq.root_id = s.id\"\n+                    \"          AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\\\')\"\n+                )\n+                like_pattern = (\n+                    \"%\"\n+                    + id_needle.replace(\"\\\\\", \"\\\\\\\\\").replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\")\n+                    + \"%\"\n+                )\n+                id_params = [like_pattern]\n+                outer_where = (\n+                    f\"{where_sql} AND {id_clause}\" if where_sql else f\"WHERE {id_clause}\"\n+                )\n             query = f\"\"\"\n                 WITH RECURSIVE chain(root_id, cur_id) AS (\n                     SELECT s.id, s.id FROM sessions s {where_sql}","@@ -1674,12 +1707,13 @@ def list_sessions_rich(\n                     COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active\n                 FROM sessions s\n                 LEFT JOIN chain_max cm ON cm.root_id = s.id\n-                {where_sql}\n+                {outer_where}\n                 ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC\n                 LIMIT ? OFFSET ?\n             \"\"\"\n-            # WHERE params apply twice (CTE seed + outer select).\n-            params = params + params + [limit, offset]\n+            # WHERE params apply twice (CTE seed + outer select); the id filter\n+            # only applies to the outer select.\n+            params = params + params + id_params + [limit, offset]\n         else:\n             query = f\"\"\"\n                 SELECT s.*,","@@ -3007,6 +3041,53 @@ def search_messages(\n \n         return matches\n \n+    def search_sessions_by_id(\n+        self,\n+        query: str,\n+        limit: int = 20,\n+        include_archived: bool = True,\n+    ) -> List[Dict[str, Any]]:\n+        \"\"\"Search surfaced sessions by exact/prefix/substring session id.\n+\n+        Desktop search uses this alongside FTS message search so users can paste\n+        a session id from logs, CLI output, or another Hermes surface and jump\n+        straight to that conversation.  Matching also checks ``_lineage_root_id``\n+        for projected compression-chain tips, so an old root id still resolves to\n+        the live continuation row.\n+        \"\"\"\n+        needle = (query or \"\").strip().lower()\n+        if not needle or limit <= 0:\n+            return []\n+\n+        # SQL-bounded: list_sessions_rich pushes the id LIKE filter into the\n+        # query (matching the row's own id AND any id in its forward\n+        # compression chain), so we only materialize matching rows instead of\n+        # scanning every session. Fetch a small multiple of `limit` so the\n+        # in-Python exact/prefix/substring ranking below has enough candidates\n+        # to order, then truncate.\n+        candidates = self.list_sessions_rich(\n+            limit=max(limit * 4, limit),\n+            offset=0,\n+            include_archived=include_archived,\n+            order_by_last_active=True,\n+            id_query=needle,\n+        )\n+\n+        def score(row: Dict[str, Any]) -> int:\n+            ids = [str(row.get(\"id\") or \"\"), str(row.get(\"_lineage_root_id\") or \"\")]\n+            normalized = [value.lower() for value in ids if value]\n+            if any(value == needle for value in normalized):\n+                return 0\n+            if any(value.startswith(needle) for value in normalized):\n+                return 1\n+            return 2\n+\n+        ranked = sorted(\n+            enumerate(candidates),\n+            key=lambda item: (score(item[1]), item[0]),\n+        )\n+        return [row for _, row in ranked[:limit]]\n+\n     def search_sessions(\n         self,\n         source: str = None,"]},{"file":"tests/hermes_cli/test_web_server_session_search.py","hunks":["@@ -0,0 +1,90 @@\n+import asyncio\n+\n+from hermes_cli import web_server\n+\n+\n+class _FakeSessionDB:\n+    \"\"\"Fake backing the /api/sessions/search endpoint.\n+\n+    The endpoint surfaces direct session-id matches first, then FTS message\n+    matches, deduping both by compression lineage root. This fake has no\n+    compression chains (get_session returns no parent), so each session is its\n+    own lineage root.\n+    \"\"\"\n+\n+    closed = False\n+\n+    def search_sessions_by_id(self, query, limit=20, include_archived=True):\n+        assert query == \"20260603\"\n+        assert include_archived is True\n+        return [\n+            {\n+                \"id\": \"20260603_090200_exact\",\n+                \"preview\": \"ID match preview\",\n+                \"source\": \"cli\",\n+                \"model\": \"claude\",\n+                \"started_at\": 100,\n+            }\n+        ]\n+\n+    def search_messages(self, query, limit=20):\n+        assert query == \"20260603*\"\n+        return [\n+            {\n+                \"session_id\": \"20260603_090200_exact\",\n+                \"snippet\": \"duplicate content hit should not replace ID hit\",\n+                \"role\": \"user\",\n+                \"source\": \"cli\",\n+                \"model\": \"claude\",\n+                \"session_started\": 100,\n+            },\n+            {\n+                \"session_id\": \"content_session\",\n+                \"snippet\": \"content hit\",\n+                \"role\": \"assistant\",\n+                \"source\": \"desktop\",\n+                \"model\": \"gpt\",\n+                \"session_started\": 200,\n+            },\n+        ]\n+\n+    def get_session(self, session_id):\n+        # No compression chains in this fixture — every session is its own root.\n+        return {\"id\": session_id, \"parent_session_id\": None}\n+\n+    def get_compression_tip(self, session_id):\n+        return session_id\n+\n+    def close(self):\n+        self.closed = True\n+\n+\n+def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch):\n+    monkeypatch.setattr(\"hermes_state.SessionDB\", _FakeSessionDB)\n+\n+    response = asyncio.run(web_server.search_sessions(q=\"20260603\", limit=2))\n+\n+    # ID match surfaces first; the content hit on the SAME session is deduped\n+    # by lineage root (not double-listed); the unrelated content hit follows.\n+    assert response == {\n+        \"results\": [\n+            {\n+                \"session_id\": \"20260603_090200_exact\",\n+                \"lineage_root\": \"20260603_090200_exact\",\n+                \"snippet\": \"ID match preview\",\n+                \"role\": None,\n+                \"source\": \"cli\",\n+                \"model\": \"claude\",\n+                \"session_started\": 100,\n+            },\n+            {\n+                \"session_id\": \"content_session\",\n+                \"lineage_root\": \"content_session\",\n+                \"snippet\": \"content hit\",\n+                \"role\": \"assistant\",\n+                \"source\": \"desktop\",\n+                \"model\": \"gpt\",\n+                \"session_started\": 200,\n+            },\n+        ]\n+    }"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -3786,3 +3786,57 @@ def test_archived_only_and_include(self, db):\n         both = {s[\"id\"] for s in db.list_sessions_rich(include_archived=True)}\n         assert both == {\"live\", \"hidden\"}\n         assert db.session_count(include_archived=True) == 2\n+\n+\n+\n+class TestSessionIdSearch:\n+    \"\"\"Session id search backs Desktop's Search Sessions UX.\"\"\"\n+\n+    def _seed(self, db, sid, *, content=\"ordinary message\", archived=False):\n+        db.create_session(session_id=sid, source=\"cli\", model=\"test-model\")\n+        db.append_message(session_id=sid, role=\"user\", content=content)\n+        if archived:\n+            db.set_session_archived(sid, True)\n+\n+    def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db):\n+        self._seed(db, \"20260603_090200_abcd12\", content=\"content without id\")\n+        self._seed(db, \"20260602_111111_other99\", content=\"other content\")\n+\n+        assert [s[\"id\"] for s in db.search_sessions_by_id(\"20260603_090200_abcd12\")] == [\n+            \"20260603_090200_abcd12\"\n+        ]\n+        assert [s[\"id\"] for s in db.search_sessions_by_id(\"20260603\")] == [\"20260603_090200_abcd12\"]\n+        assert [s[\"id\"] for s in db.search_sessions_by_id(\"ABCD12\")] == [\"20260603_090200_abcd12\"]\n+\n+    def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db):\n+        self._seed(db, \"20260603_090200_abcd12\")\n+        self._seed(db, \"20260603_090200_abcd12_child\")\n+        self._seed(db, \"x_20260603_090200_abcd12\")\n+\n+        ids = [s[\"id\"] for s in db.search_sessions_by_id(\"20260603_090200_abcd12\", limit=2)]\n+\n+        assert ids == [\"20260603_090200_abcd12\", \"20260603_090200_abcd12_child\"]\n+\n+    def test_search_sessions_by_id_can_include_or_exclude_archived(self, db):\n+        self._seed(db, \"20260603_090200_live\")\n+        self._seed(db, \"20260603_090200_archived\", archived=True)\n+\n+        included = {s[\"id\"] for s in db.search_sessions_by_id(\"20260603_090200\", include_archived=True)}\n+        excluded = {s[\"id\"] for s in db.search_sessions_by_id(\"20260603_090200\", include_archived=False)}\n+\n+        assert included == {\"20260603_090200_live\", \"20260603_090200_archived\"}\n+        assert excluded == {\"20260603_090200_live\"}\n+\n+    def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db):\n+        root = \"20260602_235959_root99\"\n+        tip = \"20260603_010000_tip01\"\n+        db.create_session(session_id=root, source=\"cli\")\n+        db.append_message(root, role=\"user\", content=\"root conversation\")\n+        db.end_session(root, \"compression\")\n+        db.create_session(session_id=tip, source=\"cli\", parent_session_id=root)\n+        db.append_message(tip, role=\"user\", content=\"continued conversation\")\n+\n+        matches = db.search_sessions_by_id(\"root99\")\n+\n+        assert [s[\"id\"] for s in matches] == [tip]\n+        assert matches[0][\"_lineage_root_id\"] == root\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}