{"repo":"NousResearch/hermes-agent","public_id":"19434","display_label":"Session search ranking and recall bug fixes","summary":"These items address several session search problems: cron sessions outranking interactive ones, child sessions not resolving to their root, FTS scan limits, a JSON/SQLite data split-brain, and the current session appearing in its own results.","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":53597,"merged_at":"2026-06-27T11:41:22+00:00"},"resolution":{"state":"resolved","by":"pr-53597"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:41:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":19434,"pr_number":53597,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-27T11:41:22+00:00"}],"members":[{"kind":"issue","number":19434,"title":"session_search recall failures: cron drowns user sessions in FTS + JSON/SQLite split-brain + child sessions hidden","url":null,"state":"closed"},{"kind":"pr","number":19438,"title":"fix(session_search): resolve child sessions to root, raise FTS scan limit, demote cron sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fe1c1c1121002166da38fc254a4fe977aa4da071","number":0},{"kind":"pr","value":"#53597","number":53597},{"kind":"pr","value":"#19434","number":19434}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53597,"title":"fix(session_search): demote cron below interactive sessions in discover ranking","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_session_search.py","tools/session_search_tool.py"],"fix_hunks":[{"file":"tests/tools/test_session_search.py","hunks":["@@ -570,3 +570,71 @@ def test_combined_value_autosplits(self, db, tmp_path, monkeypatch):\n             assert result[\"success\"] is True, kwargs\n             assert result[\"mode\"] == \"read\"\n             assert result[\"session_id\"] == \"s_other\"\n+\n+\n+# =========================================================================\n+# Cron demotion in discover ranking (#19434)\n+# =========================================================================\n+\n+class TestCronDemotion:\n+    def _seed_cron_and_interactive(self, db):\n+        \"\"\"One interactive (telegram) session and several cron sessions, all\n+        matching the same query. Cron rows accumulate repetitive vocabulary\n+        and out-number the user's single interactive session — the live-data\n+        symptom in #19434.\n+        \"\"\"\n+        now = int(time.time())\n+        # Interactive user session — older, so it loses on bare recency too.\n+        db.create_session(\"s_user\", source=\"telegram\")\n+        db._conn.execute(\"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                         (now - 90000, \"s_user\"))\n+        db.append_message(\"s_user\", role=\"user\", content=\"how is the venom project going\")\n+        db.append_message(\"s_user\", role=\"assistant\", content=\"The venom project shipped its first milestone.\")\n+        # Several cron sessions, all newer and all stuffed with the same terms.\n+        for i in range(8):\n+            sid = f\"cron_{i}\"\n+            db.create_session(sid, source=\"cron\")\n+            db._conn.execute(\"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                             (now - 1000 - i, sid))\n+            db.append_message(sid, role=\"user\", content=\"venom project daily status\")\n+            db.append_message(sid, role=\"assistant\", content=\"venom project venom project venom summary\")\n+        db._conn.commit()\n+\n+    def test_interactive_session_surfaces_above_cron(self, db):\n+        self._seed_cron_and_interactive(db)\n+        result = json.loads(session_search(query=\"venom project\", limit=1, db=db))\n+        assert result[\"success\"] is True\n+        assert result[\"count\"] == 1\n+        # With cron drowning FTS, bare BM25/recency would return a cron_* hit.\n+        # Demotion must put the user's interactive session first.\n+        assert result[\"results\"][0][\"source\"] == \"telegram\"\n+        assert result[\"results\"][0][\"session_id\"] == \"s_user\"\n+\n+    def test_cron_still_reachable_when_only_match(self, db):\n+        \"\"\"Demotion must not exclude cron — when only cron matches, it still\n+        comes back.\"\"\"\n+        now = int(time.time())\n+        db.create_session(\"cron_only\", source=\"cron\")\n+        db._conn.execute(\"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+                         (now - 500, \"cron_only\"))\n+        db.append_message(\"cron_only\", role=\"user\", content=\"quarterly archive sweep\")\n+        db.append_message(\"cron_only\", role=\"assistant\", content=\"Archive sweep complete.\")\n+        db._conn.commit()\n+        result = json.loads(session_search(query=\"archive sweep\", db=db))\n+        assert result[\"success\"] is True\n+        assert result[\"count\"] == 1\n+        assert result[\"results\"][0][\"source\"] == \"cron\"\n+\n+    def test_order_for_recall_is_stable_within_class(self):\n+        from tools.session_search_tool import _order_for_recall\n+        rows = [\n+            {\"id\": 1, \"source\": \"cron\"},\n+            {\"id\": 2, \"source\": \"telegram\"},\n+            {\"id\": 3, \"source\": \"cron\"},\n+            {\"id\": 4, \"source\": \"cli\"},\n+            {\"id\": 5, \"source\": None},\n+        ]\n+        ordered = _order_for_recall(rows)\n+        # Interactive rows first, in original relative order; cron last, in\n+        # original relative order.\n+        assert [r[\"id\"] for r in ordered] == [2, 4, 5, 1, 3]"]},{"file":"tools/session_search_tool.py","hunks":["@@ -39,6 +39,22 @@\n # user's session history.\n _HIDDEN_SESSION_SOURCES = (\"subagent\", \"tool\")\n \n+# Automation sources that are kept searchable but DEMOTED below interactive\n+# sessions in discover ranking. Cron jobs run on a schedule and accumulate\n+# large volumes of repetitive vocabulary (recurring project names, dates,\n+# \"session\", summaries); under bare BM25 they dominate the top-N FTS rows and\n+# starve out the user's own interactive sessions, producing \"recall blindness\"\n+# where only cron sessions surface (#19434). Demoting — not excluding — keeps\n+# cron content reachable when it's the only match, while interactive sessions\n+# always win when both match.\n+_DEMOTED_SESSION_SOURCES = (\"cron\",)\n+\n+# How many FTS rows discover scans before dedup-by-lineage. The interactive\n+# vs automation split below only helps if enough rows are in hand to find\n+# interactive matches buried under a wall of cron hits, so this is well above\n+# the handful of distinct sessions a typical query returns.\n+_DISCOVER_SCAN_LIMIT = 300\n+\n \n def _format_timestamp(ts: Union[int, float, str, None]) -> str:\n     \"\"\"Convert a Unix timestamp (float/int) or ISO string to a human-readable date.","@@ -87,6 +103,23 @@ def _resolve_to_parent(db, session_id: str) -> str:\n     return cur\n \n \n+def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n+    \"\"\"Stable-sort FTS rows so interactive sessions rank above automation.\n+\n+    Within each class (interactive vs demoted) the original BM25 ``rank``\n+    order is preserved — Python's sort is stable, and rows arrive already\n+    ranked by relevance. This only changes cross-class ordering: a cron hit\n+    never displaces an interactive hit during lineage dedup, so the user's\n+    own conversations surface first even when cron rows out-rank them under\n+    bare BM25 (#19434). Demoted rows still appear when they're the only\n+    matches.\n+    \"\"\"\n+    return sorted(\n+        raw_results,\n+        key=lambda r: 1 if (r.get(\"source\") or \"\") in _DEMOTED_SESSION_SOURCES else 0,\n+    )\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 = {","@@ -481,7 +514,9 @@ def _discover(\n             query=query,\n             role_filter=role_list,\n             exclude_sources=list(_HIDDEN_SESSION_SOURCES),\n-            limit=50,  # widen so dedup-by-lineage can find distinct sessions\n+            limit=_DISCOVER_SCAN_LIMIT,  # widen so dedup-by-lineage can find\n+            # distinct sessions AND so interactive matches buried under a wall\n+            # of cron rows are still in hand for the demotion pass below.\n             offset=0,\n             sort=sort,\n         )","@@ -489,6 +524,12 @@ def _discover(\n         logging.error(\"FTS5 search failed: %s\", e, exc_info=True)\n         return tool_error(f\"Search failed: {e}\", success=False)\n \n+    # Demote automation (cron) rows below interactive ones before dedup, so a\n+    # high-volume cron corpus can't starve the user's own sessions out of the\n+    # top `limit` results (#19434). Stable — preserves BM25/recency order\n+    # within each class.\n+    raw_results = _order_for_recall(raw_results)\n+\n     if not raw_results and not title_result:\n         return json.dumps({\n             \"success\": True,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}