From 9ecc331be8477b782cf733931d438b09c87555d4 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Wed, 3 Jun 2026 09:36:09 +0700 Subject: [PATCH] feat(desktop): search sessions by id --- apps/desktop/src/app/chat/sidebar/index.tsx | 4 +- apps/desktop/src/lib/session-search.test.ts | 58 +++++++++++++++ apps/desktop/src/lib/session-search.ts | 19 +++++ hermes_cli/web_server.py | 50 ++++++++++--- hermes_state.py | 44 +++++++++++ .../test_web_server_session_search.py | 73 +++++++++++++++++++ tests/test_hermes_state.py | 54 ++++++++++++++ 7 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/session-search.test.ts create mode 100644 apps/desktop/src/lib/session-search.ts create mode 100644 tests/hermes_cli/test_web_server_session_search.py diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a8aa706e2..dffb21ce7 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/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() for (const s of sortedSessions) { - if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + if (sessionMatchesSearch(s, trimmedQuery)) { out.set(s.id, s) } } diff --git a/apps/desktop/src/lib/session-search.test.ts b/apps/desktop/src/lib/session-search.test.ts new file mode 100644 index 000000000..aa40fe59c --- /dev/null +++ b/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 { + 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) + }) +}) diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts new file mode 100644 index 000000000..b8ee6ebf3 --- /dev/null +++ b/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)) +} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d25ca1643..b9afe1711 100644 --- a/hermes_cli/web_server.py +++ b/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. + """Search sessions by ID plus full-text 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. + 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,6 +1627,32 @@ async def search_sessions(q: str = "", limit: int = 20): from hermes_state import SessionDB db = SessionDB() try: + safe_limit = max(1, min(int(limit or 20), 100)) + seen: dict = {} + + def add_result(sid: str, payload: dict) -> None: + if sid and sid not in seen and len(seen) < safe_limit: + seen[sid] = 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. + 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_result( + sid, + { + "session_id": 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 @@ -1639,7 +1666,7 @@ async def search_sessions(q: str = "", limit: int = 20): 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) + fetch_limit = max(safe_limit * 5, 50) matches = db.search_messages(query=prefix_query, limit=fetch_limit) # Walk parent_session_id to the compression root, memoized so a @@ -1713,12 +1740,15 @@ async def search_sessions(q: str = "", limit: int = 20): return tip # Keep the best (first / most relevant) hit per compression root. - seen: dict = {} + # `seen` already holds the direct ID matches collected above; the + # content matches extend it without clobbering them. for m in matches: raw_sid = m["session_id"] root = compression_root(raw_sid) if root in seen: continue + if len(seen) >= safe_limit: + break seen[root] = { "session_id": lineage_tip(root), "lineage_root": root, @@ -1728,8 +1758,6 @@ async def search_sessions(q: str = "", limit: int = 20): "model": m.get("model"), "session_started": m.get("session_started"), } - if len(seen) >= limit: - break return {"results": list(seen.values())} finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index f08acdce2..8d2d89131 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3007,6 +3007,50 @@ class SessionDB: 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 [] + + scan_limit = max(limit, 10_000) + sessions = self.list_sessions_rich( + limit=scan_limit, + offset=0, + include_archived=include_archived, + order_by_last_active=True, + ) + + 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 + + matches = [ + (score(row), idx, row) + for idx, row in enumerate(sessions) + if needle in str(row.get("id") or "").lower() + or needle in str(row.get("_lineage_root_id") or "").lower() + ] + matches.sort(key=lambda item: (item[0], item[1])) + return [row for _, _, row in matches[:limit]] + def search_sessions( self, source: str = None, diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py new file mode 100644 index 000000000..035f42862 --- /dev/null +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -0,0 +1,73 @@ +import asyncio + +from hermes_cli import web_server + + +class _FakeSessionDB: + closed = False + + def search_sessions_by_id(self, query, limit=20, include_archived=True): + assert query == "20260603" + assert limit == 2 + 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*" + assert limit == 2 + 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 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)) + + assert response == { + "results": [ + { + "session_id": "20260603_090200_exact", + "snippet": "ID match preview", + "role": None, + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 572fd6489..f5e4f69ae 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3786,3 +3786,57 @@ class TestSessionArchive: 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