#!/usr/bin/env python3 """Session Search Tool - long-term conversation recall over the SQLite session DB. Single-shape tool; the mode is inferred from the args: DISCOVERY (``query``; FTS5 deduped by lineage, adaptive detail hydrates only the top result), SCROLL (``session_id`` + ``around_message_id``; ±window around the anchor), READ (``session_id`` alone; whole session or head/tail), BROWSE (no args). No LLM calls — every shape returns actual DB messages. Helpers live in ``session_search_tool_common`` / ``_discover`` and are re-exported here. """ import json import logging from typing import Any, List, Optional from tools.session_search_tool_common import ( # noqa: F401 (re-exports) _COMPACTION_PREFIXES, _DEMOTED_SESSION_SOURCES, _DISCOVER_SCAN_LIMIT, _DISCOVER_SEARCH_FIELDS, _FRESH_RESET_END_REASONS, _HIDDEN_SESSION_SOURCES, _annotate_rebuild_status, _format_timestamp, _get_message_storage_state, _is_compacted_message, _is_compacted_state, _is_compaction_summary, _ok, _order_for_recall, _quiet, _resolve_lineage, _resolve_to_parent, _session_end_reason, _session_left_live_context, _session_link, _session_meta_block, _shape_message, ) from tools.session_search_tool_discover import ( # noqa: F401 (re-exports) _discover, _normalize_title_query, _title_match_result, ) def _resolve_profile_db(profile: str): """Open another profile's ``state.db`` read-only (no write lock — safe on a live DB), or None for the current profile.""" if profile is None or not str(profile).strip(): return None from hermes_cli import profiles as profiles_mod from hermes_state import SessionDB canon = profiles_mod.normalize_profile_name(profile) profiles_mod.validate_profile_name(canon) if not profiles_mod.profile_exists(canon): raise ValueError(f"profile '{canon}' does not exist") return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True) def _locate_session_db(session_id: str): """Scan every profile's ``state.db`` for a session id -> ``(db, profile_name)`` or ``(None, None)``. Ids are globally unique, so the first hit is authoritative.""" from pathlib import Path try: from hermes_cli import profiles as profiles_mod from hermes_state import SessionDB except Exception: return None, None targets = [("default", profiles_mod.get_profile_dir("default"))] targets += _quiet(lambda: [(info.name, info.path) for info in profiles_mod.list_profiles()], [], "list_profiles failed during session locate") seen: set = set() for name, home in targets: db_path = Path(home) / "state.db" if str(db_path) in seen or not db_path.exists(): continue seen.add(str(db_path)) try: pdb = SessionDB(db_path=db_path, read_only=True) except Exception: continue if _quiet(lambda: pdb.get_session(session_id), None, "get_session probe failed for %s in %s", session_id, name): return pdb, name pdb.close() return None, None def _get_session_meta(db, session_id: str) -> dict: """``db.get_session`` that degrades to ``{}`` on error.""" return _quiet(lambda: db.get_session(session_id), None, "get_session failed for %s: %s", session_id, with_exc=True) or {} def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str: """Read shape: whole session, or first ``head`` + last ``tail`` messages with a pointer to scroll the middle.""" meta = _get_session_meta(db, session_id) if not meta: return tool_error(f"session_id not found: {session_id}", success=False) try: rows = db.get_messages(session_id) except Exception as e: logging.error("get_messages failed for %s: %s", session_id, e, exc_info=True) return tool_error(f"failed to load session: {e}", success=False) shaped = [_shape_message(m) for m in rows] total = len(shaped) truncated = total > head + tail extra = {"message": (f"Session has {total} messages; showing first {head} + last {tail}. " "Pass around_message_id (any id above) to scroll the middle.")} if truncated else {} return _ok(mode="read", session_id=session_id, link=_session_link(session_id, link_profile), session_meta=_session_meta_block(meta), message_count=total, truncated=truncated, messages=shaped[:head] + shaped[-tail:] if truncated else shaped, **extra) def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str: """Browse shape: metadata for the most recent sessions (no LLM, no FTS5).""" try: # list_sessions_rich (include_children=False) already applies the # canonical child classifier: roots, /branch children and /new-reset # children are admitted, delegation/compression children hidden. # Re-classifying here re-hid legacy reset children — trust the query. sessions = db.list_sessions_rich( limit=limit + 15, # extra so we can skip current / compression roots exclude_sources=list(_HIDDEN_SESSION_SOURCES), order_by_last_active=True, ) current_root, has_compression_hop = ( _resolve_to_parent(db, current_session_id) if current_session_id else (None, False) ) results = [] for s in sessions: sid = s.get("id", "") if sid == current_session_id: continue # Compression continuation: the root's turns were summarised into # the live child, so hide the root. /new-reset children share a # root but carry no transcript — keep that root browsable. if has_compression_hop and current_root and sid == current_root: continue results.append({ "session_id": sid, "link": _session_link(sid, link_profile), "title": s.get("title") or None, "source": s.get("source", ""), "started_at": s.get("started_at", ""), "last_active": s.get("last_active", ""), "message_count": s.get("message_count", 0), "preview": s.get("preview", ""), }) if len(results) >= limit: break return _ok(mode="browse", results=results, count=len(results), message=( f"Showing {len(results)} most recent sessions. Pass a query= to search, " "or session_id+around_message_id to scroll.")) except Exception as e: logging.error("Error listing recent sessions: %s", e, exc_info=True) return tool_error(f"Failed to list recent sessions: {e}", success=False) def _clamp_int(value, default: int, lo: int, hi: int) -> int: if not isinstance(value, int): try: value = int(value) except (TypeError, ValueError): value = default return max(lo, min(value, hi)) def _anchor_in_live_context(db, anchor_state, anchor_session_id: str, current_session_id: str) -> bool: """True when the scroll anchor is still in the caller's active context and must be rejected. Same-lineage history that has LEFT live context (compacted rows, compression-ended parents, /new-reset predecessors) is allowed, so scroll never rejects a result discovery just returned.""" a_root = _resolve_lineage(db, anchor_session_id) c_root = _resolve_lineage(db, current_session_id) if not (a_root and c_root and a_root == c_root): return False if _is_compacted_state(anchor_state): return False # Rewind/undo rows (active=0, compacted!=1) never count as out-of-context history. is_inactive_non_compacted = ( anchor_state is not None and anchor_state["active"] == 0 and anchor_state["compacted"] != 1 ) return is_inactive_non_compacted or not _session_left_live_context(db, anchor_session_id) def _rebind_to_owner(db, session_id: str, owning: str, around_message_id: int, window: int): """Lineage rebind: the caller paired a parent session_id with a message id that lives in a descendant (compaction / delegation create child sessions). Returns ``(view, warning)`` from the owning session, or ``(None, None)``.""" a_root = _resolve_lineage(db, session_id) o_root = _resolve_lineage(db, owning) if not (a_root and o_root and a_root == o_root): return None, None rebind_view = _quiet(lambda: db.get_messages_around(owning, around_message_id, window=window), None, "rebind get_messages_around failed: %s", with_exc=True) if not (rebind_view and rebind_view.get("window")): return None, None return rebind_view, f"around_message_id {around_message_id} lives in {owning} (child of {session_id}); rebound transparently" def _scroll(db, session_id: str, around_message_id: int, window: int = 5, current_session_id: str = None) -> str: """Scroll shape: a window of messages centered on an anchor (no FTS5, no bookends). Rebinds silently if the anchor lives in a same-lineage child.""" if not isinstance(session_id, str) or not session_id.strip(): return tool_error("scroll requires session_id", success=False) session_id = session_id.strip() try: around_message_id = int(around_message_id) except (TypeError, ValueError): return tool_error("scroll requires integer around_message_id", success=False) window = _clamp_int(window, 5, 1, 20) # Locate the anchor BEFORE the current-lineage guard (see _anchor_in_live_context). anchor_state = _get_message_storage_state(db, around_message_id) owning_session_id = anchor_state.get("session_id") if anchor_state is not None else None if current_session_id and _anchor_in_live_context( db, anchor_state, owning_session_id or session_id, current_session_id ): return tool_error("scroll rejected: anchor lives in the current session lineage (already in your active context)", success=False) session_meta = _get_session_meta(db, session_id) if not session_meta: return tool_error(f"session_id not found: {session_id}", success=False) try: view = db.get_messages_around(session_id, around_message_id, window=window) except Exception as e: logging.error("get_messages_around failed: %s", e, exc_info=True) return tool_error(f"failed to load messages: {e}", success=False) messages = view.get("window") or [] rebind_warning = None if not messages and owning_session_id and owning_session_id != session_id: rebind_view, rebind_warning = _rebind_to_owner( db, session_id, owning_session_id, around_message_id, window ) if rebind_view is not None: view = rebind_view messages = view["window"] session_meta = _get_session_meta(db, owning_session_id) or session_meta session_id = owning_session_id if not messages: return tool_error(f"around_message_id {around_message_id} not in session_id {session_id}", success=False) return _ok( mode="scroll", session_id=session_id, around_message_id=around_message_id, session_meta=_session_meta_block(session_meta), window=window, messages=[_shape_message(m, anchor_id=around_message_id) for m in messages], messages_before=view.get("messages_before", 0), messages_after=view.get("messages_after", 0), hint=("Scroll forward: re-call with around_message_id = the LAST message's " "id; backward: the FIRST message's id (the boundary message repeats " "as an orientation marker). messages_before/messages_after < window " "means you've hit that end of the session."), **({"warning": rebind_warning} if rebind_warning else {}), ) def _read_with_profile_fallback(db, sid: str, profile: Optional[str]) -> str: """Read shape. On a miss in the target profile, scan every profile (the model may have dropped the owning profile from the link) and tag the result with the profile it was found in.""" result = _read_session(db, sid, link_profile=profile) if json.loads(result).get("success"): return result located, owner = _locate_session_db(sid) if located is not None: try: found = json.loads(_read_session(located, sid, link_profile=owner)) finally: located.close() if found.get("success"): found["profile"] = owner return json.dumps(found, ensure_ascii=False) return result def _dispatch(query, role_filter, limit, db, current_session_id, session_id, around_message_id, window, sort, profile, detail, owned_dbs) -> str: """Mode dispatch (see module docstring). Scroll wins over read/discovery when an anchor is set — the agent asked for a specific slice. Profile DBs opened here are appended to *owned_dbs* for the caller to close.""" # A raw `@session:/` link passed as session_id: ids never # contain "/", so a slash means profile/id — strip the prefix and adopt the # embedded profile only when none was passed explicitly. if isinstance(session_id, str) and "/" in session_id: emb_profile, _, emb_id = session_id.partition("/") if emb_id: session_id = emb_id if emb_profile and (profile is None or not str(profile).strip()): profile = emb_profile # Cross-profile read: swap in the named profile's DB (read-only) for every # shape. Current-lineage guards key off ids that won't collide, so they # stay inert. if profile is not None and str(profile).strip(): try: profile_db = _resolve_profile_db(profile) except Exception as e: return tool_error(f"profile '{profile}': {e}", success=False) if profile_db is not None: db, current_session_id = profile_db, None owned_dbs.append(profile_db) has_session = isinstance(session_id, str) and bool(session_id.strip()) if has_session and around_message_id is not None: return _scroll(db, session_id, around_message_id, window, current_session_id) if has_session: return _read_with_profile_fallback(db, session_id.strip(), profile) limit = _clamp_int(limit, 3, 1, 10) if not query or not isinstance(query, str) or not query.strip(): return _list_recent_sessions(db, limit, current_session_id, link_profile=profile) role_list = ([r.strip() for r in role_filter.split(",") if r.strip()] or None) if isinstance(role_filter, str) else None sort_norm = sort.strip().lower() if isinstance(sort, str) else None if sort_norm not in ("newest", "oldest"): sort_norm = None detail_norm = "full" if isinstance(detail, str) and detail.strip().lower() == "full" else "adaptive" return _discover( db=db, query=query.strip(), role_filter=role_list, limit=limit, sort=sort_norm, detail=detail_norm, current_session_id=current_session_id, link_profile=profile, ) def session_search(query: str = "", role_filter: str = None, limit: int = 3, db=None, current_session_id: str = None, session_id: str = None, around_message_id: int = None, window: int = 5, sort: str = None, profile: str = None, detail: str = "adaptive") -> str: """Run session search and close databases opened by this invocation. Parameter order is positional-compatible with older callers.""" owned_dbs: List[Any] = [] if db is None: try: from hermes_state import get_shared_session_db db = get_shared_session_db() owned_dbs.append(db) except Exception: logging.debug("SessionDB unavailable for session_search", exc_info=True) from hermes_state import format_session_db_unavailable return tool_error(format_session_db_unavailable(), success=False) try: return _dispatch(query, role_filter, limit, db, current_session_id, session_id, around_message_id, window, sort, profile, detail, owned_dbs) finally: from hermes_state import release_or_close for owned_db in reversed(owned_dbs): _quiet(lambda: release_or_close(owned_db), None, "Failed to close session_search SessionDB") def check_session_search_requirements() -> bool: """Requires the SQLite state database.""" try: from hermes_state import _default_db_path return _default_db_path().parent.exists() except ImportError: return False SESSION_SEARCH_SCHEMA = { "name": "session_search", "description": ( "Recall past conversations: search or read old Hermes sessions (FTS5), or " "scroll inside one. Four shapes, picked by args: `query` = discovery " "(top-N matching sessions, top result fully hydrated); `session_id` + " "`around_message_id` = scroll (window of messages around an anchor); " "`session_id` alone = read a whole session — how you resolve an " "`@session:/` link (split on '/' into profile + id); no " "args = browse recent sessions. Results are actual DB messages, no LLM. " "Searches conversation history ONLY — when the user gave a direct " "source (URL, file, contact, live system), inspect that first; never " "conclude 'not found' from history alone. Use for questions about past " "conversations: 'what did we do about X', 'where did we leave Y'. When " "referring the user to a session, write its `link` value verbatim " "inline (it renders as a titled link)." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": ( "Search query (discovery shape). Keywords, phrases, or boolean " "expressions to find in past sessions. Omit to browse recent " "sessions. Ignored when session_id + around_message_id are set " "(scroll shape)." ), }, "limit": { "type": "integer", "description": ( "Discovery shape only. Max sessions to return (default 3, max 10). " "Bump to 5–10 when the topic likely spans several sessions and you " "want to pick the right one to scroll into." ), "default": 3, }, "sort": { "type": "string", "enum": ["newest", "oldest"], "description": ( "Discovery shape only. Temporal bias on top of FTS5 ranking: omit " "for relevance-only (exploratory recall), 'newest' for " "\"where did we leave X\", 'oldest' for \"how did X start\"." ), }, "detail": { "type": "string", "enum": ["adaptive", "full"], "description": ( "Discovery shape only. 'adaptive' (default) fully hydrates the " "top-ranked result and returns only the exact anchor message for " "lower-ranked results. 'full' returns bookends and the complete " "anchored window for every result." ), "default": "adaptive", }, "session_id": { "type": "string", "description": ( "Scroll shape. Session to read inside. Use the session_id returned " "from a prior discovery call. Must be paired with " "around_message_id." ), }, "around_message_id": { "type": "integer", "description": ( "Scroll shape. Message id to center the window on — use " "match_message_id from a discovery result, or any id from a " "prior window." ), }, "window": { "type": "integer", "description": ( "Scroll shape only. Messages to return on each side of the anchor " "(anchor itself always included). Clamped to [1, 20]. Default 5." ), "default": 5, }, "role_filter": { "type": "string", "description": ( "Optional. Comma-separated roles to include. Discovery defaults to " "'user,assistant' (tool output is usually noise). Pass " "'user,assistant,tool' to include tool output (debugging tool " "behaviour) or 'tool' to search tool output only." ), }, "profile": { "type": "string", "description": ( "Optional. Read sessions from another Hermes profile's database " "(read-only). Use when resolving an `@session:/` link: " "pass the profile segment here with session_id as the id segment. " "Omit to use the current profile." ), }, }, "required": [], }, } # --- Registry --- from tools.registry import registry, tool_error registry.register( name="session_search", toolset="session_search", schema=SESSION_SEARCH_SCHEMA, handler=lambda args, **kw: session_search( query=args.get("query") or "", role_filter=args.get("role_filter"), limit=args.get("limit", 3), session_id=args.get("session_id"), around_message_id=args.get("around_message_id"), window=args.get("window", 5), sort=args.get("sort"), detail=args.get("detail", "adaptive"), profile=args.get("profile"), db=kw.get("db"), current_session_id=kw.get("current_session_id"), ), check_fn=check_session_search_requirements, emoji="🔍", )