Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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 {
$pinnedSessionIds,
Expand Down Expand Up @@ -306,11 +307,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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/command-center/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function formatTimestamp(value?: number | null): string {
function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map<string, SessionInfo>) {
const row = sessionsById.get(result.session_id)
const title = row ? sessionTitle(row) : result.session_id
const detail = [result.model, result.source].filter(Boolean).join(' · ')
const detail = [result.model, result.source, result.session_id].filter(Boolean).join(' · ')

return { detail, title }
}
Expand Down
22 changes: 13 additions & 9 deletions apps/desktop/src/app/settings/sessions-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { sessionMatchesSearch } from '@/lib/session-search'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
Expand Down Expand Up @@ -88,15 +89,11 @@ export function SessionsSettings({ query }: SearchProps) {
}, [])

const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()

if (!needle) {
if (!query.trim()) {
return sessions
}

return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
)
return sessions.filter(session => sessionMatchesSearch(session, query))
}, [query, sessions])

if (loading) {
Expand Down Expand Up @@ -192,7 +189,10 @@ function DefaultProjectDirSetting() {
let alive = true

void settings.getDefaultProjectDir().then(result => {
if (!alive) return
if (!alive) {
return
}

setDir(result.dir)
setFallback(result.defaultLabel)
})
Expand All @@ -205,7 +205,9 @@ function DefaultProjectDirSetting() {
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings

if (!settings) return
if (!settings) {
return
}

setBusy(true)

Expand All @@ -229,7 +231,9 @@ function DefaultProjectDirSetting() {
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings

if (!settings) return
if (!settings) {
return
}

setBusy(true)

Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/lib/session-search.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
19 changes: 19 additions & 0 deletions apps/desktop/src/lib/session-search.ts
Original file line number Diff line number Diff line change
@@ -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))
}
39 changes: 33 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1463,13 +1463,39 @@ 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."""
if not q or not q.strip():
return {"results": []}
try:
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
Expand All @@ -1481,20 +1507,21 @@ async def search_sessions(q: str = "", limit: int = 20):
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
matches = db.search_messages(query=prefix_query, limit=limit)
matches = db.search_messages(query=prefix_query, limit=safe_limit)
# Group by session_id — return unique sessions with their best snippet
seen: dict = {}
for m in matches:
sid = m["session_id"]
if sid not in seen:
seen[sid] = {
add_result(
sid,
{
"session_id": sid,
"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()
Expand Down
44 changes: 44 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,50 @@ 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 []

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,
Expand Down
73 changes: 73 additions & 0 deletions tests/hermes_cli/test_web_server_session_search.py
Original file line number Diff line number Diff line change
@@ -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,
},
]
}
Loading
Loading