perf(desktop): make session-id search SQL-bounded, not O(n)
search_sessions_by_id previously fetched up to 10k sessions via list_sessions_rich and filtered them in Python — O(n) per keystroke. Push the id match into SQL instead. - list_sessions_rich gains an optional id_query param: a case-insensitive LIKE pushed into the outer WHERE, matched against each surfaced row's id AND every id in its forward compression chain (via the existing chain CTE). Searching a compression root id or a tip id both resolve to the same projected conversation. LIKE wildcards in the needle are escaped. - search_sessions_by_id now fetches only matching rows (limit*4) and ranks exact > prefix > substring in Python over that small set. - web_server /api/sessions/search: route ID matches and content matches through one lineage-keyed dedup helper so an id-hit and a content-hit on the same conversation collapse to a single result (the contributor's version keyed ID hits by raw sid and content hits by root, which could double-list a compression tip). - command-center haystack also matches _lineage_root_id for parity. E2E verified against a real DB: exact match over 3000+ sessions materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip; LIKE-wildcard escaping holds. Follow-up to @0xharryriddle's feat(desktop): search sessions by id.
This commit is contained in:
@@ -156,7 +156,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sorted.filter(session => {
|
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)
|
return haystack.includes(needle)
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-56
@@ -1628,46 +1628,6 @@ async def search_sessions(q: str = "", limit: int = 20):
|
|||||||
db = SessionDB()
|
db = SessionDB()
|
||||||
try:
|
try:
|
||||||
safe_limit = max(1, min(int(limit or 20), 100))
|
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
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Walk parent_session_id to the compression root, memoized so a
|
# Walk parent_session_id to the compression root, memoized so a
|
||||||
# chain of compression segments only costs one walk. We deliberately
|
# chain of compression segments only costs one walk. We deliberately
|
||||||
@@ -1739,25 +1699,71 @@ async def search_sessions(q: str = "", limit: int = 20):
|
|||||||
tip_cache[root_id] = tip
|
tip_cache[root_id] = tip
|
||||||
return tip
|
return tip
|
||||||
|
|
||||||
# Keep the best (first / most relevant) hit per compression root.
|
# Both ID matches and content matches share one keyspace, keyed by
|
||||||
# `seen` already holds the direct ID matches collected above; the
|
# compression lineage root, so an id-hit and a content-hit on the
|
||||||
# content matches extend it without clobbering them.
|
# same logical conversation collapse to a single result. The first
|
||||||
for m in matches:
|
# hit for a lineage wins; ID matches run first and take priority.
|
||||||
raw_sid = m["session_id"]
|
seen: dict = {}
|
||||||
|
|
||||||
|
def add_lineage_result(raw_sid: str, payload: dict) -> None:
|
||||||
|
if not raw_sid:
|
||||||
|
return
|
||||||
root = compression_root(raw_sid)
|
root = compression_root(raw_sid)
|
||||||
if root in seen:
|
if root in seen or len(seen) >= safe_limit:
|
||||||
continue
|
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:
|
if len(seen) >= safe_limit:
|
||||||
break
|
break
|
||||||
seen[root] = {
|
add_lineage_result(
|
||||||
"session_id": lineage_tip(root),
|
m["session_id"],
|
||||||
"lineage_root": root,
|
{
|
||||||
"snippet": m.get("snippet", ""),
|
"snippet": m.get("snippet", ""),
|
||||||
"role": m.get("role"),
|
"role": m.get("role"),
|
||||||
"source": m.get("source"),
|
"source": m.get("source"),
|
||||||
"model": m.get("model"),
|
"model": m.get("model"),
|
||||||
"session_started": m.get("session_started"),
|
"session_started": m.get("session_started"),
|
||||||
}
|
},
|
||||||
|
)
|
||||||
return {"results": list(seen.values())}
|
return {"results": list(seen.values())}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
+51
-14
@@ -1565,6 +1565,7 @@ class SessionDB:
|
|||||||
order_by_last_active: bool = False,
|
order_by_last_active: bool = False,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
archived_only: bool = False,
|
archived_only: bool = False,
|
||||||
|
id_query: str = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""List sessions with preview (first user message) and last active timestamp.
|
"""List sessions with preview (first user message) and last active timestamp.
|
||||||
|
|
||||||
@@ -1626,6 +1627,16 @@ class SessionDB:
|
|||||||
where_clauses.append("s.archived = 0")
|
where_clauses.append("s.archived = 0")
|
||||||
|
|
||||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
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:
|
if order_by_last_active:
|
||||||
# Compute effective_last_active by walking each surfaced session's
|
# Compute effective_last_active by walking each surfaced session's
|
||||||
# compression-continuation chain forward in SQL and taking the MAX
|
# compression-continuation chain forward in SQL and taking the MAX
|
||||||
@@ -1638,6 +1649,28 @@ class SessionDB:
|
|||||||
# compression-continuation edges using the same criteria as
|
# compression-continuation edges using the same criteria as
|
||||||
# get_compression_tip (parent.end_reason='compression' AND
|
# get_compression_tip (parent.end_reason='compression' AND
|
||||||
# child.started_at >= parent.ended_at).
|
# 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"""
|
query = f"""
|
||||||
WITH RECURSIVE chain(root_id, cur_id) AS (
|
WITH RECURSIVE chain(root_id, cur_id) AS (
|
||||||
SELECT s.id, s.id FROM sessions s {where_sql}
|
SELECT s.id, s.id FROM sessions s {where_sql}
|
||||||
@@ -1674,12 +1707,13 @@ class SessionDB:
|
|||||||
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
|
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
LEFT JOIN chain_max cm ON cm.root_id = s.id
|
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
|
ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
# WHERE params apply twice (CTE seed + outer select).
|
# WHERE params apply twice (CTE seed + outer select); the id filter
|
||||||
params = params + params + [limit, offset]
|
# only applies to the outer select.
|
||||||
|
params = params + params + id_params + [limit, offset]
|
||||||
else:
|
else:
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
@@ -3025,12 +3059,18 @@ class SessionDB:
|
|||||||
if not needle or limit <= 0:
|
if not needle or limit <= 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
scan_limit = max(limit, 10_000)
|
# SQL-bounded: list_sessions_rich pushes the id LIKE filter into the
|
||||||
sessions = self.list_sessions_rich(
|
# query (matching the row's own id AND any id in its forward
|
||||||
limit=scan_limit,
|
# 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,
|
offset=0,
|
||||||
include_archived=include_archived,
|
include_archived=include_archived,
|
||||||
order_by_last_active=True,
|
order_by_last_active=True,
|
||||||
|
id_query=needle,
|
||||||
)
|
)
|
||||||
|
|
||||||
def score(row: Dict[str, Any]) -> int:
|
def score(row: Dict[str, Any]) -> int:
|
||||||
@@ -3042,14 +3082,11 @@ class SessionDB:
|
|||||||
return 1
|
return 1
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
matches = [
|
ranked = sorted(
|
||||||
(score(row), idx, row)
|
enumerate(candidates),
|
||||||
for idx, row in enumerate(sessions)
|
key=lambda item: (score(item[1]), item[0]),
|
||||||
if needle in str(row.get("id") or "").lower()
|
)
|
||||||
or needle in str(row.get("_lineage_root_id") or "").lower()
|
return [row for _, row in ranked[:limit]]
|
||||||
]
|
|
||||||
matches.sort(key=lambda item: (item[0], item[1]))
|
|
||||||
return [row for _, _, row in matches[:limit]]
|
|
||||||
|
|
||||||
def search_sessions(
|
def search_sessions(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ from hermes_cli import web_server
|
|||||||
|
|
||||||
|
|
||||||
class _FakeSessionDB:
|
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
|
closed = False
|
||||||
|
|
||||||
def search_sessions_by_id(self, query, limit=20, include_archived=True):
|
def search_sessions_by_id(self, query, limit=20, include_archived=True):
|
||||||
assert query == "20260603"
|
assert query == "20260603"
|
||||||
assert limit == 2
|
|
||||||
assert include_archived is True
|
assert include_archived is True
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -22,7 +29,6 @@ class _FakeSessionDB:
|
|||||||
|
|
||||||
def search_messages(self, query, limit=20):
|
def search_messages(self, query, limit=20):
|
||||||
assert query == "20260603*"
|
assert query == "20260603*"
|
||||||
assert limit == 2
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"session_id": "20260603_090200_exact",
|
"session_id": "20260603_090200_exact",
|
||||||
@@ -42,6 +48,13 @@ class _FakeSessionDB:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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):
|
def close(self):
|
||||||
self.closed = True
|
self.closed = True
|
||||||
|
|
||||||
@@ -51,10 +64,13 @@ def test_desktop_session_search_merges_id_matches_before_content_matches(monkeyp
|
|||||||
|
|
||||||
response = asyncio.run(web_server.search_sessions(q="20260603", limit=2))
|
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 == {
|
assert response == {
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"session_id": "20260603_090200_exact",
|
"session_id": "20260603_090200_exact",
|
||||||
|
"lineage_root": "20260603_090200_exact",
|
||||||
"snippet": "ID match preview",
|
"snippet": "ID match preview",
|
||||||
"role": None,
|
"role": None,
|
||||||
"source": "cli",
|
"source": "cli",
|
||||||
@@ -63,6 +79,7 @@ def test_desktop_session_search_merges_id_matches_before_content_matches(monkeyp
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"session_id": "content_session",
|
"session_id": "content_session",
|
||||||
|
"lineage_root": "content_session",
|
||||||
"snippet": "content hit",
|
"snippet": "content hit",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"source": "desktop",
|
"source": "desktop",
|
||||||
|
|||||||
Reference in New Issue
Block a user