{"repo":"NousResearch/hermes-agent","public_id":"18593","display_label":"Session search feature additions and fixes","summary":"These members cover the development and patching of a session search tool, including a single-shape no-LLM implementation, MiniMax endpoint compatibility, full-text fallback behavior, and returning complete conversation content instead of truncated previews.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":27590,"merged_at":"2026-05-18T06:28:46+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-18T06:28:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4239,"pr_number":27590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-18T06:28:46+00:00"},{"issue_number":7725,"pr_number":27590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-18T06:28:46+00:00"},{"issue_number":18593,"pr_number":27590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-18T06:28:46+00:00"},{"issue_number":24280,"pr_number":27590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-18T06:28:46+00:00"},{"issue_number":27059,"pr_number":27590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-18T06:28:46+00:00"}],"members":[{"kind":"issue","number":18593,"title":"[Feature] session_search: 返回完整对话原文而非500字符预览","url":null,"state":"closed"},{"kind":"pr","number":18676,"title":"fix(session-search): return raw fallback content","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"abf1af540193c30047ff3e7e759c330faf3a880f","number":0},{"kind":"pr","value":"#27590","number":27590},{"kind":"pr","value":"#18593","number":18593}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27590,"title":"feat(session_search): single-shape tool with discovery, scroll, browse — no LLM","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["CONTRIBUTING.md","agent/agent_runtime_helpers.py","agent/tool_executor.py","hermes_cli/config.py","hermes_cli/tips.py","hermes_state.py","scripts/release.py","tests/hermes_state/test_get_anchored_view.py","tests/hermes_state/test_get_messages_around.py","tests/tools/test_llm_content_none_guard.py","tests/tools/test_session_search.py","tools/session_search_tool.py","website/docs/reference/tools-reference.md","website/docs/user-guide/configuration.md","website/docs/user-guide/features/fallback-providers.md"],"fix_hunks":[{"file":"CONTRIBUTING.md","hunks":["@@ -172,7 +172,7 @@ hermes-agent/\n │   ├── vision_tools.py           # Image analysis via multimodal models\n │   ├── delegate_tool.py          # Subagent spawning and parallel task execution\n │   ├── code_execution_tool.py    # Sandboxed Python with RPC tool access\n-│   ├── session_search_tool.py    # Search past conversations with FTS5 + summarization\n+│   ├── session_search_tool.py    # Search past conversations with FTS5 + anchored windows\n │   ├── cronjob_tools.py          # Scheduled task management\n │   ├── skill_tools.py            # Skill search, load, manage\n │   └── environments/             # Terminal execution backends"]},{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1503,6 +1503,10 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i\n             query=function_args.get(\"query\", \"\"),\n             role_filter=function_args.get(\"role_filter\"),\n             limit=function_args.get(\"limit\", 3),\n+            session_id=function_args.get(\"session_id\"),\n+            around_message_id=function_args.get(\"around_message_id\"),\n+            window=function_args.get(\"window\", 5),\n+            sort=function_args.get(\"sort\"),\n             db=session_db,\n             current_session_id=agent.session_id,\n         )"]},{"file":"agent/tool_executor.py","hunks":["@@ -622,6 +622,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe\n                     query=function_args.get(\"query\", \"\"),\n                     role_filter=function_args.get(\"role_filter\"),\n                     limit=function_args.get(\"limit\", 3),\n+                    session_id=function_args.get(\"session_id\"),\n+                    around_message_id=function_args.get(\"around_message_id\"),\n+                    window=function_args.get(\"window\", 5),\n+                    sort=function_args.get(\"sort\"),\n                     db=session_db,\n                     current_session_id=agent.session_id,\n                 )"]},{"file":"hermes_cli/config.py","hunks":["@@ -871,15 +871,10 @@ def _ensure_hermes_home_managed(home: Path):\n             \"timeout\": 120,        # seconds — compression summarises large contexts; increase for local models\n             \"extra_body\": {},\n         },\n-        \"session_search\": {\n-            \"provider\": \"auto\",\n-            \"model\": \"\",\n-            \"base_url\": \"\",\n-            \"api_key\": \"\",\n-            \"timeout\": 30,\n-            \"extra_body\": {},\n-            \"max_concurrency\": 3,  # Clamp parallel summaries to avoid request-burst 429s on small providers\n-        },\n+        # Note: session_search no longer uses an auxiliary LLM (PR #27590 —\n+        # single-shape tool returns DB content directly). The old\n+        # ``auxiliary.session_search.*`` block was removed here. Existing\n+        # values in user config.yaml files are harmless leftovers and ignored.\n         \"skills_hub\": {\n             \"provider\": \"auto\",\n             \"model\": \"\","]},{"file":"hermes_cli/tips.py","hunks":["@@ -458,8 +458,6 @@\n     'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.',\n     'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.',\n     'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.',\n-    'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).',\n-    'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.',\n \n     # --- Security ---\n     'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.',"]},{"file":"hermes_state.py","hunks":["@@ -25,7 +25,7 @@\n \n from agent.memory_manager import sanitize_context\n from hermes_constants import get_hermes_home\n-from typing import Any, Callable, Dict, List, Optional, TypeVar\n+from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar\n \n logger = logging.getLogger(__name__)\n ","@@ -1618,6 +1618,204 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:\n             result.append(msg)\n         return result\n \n+    def get_messages_around(\n+        self,\n+        session_id: str,\n+        around_message_id: int,\n+        window: int = 5,\n+    ) -> Dict[str, Any]:\n+        \"\"\"Load a window of messages anchored on a specific message id.\n+\n+        Returns a dict with:\n+          - ``window``: up to ``window`` messages before the anchor, the anchor\n+            itself, and up to ``window`` messages after, ordered by id ascending.\n+          - ``messages_before``: count of messages strictly before the anchor\n+            still in the session (== window unless we hit the start).\n+          - ``messages_after``: count of messages strictly after the anchor\n+            still in the session (== window unless we hit the end).\n+\n+        Used by ``session_search`` for both the discovery shape (anchored on the\n+        FTS5 match) and the scroll shape (anchored on any message id). The\n+        ``messages_before`` / ``messages_after`` counts let the caller detect\n+        session boundaries: when either is less than ``window``, the agent has\n+        reached one end of the session.\n+\n+        Returns an empty window when ``around_message_id`` is not a real id in\n+        ``session_id`` — callers decide how to surface that.\n+        \"\"\"\n+        if window < 0:\n+            window = 0\n+        with self._lock:\n+            # Confirm the anchor exists in this session.\n+            anchor_exists = self._conn.execute(\n+                \"SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1\",\n+                (around_message_id, session_id),\n+            ).fetchone()\n+            if not anchor_exists:\n+                return {\"window\": [], \"messages_before\": 0, \"messages_after\": 0}\n+\n+            # Two queries: anchor + before (DESC, take window+1), and after\n+            # (ASC, take window). Final order is id ASC.\n+            before_rows = self._conn.execute(\n+                \"SELECT * FROM messages \"\n+                \"WHERE session_id = ? AND id <= ? \"\n+                \"ORDER BY id DESC LIMIT ?\",\n+                (session_id, around_message_id, window + 1),\n+            ).fetchall()\n+            after_rows = self._conn.execute(\n+                \"SELECT * FROM messages \"\n+                \"WHERE session_id = ? AND id > ? \"\n+                \"ORDER BY id ASC LIMIT ?\",\n+                (session_id, around_message_id, window),\n+            ).fetchall()\n+\n+        # before_rows is DESC; reverse so it's ASC, then concatenate after_rows.\n+        rows = list(reversed(before_rows)) + list(after_rows)\n+        result = []\n+        for row in rows:\n+            msg = dict(row)\n+            if \"content\" in msg:\n+                msg[\"content\"] = self._decode_content(msg[\"content\"])\n+            if msg.get(\"tool_calls\"):\n+                try:\n+                    msg[\"tool_calls\"] = json.loads(msg[\"tool_calls\"])\n+                except (json.JSONDecodeError, TypeError):\n+                    logger.warning(\n+                        \"Failed to deserialize tool_calls in get_messages_around, falling back to []\"\n+                    )\n+                    msg[\"tool_calls\"] = []\n+            result.append(msg)\n+\n+        # before_rows includes the anchor itself; subtract 1 for the count of\n+        # messages strictly before the anchor in the returned slice.\n+        messages_before = max(0, len(before_rows) - 1)\n+        messages_after = len(after_rows)\n+        return {\n+            \"window\": result,\n+            \"messages_before\": messages_before,\n+            \"messages_after\": messages_after,\n+        }\n+\n+    def get_anchored_view(\n+        self,\n+        session_id: str,\n+        around_message_id: int,\n+        window: int = 5,\n+        bookend: int = 3,\n+        keep_roles: Optional[Tuple[str, ...]] = (\"user\", \"assistant\"),\n+    ) -> Dict[str, Any]:\n+        \"\"\"Return an anchored window plus session bookends.\n+\n+        Built on top of ``get_messages_around``. Three slices:\n+\n+          - ``window``: messages immediately surrounding the anchor. Filtered\n+            to ``keep_roles`` (tool-response noise dropped by default), EXCEPT\n+            the anchor itself is always preserved regardless of role.\n+          - ``bookend_start``: first ``bookend`` user/assistant messages of the\n+            session — but only those whose id is strictly before the window's\n+            first message id. Empty when the window already overlaps the\n+            session head. Empty-content messages (tool-call-only assistant\n+            turns) are skipped so they don't crowd out actual prose openings.\n+          - ``bookend_end``: last ``bookend`` user/assistant messages of the\n+            session, same non-overlap rule at the tail.\n+\n+        Bookends let an FTS5 hit anywhere in a long session yield the goal\n+        (opening) and the resolution (closing) on a single call — without\n+        loading the whole transcript.\n+\n+        Returns ``{\"window\": [], \"messages_before\": 0, \"messages_after\": 0,\n+        \"bookend_start\": [], \"bookend_end\": []}`` when the anchor isn't in\n+        the session.\n+\n+        ``keep_roles=None`` disables role filtering (raw window + raw\n+        bookends).\n+        \"\"\"\n+        if bookend < 0:\n+            bookend = 0\n+\n+        # Reuse the primitive — handles anchor-existence, content decoding,\n+        # tool_calls deserialisation, and boundary counts.\n+        primitive = self.get_messages_around(\n+            session_id, around_message_id, window=window\n+        )\n+        window_rows = primitive[\"window\"]\n+        if not window_rows:\n+            return {\n+                \"window\": [],\n+                \"messages_before\": 0,\n+                \"messages_after\": 0,\n+                \"bookend_start\": [],\n+                \"bookend_end\": [],\n+            }\n+\n+        # Apply role filter to the window, but never drop the anchor itself.\n+        if keep_roles is not None:\n+            keep_set = set(keep_roles)\n+            filtered_window = [\n+                m for m in window_rows\n+                if m.get(\"id\") == around_message_id or m.get(\"role\") in keep_set\n+            ]\n+        else:\n+            filtered_window = window_rows\n+\n+        window_min_id = window_rows[0][\"id\"]\n+        window_max_id = window_rows[-1][\"id\"]\n+\n+        # Fetch bookends only when there's room outside the window. SQL filters\n+        # by id range, role, and non-empty content — tool-call-only assistant\n+        # turns (content='' with tool_calls populated) are excluded so they\n+        # don't crowd out actual prose openings/closings.\n+        bookend_start_rows: List[Any] = []\n+        bookend_end_rows: List[Any] = []\n+        if bookend > 0:\n+            with self._lock:\n+                role_clause = \"\"\n+                role_params: list = []\n+                if keep_roles is not None:\n+                    role_placeholders = \",\".join(\"?\" for _ in keep_roles)\n+                    role_clause = f\" AND role IN ({role_placeholders})\"\n+                    role_params = list(keep_roles)\n+\n+                bookend_start_rows = self._conn.execute(\n+                    f\"SELECT * FROM messages \"\n+                    f\"WHERE session_id = ? AND id < ?{role_clause} \"\n+                    f\"AND length(content) > 0 \"\n+                    f\"ORDER BY id ASC LIMIT ?\",\n+                    (session_id, window_min_id, *role_params, bookend),\n+                ).fetchall()\n+\n+                bookend_end_rows = self._conn.execute(\n+                    f\"SELECT * FROM messages \"\n+                    f\"WHERE session_id = ? AND id > ?{role_clause} \"\n+                    f\"AND length(content) > 0 \"\n+                    f\"ORDER BY id DESC LIMIT ?\",\n+                    (session_id, window_max_id, *role_params, bookend),\n+                ).fetchall()\n+                # End rows came back DESC for the LIMIT cap; flip to ASC.\n+                bookend_end_rows = list(reversed(bookend_end_rows))\n+\n+        def _hydrate(row) -> Dict[str, Any]:\n+            msg = dict(row)\n+            if \"content\" in msg:\n+                msg[\"content\"] = self._decode_content(msg[\"content\"])\n+            if msg.get(\"tool_calls\"):\n+                try:\n+                    msg[\"tool_calls\"] = json.loads(msg[\"tool_calls\"])\n+                except (json.JSONDecodeError, TypeError):\n+                    logger.warning(\n+                        \"Failed to deserialize tool_calls in get_anchored_view, falling back to []\"\n+                    )\n+                    msg[\"tool_calls\"] = []\n+            return msg\n+\n+        return {\n+            \"window\": filtered_window,\n+            \"messages_before\": primitive[\"messages_before\"],\n+            \"messages_after\": primitive[\"messages_after\"],\n+            \"bookend_start\": [_hydrate(r) for r in bookend_start_rows],\n+            \"bookend_end\": [_hydrate(r) for r in bookend_end_rows],\n+        }\n+\n     def resolve_resume_session_id(self, session_id: str) -> str:\n         \"\"\"Redirect a resume target to the descendant session that holds the messages.\n ","@@ -1885,6 +2083,7 @@ def search_messages(\n         role_filter: List[str] = None,\n         limit: int = 20,\n         offset: int = 0,\n+        sort: str = None,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"\n         Full-text search across session messages using FTS5.","@@ -1897,6 +2096,15 @@ def search_messages(\n \n         Returns matching messages with session metadata, content snippet,\n         and surrounding context (1 message before and after the match).\n+\n+        ``sort`` controls temporal ordering:\n+          - ``None`` (default): FTS5 BM25 relevance only. Time-neutral.\n+          - ``\"newest\"``: order by message timestamp DESC, then by rank.\n+          - ``\"oldest\"``: order by message timestamp ASC, then by rank.\n+\n+        The short-CJK LIKE fallback already orders by timestamp DESC and\n+        ignores ``sort``. The trigram CJK path honours ``sort`` like the main\n+        FTS5 path.\n         \"\"\"\n         if not query or not query.strip():\n             return []","@@ -1905,6 +2113,25 @@ def search_messages(\n         if not query:\n             return []\n \n+        # Normalise sort. Anything not in the allowed set falls back to None\n+        # (FTS5 rank-only) so callers can pass through user input without\n+        # validation.\n+        if isinstance(sort, str):\n+            sort_norm = sort.strip().lower()\n+            if sort_norm not in (\"newest\", \"oldest\"):\n+                sort_norm = None\n+        else:\n+            sort_norm = None\n+\n+        # ORDER BY shared across the main FTS5 path and trigram CJK path.\n+        # With sort set, timestamp is primary and rank is the tiebreaker.\n+        if sort_norm == \"newest\":\n+            order_by_sql = \"ORDER BY m.timestamp DESC, rank\"\n+        elif sort_norm == \"oldest\":\n+            order_by_sql = \"ORDER BY m.timestamp ASC, rank\"\n+        else:\n+            order_by_sql = \"ORDER BY rank\"\n+\n         # Build WHERE clauses dynamically\n         where_clauses = [\"messages_fts MATCH ?\"]\n         params: list = [query]","@@ -1943,7 +2170,7 @@ def search_messages(\n             JOIN messages m ON m.id = messages_fts.rowid\n             JOIN sessions s ON s.id = m.session_id\n             WHERE {where_sql}\n-            ORDER BY rank\n+            {order_by_sql}\n             LIMIT ? OFFSET ?\n         \"\"\"\n ","@@ -2012,7 +2239,7 @@ def search_messages(\n                     JOIN messages m ON m.id = messages_fts_trigram.rowid\n                     JOIN sessions s ON s.id = m.session_id\n                     WHERE {' AND '.join(tri_where)}\n-                    ORDER BY rank\n+                    {order_by_sql}\n                     LIMIT ? OFFSET ?\n                 \"\"\"\n                 tri_params.extend([limit, offset])"]},{"file":"scripts/release.py","hunks":["@@ -1058,6 +1058,7 @@\n     \"[redacted:email]\": \"29206394\",  # PR #22194 salvage (sudo -S brute-force guard, #9590)\n     \"[redacted:email]\": \"fr33d3m0n\",  # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)\n     \"[redacted:email]\": \"VinceZcrikl\",  # PR #23647 salvage (npm UTF-8 decode on GBK Windows)\n+    \"[redacted:email]\": \"JabberELF\",  # PR #20238 seed (session_search dual-mode, evolved into single-shape)\n     \"[redacted:email]\": \"ZeterMordio\",  # PR #11754 salvage (zsh completion compdef + _arguments syntax)\n     \"[redacted:email]\": \"iuyup\",  # PR #6155 salvage (shell=True hardening)\n     \"86501179+1RB@users.noreply.github.com\": \"1RB\",  # PR #25462 salvage (discord forwarded messages)"]},{"file":"tests/hermes_state/test_get_anchored_view.py","hunks":["@@ -0,0 +1,161 @@\n+\"\"\"Tests for SessionDB.get_anchored_view — anchored window + session bookends.\n+\n+Used by the discovery shape of session_search: an FTS5 match becomes the\n+anchor, the call returns goal (bookend_start) + match (window) + resolution\n+(bookend_end) in a single round trip, no LLM.\n+\"\"\"\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path):\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+def _seed_long_session(db, sid=\"s1\", n=30):\n+    \"\"\"Create a long session with alternating user/assistant prose. Returns ids ascending.\"\"\"\n+    db.create_session(sid, source=\"cli\")\n+    ids = []\n+    for i in range(n):\n+        role = \"user\" if i % 2 == 0 else \"assistant\"\n+        mid = db.append_message(sid, role=role, content=f\"prose msg {i}\")\n+        ids.append(mid)\n+    return ids\n+\n+\n+class TestWindowAndBookendShape:\n+    def test_returns_window_with_bookend_start_and_end(self, db):\n+        ids = _seed_long_session(db, n=30)\n+        # Anchor mid-session\n+        anchor = ids[15]\n+        view = db.get_anchored_view(\"s1\", anchor, window=3, bookend=3)\n+        assert len(view[\"window\"]) == 7  # ±3 + anchor\n+        assert len(view[\"bookend_start\"]) == 3\n+        assert len(view[\"bookend_end\"]) == 3\n+        # bookend_start is the first 3 ids of the session\n+        assert [m[\"id\"] for m in view[\"bookend_start\"]] == ids[:3]\n+        # bookend_end is the last 3 ids of the session\n+        assert [m[\"id\"] for m in view[\"bookend_end\"]] == ids[-3:]\n+\n+    def test_window_anchor_marked_correctly(self, db):\n+        ids = _seed_long_session(db, n=20)\n+        anchor = ids[10]\n+        view = db.get_anchored_view(\"s1\", anchor, window=2, bookend=3)\n+        # Anchor message is present in the window\n+        anchor_msgs = [m for m in view[\"window\"] if m[\"id\"] == anchor]\n+        assert len(anchor_msgs) == 1\n+\n+\n+class TestBookendOverlap:\n+    \"\"\"Bookends shouldn't duplicate messages that are already in the window.\"\"\"\n+\n+    def test_bookend_start_empty_when_window_covers_session_head(self, db):\n+        ids = _seed_long_session(db, n=10)\n+        # Anchor on msg 1 (id index 1), window=3 → covers ids[0..4]\n+        anchor = ids[1]\n+        view = db.get_anchored_view(\"s1\", anchor, window=3, bookend=3)\n+        # Window includes session head, so bookend_start should be empty\n+        assert view[\"bookend_start\"] == []\n+        # bookend_end is still populated\n+        assert len(view[\"bookend_end\"]) > 0\n+\n+    def test_bookend_end_empty_when_window_covers_session_tail(self, db):\n+        ids = _seed_long_session(db, n=10)\n+        # Anchor on second-to-last\n+        anchor = ids[-2]\n+        view = db.get_anchored_view(\"s1\", anchor, window=3, bookend=3)\n+        assert view[\"bookend_end\"] == []\n+        assert len(view[\"bookend_start\"]) > 0\n+\n+    def test_short_session_both_bookends_empty(self, db):\n+        ids = _seed_long_session(db, n=5)\n+        view = db.get_anchored_view(\"s1\", ids[2], window=10, bookend=3)\n+        # Window covers entire session\n+        assert view[\"bookend_start\"] == []\n+        assert view[\"bookend_end\"] == []\n+        # And window has all 5 messages\n+        assert len(view[\"window\"]) == 5\n+\n+\n+class TestRoleFiltering:\n+    def test_tool_role_filtered_from_window(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        user_ids = []\n+        for i in range(5):\n+            user_ids.append(db.append_message(\"s1\", role=\"user\", content=f\"u{i}\"))\n+            db.append_message(\"s1\", role=\"tool\", content=f\"tool output {i}\", tool_name=\"x\")\n+        # Anchor on user message\n+        view = db.get_anchored_view(\"s1\", user_ids[2], window=5, bookend=0)\n+        # No tool messages should appear in the window\n+        roles = [m.get(\"role\") for m in view[\"window\"]]\n+        assert \"tool\" not in roles\n+\n+    def test_anchor_preserved_even_when_tool_role(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.append_message(\"s1\", role=\"user\", content=\"ask\")\n+        tool_id = db.append_message(\"s1\", role=\"tool\", content=\"tool output\", tool_name=\"x\")\n+        db.append_message(\"s1\", role=\"user\", content=\"follow-up\")\n+        # Anchor on the tool message — should still appear despite default filter\n+        view = db.get_anchored_view(\"s1\", tool_id, window=5, bookend=0)\n+        ids_in_window = [m[\"id\"] for m in view[\"window\"]]\n+        assert tool_id in ids_in_window\n+\n+    def test_keep_roles_none_disables_filter(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        anchor_id = db.append_message(\"s1\", role=\"user\", content=\"ask\")\n+        db.append_message(\"s1\", role=\"tool\", content=\"output\", tool_name=\"x\")\n+        view = db.get_anchored_view(\"s1\", anchor_id, window=5, bookend=0, keep_roles=None)\n+        roles = [m.get(\"role\") for m in view[\"window\"]]\n+        assert \"tool\" in roles\n+\n+\n+class TestEmptyContentFilter:\n+    \"\"\"Tool-call-only assistant turns (empty content) should be skipped in bookends.\"\"\"\n+\n+    def test_empty_content_messages_excluded_from_bookends(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        # Real prose opener\n+        opener = db.append_message(\"s1\", role=\"user\", content=\"Let's start the work\")\n+        # Empty content assistant turn (tool-call-only — common in agent loops)\n+        db.append_message(\"s1\", role=\"assistant\", content=\"\", tool_calls=[{\"id\": \"t1\", \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}])\n+        # More prose\n+        for i in range(20):\n+            db.append_message(\"s1\", role=\"user\" if i % 2 == 0 else \"assistant\", content=f\"prose {i}\")\n+        # Another empty assistant near the end\n+        db.append_message(\"s1\", role=\"assistant\", content=\"\", tool_calls=[{\"id\": \"t2\", \"function\": {\"name\": \"y\", \"arguments\": \"{}\"}}])\n+        # Prose closer\n+        closer = db.append_message(\"s1\", role=\"assistant\", content=\"Final decision: ship it.\")\n+\n+        # Anchor mid-session\n+        view = db.get_anchored_view(\"s1\", opener + 15, window=2, bookend=3)\n+        # Bookend_start should not contain the empty-content tool-call turn\n+        for m in view[\"bookend_start\"]:\n+            assert m.get(\"content\"), \"bookend_start should skip empty-content messages\"\n+        # Bookend_end should include the closer\n+        end_contents = [m.get(\"content\") for m in view[\"bookend_end\"]]\n+        assert any(\"Final decision\" in (c or \"\") for c in end_contents)\n+\n+\n+class TestAnchorValidation:\n+    def test_missing_anchor_returns_empty_view(self, db):\n+        _seed_long_session(db, n=10)\n+        view = db.get_anchored_view(\"s1\", 999999, window=5, bookend=3)\n+        assert view[\"window\"] == []\n+        assert view[\"bookend_start\"] == []\n+        assert view[\"bookend_end\"] == []\n+        assert view[\"messages_before\"] == 0\n+        assert view[\"messages_after\"] == 0\n+\n+\n+class TestSessionIsolation:\n+    \"\"\"Bookends must not cross session boundaries.\"\"\"\n+\n+    def test_bookends_only_from_anchor_session(self, db):\n+        ids1 = _seed_long_session(db, sid=\"s1\", n=20)\n+        _seed_long_session(db, sid=\"s2\", n=20)\n+        view = db.get_anchored_view(\"s1\", ids1[10], window=2, bookend=3)\n+        # All bookend messages should have session_id = s1 (or session_id col)\n+        for m in view[\"bookend_start\"] + view[\"bookend_end\"]:\n+            assert m.get(\"session_id\") == \"s1\""]},{"file":"tests/hermes_state/test_get_messages_around.py","hunks":["@@ -0,0 +1,148 @@\n+\"\"\"Tests for SessionDB.get_messages_around (anchored-window primitive).\n+\n+Used by session_search both for the discovery shape (FTS5 match as anchor)\n+and the scroll shape (user-supplied anchor). Returns a window of messages\n+around the anchor plus before/after counts so callers can detect session\n+boundaries.\n+\"\"\"\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path):\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+def _seed(db, sid=\"s1\", n=10):\n+    \"\"\"Create session with n alternating user/assistant messages, return ids ascending.\"\"\"\n+    db.create_session(sid, source=\"cli\")\n+    ids = []\n+    for i in range(n):\n+        role = \"user\" if i % 2 == 0 else \"assistant\"\n+        # append_message returns the new id\n+        mid = db.append_message(sid, role=role, content=f\"msg {i}\")\n+        ids.append(mid)\n+    return ids\n+\n+\n+class TestBasicWindow:\n+    def test_returns_window_around_anchor(self, db):\n+        ids = _seed(db, n=10)\n+        anchor = ids[5]\n+        view = db.get_messages_around(\"s1\", anchor, window=2)\n+        # Expected: 2 before + anchor + 2 after = 5 messages\n+        msgs = view[\"window\"]\n+        assert len(msgs) == 5\n+        assert [m[\"id\"] for m in msgs] == [ids[3], ids[4], ids[5], ids[6], ids[7]]\n+        assert view[\"messages_before\"] == 2\n+        assert view[\"messages_after\"] == 2\n+\n+    def test_window_zero_returns_only_anchor(self, db):\n+        ids = _seed(db, n=5)\n+        view = db.get_messages_around(\"s1\", ids[2], window=0)\n+        assert len(view[\"window\"]) == 1\n+        assert view[\"window\"][0][\"id\"] == ids[2]\n+        assert view[\"messages_before\"] == 0\n+        assert view[\"messages_after\"] == 0\n+\n+    def test_negative_window_clamps_to_zero(self, db):\n+        ids = _seed(db, n=5)\n+        view = db.get_messages_around(\"s1\", ids[2], window=-3)\n+        # Just anchor, like window=0\n+        assert len(view[\"window\"]) == 1\n+        assert view[\"window\"][0][\"id\"] == ids[2]\n+\n+\n+class TestBoundaryDetection:\n+    \"\"\"messages_before / messages_after tell the agent it's at start/end.\"\"\"\n+\n+    def test_at_session_start_messages_before_is_short(self, db):\n+        ids = _seed(db, n=10)\n+        # Anchor on first message; ask for window=5\n+        view = db.get_messages_around(\"s1\", ids[0], window=5)\n+        assert view[\"messages_before\"] == 0  # nothing before the first msg\n+        assert view[\"messages_after\"] == 5\n+        # window contains anchor + 5 after = 6 messages\n+        assert len(view[\"window\"]) == 6\n+\n+    def test_at_session_end_messages_after_is_short(self, db):\n+        ids = _seed(db, n=10)\n+        view = db.get_messages_around(\"s1\", ids[-1], window=5)\n+        assert view[\"messages_before\"] == 5\n+        assert view[\"messages_after\"] == 0\n+        assert len(view[\"window\"]) == 6\n+\n+    def test_window_larger_than_session(self, db):\n+        ids = _seed(db, n=3)\n+        view = db.get_messages_around(\"s1\", ids[1], window=50)\n+        # All 3 messages return, both boundaries hit\n+        assert len(view[\"window\"]) == 3\n+        assert view[\"messages_before\"] == 1\n+        assert view[\"messages_after\"] == 1\n+\n+\n+class TestAnchorValidation:\n+    def test_missing_anchor_returns_empty(self, db):\n+        _seed(db, n=5)\n+        view = db.get_messages_around(\"s1\", 99999, window=5)\n+        assert view[\"window\"] == []\n+        assert view[\"messages_before\"] == 0\n+        assert view[\"messages_after\"] == 0\n+\n+    def test_anchor_in_different_session_returns_empty(self, db):\n+        # Two sessions, ask for s1's anchor in s2's namespace\n+        ids1 = _seed(db, sid=\"s1\", n=5)\n+        _seed(db, sid=\"s2\", n=5)\n+        view = db.get_messages_around(\"s2\", ids1[2], window=2)\n+        assert view[\"window\"] == []\n+\n+\n+class TestScrollPattern:\n+    \"\"\"The forward/backward scroll loop the agent will run.\"\"\"\n+\n+    def test_scroll_forward_re_anchored_on_last_id(self, db):\n+        ids = _seed(db, n=20)\n+        anchor = ids[5]\n+        v1 = db.get_messages_around(\"s1\", anchor, window=3)\n+        last_id = v1[\"window\"][-1][\"id\"]\n+        v2 = db.get_messages_around(\"s1\", last_id, window=3)\n+        # Boundary id (last_id) appears in both windows (in v2 it's the anchor)\n+        assert last_id in [m[\"id\"] for m in v1[\"window\"]]\n+        assert last_id in [m[\"id\"] for m in v2[\"window\"]]\n+        # v2's window extends beyond v1\n+        assert max(m[\"id\"] for m in v2[\"window\"]) > max(m[\"id\"] for m in v1[\"window\"])\n+\n+    def test_scroll_backward_re_anchored_on_first_id(self, db):\n+        ids = _seed(db, n=20)\n+        anchor = ids[10]\n+        v1 = db.get_messages_around(\"s1\", anchor, window=3)\n+        first_id = v1[\"window\"][0][\"id\"]\n+        v2 = db.get_messages_around(\"s1\", first_id, window=3)\n+        assert first_id in [m[\"id\"] for m in v1[\"window\"]]\n+        assert first_id in [m[\"id\"] for m in v2[\"window\"]]\n+        assert min(m[\"id\"] for m in v2[\"window\"]) < min(m[\"id\"] for m in v1[\"window\"])\n+\n+\n+class TestContentHydration:\n+    def test_content_is_decoded(self, db):\n+        ids = _seed(db, n=3)\n+        view = db.get_messages_around(\"s1\", ids[1], window=1)\n+        for m in view[\"window\"]:\n+            assert isinstance(m.get(\"content\"), str)\n+            assert m[\"content\"].startswith(\"msg \")\n+\n+    def test_tool_calls_deserialized(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        # Message with tool_calls (pass list — append_message JSON-encodes it)\n+        tc_payload = [{\"id\": \"t1\", \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]\n+        db.append_message(\"s1\", role=\"assistant\", content=\"\", tool_calls=tc_payload)\n+        mid = db.append_message(\"s1\", role=\"tool\", content=\"result\", tool_name=\"x\")\n+\n+        view = db.get_messages_around(\"s1\", mid, window=2)\n+        # Find the assistant message with tool_calls\n+        asst = [m for m in view[\"window\"] if m.get(\"role\") == \"assistant\"]\n+        assert asst, \"expected an assistant message\"\n+        # tool_calls should be a list after hydration, not a string\n+        assert isinstance(asst[0].get(\"tool_calls\"), list)"]},{"file":"tests/tools/test_llm_content_none_guard.py","hunks":["@@ -155,24 +155,6 @@ def test_none_content_safe_with_or_guard(self):\n         assert content == \"\"\n \n \n-# ── session_search_tool (line 164) ────────────────────────────────────────\n-\n-class TestSessionSearchContentNone:\n-    \"\"\"tools/session_search_tool.py — _summarize_session() return line\"\"\"\n-\n-    def test_none_content_raises_before_fix(self):\n-        response = _make_response(None)\n-\n-        with pytest.raises(AttributeError):\n-            response.choices[0].message.content.strip()\n-\n-    def test_none_content_safe_with_or_guard(self):\n-        response = _make_response(None)\n-\n-        content = (response.choices[0].message.content or \"\").strip()\n-        assert content == \"\"\n-\n-\n # ── integration: verify the actual source lines are guarded ───────────────\n \n class TestSourceLinesAreGuarded:","@@ -218,13 +200,6 @@ def test_skills_guard_guarded(self):\n             \".content.strip() — apply `(... or \\\"\\\").strip()` guard\"\n         )\n \n-    def test_session_search_tool_guarded(self):\n-        src = self._read_file(\"tools/session_search_tool.py\")\n-        assert \".message.content.strip()\" not in src, (\n-            \"tools/session_search_tool.py still has unguarded \"\n-            \".content.strip() — apply `(... or \\\"\\\").strip()` guard\"\n-        )\n-\n \n # ── extract_content_or_reasoning() ────────────────────────────────────────\n "]},{"file":"tests/tools/test_session_search.py","hunks":["@@ -1,578 +1,401 @@\n-\"\"\"Tests for tools/session_search_tool.py — helper functions and search dispatcher.\"\"\"\n+\"\"\"Tests for the single-shape session_search tool.\n \n-import asyncio\n+Three calling shapes:\n+  1. DISCOVERY — pass query → FTS5 + anchored window + bookends per hit\n+  2. SCROLL    — pass session_id + around_message_id → just the window\n+  3. BROWSE    — no args → recent sessions chronologically\n+\n+All run zero LLM calls.\n+\"\"\"\n import json\n import time\n+\n import pytest\n \n+from hermes_state import SessionDB\n from tools.session_search_tool import (\n-    _format_timestamp,\n-    _format_conversation,\n-    _truncate_around_matches,\n-    _get_session_search_max_concurrency,\n-    _list_recent_sessions,\n-    _HIDDEN_SESSION_SOURCES,\n-    MAX_SESSION_CHARS,\n     SESSION_SEARCH_SCHEMA,\n+    _HIDDEN_SESSION_SOURCES,\n+    _format_timestamp,\n+    session_search,\n )\n \n \n-# =========================================================================\n-# Tool schema guidance\n-# =========================================================================\n-\n-class TestHiddenSessionSources:\n-    \"\"\"Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation.\"\"\"\n-\n-    def test_tool_source_is_hidden(self):\n-        assert \"tool\" in _HIDDEN_SESSION_SOURCES\n-\n-    def test_standard_sources_not_hidden(self):\n-        for src in (\"cli\", \"telegram\", \"discord\", \"slack\", \"cron\"):\n-            assert src not in _HIDDEN_SESSION_SOURCES\n-\n-\n-class TestSessionSearchSchema:\n-    def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self):\n-        description = SESSION_SEARCH_SCHEMA[\"description\"]\n-        assert \"past conversations\" in description\n-        assert \"recent turns of the current session\" not in description\n[redacted:email]\n+def db(tmp_path):\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+def _seed_modpack_sessions(db):\n+    \"\"\"Create three sessions about a modpack so FTS5 has hits to dedupe.\"\"\"\n+    now = int(time.time())\n+    # Older session — modpack origin\n+    db.create_session(\"s_oldest\", source=\"cli\")\n+    db._conn.execute(\"UPDATE sessions SET started_at = ?, title = ? WHERE id = ?\",\n+                     (now - 30000, \"Building the Modpack\", \"s_oldest\"))\n+    db.append_message(\"s_oldest\", role=\"user\", content=\"Let's build a Minecraft modpack\")\n+    db.append_message(\"s_oldest\", role=\"assistant\", content=\"Great. Let me scaffold the modpack repo.\")\n+    db.append_message(\"s_oldest\", role=\"user\", content=\"Use NeoForge 1.21.1\")\n+    db.append_message(\"s_oldest\", role=\"assistant\", content=\"Done. Modpack repo created with NeoForge 1.21.1.\")\n+    db.append_message(\"s_oldest\", role=\"assistant\", content=\"Tier-0 mods installed; modpack smoke test passes.\")\n+\n+    # Middle session — modpack quest coverage\n+    db.create_session(\"s_middle\", source=\"cli\")\n+    db._conn.execute(\"UPDATE sessions SET started_at = ?, title = ? WHERE id = ?\",\n+                     (now - 15000, \"Modpack Quest Coverage\", \"s_middle\"))\n+    db.append_message(\"s_middle\", role=\"user\", content=\"Deep-dive every modpack reference quest guide\")\n+    db.append_message(\"s_middle\", role=\"assistant\", content=\"Surveying ATM10 questbook for modpack inspiration.\")\n+    db.append_message(\"s_middle\", role=\"user\", content=\"Update the modpack version too\")\n+    db.append_message(\"s_middle\", role=\"assistant\", content=\"Modpack version bumped 0.4 → 0.8.5; quest coverage page added.\")\n+\n+    # Newest session — modpack mob spawn fix\n+    db.create_session(\"s_newest\", source=\"cli\")\n+    db._conn.execute(\"UPDATE sessions SET started_at = ?, title = ? WHERE id = ?\",\n+                     (now - 1000, \"Modpack Mob Spawn Fix\", \"s_newest\"))\n+    db.append_message(\"s_newest\", role=\"user\", content=\"Fix the modpack mob spawning\")\n+    db.append_message(\"s_newest\", role=\"assistant\", content=\"Investigating elite mob gating in the modpack KubeJS.\")\n+    db.append_message(\"s_newest\", role=\"assistant\", content=\"Shipped commit b850442. Modpack alternator nerfed too.\")\n+    db._conn.commit()\n \n \n # =========================================================================\n-# _format_timestamp\n+# Schema invariants\n # =========================================================================\n \n-class TestFormatTimestamp:\n-    def test_unix_float(self):\n-        ts = 1700000000.0  # Nov 14, 2023\n-        result = _format_timestamp(ts)\n-        assert \"2023\" in result or \"November\" in result\n+class TestSchema:\n+    def test_schema_has_required_params(self):\n+        params = SESSION_SEARCH_SCHEMA[\"parameters\"][\"properties\"]\n+        # Discovery shape\n+        assert \"query\" in params\n+        assert \"limit\" in params\n+        assert \"sort\" in params\n+        # Scroll shape\n+        assert \"session_id\" in params\n+        assert \"around_message_id\" in params\n+        assert \"window\" in params\n+        # Shared\n+        assert \"role_filter\" in params\n+\n+    def test_no_mode_parameter(self):\n+        # Mode is inferred from which args are set — no explicit mode param\n+        params = SESSION_SEARCH_SCHEMA[\"parameters\"][\"properties\"]\n+        assert \"mode\" not in params\n+\n+    def test_sort_enum(self):\n+        params = SESSION_SEARCH_SCHEMA[\"parameters\"][\"properties\"]\n+        assert params[\"sort\"][\"enum\"] == [\"newest\", \"oldest\"]\n+\n+    def test_schema_description_teaches_scroll(self):\n+        desc = SESSION_SEARCH_SCHEMA[\"description\"]\n+        assert \"SCROLL\" in desc\n+        assert \"DISCOVERY\" in desc\n+        assert \"BROWSE\" in desc\n+        # Must explain how to scroll\n+        assert \"scroll FORWARD\" in desc or \"messages[-1]\" in desc\n+\n+    def test_no_llm_promise_in_description(self):\n+        # The new design never calls an LLM\n+        desc = SESSION_SEARCH_SCHEMA[\"description\"].lower()\n+        assert \"no llm\" in desc\n+\n+\n+class TestHiddenSources:\n+    def test_tool_source_hidden(self):\n+        assert \"tool\" in _HIDDEN_SESSION_SOURCES\n \n-    def test_unix_int(self):\n-        result = _format_timestamp(1700000000)\n-        assert isinstance(result, str)\n-        assert len(result) > 5\n \n-    def test_iso_string(self):\n-        result = _format_timestamp(\"2024-01-15T10:30:00\")\n-        assert isinstance(result, str)\n+class TestFormatTimestamp:\n+    def test_unix_timestamp(self):\n+        out = _format_timestamp(1700000000)\n+        assert \"2023\" in out\n \n-    def test_none_returns_unknown(self):\n+    def test_none(self):\n         assert _format_timestamp(None) == \"unknown\"\n \n-    def test_numeric_string(self):\n-        result = _format_timestamp(\"1700000000.0\")\n-        assert isinstance(result, str)\n-        assert \"unknown\" not in result.lower()\n-\n-\n-# =========================================================================\n-# _format_conversation\n-# =========================================================================\n-\n-class TestFormatConversation:\n-    def test_basic_messages(self):\n-        msgs = [\n-            {\"role\": \"user\", \"content\": \"Hello\"},\n-            {\"role\": \"assistant\", \"content\": \"Hi there!\"},\n-        ]\n-        result = _format_conversation(msgs)\n-        assert \"[USER]: Hello\" in result\n-        assert \"[ASSISTANT]: Hi there!\" in result\n-\n-    def test_tool_message(self):\n-        msgs = [\n-            {\"role\": \"tool\", \"content\": \"search results\", \"tool_name\": \"web_search\"},\n-        ]\n-        result = _format_conversation(msgs)\n-        assert \"[TOOL:web_search]\" in result\n-\n-    def test_long_tool_output_truncated(self):\n-        msgs = [\n-            {\"role\": \"tool\", \"content\": \"x\" * 1000, \"tool_name\": \"terminal\"},\n-        ]\n-        result = _format_conversation(msgs)\n-        assert \"[truncated]\" in result\n-\n-    def test_assistant_with_tool_calls(self):\n-        msgs = [\n-            {\n-                \"role\": \"assistant\",\n-                \"content\": \"\",\n-                \"tool_calls\": [\n-                    {\"function\": {\"name\": \"web_search\"}},\n-                    {\"function\": {\"name\": \"terminal\"}},\n-                ],\n-            },\n-        ]\n-        result = _format_conversation(msgs)\n-        assert \"web_search\" in result\n-        assert \"terminal\" in result\n-\n-    def test_empty_messages(self):\n-        result = _format_conversation([])\n-        assert result == \"\"\n+    def test_iso_string_passthrough(self):\n+        out = _format_timestamp(\"not-a-number-string\")\n+        assert out == \"not-a-number-string\"\n \n \n # =========================================================================\n-# _truncate_around_matches\n+# Browse shape (no args)\n # =========================================================================\n \n-class TestTruncateAroundMatches:\n-    def test_short_text_unchanged(self):\n-        text = \"Short text about docker\"\n-        result = _truncate_around_matches(text, \"docker\")\n-        assert result == text\n-\n-    def test_long_text_truncated(self):\n-        # Create text longer than MAX_SESSION_CHARS with query term in middle\n-        padding = \"x\" * (MAX_SESSION_CHARS + 5000)\n-        text = padding + \" KEYWORD_HERE \" + padding\n-        result = _truncate_around_matches(text, \"KEYWORD_HERE\")\n-        assert len(result) <= MAX_SESSION_CHARS + 100  # +100 for prefix/suffix markers\n-        assert \"KEYWORD_HERE\" in result\n-\n-    def test_truncation_adds_markers(self):\n-        text = \"a\" * 50000 + \" target \" + \"b\" * (MAX_SESSION_CHARS + 5000)\n-        result = _truncate_around_matches(text, \"target\")\n-        assert \"truncated\" in result.lower()\n-\n-    def test_no_match_takes_from_start(self):\n-        text = \"x\" * (MAX_SESSION_CHARS + 5000)\n-        result = _truncate_around_matches(text, \"nonexistent\")\n-        # Should take from the beginning\n-        assert result.startswith(\"x\")\n-\n-    def test_match_at_beginning(self):\n-        text = \"KEYWORD \" + \"x\" * (MAX_SESSION_CHARS + 5000)\n-        result = _truncate_around_matches(text, \"KEYWORD\")\n-        assert \"KEYWORD\" in result\n-\n-    def test_multiword_phrase_match_beats_individual_term(self):\n-        \"\"\"Full phrase deep in text should be found even when a single term\n-        appears much earlier in boilerplate.\"\"\"\n-        boilerplate = \"The project setup is complex. \" * 500  # ~15K, has 'project' early\n-        filler = \"x\" * (MAX_SESSION_CHARS + 20000)\n-        target = \"We reviewed the keystone project roadmap in detail.\"\n-        text = boilerplate + filler + target + filler\n-        result = _truncate_around_matches(text, \"keystone project\")\n-        assert \"keystone project\" in result.lower()\n-\n-    def test_multiword_proximity_cooccurrence(self):\n-        \"\"\"When exact phrase is absent, terms co-occurring within proximity\n-        should be preferred over a lone early term.\"\"\"\n-        early = \"project \" + \"a\" * (MAX_SESSION_CHARS + 20000)\n-        # Place 'keystone' and 'project' near each other (but not as exact phrase)\n-        cooccur = \"this keystone initiative for the project was pivotal\"\n-        tail = \"b\" * (MAX_SESSION_CHARS + 20000)\n-        text = early + cooccur + tail\n-        result = _truncate_around_matches(text, \"keystone project\")\n-        assert \"keystone\" in result.lower()\n-        assert \"project\" in result.lower()\n-\n-    def test_multiword_window_maximises_coverage(self):\n-        \"\"\"Sliding window should capture as many match clusters as possible.\"\"\"\n-        # Place two phrase matches: one at ~50K, one at ~60K, both should fit\n-        pre = \"z\" * 50000\n-        match1 = \" alpha beta \"\n-        gap = \"z\" * 10000\n-        match2 = \" alpha beta \"\n-        post = \"z\" * (MAX_SESSION_CHARS + 40000)\n-        text = pre + match1 + gap + match2 + post\n-        result = _truncate_around_matches(text, \"alpha beta\")\n-        assert result.lower().count(\"alpha beta\") == 2\n-\n-\n-class TestSessionSearchConcurrency:\n-    def test_defaults_to_three(self):\n-        assert _get_session_search_max_concurrency() == 3\n-\n-    def test_reads_and_clamps_configured_value(self, monkeypatch):\n-        monkeypatch.setattr(\n-            \"hermes_cli.config.load_config\",\n-            lambda: {\"auxiliary\": {\"session_search\": {\"max_concurrency\": 9}}},\n-        )\n-        assert _get_session_search_max_concurrency() == 5\n-\n-    def test_session_search_respects_configured_concurrency_limit(self, monkeypatch):\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        monkeypatch.setattr(\n-            \"hermes_cli.config.load_config\",\n-            lambda: {\"auxiliary\": {\"session_search\": {\"max_concurrency\": 1}}},\n-        )\n-\n-        max_seen = {\"value\": 0}\n-        active = {\"value\": 0}\n-\n-        async def fake_summarize(_text, _query, _meta):\n-            active[\"value\"] += 1\n-            max_seen[\"value\"] = max(max_seen[\"value\"], active[\"value\"])\n-            await asyncio.sleep(0.01)\n-            active[\"value\"] -= 1\n-            return \"summary\"\n-\n-        monkeypatch.setattr(\"tools.session_search_tool._summarize_session\", fake_summarize)\n-        monkeypatch.setattr(\"model_tools._run_async\", lambda coro: asyncio.run(coro))\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = [\n-            {\"session_id\": \"s1\", \"source\": \"cli\", \"session_started\": 1709500000, \"model\": \"test\"},\n-            {\"session_id\": \"s2\", \"source\": \"cli\", \"session_started\": 1709500001, \"model\": \"test\"},\n-            {\"session_id\": \"s3\", \"source\": \"cli\", \"session_started\": 1709500002, \"model\": \"test\"},\n-        ]\n-        mock_db.get_session.side_effect = lambda sid: {\n-            \"id\": sid,\n-            \"parent_session_id\": None,\n-            \"source\": \"cli\",\n-            \"started_at\": 1709500000,\n-        }\n-        mock_db.get_messages_as_conversation.side_effect = lambda sid: [\n-            {\"role\": \"user\", \"content\": f\"message from {sid}\"},\n-            {\"role\": \"assistant\", \"content\": \"response\"},\n-        ]\n-\n-        result = json.loads(session_search(query=\"message\", db=mock_db, limit=3))\n-\n+class TestBrowseShape:\n+    def test_no_args_returns_recent_sessions(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(db=db))\n         assert result[\"success\"] is True\n-        assert result[\"count\"] == 3\n-        assert max_seen[\"value\"] == 1\n-\n-\n-class TestRecentSessionListing:\n-    def test_recent_mode_requests_last_active_ordering(self):\n-        from unittest.mock import MagicMock\n+        assert result[\"mode\"] == \"browse\"\n+        assert result[\"count\"] >= 3\n \n-        mock_db = MagicMock()\n-        mock_db.list_sessions_rich.return_value = []\n+    def test_browse_excludes_current_session(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(db=db, current_session_id=\"s_newest\"))\n+        sids = [r[\"session_id\"] for r in result[\"results\"]]\n+        assert \"s_newest\" not in sids\n \n-        result = json.loads(_list_recent_sessions(mock_db, limit=5))\n-\n-        assert result[\"success\"] is True\n-        mock_db.list_sessions_rich.assert_called_once_with(\n-            limit=10,\n-            exclude_sources=[\"tool\"],\n-            order_by_last_active=True,\n-        )\n-\n-    def test_current_child_session_excludes_root_lineage_even_when_child_id_is_longer(self):\n-        from unittest.mock import MagicMock\n-\n-        mock_db = MagicMock()\n-        mock_db.list_sessions_rich.return_value = [\n-            {\n-                \"id\": \"root\",\n-                \"title\": \"Current conversation\",\n-                \"source\": \"cli\",\n-                \"started_at\": 1709500000,\n-                \"last_active\": 1709500100,\n-                \"message_count\": 4,\n-                \"preview\": \"current root\",\n-                \"parent_session_id\": None,\n-            },\n-            {\n-                \"id\": \"other_session\",\n-                \"title\": \"Other conversation\",\n-                \"source\": \"cli\",\n-                \"started_at\": 1709400000,\n-                \"last_active\": 1709400100,\n-                \"message_count\": 3,\n-                \"preview\": \"other root\",\n-                \"parent_session_id\": None,\n-            },\n-        ]\n-\n-        def _get_session(session_id):\n-            if session_id == \"child_session_id_that_is_definitely_longer\":\n-                return {\"parent_session_id\": \"root\"}\n-            if session_id == \"root\":\n-                return {\"parent_session_id\": None}\n-            return None\n-\n-        mock_db.get_session.side_effect = _get_session\n-\n-        result = json.loads(_list_recent_sessions(\n-            mock_db,\n-            limit=5,\n-            current_session_id=\"child_session_id_that_is_definitely_longer\",\n-        ))\n-\n-        assert result[\"success\"] is True\n-        assert [item[\"session_id\"] for item in result[\"results\"]] == [\"other_session\"]\n-        assert all(item[\"session_id\"] != \"root\" for item in result[\"results\"])\n+    def test_browse_returns_titles(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(db=db))\n+        titles = [r.get(\"title\") for r in result[\"results\"]]\n+        assert any(\"Modpack\" in (t or \"\") for t in titles)\n \n \n # =========================================================================\n-# session_search (dispatcher)\n+# Discovery shape (with query)\n # =========================================================================\n \n-class TestSessionSearch:\n-    def test_no_db_lazily_opens_default_session_db(self, monkeypatch):\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = []\n-\n-        class FakeSessionDB:\n-            def __new__(cls):\n-                return mock_db\n-\n-        import types\n-        import sys\n+class TestDiscoveryShape:\n+    def test_query_returns_anchored_windows(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", db=db))\n+        assert result[\"success\"] is True\n+        assert result[\"mode\"] == \"discover\"\n+        assert result[\"count\"] >= 1\n+\n+    def test_discovery_result_has_bookends_and_window(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=3, db=db))\n+        for hit in result[\"results\"]:\n+            assert \"bookend_start\" in hit\n+            assert \"messages\" in hit\n+            assert \"bookend_end\" in hit\n+            assert \"match_message_id\" in hit\n+            assert \"snippet\" in hit\n+            assert \"messages_before\" in hit\n+            assert \"messages_after\" in hit\n+\n+    def test_match_message_id_is_anchor_in_window(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=3, db=db))\n+        for hit in result[\"results\"]:\n+            anchor_id = hit[\"match_message_id\"]\n+            window_ids = [m[\"id\"] for m in hit[\"messages\"]]\n+            assert anchor_id in window_ids\n+\n+    def test_no_results_returns_empty_list(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"zzz_no_such_term_zzz\", db=db))\n+        assert result[\"success\"] is True\n+        assert result[\"results\"] == []\n+        assert result[\"count\"] == 0\n \n-        fake_state = types.ModuleType(\"hermes_state\")\n-        fake_state.SessionDB = FakeSessionDB\n-        monkeypatch.setitem(sys.modules, \"hermes_state\", fake_state)\n+    def test_limit_clamped_to_max_10(self, db):\n+        _seed_modpack_sessions(db)\n+        # Pass huge limit; should not error and should cap\n+        result = json.loads(session_search(query=\"modpack\", limit=999, db=db))\n+        assert result[\"count\"] <= 10\n+\n+    def test_limit_floor_to_1(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=0, db=db))\n+        # Result count depends on hits, but the limit must be at least 1\n+        assert result[\"count\"] >= 0\n+\n+    def test_non_int_limit_falls_back(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=\"bogus\", db=db))\n+        assert result[\"success\"] is True\n \n-        result = json.loads(session_search(query=\"test\"))\n+    def test_current_session_filtered_out(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", db=db, current_session_id=\"s_newest\"))\n+        sids = [r[\"session_id\"] for r in result[\"results\"]]\n+        assert \"s_newest\" not in sids\n+\n+\n+class TestDiscoverySort:\n+    def test_sort_newest_orders_by_recency(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=3, sort=\"newest\", db=db))\n+        # First result should be the most recent session\n+        first = result[\"results\"][0]\n+        assert first[\"session_id\"] == \"s_newest\" or \"Newest\" in (first.get(\"title\") or \"\")\n+\n+    def test_sort_oldest_orders_by_age(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"modpack\", limit=3, sort=\"oldest\", db=db))\n+        first = result[\"results\"][0]\n+        assert first[\"session_id\"] == \"s_oldest\"\n+\n+    def test_invalid_sort_silently_ignored(self, db):\n+        _seed_modpack_sessions(db)\n+        # Should not error\n+        result = json.loads(session_search(query=\"modpack\", sort=\"bogus\", db=db))\n         assert result[\"success\"] is True\n-        mock_db.search_messages.assert_called_once()\n \n-    def test_empty_query_returns_error(self):\n-        from tools.session_search_tool import session_search\n-        mock_db = object()\n-        result = json.loads(session_search(query=\"\", db=mock_db))\n-        assert result[\"success\"] is False\n \n-    def test_whitespace_query_returns_error(self):\n-        from tools.session_search_tool import session_search\n-        mock_db = object()\n-        result = json.loads(session_search(query=\"   \", db=mock_db))\n-        assert result[\"success\"] is False\n+class TestRoleFilter:\n+    def test_default_excludes_tool_role(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.append_message(\"s1\", role=\"user\", content=\"modpack question\")\n+        db.append_message(\"s1\", role=\"tool\", content=\"modpack tool output\", tool_name=\"x\")\n+        result = json.loads(session_search(query=\"modpack\", db=db))\n+        # The FTS5 match should be on the user message, not the tool message\n+        if result[\"count\"] > 0:\n+            matched_role = result[\"results\"][0][\"matched_role\"]\n+            assert matched_role in (\"user\", \"assistant\")\n+\n+    def test_explicit_tool_role_includes_tool(self, db):\n+        db.create_session(\"s1\", source=\"cli\")\n+        db.append_message(\"s1\", role=\"tool\", content=\"modpack tool output\", tool_name=\"x\")\n+        result = json.loads(session_search(query=\"modpack\", role_filter=\"tool\", db=db))\n+        # Should now match the tool message\n+        if result[\"count\"] > 0:\n+            assert result[\"results\"][0][\"matched_role\"] == \"tool\"\n \n-    def test_current_session_excluded(self):\n-        \"\"\"session_search should never return the current session.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n \n-        mock_db = MagicMock()\n-        current_sid = \"20260304_120000_abc123\"\n+# =========================================================================\n+# Scroll shape (session_id + around_message_id)\n+# =========================================================================\n \n-        # Simulate FTS5 returning matches only from the current session\n-        mock_db.search_messages.return_value = [\n-            {\"session_id\": current_sid, \"content\": \"test match\", \"source\": \"cli\",\n-             \"session_started\": 1709500000, \"model\": \"test\"},\n-        ]\n-        mock_db.get_session.return_value = {\"parent_session_id\": None}\n+class TestScrollShape:\n+    def test_scroll_returns_window_without_bookends(self, db):\n+        _seed_modpack_sessions(db)\n+        # Get an anchor first via discovery\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n \n+        # Now scroll\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, current_session_id=current_sid,\n+            session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db\n         ))\n         assert result[\"success\"] is True\n-        assert result[\"count\"] == 0\n-        assert result[\"results\"] == []\n-\n-    def test_current_session_excluded_keeps_others(self):\n-        \"\"\"Other sessions should still be returned when current is excluded.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        current_sid = \"20260304_120000_abc123\"\n-        other_sid = \"20260303_100000_def456\"\n-\n-        mock_db.search_messages.return_value = [\n-            {\"session_id\": current_sid, \"content\": \"match 1\", \"source\": \"cli\",\n-             \"session_started\": 1709500000, \"model\": \"test\"},\n-            {\"session_id\": other_sid, \"content\": \"match 2\", \"source\": \"telegram\",\n-             \"session_started\": 1709400000, \"model\": \"test\"},\n-        ]\n-        mock_db.get_session.return_value = {\"parent_session_id\": None}\n-        mock_db.get_messages_as_conversation.return_value = [\n-            {\"role\": \"user\", \"content\": \"hello\"},\n-            {\"role\": \"assistant\", \"content\": \"hi there\"},\n-        ]\n-\n-        # Mock async_call_llm to raise RuntimeError → summarizer returns None\n-        from unittest.mock import AsyncMock, patch as _patch\n-        with _patch(\"tools.session_search_tool.async_call_llm\",\n-                     new_callable=AsyncMock,\n-                     side_effect=RuntimeError(\"no provider\")):\n-            result = json.loads(session_search(\n-                query=\"test\", db=mock_db, current_session_id=current_sid,\n-            ))\n-\n-        assert result[\"success\"] is True\n-        # Current session should be skipped, only other_sid should appear\n-        assert result[\"sessions_searched\"] == 1\n-        assert current_sid not in [r.get(\"session_id\") for r in result.get(\"results\", [])]\n-\n-    def test_current_child_session_excludes_parent_lineage(self):\n-        \"\"\"Compression/delegation parents should be excluded for the active child session.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = [\n-            {\"session_id\": \"parent_sid\", \"content\": \"match\", \"source\": \"cli\",\n-             \"session_started\": 1709500000, \"model\": \"test\"},\n-        ]\n-\n-        def _get_session(session_id):\n-            if session_id == \"child_sid\":\n-                return {\"parent_session_id\": \"parent_sid\"}\n-            if session_id == \"parent_sid\":\n-                return {\"parent_session_id\": None}\n-            return None\n-\n-        mock_db.get_session.side_effect = _get_session\n-\n+        assert result[\"mode\"] == \"scroll\"\n+        assert \"messages\" in result\n+        # Scroll shape has no bookends\n+        assert \"bookend_start\" not in result\n+        assert \"bookend_end\" not in result\n+\n+    def test_scroll_window_clamped_to_20(self, db):\n+        _seed_modpack_sessions(db)\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, current_session_id=\"child_sid\",\n+            session_id=anchor_sid, around_message_id=anchor_mid, window=999, db=db\n         ))\n+        assert result[\"window\"] == 20\n \n-        assert result[\"success\"] is True\n-        assert result[\"count\"] == 0\n-        assert result[\"results\"] == []\n-        assert result[\"sessions_searched\"] == 0\n-\n-    def test_limit_none_coerced_to_default(self):\n-        \"\"\"Model sends limit=null → should fall back to 3, not TypeError.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = []\n-\n+    def test_scroll_window_floor_to_1(self, db):\n+        _seed_modpack_sessions(db)\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, limit=None,\n+            session_id=anchor_sid, around_message_id=anchor_mid, window=-5, db=db\n         ))\n-        assert result[\"success\"] is True\n-\n-    def test_limit_type_object_coerced_to_default(self):\n-        \"\"\"Model sends limit as a type object → should fall back to 3, not TypeError.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = []\n+        assert result[\"window\"] == 1\n \n+    def test_scroll_returns_messages_before_after_counts(self, db):\n+        _seed_modpack_sessions(db)\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, limit=int,\n+            session_id=anchor_sid, around_message_id=anchor_mid, window=3, db=db\n         ))\n-        assert result[\"success\"] is True\n-\n-    def test_limit_string_coerced(self):\n-        \"\"\"Model sends limit as string '2' → should coerce to int.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = []\n-\n+        assert \"messages_before\" in result\n+        assert \"messages_after\" in result\n+\n+    def test_scroll_anchor_in_window(self, db):\n+        _seed_modpack_sessions(db)\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, limit=\"2\",\n+            session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db\n         ))\n-        assert result[\"success\"] is True\n+        anchor_in_window = [m for m in result[\"messages\"] if m[\"id\"] == anchor_mid]\n+        assert len(anchor_in_window) == 1\n+        assert anchor_in_window[0].get(\"anchor\") is True\n \n-    def test_limit_clamped_to_range(self):\n-        \"\"\"Negative or zero limit should be clamped to 1.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = []\n+    def test_scroll_missing_anchor_errors(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(\n+            session_id=\"s_oldest\", around_message_id=999999, db=db\n+        ))\n+        assert result[\"success\"] is False\n+        assert \"not in\" in result.get(\"error\", \"\")\n \n+    def test_scroll_missing_session_errors(self, db):\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, limit=-5,\n+            session_id=\"nonexistent\", around_message_id=1, db=db\n         ))\n-        assert result[\"success\"] is True\n+        assert result[\"success\"] is False\n \n+    def test_scroll_rejects_current_session_lineage(self, db):\n+        _seed_modpack_sessions(db)\n+        # Grab some valid id from s_oldest\n+        disc = json.loads(session_search(query=\"modpack\", limit=3, db=db))\n+        match = [r for r in disc[\"results\"] if r[\"session_id\"] == \"s_oldest\"]\n+        if match:\n+            mid = match[0][\"match_message_id\"]\n+            result = json.loads(session_search(\n+                session_id=\"s_oldest\", around_message_id=mid, db=db,\n+                current_session_id=\"s_oldest\",\n+            ))\n+            assert result[\"success\"] is False\n+            assert \"current session\" in result.get(\"error\", \"\").lower()\n+\n+    def test_scroll_invalid_around_message_id_errors(self, db):\n+        _seed_modpack_sessions(db)\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, limit=0,\n+            session_id=\"s_oldest\", around_message_id=\"not-an-int\", db=db\n         ))\n-        assert result[\"success\"] is True\n+        assert result[\"success\"] is False\n \n-    def test_current_root_session_excludes_child_lineage(self):\n-        \"\"\"Delegation child hits should be excluded when they resolve to the current root session.\"\"\"\n-        from unittest.mock import MagicMock\n-        from tools.session_search_tool import session_search\n \n-        mock_db = MagicMock()\n-        mock_db.search_messages.return_value = [\n-            {\"session_id\": \"child_sid\", \"content\": \"match\", \"source\": \"cli\",\n-             \"session_started\": 1709500000, \"model\": \"test\"},\n-        ]\n+class TestScrollPattern:\n+    \"\"\"The forward/backward scroll loop using tool output.\"\"\"\n \n-        def _get_session(session_id):\n-            if session_id == \"root_sid\":\n-                return {\"parent_session_id\": None}\n-            if session_id == \"child_sid\":\n-                return {\"parent_session_id\": \"root_sid\"}\n-            return None\n+    def test_scroll_forward_from_last_id(self, db):\n+        # Long session\n+        db.create_session(\"s_long\", source=\"cli\")\n+        ids = []\n+        for i in range(20):\n+            ids.append(db.append_message(\"s_long\", role=\"user\" if i % 2 == 0 else \"assistant\",\n+                                         content=f\"long session msg {i}\"))\n \n-        mock_db.get_session.side_effect = _get_session\n+        v1 = json.loads(session_search(\n+            session_id=\"s_long\", around_message_id=ids[5], window=3, db=db\n+        ))\n+        last_id = v1[\"messages\"][-1][\"id\"]\n+        v2 = json.loads(session_search(\n+            session_id=\"s_long\", around_message_id=last_id, window=3, db=db\n+        ))\n+        # Forward scroll: v2 should reach further than v1\n+        assert max(m[\"id\"] for m in v2[\"messages\"]) > max(m[\"id\"] for m in v1[\"messages\"])\n+        # Boundary id appears in both\n+        assert last_id in [m[\"id\"] for m in v1[\"messages\"]]\n+        assert last_id in [m[\"id\"] for m in v2[\"messages\"]]\n+\n+\n+# =========================================================================\n+# Shape precedence\n+# =========================================================================\n \n+class TestShapePrecedence:\n+    def test_scroll_args_beat_query(self, db):\n+        _seed_modpack_sessions(db)\n+        disc = json.loads(session_search(query=\"modpack\", limit=1, db=db))\n+        anchor_sid = disc[\"results\"][0][\"session_id\"]\n+        anchor_mid = disc[\"results\"][0][\"match_message_id\"]\n+        # Pass both query and scroll args — scroll should win\n         result = json.loads(session_search(\n-            query=\"test\", db=mock_db, current_session_id=\"root_sid\",\n+            query=\"modpack\",  # would normally trigger discovery\n+            session_id=anchor_sid, around_message_id=anchor_mid, db=db,\n         ))\n+        assert result[\"mode\"] == \"scroll\"\n \n-        assert result[\"success\"] is True\n-        assert result[\"count\"] == 0\n-        assert result[\"results\"] == []\n-        assert result[\"sessions_searched\"] == 0\n-\n-    def test_source_from_resolved_parent_not_fts5_child(self):\n-        \"\"\"source in output must reflect the resolved parent session, not the child that matched FTS5.\n-\n-        Regression test for #15909: when a delegation child session (source='telegram')\n-        resolves to a parent (source='api_server'), the result entry must report\n-        'api_server', not 'telegram'.\n-        \"\"\"\n-        from unittest.mock import MagicMock, AsyncMock, patch as _patch\n-        from tools.session_search_tool import session_search\n-\n-        mock_db = MagicMock()\n-        # FTS5 hit is in the child delegation session which carries source='telegram'\n-        mock_db.search_messages.return_value = [\n-            {\n-                \"session_id\": \"child_sid\",\n-                \"content\": \"hello world\",\n-                \"source\": \"telegram\",       # child session source — wrong value to surface\n-                \"session_started\": 1709400000,\n-                \"model\": \"gpt-4o-mini\",\n-            },\n-        ]\n-\n-        def _get_session(session_id):\n-            if session_id == \"child_sid\":\n-                return {\n-                    \"id\": \"child_sid\",\n-                    \"parent_session_id\": \"parent_sid\",\n-                    \"source\": \"telegram\",\n-                    \"started_at\": 1709400000,\n-                    \"model\": \"gpt-4o-mini\",\n-                }\n-            if session_id == \"parent_sid\":\n-                return {\n-                    \"id\": \"parent_sid\",\n-                    \"parent_session_id\": None,\n-                    \"source\": \"api_server\",  # correct parent source\n-                    \"started_at\": 1709300000,\n-                    \"model\": \"gpt-4o-mini\",\n-                }\n-            return None\n-\n-        mock_db.get_session.side_effect = _get_session\n-        mock_db.get_messages_as_conversation.return_value = [\n-            {\"role\": \"user\", \"content\": \"hello world\"},\n-            {\"role\": \"assistant\", \"content\": \"hi there\"},\n-        ]\n-\n-        with _patch(\n-            \"tools.session_search_tool.async_call_llm\",\n-            new_callable=AsyncMock,\n-            side_effect=RuntimeError(\"no provider\"),\n-        ):\n-            result = json.loads(session_search(query=\"hello world\", db=mock_db))\n+    def test_empty_query_falls_back_to_browse(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=\"   \", db=db))\n+        assert result[\"mode\"] == \"browse\"\n \n-        assert result[\"success\"] is True\n-        assert result[\"count\"] == 1\n-        entry = result[\"results\"][0]\n-        assert entry[\"session_id\"] == \"parent_sid\", \"should report resolved parent session ID\"\n-        assert entry[\"source\"] == \"api_server\", (\n-            f\"source should be parent's 'api_server', got {entry['source']!r}\"\n-        )\n+    def test_non_string_query_falls_back_to_browse(self, db):\n+        _seed_modpack_sessions(db)\n+        result = json.loads(session_search(query=None, db=db))  # type: ignore\n+        assert result[\"mode\"] == \"browse\""]},{"file":"tools/session_search_tool.py","hunks":["@@ -2,52 +2,41 @@\n \"\"\"\n Session Search Tool - Long-Term Conversation Recall\n \n-Searches past session transcripts in SQLite via FTS5, then summarizes the top\n-matching sessions using the configured auxiliary session_search model (same\n-pattern as web_extract). By default, auxiliary \"auto\" routing uses the main\n-chat provider/model unless the user overrides auxiliary.session_search.\n-Returns focused summaries of past conversations rather than raw transcripts,\n-keeping the main model's context window clean.\n-\n-Flow:\n-  1. FTS5 search finds matching messages ranked by relevance\n-  2. Groups by session, takes the top N unique sessions (default 3)\n-  3. Loads each session's conversation, truncates to ~100k chars centered on matches\n-  4. Sends to the configured auxiliary model with a focused summarization prompt\n-  5. Returns per-session summaries with metadata\n+Single-shape tool with three calling modes (inferred from args, no explicit\n+mode parameter):\n+\n+  1. DISCOVERY — pass ``query``. Runs FTS5, dedupes hits by session lineage,\n+     returns top N sessions each with: snippet, ±5 message window around the\n+     match, plus bookend_start (first 3 user+assistant msgs of session) and\n+     bookend_end (last 3). Zero LLM cost.\n+\n+  2. SCROLL — pass ``session_id`` + ``around_message_id``. Returns a window\n+     of ±window messages centered on the anchor, no FTS5, no bookends. To\n+     scroll forward / backward, re-anchor on the last / first message id of\n+     the returned window.\n+\n+  3. BROWSE — no args. Returns recent sessions chronologically (titles,\n+     previews, timestamps).\n+\n+All three modes operate on the SQLite session DB via the FTS5 index and\n+the get_anchored_view / get_messages_around primitives in hermes_state.\n+No LLM calls anywhere — every shape returns actual messages from the DB.\n+\n+History: PR #20238 (JabberELF) seeded a fast/summary dual-mode split; the\n+toolkit expansion in PR #26419 (yoniebans) added the anchored drill-down,\n+bookends, and sort. This module merges all of that into a single calling\n+shape with no mode parameter, no summary LLM path, and explicit scroll\n+support.\n \"\"\"\n \n-import asyncio\n-import concurrent.futures\n import json\n import logging\n-import re\n-from typing import Dict, Any, List, Optional, Union\n+from typing import Any, Dict, List, Optional, Union\n \n-from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning\n-MAX_SESSION_CHARS = 100_000\n-MAX_SUMMARY_TOKENS = 10000\n-\n-\n-def _get_session_search_max_concurrency(default: int = 3) -> int:\n-    \"\"\"Read auxiliary.session_search.max_concurrency with sane bounds.\"\"\"\n-    try:\n-        from hermes_cli.config import load_config\n-        config = load_config()\n-    except ImportError:\n-        return default\n-    aux = config.get(\"auxiliary\", {}) if isinstance(config, dict) else {}\n-    task_config = aux.get(\"session_search\", {}) if isinstance(aux, dict) else {}\n-    if not isinstance(task_config, dict):\n-        return default\n-    raw = task_config.get(\"max_concurrency\")\n-    if raw is None:\n-        return default\n-    try:\n-        value = int(raw)\n-    except (TypeError, ValueError):\n-        return default\n-    return max(1, min(value, 5))\n+# Sources that are excluded from session browsing/searching by default.\n+# Third-party integrations tag their sessions with HERMES_SESSION_SOURCE=tool\n+# so they don't clutter the user's session history.\n+_HIDDEN_SESSION_SOURCES = (\"tool\",)\n \n \n def _format_timestamp(ts: Union[int, float, str, None]) -> str:","@@ -69,233 +58,72 @@ def _format_timestamp(ts: Union[int, float, str, None]) -> str:\n                 return dt.strftime(\"%B %d, %Y at %I:%M %p\")\n             return ts\n     except (ValueError, OSError, OverflowError) as e:\n-        # Log specific errors for debugging while gracefully handling edge cases\n         logging.debug(\"Failed to format timestamp %s: %s\", ts, e, exc_info=True)\n     except Exception as e:\n         logging.debug(\"Unexpected error formatting timestamp %s: %s\", ts, e, exc_info=True)\n     return str(ts)\n \n \n-def _format_conversation(messages: List[Dict[str, Any]]) -> str:\n-    \"\"\"Format session messages into a readable transcript for summarization.\"\"\"\n-    parts = []\n-    for msg in messages:\n-        role = msg.get(\"role\", \"unknown\").upper()\n-        content = msg.get(\"content\") or \"\"\n-        tool_name = msg.get(\"tool_name\")\n-\n-        if role == \"TOOL\" and tool_name:\n-            # Truncate long tool outputs\n-            if len(content) > 500:\n-                content = content[:250] + \"\\n...[truncated]...\\n\" + content[-250:]\n-            parts.append(f\"[TOOL:{tool_name}]: {content}\")\n-        elif role == \"ASSISTANT\":\n-            # Include tool call names if present\n-            tool_calls = msg.get(\"tool_calls\")\n-            if tool_calls and isinstance(tool_calls, list):\n-                tc_names = []\n-                for tc in tool_calls:\n-                    if isinstance(tc, dict):\n-                        name = tc.get(\"name\") or tc.get(\"function\", {}).get(\"name\", \"?\")\n-                        tc_names.append(name)\n-                if tc_names:\n-                    parts.append(f\"[ASSISTANT]: [Called: {', '.join(tc_names)}]\")\n-                if content:\n-                    parts.append(f\"[ASSISTANT]: {content}\")\n-            else:\n-                parts.append(f\"[ASSISTANT]: {content}\")\n-        else:\n-            parts.append(f\"[{role}]: {content}\")\n-\n-    return \"\\n\\n\".join(parts)\n-\n-\n-def _truncate_around_matches(\n-    full_text: str, query: str, max_chars: int = MAX_SESSION_CHARS\n-) -> str:\n-    \"\"\"\n-    Truncate a conversation transcript to *max_chars*, choosing a window\n-    that maximises coverage of positions where the *query* actually appears.\n-\n-    Strategy (in priority order):\n-    1. Try to find the full query as a phrase (case-insensitive).\n-    2. If no phrase hit, look for positions where all query terms appear\n-       within a 200-char proximity window (co-occurrence).\n-    3. Fall back to individual term positions.\n-\n-    Once candidate positions are collected the function picks the window\n-    start that covers the most of them.\n-    \"\"\"\n-    if len(full_text) <= max_chars:\n-        return full_text\n-\n-    text_lower = full_text.lower()\n-    query_lower = query.lower().strip()\n-    match_positions: list[int] = []\n-\n-    # --- 1. Full-phrase search ------------------------------------------------\n-    phrase_pat = re.compile(re.escape(query_lower))\n-    match_positions = [m.start() for m in phrase_pat.finditer(text_lower)]\n-\n-    # --- 2. Proximity co-occurrence of all terms (within 200 chars) -----------\n-    if not match_positions:\n-        terms = query_lower.split()\n-        if len(terms) > 1:\n-            # Collect every occurrence of each term\n-            term_positions: dict[str, list[int]] = {}\n-            for t in terms:\n-                term_positions[t] = [\n-                    m.start() for m in re.finditer(re.escape(t), text_lower)\n-                ]\n-            # Slide through positions of the rarest term and check proximity\n-            rarest = min(terms, key=lambda t: len(term_positions.get(t, [])))\n-            for pos in term_positions.get(rarest, []):\n-                if all(\n-                    any(abs(p - pos) < 200 for p in term_positions.get(t, []))\n-                    for t in terms\n-                    if t != rarest\n-                ):\n-                    match_positions.append(pos)\n-\n-    # --- 3. Individual term positions (last resort) ---------------------------\n-    if not match_positions:\n-        terms = query_lower.split()\n-        for t in terms:\n-            for m in re.finditer(re.escape(t), text_lower):\n-                match_positions.append(m.start())\n-\n-    if not match_positions:\n-        # Nothing at all — take from the start\n-        truncated = full_text[:max_chars]\n-        suffix = \"\\n\\n...[later conversation truncated]...\" if max_chars < len(full_text) else \"\"\n-        return truncated + suffix\n-\n-    # --- Pick window that covers the most match positions ---------------------\n-    match_positions.sort()\n-\n-    best_start = 0\n-    best_count = 0\n-    for candidate in match_positions:\n-        ws = max(0, candidate - max_chars // 4)  # bias: 25% before, 75% after\n-        we = ws + max_chars\n-        if we > len(full_text):\n-            ws = max(0, len(full_text) - max_chars)\n-            we = len(full_text)\n-        count = sum(1 for p in match_positions if ws <= p < we)\n-        if count > best_count:\n-            best_count = count\n-            best_start = ws\n-\n-    start = best_start\n-    end = min(len(full_text), start + max_chars)\n-\n-    truncated = full_text[start:end]\n-    prefix = \"...[earlier conversation truncated]...\\n\\n\" if start > 0 else \"\"\n-    suffix = \"\\n\\n...[later conversation truncated]...\" if end < len(full_text) else \"\"\n-    return prefix + truncated + suffix\n-\n-\n-async def _summarize_session(\n-    conversation_text: str, query: str, session_meta: Dict[str, Any]\n-) -> Optional[str]:\n-    \"\"\"Summarize a single session conversation focused on the search query.\"\"\"\n-    system_prompt = (\n-        \"You are reviewing a past conversation transcript to help recall what happened. \"\n-        \"Summarize the conversation with a focus on the search topic. Include:\\n\"\n-        \"1. What the user asked about or wanted to accomplish\\n\"\n-        \"2. What actions were taken and what the outcomes were\\n\"\n-        \"3. Key decisions, solutions found, or conclusions reached\\n\"\n-        \"4. Any specific commands, files, URLs, or technical details that were important\\n\"\n-        \"5. Anything left unresolved or notable\\n\\n\"\n-        \"Be thorough but concise. Preserve specific details (commands, paths, error messages) \"\n-        \"that would be useful to recall. Write in past tense as a factual recap.\"\n-    )\n-\n-    source = session_meta.get(\"source\", \"unknown\")\n-    started = _format_timestamp(session_meta.get(\"started_at\"))\n-\n-    user_prompt = (\n-        f\"Search topic: {query}\\n\"\n-        f\"Session source: {source}\\n\"\n-        f\"Session date: {started}\\n\\n\"\n-        f\"CONVERSATION TRANSCRIPT:\\n{conversation_text}\\n\\n\"\n-        f\"Summarize this conversation with focus on: {query}\"\n-    )\n-\n-    max_retries = 3\n-    for attempt in range(max_retries):\n+def _resolve_to_parent(db, session_id: str) -> str:\n+    \"\"\"Walk parent_session_id chain to the lineage root. Falls back to input on errors.\"\"\"\n+    if not session_id:\n+        return session_id\n+    visited = set()\n+    cur = session_id\n+    while cur and cur not in visited:\n+        visited.add(cur)\n         try:\n-            response = await async_call_llm(\n-                task=\"session_search\",\n-                messages=[\n-                    {\"role\": \"system\", \"content\": system_prompt},\n-                    {\"role\": \"user\", \"content\": user_prompt},\n-                ],\n-                temperature=0.1,\n-                max_tokens=[redacted:secret-assignment]\n-            )\n-            content = extract_content_or_reasoning(response)\n-            if content:\n-                return content\n-            # Reasoning-only / empty — let the retry loop handle it\n-            logging.warning(\"Session search LLM returned empty content (attempt %d/%d)\", attempt + 1, max_retries)\n-            if attempt < max_retries - 1:\n-                await asyncio.sleep(1 * (attempt + 1))\n-                continue\n-            return content\n-        except RuntimeError:\n-            logging.warning(\"No auxiliary model available for session summarization\")\n-            return None\n+            s = db.get_session(cur)\n+            if not s:\n+                break\n+            parent = s.get(\"parent_session_id\")\n+            if not parent:\n+                break\n+            cur = parent\n         except Exception as e:\n-            if attempt < max_retries - 1:\n-                await asyncio.sleep(1 * (attempt + 1))\n-            else:\n-                logging.warning(\n-                    \"Session summarization failed after %d attempts: %s\",\n-                    max_retries,\n-                    e,\n-                    exc_info=True,\n-                )\n-                return None\n-\n-\n-# Sources that are excluded from session browsing/searching by default.\n-# Third-party integrations (Paperclip agents, etc.) tag their sessions with\n-# HERMES_SESSION_SOURCE=tool so they don't clutter the user's session history.\n-_HIDDEN_SESSION_SOURCES = (\"tool\",)\n+            logging.debug(\"Error resolving parent for %s: %s\", cur, e, exc_info=True)\n+            break\n+    return cur\n+\n+\n+def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[str, Any]:\n+    \"\"\"Slim a message row for the tool response. Keeps content even if empty.\"\"\"\n+    entry = {\n+        \"id\": m.get(\"id\"),\n+        \"role\": m.get(\"role\"),\n+        \"content\": m.get(\"content\"),\n+        \"timestamp\": m.get(\"timestamp\"),\n+    }\n+    if m.get(\"tool_name\"):\n+        entry[\"tool_name\"] = m.get(\"tool_name\")\n+    if m.get(\"tool_calls\"):\n+        entry[\"tool_calls\"] = m.get(\"tool_calls\")\n+    if m.get(\"tool_call_id\"):\n+        entry[\"tool_call_id\"] = m.get(\"tool_call_id\")\n+    if anchor_id is not None and m.get(\"id\") == anchor_id:\n+        entry[\"anchor\"] = True\n+    # Strip None values to keep payload tight, but always keep content\n+    # (absent content is meaningful — tool-call-only assistant turns).\n+    return {k: v for k, v in entry.items() if v is not None or k in (\"content\",)}\n \n \n def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:\n-    \"\"\"Return metadata for the most recent sessions (no LLM calls).\"\"\"\n+    \"\"\"Return metadata for the most recent sessions (no LLM calls, no FTS5).\"\"\"\n     try:\n         sessions = db.list_sessions_rich(\n             limit=limit + 5,\n             exclude_sources=list(_HIDDEN_SESSION_SOURCES),\n             order_by_last_active=True,\n-        )  # fetch extra to skip current\n-\n-        # Resolve current session lineage to exclude it\n-        current_root = None\n-        if current_session_id:\n-            try:\n-                sid = current_session_id\n-                visited = set()\n-                current_root = current_session_id\n-                while sid and sid not in visited:\n-                    visited.add(sid)\n-                    current_root = sid\n-                    s = db.get_session(sid)\n-                    parent = s.get(\"parent_session_id\") if s else None\n-                    sid = parent if parent else None\n-            except Exception:\n-                current_root = current_session_id\n+        )  # fetch extra so we can skip current\n+\n+        current_root = _resolve_to_parent(db, current_session_id) if current_session_id else None\n \n         results = []\n         for s in sessions:\n             sid = s.get(\"id\", \"\")\n             if current_root and (sid == current_root or sid == current_session_id):\n                 continue\n-            # Skip child/delegation sessions (they have parent_session_id)\n+            # Skip child / delegation sessions\n             if s.get(\"parent_session_id\"):\n                 continue\n             results.append({","@@ -312,234 +140,318 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str\n \n         return json.dumps({\n             \"success\": True,\n-            \"mode\": \"recent\",\n+            \"mode\": \"browse\",\n             \"results\": results,\n             \"count\": len(results),\n-            \"message\": f\"Showing {len(results)} most recent sessions. Use a keyword query to search specific topics.\",\n+            \"message\": f\"Showing {len(results)} most recent sessions. Pass a query= to search, or session_id+around_message_id to scroll.\",\n         }, ensure_ascii=False)\n     except Exception as e:\n         logging.error(\"Error listing recent sessions: %s\", e, exc_info=True)\n         return tool_error(f\"Failed to list recent sessions: {e}\", success=False)\n \n \n-def session_search(\n-    query: str,\n-    role_filter: str = None,\n-    limit: int = 3,\n-    db=None,\n+def _scroll(\n+    db,\n+    session_id: str,\n+    around_message_id: int,\n+    window: int = 5,\n     current_session_id: str = None,\n ) -> str:\n-    \"\"\"\n-    Search past sessions and return focused summaries of matching conversations.\n+    \"\"\"Scroll shape: return a window of messages centered on an anchor.\n \n-    Uses FTS5 to find matches, then summarizes the top sessions with the\n-    configured auxiliary session_search model.\n-    The current session is excluded from results since the agent already has that context.\n+    No FTS5, no bookends — just the slice. The discovery shape's lineage\n+    fixup is preserved: if the anchor doesn't live in the named session\n+    but does live in a child session in the same lineage, rebind silently.\n     \"\"\"\n-    if db is None:\n-        try:\n-            from hermes_state import SessionDB\n+    if not isinstance(session_id, str) or not session_id.strip():\n+        return tool_error(\"scroll requires session_id\", success=False)\n+    session_id = session_id.strip()\n \n-            db = SessionDB()\n-        except Exception:\n-            logging.debug(\"SessionDB unavailable for session_search\", exc_info=True)\n-            from hermes_state import format_session_db_unavailable\n-            return tool_error(format_session_db_unavailable(), success=False)\n+    try:\n+        around_message_id = int(around_message_id)\n+    except (TypeError, ValueError):\n+        return tool_error(\"scroll requires integer around_message_id\", success=False)\n \n-    # Defensive: models (especially open-source) may send non-int limit values\n-    # (None when JSON null, string \"int\", or even a type object).  Coerce to a\n-    # safe integer before any arithmetic/comparison to prevent TypeError.\n-    if not isinstance(limit, int):\n+    # Window clamp [1, 20]\n+    if not isinstance(window, int):\n         try:\n-            limit = int(limit)\n+            window = int(window)\n         except (TypeError, ValueError):\n-            limit = 3\n-    limit = max(1, min(limit, 5))  # Clamp to [1, 5]\n-\n-    # Recent sessions mode: when query is empty, return metadata for recent sessions.\n-    # No LLM calls — just DB queries for titles, previews, timestamps.\n-    if not query or not query.strip():\n-        return _list_recent_sessions(db, limit, current_session_id)\n+            window = 5\n+    window = max(1, min(window, 20))\n+\n+    # Reject scrolling inside the active session lineage — those messages are\n+    # already in context.\n+    if current_session_id:\n+        a_root = _resolve_to_parent(db, session_id)\n+        c_root = _resolve_to_parent(db, current_session_id)\n+        if a_root and c_root and a_root == c_root:\n+            return tool_error(\n+                \"scroll rejected: anchor lives in the current session lineage (already in your active context)\",\n+                success=False,\n+            )\n \n-    query = query.strip()\n+    # Session existence check\n+    try:\n+        session_meta = db.get_session(session_id) or {}\n+    except Exception as e:\n+        logging.debug(\"get_session failed for %s: %s\", session_id, e, exc_info=True)\n+        session_meta = {}\n+    if not session_meta:\n+        return tool_error(f\"session_id not found: {session_id}\", success=False)\n \n+    # Fetch the window\n     try:\n-        # Parse role filter\n-        role_list = None\n-        if role_filter and role_filter.strip():\n-            role_list = [r.strip() for r in role_filter.split(\",\") if r.strip()]\n+        view = db.get_messages_around(session_id, around_message_id, window=window)\n+    except Exception as e:\n+        logging.error(\"get_messages_around failed: %s\", e, exc_info=True)\n+        return tool_error(f\"failed to load messages: {e}\", success=False)\n+\n+    messages = view.get(\"window\") or []\n+\n+    # Lineage rebind: caller may have paired a parent session_id with a\n+    # message id that lives in a descendant (compaction / delegation creates\n+    # child sessions). Locate the real owning session and refetch.\n+    rebind_warning = None\n+    if not messages:\n+        owning = None\n+        try:\n+            conn = getattr(db, \"_conn\", None)\n+            if conn is not None:\n+                row = conn.execute(\n+                    \"SELECT session_id FROM messages WHERE id = ?\",\n+                    (around_message_id,),\n+                ).fetchone()\n+                owning = row[0] if row else None\n+        except Exception as e:\n+            logging.debug(\"owning-session lookup failed: %s\", e, exc_info=True)\n+            owning = None\n+        if owning and owning != session_id:\n+            a_root = _resolve_to_parent(db, session_id)\n+            o_root = _resolve_to_parent(db, owning)\n+            if a_root and o_root and a_root == o_root:\n+                try:\n+                    rebind_view = db.get_messages_around(owning, around_message_id, window=window)\n+                    messages = rebind_view.get(\"window\") or []\n+                    if messages:\n+                        view = rebind_view\n+                        rebind_warning = (\n+                            f\"around_message_id {around_message_id} lives in {owning} \"\n+                            f\"(child of {session_id}); rebound transparently\"\n+                        )\n+                        try:\n+                            session_meta = db.get_session(owning) or session_meta\n+                        except Exception:\n+                            pass\n+                        session_id = owning\n+                except Exception as e:\n+                    logging.debug(\"rebind get_messages_around failed: %s\", e, exc_info=True)\n+\n+    if not messages:\n+        return tool_error(\n+            f\"around_message_id {around_message_id} not in session_id {session_id}\",\n+            success=False,\n+        )\n \n-        # FTS5 search -- get matches ranked by relevance\n+    response = {\n+        \"success\": True,\n+        \"mode\": \"scroll\",\n+        \"session_id\": session_id,\n+        \"around_message_id\": around_message_id,\n+        \"session_meta\": {\n+            \"when\": _format_timestamp(session_meta.get(\"started_at\")),\n+            \"source\": session_meta.get(\"source\"),\n+            \"model\": session_meta.get(\"model\"),\n+            \"title\": session_meta.get(\"title\"),\n+        },\n+        \"window\": window,\n+        \"messages\": [_shape_message(m, anchor_id=around_message_id) for m in messages],\n+        \"messages_before\": view.get(\"messages_before\", 0),\n+        \"messages_after\": view.get(\"messages_after\", 0),\n+    }\n+    if rebind_warning:\n+        response[\"warning\"] = rebind_warning\n+    return json.dumps(response, ensure_ascii=False)\n+\n+\n+def _discover(\n+    db,\n+    query: str,\n+    role_filter: Optional[List[str]],\n+    limit: int,\n+    sort: Optional[str],\n+    current_session_id: str = None,\n+) -> str:\n+    \"\"\"Discovery shape: FTS5 + anchored window + bookends per hit. Single call.\"\"\"\n+    role_list = role_filter if role_filter else [\"user\", \"assistant\"]\n+\n+    try:\n         raw_results = db.search_messages(\n             query=query,\n             role_filter=role_list,\n             exclude_sources=list(_HIDDEN_SESSION_SOURCES),\n-            limit=50,  # Get more matches to find unique sessions\n+            limit=50,  # widen so dedup-by-lineage can find distinct sessions\n             offset=0,\n+            sort=sort,\n         )\n+    except Exception as e:\n+        logging.error(\"FTS5 search failed: %s\", e, exc_info=True)\n+        return tool_error(f\"Search failed: {e}\", success=False)\n \n-        if not raw_results:\n-            return json.dumps({\n-                \"success\": True,\n-                \"query\": query,\n-                \"results\": [],\n-                \"count\": 0,\n-                \"message\": \"No matching sessions found.\",\n-            }, ensure_ascii=False)\n-\n-        # Resolve child sessions to their parent — delegation stores detailed\n-        # content in child sessions, but the user's conversation is the parent.\n-        def _resolve_to_parent(session_id: str) -> str:\n-            \"\"\"Walk delegation chain to find the root parent session ID.\"\"\"\n-            visited = set()\n-            sid = session_id\n-            while sid and sid not in visited:\n-                visited.add(sid)\n-                try:\n-                    session = db.get_session(sid)\n-                    if not session:\n-                        break\n-                    parent = session.get(\"parent_session_id\")\n-                    if parent:\n-                        sid = parent\n-                    else:\n-                        break\n-                except Exception as e:\n-                    logging.debug(\n-                        \"Error resolving parent for session %s: %s\",\n-                        sid,\n-                        e,\n-                        exc_info=True,\n-                    )\n-                    break\n-            return sid\n-\n-        current_lineage_root = (\n-            _resolve_to_parent(current_session_id) if current_session_id else None\n-        )\n+    if not raw_results:\n+        return json.dumps({\n+            \"success\": True,\n+            \"mode\": \"discover\",\n+            \"query\": query,\n+            \"results\": [],\n+            \"count\": 0,\n+            \"message\": \"No matching sessions found.\",\n+        }, ensure_ascii=False)\n \n-        # Group by resolved (parent) session_id, dedup, skip the current\n-        # session lineage. Compression and delegation create child sessions\n-        # that still belong to the same active conversation.\n-        seen_sessions = {}\n-        for result in raw_results:\n-            raw_sid = result[\"session_id\"]\n-            resolved_sid = _resolve_to_parent(raw_sid)\n-            # Skip the current session lineage — the agent already has that\n-            # context, even if older turns live in parent fragments.\n-            if current_lineage_root and resolved_sid == current_lineage_root:\n-                continue\n-            if current_session_id and raw_sid == current_session_id:\n-                continue\n-            if resolved_sid not in seen_sessions:\n-                result = dict(result)\n-                result[\"session_id\"] = resolved_sid\n-                seen_sessions[resolved_sid] = result\n-            if len(seen_sessions) >= limit:\n-                break\n+    current_lineage_root = _resolve_to_parent(db, current_session_id) if current_session_id else None\n+\n+    # Dedupe by lineage. Keep the raw owning session_id on the surviving\n+    # row — only that pairs validly with the FTS5 match id for the anchored\n+    # window. parent_session_id is exposed separately when different.\n+    seen_sessions = {}\n+    for r in raw_results:\n+        raw_sid = r[\"session_id\"]\n+        resolved_sid = _resolve_to_parent(db, raw_sid)\n+        # Skip the current session lineage\n+        if current_lineage_root and resolved_sid == current_lineage_root:\n+            continue\n+        if current_session_id and raw_sid == current_session_id:\n+            continue\n+        if resolved_sid not in seen_sessions:\n+            row = dict(r)\n+            row[\"_lineage_root\"] = resolved_sid\n+            seen_sessions[resolved_sid] = row\n+        if len(seen_sessions) >= limit:\n+            break\n+\n+    results = []\n+    for lineage_root, match_info in seen_sessions.items():\n+        hit_sid = match_info.get(\"session_id\") or lineage_root\n+        msg_id = match_info.get(\"id\")\n+        try:\n+            view = db.get_anchored_view(hit_sid, msg_id, window=5, bookend=3)\n+        except Exception as e:\n+            logging.warning(\"get_anchored_view failed for %s/%s: %s\", hit_sid, msg_id, e, exc_info=True)\n+            continue\n+\n+        try:\n+            session_meta = db.get_session(lineage_root) or {}\n+        except Exception:\n+            session_meta = {}\n+\n+        entry = {\n+            \"session_id\": hit_sid,\n+            \"when\": _format_timestamp(\n+                session_meta.get(\"started_at\") or match_info.get(\"session_started\")\n+            ),\n+            \"source\": session_meta.get(\"source\") or match_info.get(\"source\", \"unknown\"),\n+            \"model\": session_meta.get(\"model\") or match_info.get(\"model\") or \"unknown\",\n+            \"title\": session_meta.get(\"title\") or None,\n+            \"matched_role\": match_info.get(\"role\"),\n+            \"match_message_id\": msg_id,\n+            \"snippet\": match_info.get(\"snippet\") or \"\",\n+            \"bookend_start\": [_shape_message(m) for m in (view.get(\"bookend_start\") or [])],\n+            \"messages\": [_shape_message(m, anchor_id=msg_id) for m in (view.get(\"window\") or [])],\n+            \"bookend_end\": [_shape_message(m) for m in (view.get(\"bookend_end\") or [])],\n+            \"messages_before\": view.get(\"messages_before\", 0),\n+            \"messages_after\": view.get(\"messages_after\", 0),\n+        }\n+        if lineage_root and lineage_root != hit_sid:\n+            entry[\"parent_session_id\"] = lineage_root\n+        results.append(entry)\n+\n+    return json.dumps({\n+        \"success\": True,\n+        \"mode\": \"discover\",\n+        \"query\": query,\n+        \"results\": results,\n+        \"count\": len(results),\n+        \"sessions_searched\": len(seen_sessions),\n+    }, ensure_ascii=False)\n+\n+\n+def session_search(\n+    query: str = \"\",\n+    role_filter: str = None,\n+    limit: int = 3,\n+    db=None,\n+    current_session_id: str = None,\n+    # Scroll shape\n+    session_id: str = None,\n+    around_message_id: int = None,\n+    window: int = 5,\n+    # Discovery shape\n+    sort: str = None,\n+) -> str:\n+    \"\"\"Single-shape tool. Mode inferred from which args are set.\n \n-        # Prepare all sessions for parallel summarization\n-        tasks = []\n-        for session_id, match_info in seen_sessions.items():\n-            try:\n-                messages = db.get_messages_as_conversation(session_id)\n-                if not messages:\n-                    continue\n-                session_meta = db.get_session(session_id) or {}\n-                conversation_text = _format_conversation(messages)\n-                conversation_text = _truncate_around_matches(conversation_text, query)\n-                tasks.append((session_id, match_info, conversation_text, session_meta))\n-            except Exception as e:\n-                logging.warning(\n-                    \"Failed to prepare session %s: %s\",\n-                    session_id,\n-                    e,\n-                    exc_info=True,\n-                )\n-\n-        # Summarize all sessions in parallel\n-        async def _summarize_all() -> List[Union[str, Exception]]:\n-            \"\"\"Summarize all sessions with bounded concurrency.\"\"\"\n-            max_concurrency = min(_get_session_search_max_concurrency(), max(1, len(tasks)))\n-            semaphore = asyncio.Semaphore(max_concurrency)\n-\n-            async def _bounded_summary(text: str, meta: Dict[str, Any]) -> Optional[str]:\n-                async with semaphore:\n-                    return await _summarize_session(text, query, meta)\n-\n-            coros = [\n-                _bounded_summary(text, meta)\n-                for _, _, text, meta in tasks\n-            ]\n-            return await asyncio.gather(*coros, return_exceptions=True)\n+    Discovery: pass ``query``.\n+    Scroll:    pass ``session_id`` + ``around_message_id``.\n+    Browse:    pass nothing.\n \n+    Scroll wins over discovery when both are set — the agent has explicitly\n+    asked for a slice of a known session.\n+    \"\"\"\n+    if db is None:\n         try:\n-            # Use _run_async() which properly manages event loops across\n-            # CLI, gateway, and worker-thread contexts.  The previous\n-            # pattern (asyncio.run() in a ThreadPoolExecutor) created a\n-            # disposable event loop that conflicted with cached\n-            # AsyncOpenAI/httpx clients bound to a different loop,\n-            # causing deadlocks in gateway mode (#2681).\n-            from model_tools import _run_async\n-            results = _run_async(_summarize_all())\n-        except concurrent.futures.TimeoutError:\n-            logging.warning(\n-                \"Session summarization timed out after 60 seconds\",\n-                exc_info=True,\n-            )\n-            return json.dumps({\n-                \"success\": False,\n-                \"error\": \"Session summarization timed out. Try a more specific query or reduce the limit.\",\n-            }, ensure_ascii=False)\n-\n-        summaries = []\n-        for (session_id, match_info, conversation_text, session_meta), result in zip(tasks, results):\n-            if isinstance(result, Exception):\n-                logging.warning(\n-                    \"Failed to summarize session %s: %s\",\n-                    session_id, result, exc_info=True,\n-                )\n-                result = None\n-\n-            # Prefer resolved parent session metadata over FTS5 match metadata.\n-            # match_info carries source/model from the *child* session that contained\n-            # the FTS5 hit; after _resolve_to_parent() the session_id points to the\n-            # root, so session_meta has the authoritative platform/source for the\n-            # session the user actually cares about (#15909).\n-            entry = {\n-                \"session_id\": session_id,\n-                \"when\": _format_timestamp(\n-                    session_meta.get(\"started_at\") or match_info.get(\"session_started\")\n-                ),\n-                \"source\": session_meta.get(\"source\") or match_info.get(\"source\", \"unknown\"),\n-                \"model\": session_meta.get(\"model\") or match_info.get(\"model\"),\n-            }\n+            from hermes_state import SessionDB\n+            db = SessionDB()\n+        except Exception:\n+            logging.debug(\"SessionDB unavailable for session_search\", exc_info=True)\n+            from hermes_state import format_session_db_unavailable\n+            return tool_error(format_session_db_unavailable(), success=False)\n \n-            if result:\n-                entry[\"summary\"] = result\n-            else:\n-                # Fallback: raw preview so matched sessions aren't silently\n-                # dropped when the summarizer is unavailable (fixes #3409).\n-                preview = (conversation_text[:500] + \"\\n…[truncated]\") if conversation_text else \"No preview available.\"\n-                entry[\"summary\"] = f\"[Raw preview — summarization unavailable]\\n{preview}\"\n+    # Scroll shape takes precedence — explicit anchor beats any query.\n+    if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None:\n+        return _scroll(\n+            db=db,\n+            session_id=session_id,\n+            around_message_id=around_message_id,\n+            window=window,\n+            current_session_id=current_session_id,\n+        )\n \n-            summaries.append(entry)\n+    # Limit clamp [1, 10]\n+    if not isinstance(limit, int):\n+        try:\n+            limit = int(limit)\n+        except (TypeError, ValueError):\n+            limit = 3\n+    limit = max(1, min(limit, 10))\n \n-        return json.dumps({\n-            \"success\": True,\n-            \"query\": query,\n-            \"results\": summaries,\n-            \"count\": len(summaries),\n-            \"sessions_searched\": len(seen_sessions),\n-        }, ensure_ascii=False)\n+    # Browse shape: no query → recent sessions.\n+    if not query or not isinstance(query, str) or not query.strip():\n+        return _list_recent_sessions(db, limit, current_session_id)\n \n-    except Exception as e:\n-        logging.error(\"Session search failed: %s\", e, exc_info=True)\n-        return tool_error(f\"Search failed: {str(e)}\", success=False)\n+    # Parse role_filter\n+    role_list: Optional[List[str]] = None\n+    if isinstance(role_filter, str) and role_filter.strip():\n+        role_list = [r.strip() for r in role_filter.split(\",\") if r.strip()]\n+\n+    # Normalise sort\n+    sort_norm: Optional[str] = None\n+    if isinstance(sort, str):\n+        candidate = sort.strip().lower()\n+        if candidate in (\"newest\", \"oldest\"):\n+            sort_norm = candidate\n+\n+    return _discover(\n+        db=db,\n+        query=query.strip(),\n+        role_filter=role_list,\n+        limit=limit,\n+        sort=sort_norm,\n+        current_session_id=current_session_id,\n+    )\n \n \n def check_session_search_requirements() -> bool:\n-    \"\"\"Requires SQLite state database and an auxiliary text model.\"\"\"\n+    \"\"\"Requires the SQLite state database.\"\"\"\n     try:\n         from hermes_state import DEFAULT_DB_PATH\n         return DEFAULT_DB_PATH.parent.exists()","@@ -550,44 +462,117 @@ def check_session_search_requirements() -> bool:\n SESSION_SEARCH_SCHEMA = {\n     \"name\": \"session_search\",\n     \"description\": (\n-        \"Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- \"\n-        \"every past session is searchable, and this tool summarizes what happened.\\n\\n\"\n-        \"TWO MODES:\\n\"\n-        \"1. Recent sessions (no query): Call with no arguments to see what was worked on recently. \"\n-        \"Returns titles, previews, and timestamps. Zero LLM cost, instant. \"\n-        \"Start here when the user asks what were we working on or what did we do recently.\\n\"\n-        \"2. Keyword search (with query): Search for specific topics across all past sessions. \"\n-        \"Returns LLM-generated summaries of matching sessions.\\n\\n\"\n-        \"USE THIS PROACTIVELY when:\\n\"\n-        \"- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\\n\"\n-        \"- The user asks about a topic you worked on before but don't have in current context\\n\"\n-        \"- The user references a project, person, or concept that seems familiar but isn't in memory\\n\"\n-        \"- You want to check if you've solved a similar problem before\\n\"\n-        \"- The user asks 'what did we do about X?' or 'how did we fix Y?'\\n\\n\"\n-        \"Don't hesitate to search when it is actually cross-session -- it's fast and cheap. \"\n-        \"Better to search and confirm than to guess or ask the user to repeat themselves.\\n\\n\"\n-        \"Search syntax: keywords joined with OR for broad recall (elevenlabs OR baseten OR funding), \"\n-        \"phrases for exact match (\\\"docker networking\\\"), boolean (python NOT java), prefix (deploy*). \"\n-        \"IMPORTANT: Use OR between keywords for best results — FTS5 defaults to AND which misses \"\n-        \"sessions that only mention some terms. If a broad OR query returns nothing, try individual \"\n-        \"keyword searches in parallel. Returns summaries of the top matching sessions.\"\n+        \"Search past sessions stored in the local session DB, or scroll inside one. \"\n+        \"FTS5-backed retrieval over the SQLite message store. No LLM calls — every \"\n+        \"shape returns actual messages from the DB.\\n\\n\"\n+        \"THREE CALLING SHAPES\\n\\n\"\n+        \"  1) DISCOVERY — pass `query`:\\n\"\n+        \"     session_search(query=\\\"auth refactor\\\", limit=3)\\n\"\n+        \"     Runs FTS5, dedupes hits by session lineage, returns the top N sessions. \"\n+        \"Each result carries:\\n\"\n+        \"       - session_id, title, when, source\\n\"\n+        \"       - snippet: FTS5-highlighted match excerpt\\n\"\n+        \"       - bookend_start: first 3 user+assistant messages of the session \"\n+        \"(the goal / kickoff)\\n\"\n+        \"       - messages: ±5 messages around the FTS5 match, with the anchor message \"\n+        \"flagged (the hit in context)\\n\"\n+        \"       - bookend_end: last 3 user+assistant messages of the session \"\n+        \"(the resolution / decisions)\\n\"\n+        \"       - match_message_id, messages_before, messages_after\\n\"\n+        \"     Bookends + window together let you reconstruct goal → match → resolution \"\n+        \"without paying for the whole transcript.\\n\\n\"\n+        \"  2) SCROLL — pass `session_id` + `around_message_id`:\\n\"\n+        \"     session_search(session_id=\\\"...\\\", around_message_id=12345, window=10)\\n\"\n+        \"     Returns a window of ±`window` messages centered on the anchor. No FTS5, \"\n+        \"no bookends — just the slice. Use after a discovery call when you need more \"\n+        \"context than the ±5 default window.\\n\"\n+        \"       - To scroll FORWARD: pass messages[-1].id back as around_message_id.\\n\"\n+        \"       - To scroll BACKWARD: pass messages[0].id back as around_message_id.\\n\"\n+        \"       - The boundary message appears in both windows — orientation marker.\\n\"\n+        \"       - When messages_before or messages_after is < window, you're at the \"\n+        \"start or end of the session.\\n\\n\"\n+        \"  3) BROWSE — no args:\\n\"\n+        \"     session_search()\\n\"\n+        \"     Returns recent sessions chronologically: titles, previews, timestamps. \"\n+        \"Use when the user asks \\\"what was I working on\\\" without naming a topic.\\n\\n\"\n+        \"FTS5 SYNTAX\\n\\n\"\n+        \"  AND is the default — multi-word queries require all terms. Use OR explicitly \"\n+        \"for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match \"\n+        \"(`\\\"docker networking\\\"`), boolean (`python NOT java`), or prefix wildcards \"\n+        \"(`deploy*`).\\n\\n\"\n+        \"WHEN TO USE\\n\\n\"\n+        \"  Reach for this on any \\\"what did we do about X\\\" / \\\"where did we leave Y\\\" / \"\n+        \"\\\"find the session where Z\\\" question — before gh, web search, or filesystem \"\n+        \"inspection. The session DB carries what was said when; external tools show \"\n+        \"current world state.\"\n     ),\n     \"parameters\": {\n         \"type\": \"object\",\n         \"properties\": {\n             \"query\": {\n                 \"type\": \"string\",\n-                \"description\": \"Search query — keywords, phrases, or boolean expressions to find in past sessions. Omit this parameter entirely to browse recent sessions instead (returns titles, previews, timestamps with no LLM cost).\",\n-            },\n-            \"role_filter\": {\n-                \"type\": \"string\",\n-                \"description\": \"Optional: only search messages from specific roles (comma-separated). E.g. 'user,assistant' to skip tool outputs.\",\n+                \"description\": (\n+                    \"Search query (discovery shape). Keywords, phrases, or boolean \"\n+                    \"expressions to find in past sessions. Omit to browse recent \"\n+                    \"sessions. Ignored when session_id + around_message_id are set \"\n+                    \"(scroll shape).\"\n+                ),\n             },\n             \"limit\": {\n                 \"type\": \"integer\",\n-                \"description\": \"Max sessions to summarize (default: 3, max: 5).\",\n+                \"description\": (\n+                    \"Discovery shape only. Max sessions to return (default 3, max 10). \"\n+                    \"Bump to 5–10 when the topic likely spans several sessions and you \"\n+                    \"want to pick the right one to scroll into.\"\n+                ),\n                 \"default\": 3,\n             },\n+            \"sort\": {\n+                \"type\": \"string\",\n+                \"enum\": [\"newest\", \"oldest\"],\n+                \"description\": (\n+                    \"Discovery shape only. Temporal bias on top of FTS5 ranking. Omit \"\n+                    \"to keep relevance-only ordering (suitable for exploratory recall — \"\n+                    \"\\\"what do we know about X\\\"). Set 'newest' for recency-shaped \"\n+                    \"questions (\\\"where did we leave X\\\"). Set 'oldest' for \"\n+                    \"origin-shaped questions (\\\"how did X start\\\"). Ignored in scroll \"\n+                    \"and browse shapes.\"\n+                ),\n+            },\n+            \"session_id\": {\n+                \"type\": \"string\",\n+                \"description\": (\n+                    \"Scroll shape. Session to read inside. Use the session_id returned \"\n+                    \"from a prior discovery call. Must be paired with \"\n+                    \"around_message_id.\"\n+                ),\n+            },\n+            \"around_message_id\": {\n+                \"type\": \"integer\",\n+                \"description\": (\n+                    \"Scroll shape. Message id to center the window on. From a discovery \"\n+                    \"result use match_message_id, or any id seen in a prior window. To \"\n+                    \"scroll forward pass the last window message's id; to scroll \"\n+                    \"backward pass the first.\"\n+                ),\n+            },\n+            \"window\": {\n+                \"type\": \"integer\",\n+                \"description\": (\n+                    \"Scroll shape only. Messages to return on each side of the anchor \"\n+                    \"(anchor itself always included). Clamped to [1, 20]. Default 5.\"\n+                ),\n+                \"default\": 5,\n+            },\n+            \"role_filter\": {\n+                \"type\": \"string\",\n+                \"description\": (\n+                    \"Optional. Comma-separated roles to include. Discovery defaults to \"\n+                    \"'user,assistant' (tool output is usually noise). Pass \"\n+                    \"'user,assistant,tool' to include tool output (debugging tool \"\n+                    \"behaviour) or 'tool' to search tool output only.\"\n+                ),\n+            },\n         },\n         \"required\": [],\n     },","@@ -605,8 +590,13 @@ def check_session_search_requirements() -> bool:\n         query=args.get(\"query\") or \"\",\n         role_filter=args.get(\"role_filter\"),\n         limit=args.get(\"limit\", 3),\n+        session_id=args.get(\"session_id\"),\n+        around_message_id=args.get(\"around_message_id\"),\n+        window=args.get(\"window\", 5),\n+        sort=args.get(\"sort\"),\n         db=kw.get(\"db\"),\n-        current_session_id=kw.get(\"current_session_id\")),\n+        current_session_id=kw.get(\"current_session_id\"),\n+    ),\n     check_fn=check_session_search_requirements,\n     emoji=\"🔍\",\n )"]},{"file":"website/docs/reference/tools-reference.md","hunks":["@@ -152,7 +152,7 @@ Registered only when the agent is spawned by the kanban dispatcher (`HERMES_KANB\n \n | Tool | Description | Requires environment |\n |------|-------------|----------------------|\n-| `session_search` | Search your long-term memory of past conversations. This is your recall -- every past session is searchable, and this tool summarizes what happened. USE THIS PROACTIVELY when: - The user says 'we did this before', 'remember when', 'last ti… | — |\n+| `session_search` | Search past sessions stored in the local session DB, or scroll inside one. FTS5-backed retrieval; returns actual messages from the DB (no LLM calls). Three shapes: discovery (pass `query`), scroll (pass `session_id` + `around_message_id`), browse (no args). | — |\n \n ## `skills` toolset\n "]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -780,7 +780,6 @@ $ hermes model\n \n [ ] vision               currently: auto / main model\n [ ] web_extract          currently: auto / main model\n-[ ] session_search       currently: openrouter / google/gemini-2.5-flash\n [ ] title_generation     currently: openrouter / google/gemini-3-flash-preview\n [ ] compression          currently: auto / main model\n [ ] approval             currently: auto / main model","@@ -862,16 +861,6 @@ auxiliary:\n   compression:\n     timeout: 120               # seconds — compression summarizes long conversations, needs more time\n \n-  # Session search — summarizes past session matches\n-  session_search:\n-    provider: \"auto\"\n-    model: \"\"\n-    base_url: \"\"\n-    api_key: \"\"\n-    timeout: 30\n-    max_concurrency: 3       # Limit parallel summaries to reduce request-burst 429s\n-    extra_body: {}           # Provider-specific OpenAI-compatible request fields\n-\n   # Skills hub — skill matching and search\n   skills_hub:\n     provider: \"auto\"","@@ -909,34 +898,6 @@ Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision\n Context compression has its own `compression:` block for thresholds and an `auxiliary.compression:` block for model/provider settings — see [Context Compression](#context-compression) above. The fallback model uses a `fallback_model:` block — see [Fallback Model](/docs/integrations/providers#fallback-model). All three follow the same provider/model/base_url pattern.\n :::\n \n-### Session Search Tuning\n-\n-If you use a reasoning-heavy model for `auxiliary.session_search`, Hermes now gives you two built-in controls:\n-\n-- `auxiliary.session_search.max_concurrency`: limits how many matched sessions Hermes summarizes at once\n-- `auxiliary.session_search.extra_body`: forwards provider-specific OpenAI-compatible request fields on the summarization calls\n-\n-Example:\n-\n-```yaml\n-auxiliary:\n-  session_search:\n-    provider: \"main\"\n-    model: \"glm-4.5-air\"\n-    timeout: 60\n-    max_concurrency: 2\n-    extra_body:\n-      enable_thinking: false\n-```\n-\n-Use `max_concurrency` when your provider rate-limits request bursts and you want `session_search` to trade some parallelism for stability.\n-\n-Use `extra_body` only when your provider documents OpenAI-compatible request-body fields you want Hermes to pass through for that task. Hermes forwards the object as-is.\n-\n-:::warning\n-`extra_body` is only effective when your provider actually supports the field you send. If the provider does not expose a native OpenAI-compatible reasoning-off flag, Hermes cannot synthesize one on its behalf.\n-:::\n-\n ### OpenRouter routing & Pareto Code for auxiliary tasks\n \n When an auxiliary task resolves to OpenRouter (either explicitly or via `provider: \"main\"` while your main agent is on OpenRouter), the main agent's `provider_routing` and `openrouter.min_coding_score` settings **do not propagate** — by design, each auxiliary task is independent. To set OpenRouter provider preferences or use the [Pareto Code router](/docs/integrations/providers#openrouter-pareto-code-router) for a specific aux task, set them per-task via `extra_body`:"]},{"file":"website/docs/user-guide/features/fallback-providers.md","hunks":["@@ -188,7 +188,6 @@ Hermes uses separate lightweight models for side tasks. Each task has its own pr\n | Vision | Image analysis, browser screenshots | `auxiliary.vision` |\n | Web Extract | Web page summarization | `auxiliary.web_extract` |\n | Compression | Context compression summaries | `auxiliary.compression` |\n-| Session Search | Past session summarization | `auxiliary.session_search` |\n | Skills Hub | Skill search and discovery | `auxiliary.skills_hub` |\n | MCP | MCP helper operations | `auxiliary.mcp` |\n | Approval | Smart command-approval classification | `auxiliary.approval` |","@@ -235,13 +234,6 @@ auxiliary:\n     provider: \"auto\"\n     model: \"\"\n \n-  session_search:\n-    provider: \"auto\"\n-    model: \"\"\n-    timeout: 30\n-    max_concurrency: 3\n-    extra_body: {}\n-\n   skills_hub:\n     provider: \"auto\"\n     model: \"\"","@@ -270,25 +262,6 @@ fallback_model:\n   # base_url: http://localhost:8000/v1               # Optional custom endpoint\n ```\n \n-For `auxiliary.session_search`, Hermes also supports:\n-\n-- `max_concurrency` to limit how many session summaries run at once\n-- `extra_body` to pass provider-specific OpenAI-compatible request fields through on the summarization calls\n-\n-Example:\n-\n-```yaml\n-auxiliary:\n-  session_search:\n-    provider: main\n-    model: glm-4.5-air\n-    max_concurrency: 2\n-    extra_body:\n-      enable_thinking: false\n-```\n-\n-If your provider does not support a native OpenAI-compatible reasoning-control field, `extra_body` will not help for that part; in that case `max_concurrency` is still useful for reducing request-burst 429s.\n-\n All three — auxiliary, compression, fallback — work the same way: set `provider` to pick who handles the request, `model` to pick which model, and `base_url` to point at a custom endpoint (overrides provider).\n \n ### Provider Options for Auxiliary Tasks","@@ -432,7 +405,6 @@ See [Scheduled Tasks (Cron)](/docs/user-guide/features/cron) for full configurat\n | Vision | Layered (see above) + internal OpenRouter retry | `auxiliary.vision` |\n | Web extraction | Layered (see above) + internal OpenRouter retry | `auxiliary.web_extract` |\n | Context compression | Layered (see above); degrades to no-summary if all layers unavailable | `auxiliary.compression` |\n-| Session search | Layered (see above) | `auxiliary.session_search` |\n | Skills hub | Layered (see above) | `auxiliary.skills_hub` |\n | MCP helpers | Layered (see above) | `auxiliary.mcp` |\n | Approval classification | Layered (see above) | `auxiliary.approval` |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}