feat(desktop): search sessions by id

This commit is contained in:
Harry Riddle
2026-06-04 07:49:34 -07:00
committed by Teknium
parent 62f0cfd902
commit 9ecc331be8
7 changed files with 289 additions and 13 deletions
+39 -11
View File
@@ -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()