Nothing open on GitHub. Fix merged in #39062 on 4 Jun 2026. Check it covers your case.
The short answer
- 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
Open PRs0
Fixes on record
-
#39062 feat(desktop): search sessions by id (SQL-bounded)
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.pyShow 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.00means 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 LabAt a glance
- Merged PRs
- #39062
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 4 Jun 2026