231 lines
10 KiB
Python
231 lines
10 KiB
Python
"""Shared helpers for the session_search tool: source classification, lineage
|
|
resolution, message storage state, and response shaping. Imported by
|
|
``tools.session_search_tool`` (which re-exports the names) and
|
|
``tools.session_search_tool_discover``."""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from hermes_state_common import _RESET_END_REASONS
|
|
|
|
# Hidden from browsing/searching: integrations (HERMES_SESSION_SOURCE=tool),
|
|
# delegate subagent runs, kanban workers — not the user's history.
|
|
_HIDDEN_SESSION_SOURCES = ("kanban", "subagent", "tool")
|
|
|
|
# Searchable but DEMOTED below interactive sessions: cron sessions' repetitive
|
|
# vocabulary dominates bare BM25 and starves out the user's own sessions
|
|
# ("recall blindness"). Demoting keeps them reachable when they're the only match.
|
|
_DEMOTED_SESSION_SOURCES = ("cron",)
|
|
|
|
# FTS rows scanned before dedup-by-lineage — well above the handful of distinct
|
|
# sessions a query returns, so interactive matches buried under cron hits are
|
|
# still in hand for the demotion pass.
|
|
_DISCOVER_SCAN_LIMIT = 300
|
|
|
|
# Raw FTS rows are only a discovery-plan input; the response hydrates its own
|
|
# anchored window and bookends after lineage dedup.
|
|
_DISCOVER_SEARCH_FIELDS = ("id", "session_id", "role", "snippet", "source", "model", "session_started")
|
|
|
|
# Generated context-compaction handoff summaries (agent/context_compressor.py);
|
|
# excluded from bookends so huge compaction payloads aren't re-introduced.
|
|
_COMPACTION_PREFIXES = ("[CONTEXT COMPACTION", "[CONTEXT SUMMARY]:")
|
|
|
|
# /new, /reset, idle/daily expiry and CLI /new ("new_session") end the
|
|
# predecessor WITHOUT carrying its transcript forward — unlike compression
|
|
# continuations and live delegation children. Derived from the gateway set so
|
|
# this tool and the recovery fence cannot drift.
|
|
_FRESH_RESET_END_REASONS = frozenset(_RESET_END_REASONS) | {"new_session"}
|
|
|
|
|
|
def _quiet(fn, default, msg, *log_args, with_exc: bool = False):
|
|
"""Call ``fn()``; on any exception debug-log *msg* (appending the exception
|
|
as a final ``%s`` arg when *with_exc*) and return *default*."""
|
|
try:
|
|
return fn()
|
|
except Exception as e:
|
|
logging.debug(msg, *(log_args + (e,) if with_exc else log_args), exc_info=True)
|
|
return default
|
|
|
|
|
|
def _format_timestamp(ts: Union[int, float, str, None]) -> str:
|
|
"""Unix timestamp (number or numeric string) or ISO string -> readable date.
|
|
"unknown" for None; str(ts) if conversion fails."""
|
|
if ts is None:
|
|
return "unknown"
|
|
try:
|
|
value = ts
|
|
if isinstance(ts, str):
|
|
if not ts.replace(".", "").replace("-", "").isdigit():
|
|
return ts
|
|
value = float(ts)
|
|
if isinstance(value, (int, float)):
|
|
return datetime.fromtimestamp(value).strftime("%B %d, %Y at %I:%M %p")
|
|
except (ValueError, OSError, OverflowError) as e:
|
|
logging.debug("Failed to format timestamp %s: %s", ts, e, exc_info=True)
|
|
except Exception as e:
|
|
logging.debug("Unexpected error formatting timestamp %s: %s", ts, e, exc_info=True)
|
|
return str(ts)
|
|
|
|
|
|
def _session_meta_block(meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""The ``session_meta`` sub-object shared by read/scroll responses."""
|
|
return {"when": _format_timestamp(meta.get("started_at")), "source": meta.get("source"),
|
|
"model": meta.get("model"), "title": meta.get("title")}
|
|
|
|
|
|
def _ok(**payload) -> str:
|
|
"""Serialize a successful tool result (``success`` first, then *payload* in order)."""
|
|
return json.dumps({"success": True, **payload}, ensure_ascii=False)
|
|
|
|
|
|
def _is_compaction_summary(content: str) -> bool:
|
|
"""Return True if *content* looks like a generated compaction handoff."""
|
|
return bool(content) and content.lstrip().startswith(_COMPACTION_PREFIXES)
|
|
|
|
|
|
def _resolve_to_parent(db, session_id: str) -> tuple[str, bool]:
|
|
"""Walk parent_session_id to the lineage root -> ``(root_id, has_compression_hop)``.
|
|
The flag distinguishes a compression-split lineage (parent content summarised
|
|
away) from a delegation lineage (child content still visible to the parent).
|
|
Falls back to ``(session_id, False)`` on errors."""
|
|
if not session_id:
|
|
return session_id, False
|
|
visited: set[str] = set()
|
|
cur, has_compression = session_id, False
|
|
while cur and cur not in visited:
|
|
visited.add(cur)
|
|
s = _quiet(lambda: db.get_session(cur), None, "Error resolving parent for %s: %s", cur, with_exc=True)
|
|
if not s:
|
|
break
|
|
if s.get("end_reason") == "compression":
|
|
has_compression = True
|
|
if not s.get("parent_session_id"):
|
|
break
|
|
cur = s["parent_session_id"]
|
|
return cur, has_compression
|
|
|
|
|
|
def _resolve_lineage(db, session_id: str) -> str:
|
|
"""Return only the lineage root (ignores the compression hop)."""
|
|
return _resolve_to_parent(db, session_id)[0]
|
|
|
|
|
|
def _session_end_reason(db, session_id: str) -> Optional[str]:
|
|
"""Return the session's ``end_reason``, or None if missing/unended/error."""
|
|
if not session_id:
|
|
return None
|
|
try:
|
|
s = db.get_session(session_id)
|
|
return (s.get("end_reason") or None) if s else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _session_left_live_context(db, session_id: str) -> bool:
|
|
"""True when *session_id*'s transcript is no longer in anyone's live context:
|
|
``compression`` (summarised into the continuation child) or a fresh reset
|
|
(child starts empty). Everything else stays excluded from same-lineage
|
|
recall — live delegation children (``end_reason is None``) are visible to
|
|
the parent agent, and ``branched`` parents were copied verbatim into the
|
|
branch child, so their content IS the current context."""
|
|
end_reason = _session_end_reason(db, session_id)
|
|
return end_reason == "compression" or end_reason in _FRESH_RESET_END_REASONS
|
|
|
|
|
|
def _get_message_storage_state(db, message_id) -> Optional[Dict[str, Any]]:
|
|
"""Return the owning session and visibility flags for *message_id*."""
|
|
if not message_id:
|
|
return None
|
|
|
|
def _lookup():
|
|
with db._lock:
|
|
return db._conn.execute(
|
|
"SELECT session_id, active, compacted FROM messages WHERE id = ?", (message_id,)
|
|
).fetchone()
|
|
|
|
row = _quiet(_lookup, None, "message storage-state lookup failed for %s", message_id)
|
|
return dict(row) if row is not None else None
|
|
|
|
|
|
def _is_compacted_state(state: Optional[Dict[str, Any]]) -> bool:
|
|
"""Compaction archives are ``active=0, compacted=1`` (content summarised
|
|
away by archive_and_compact). Rewind/undo rows are ``active=0, compacted=0``
|
|
and must stay hidden."""
|
|
return state is not None and state["active"] == 0 and state["compacted"] == 1
|
|
|
|
|
|
def _is_compacted_message(db, message_id) -> bool:
|
|
"""True if *message_id* is a compaction-archived row — pre-compaction content
|
|
no longer in live context, so it should stay discoverable even on the
|
|
current session. False on any error (caller falls back to skipping)."""
|
|
return _is_compacted_state(_get_message_storage_state(db, message_id))
|
|
|
|
|
|
def _annotate_rebuild_status(db, payload: Dict[str, Any]) -> None:
|
|
"""Add a rebuild-progress note while the deferred FTS backfill is running,
|
|
so the agent can explain thin/slow results instead of treating them as
|
|
ground truth. No-op (never raises) when no rebuild is pending."""
|
|
try:
|
|
status = db.fts_rebuild_status()
|
|
except Exception:
|
|
return
|
|
if status is None:
|
|
return
|
|
payload["index_rebuild"] = {"percent": status["percent"], "note": (
|
|
f"The search index is rebuilding in the background ({status['percent']}% done, "
|
|
f"{status['indexed']:,} of {status['total']:,} messages). Results from older messages "
|
|
f"may be incomplete until it finishes."
|
|
)}
|
|
|
|
|
|
def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Stable-sort FTS rows so interactive sessions rank above automation.
|
|
BM25 order is preserved within each class; only cross-class order changes,
|
|
so a cron hit never displaces an interactive hit during lineage dedup."""
|
|
return sorted(raw_results, key=lambda r: 1 if (r.get("source") or "") in _DEMOTED_SESSION_SOURCES else 0)
|
|
|
|
|
|
def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None,
|
|
max_content_len: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Slim a message row for the tool response. Keeps content even if empty
|
|
(absent content is meaningful — tool-call-only assistant turns). With
|
|
*max_content_len*, content is truncated and ``content_truncated`` /
|
|
``original_content_chars`` added."""
|
|
content = m.get("content")
|
|
if isinstance(content, str) and "\x1b" in content:
|
|
# Recalled messages can carry ANSI escapes (archived terminal output).
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
content = strip_ansi(content)
|
|
original_chars = None
|
|
if max_content_len and content and len(content) > max_content_len:
|
|
original_chars = len(content)
|
|
content = content[:max_content_len] + "…"
|
|
entry = {"id": m.get("id"), "role": m.get("role"), "content": content, "timestamp": m.get("timestamp")}
|
|
entry.update({k: m.get(k) for k in ("tool_name", "tool_calls", "tool_call_id") if m.get(k)})
|
|
if anchor_id is not None and m.get("id") == anchor_id:
|
|
entry["anchor"] = True
|
|
if original_chars is not None:
|
|
entry["content_truncated"] = True
|
|
entry["original_content_chars"] = original_chars
|
|
return {k: v for k, v in entry.items() if v is not None or k == "content"}
|
|
|
|
|
|
def _session_link(session_id: str, profile: str = None) -> str:
|
|
"""The reference the agent writes to point the user at a session — same
|
|
value the desktop composer emits, so it renders as a titled link. The
|
|
profile segment is omitted when it can't be named confidently (a bare id
|
|
still resolves, it just can't disambiguate across profiles)."""
|
|
name = (profile or "").strip()
|
|
if not name:
|
|
def _active():
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
|
|
resolved = get_active_profile_name()
|
|
return "" if resolved == "custom" else resolved
|
|
|
|
name = _quiet(_active, "", "get_active_profile_name failed for session link")
|
|
return f"@session:{name}/{session_id}" if name else f"@session:{session_id}"
|