diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e9e08c8d6be88..64d0c0fdf17c9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1641,6 +1641,29 @@ def _resolve_last_session(source: str = "cli") -> Optional[str]: return None +def _resolve_platform_resume(resume_val: str) -> Optional[str]: + """Resolve ``--resume @`` to that platform's most recent session. + + The reverse direction of ``/handoff``: a conversation started on a + gateway platform (telegram, whatsapp, …) continues in the terminal + without the user hunting for a session id. ``@claude`` / ``@codex`` are + NOT handled here — those import foreign-CLI sessions and are resolved + before this runs. Returns None when ``resume_val`` is not an ``@`` form; + exits with a hint when the platform has no sessions yet. + """ + val = (resume_val or "").strip().lower() + if not val.startswith("@") or len(val) < 2: + return None + platform_source = val[1:] + session_id = _resolve_last_session(source=platform_source) + if session_id: + print(f"↪ resuming most recent {platform_source} session: {session_id}") + return session_id + print(f"No {platform_source} session found to resume.") + print("Use 'hermes sessions list' to see available sessions.") + sys.exit(1) + + def _probe_container(cmd: list, backend: str, via_sudo: bool = False): """Run a container inspect probe, returning the CompletedProcess. @@ -3023,6 +3046,15 @@ def cmd_chat(args): print(f" (later: hermes --resume {_imported_id})") args.resume = _imported_id + # --resume @telegram / @whatsapp / @: continue that + # platform's most recent session in the terminal — the reverse direction + # of /handoff. (@claude/@codex were consumed by the import block above.) + _resume_platform = getattr(args, "resume", None) + if isinstance(_resume_platform, str): + _platform_id = _resolve_platform_resume(_resume_platform) + if _platform_id: + args.resume = _platform_id + # Resolve --resume by title if it's not a direct session ID resume_val = getattr(args, "resume", None) if resume_val: diff --git a/hermes_state.py b/hermes_state.py index 28505449090be..05c0913a2ba8c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -11596,15 +11596,26 @@ def get_messages_around( # Two queries: anchor + before (DESC, take window+1), and after # (ASC, take window). Final order is id ASC. + # + # Rewound rows (active=0, compacted=0) are excluded — the user + # took those back, and search_messages already hides them from + # hits, so surfacing them through the surrounding window leaked + # exactly the content the rewind removed. Compaction-archived + # rows (compacted=1) stay visible, mirroring the search rule + # (#38763). The anchor itself is always kept so callers' + # anchor-in-window invariant holds even for an explicit scroll + # to a rewound id. before_rows = conn.execute( "SELECT * FROM messages " "WHERE session_id = ? AND id <= ? " + "AND (active = 1 OR compacted = 1 OR id = ?) " "ORDER BY id DESC LIMIT ?", - (session_id, around_message_id, window + 1), + (session_id, around_message_id, around_message_id, window + 1), ).fetchall() after_rows = conn.execute( "SELECT * FROM messages " "WHERE session_id = ? AND id > ? " + "AND (active = 1 OR compacted = 1) " "ORDER BY id ASC LIMIT ?", (session_id, around_message_id, window), ).fetchall() diff --git a/hermes_state_search.py b/hermes_state_search.py index 738dbbe6b28a1..0d0d0f8dbb2de 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -1053,10 +1053,13 @@ def get_anchored_view( role_clause = f" AND role IN ({role_placeholders})" role_params = list(keep_roles) + # Rewound rows are excluded for the same reason as in + # get_messages_around: the user took them back. bookend_start_rows = conn.execute( f"SELECT * FROM messages " f"WHERE session_id = ? AND id < ?{role_clause} " f"AND length(content) > 0 " + f"AND (active = 1 OR compacted = 1) " f"ORDER BY id ASC LIMIT ?", (session_id, window_min_id, *role_params, bookend), ).fetchall() @@ -1065,6 +1068,7 @@ def get_anchored_view( f"SELECT * FROM messages " f"WHERE session_id = ? AND id > ?{role_clause} " f"AND length(content) > 0 " + f"AND (active = 1 OR compacted = 1) " f"ORDER BY id DESC LIMIT ?", (session_id, window_max_id, *role_params, bookend), ).fetchall() @@ -1446,6 +1450,28 @@ def search_messages( include_inactive=include_inactive, fields=fields, ) + if not rows and offset == 0: + # Implicit-AND found nothing: natural multi-word queries + # ("apresentação deck horário") require every term in ONE + # message, which silently zeroes out whenever the words are + # spread across a conversation. Retry the same terms + # OR-joined — BM25 still ranks, so messages matching more + # terms surface first. Never rewrites queries that carry + # explicit intent (operators/phrases/prefixes) or CJK, which + # has its own routing. + relaxed = self._relaxed_or_query(query) + if relaxed: + rows = self._search_messages_impl( + relaxed, + source_filter=source_filter, + exclude_sources=exclude_sources, + role_filter=role_filter, + limit=limit, + offset=offset, + sort=sort, + include_inactive=include_inactive, + fields=fields, + ) return rows finally: try: @@ -1486,6 +1512,38 @@ def _describe_search_path(self, query: str) -> str: except Exception: return "unknown" + # Only queries relaxed past this many terms would OR-join into noise; + # keep the strongest (first) terms — natural questions front-load topic + # words ("planilha estimativa custo da reunião de ontem…"). + _RELAX_MAX_TERMS = 8 + + def _relaxed_or_query(self, query: str) -> Optional[str]: + """OR-joined rewrite of a plain multi-word query, or None. + + Returns None — meaning "do not retry" — for queries that carry + explicit FTS5 intent (phrases, prefix globs, boolean operators), + CJK queries (their routing owns fallback behavior), and single-term + queries (nothing to relax). + """ + if not query: + return None + if '"' in query or "*" in query: + return None + raw_tokens = query.split() + if any(t in ("AND", "OR", "NOT") for t in raw_tokens): + return None + sanitized = self._sanitize_fts5_query(query) + if not sanitized or self._contains_cjk(sanitized): + return None + seen: dict = {} + for tok in sanitized.split(): + if tok and tok not in seen: + seen[tok] = None + tokens = list(seen)[: self._RELAX_MAX_TERMS] + if len(tokens) < 2: + return None + return " OR ".join(tokens) + @staticmethod def _compile_like_boolean_query( query: str, diff --git a/tests/hermes_cli/test_resume_platform.py b/tests/hermes_cli/test_resume_platform.py new file mode 100644 index 0000000000000..db8e007ad490f --- /dev/null +++ b/tests/hermes_cli/test_resume_platform.py @@ -0,0 +1,61 @@ +"""Tests for `--resume @` — continue a gateway platform's most +recent session in the terminal (the reverse direction of /handoff). + +`@claude` / `@codex` are resolved earlier by the foreign-session import block; +this helper only sees the remaining `@` forms. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def main_mod(): + import hermes_cli.main as mod + + return mod + + +class TestResolvePlatformResume: + def test_platform_with_session_resolves_to_its_mru_id(self, main_mod, monkeypatch, capsys): + seen = {} + + def fake_mru(source="cli"): + seen["source"] = source + return "20260824_104314_b8ec37eb" + + monkeypatch.setattr(main_mod, "_resolve_last_session", fake_mru) + resolved = main_mod._resolve_platform_resume("@telegram") + assert resolved == "20260824_104314_b8ec37eb" + assert seen["source"] == "telegram" + assert "telegram" in capsys.readouterr().out + + def test_case_and_whitespace_are_normalized(self, main_mod, monkeypatch): + seen = {} + monkeypatch.setattr( + main_mod, "_resolve_last_session", + lambda source="cli": seen.setdefault("source", source) and "sid" or "sid", + ) + assert main_mod._resolve_platform_resume(" @WhatsApp ") == "sid" + assert seen["source"] == "whatsapp" + + def test_non_at_values_pass_through_untouched(self, main_mod, monkeypatch): + monkeypatch.setattr( + main_mod, "_resolve_last_session", + lambda source="cli": pytest.fail("must not query MRU for non-@ values"), + ) + assert main_mod._resolve_platform_resume("20260824_1043") is None + assert main_mod._resolve_platform_resume("latest") is None + assert main_mod._resolve_platform_resume("my session title") is None + # Bare "@" has no platform name — treated as a normal resume value. + assert main_mod._resolve_platform_resume("@") is None + + def test_platform_without_sessions_exits_with_hint(self, main_mod, monkeypatch, capsys): + monkeypatch.setattr(main_mod, "_resolve_last_session", lambda source="cli": None) + with pytest.raises(SystemExit) as exc: + main_mod._resolve_platform_resume("@signal") + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "signal" in out + assert "hermes sessions list" in out diff --git a/tests/hermes_state/test_get_messages_around.py b/tests/hermes_state/test_get_messages_around.py index eb175b144ec74..c3f5f6d1cc67a 100644 --- a/tests/hermes_state/test_get_messages_around.py +++ b/tests/hermes_state/test_get_messages_around.py @@ -104,3 +104,49 @@ def test_tool_calls_deserialized(self, db): assert asst, "expected an assistant message" # tool_calls should be a list after hydration, not a string assert isinstance(asst[0].get("tool_calls"), list) + + +class TestRewoundRowsHidden: + """Rewound rows (active=0, compacted=0) must not surface through the + window — search already hides them as hits, and the window otherwise + leaked exactly the content the user's rewind removed. Compaction-archived + rows (compacted=1) stay visible, mirroring search_messages (#38763).""" + + def _rewind(self, db, mid): + db._conn.execute( + "UPDATE messages SET active = 0, compacted = 0 WHERE id = ?", (mid,) + ) + db._conn.commit() + + def test_rewound_neighbor_is_excluded_from_window(self, db): + ids = _seed(db, n=6) + self._rewind(db, ids[3]) + view = db.get_messages_around("s1", ids[2], window=2) + got = [m["id"] for m in view["window"]] + assert ids[3] not in got + # Window still fills from remaining live rows. + assert ids[2] in got + + def test_rewound_anchor_itself_is_still_returned(self, db): + ids = _seed(db, n=4) + self._rewind(db, ids[1]) + view = db.get_messages_around("s1", ids[1], window=1) + assert ids[1] in [m["id"] for m in view["window"]] + + def test_compaction_archived_neighbor_stays_visible(self, db): + ids = _seed(db, n=4) + db._conn.execute( + "UPDATE messages SET active = 0, compacted = 1 WHERE id = ?", (ids[1],) + ) + db._conn.commit() + view = db.get_messages_around("s1", ids[2], window=2) + assert ids[1] in [m["id"] for m in view["window"]] + + def test_rewound_rows_excluded_from_bookends(self, db): + ids = _seed(db, n=10) + self._rewind(db, ids[0]) + self._rewind(db, ids[9]) + view = db.get_anchored_view("s1", ids[5], window=1, bookend=2) + bookend_ids = [m["id"] for m in view["bookend_start"] + view["bookend_end"]] + assert ids[0] not in bookend_ids + assert ids[9] not in bookend_ids diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 28480c1d1548c..da6f8016986c0 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -879,6 +879,34 @@ def test_sanitize_fts5_query_strips_dangerous_chars(self): + def test_multi_term_query_falls_back_to_or_when_and_finds_nothing(self, db): + # Terms spread across different messages: implicit-AND finds nothing, + # so the search retries the same terms OR-joined (BM25 still ranks). + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="A apresentação ficou pronta") + db.append_message("s1", role="assistant", content="Subi o deck em produção") + + results = db.search_messages("apresentação deck horário") + assert len(results) == 2 + + def test_or_fallback_skips_single_term_and_explicit_operator_queries(self, db): + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="Só falamos de deck aqui") + + # Single missing term: nothing to relax. + assert db.search_messages("horário") == [] + # Explicit operators are user intent — never rewritten. + assert db.search_messages("deck AND horário") == [] + assert db.search_messages('"deck horário"') == [] + + def test_or_fallback_ignores_unknown_terms(self, db): + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="planilha de custo mensal") + + results = db.search_messages("planilha custo inexistenteterm") + assert len(results) == 1 + assert "planilha" in results[0]["snippet"] + def test_long_search_query_is_capped_and_does_not_crash(self, db): db.create_session(session_id="s1", source="cli") db.append_message("s1", role="user", content="bounded sanitizer target") diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index fb61db973f72e..852659444223f 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -17,6 +17,7 @@ from hermes_state import SessionDB from tools.session_search_tool import ( SESSION_SEARCH_SCHEMA, + _DISCOVER_SCAN_LIMIT, _format_timestamp, _is_compacted_message, _is_compression_ended, @@ -578,6 +579,33 @@ def test_interactive_session_surfaces_above_cron(self, db): assert result["results"][0]["source"] == "telegram" assert result["results"][0]["session_id"] == "s_user" + def test_interactive_match_survives_cron_flooding_the_scan_window(self, db): + """Post-hoc demotion is not enough when cron rows fill the entire + _DISCOVER_SCAN_LIMIT window — the interactive hit never even reaches + the demotion pass. The scan itself must fetch interactive sources + first and only top up with demoted ones. + """ + now = int(time.time()) + db.create_session("s_user", source="telegram") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", + (now - 90000, "s_user")) + db.append_message("s_user", role="user", content="review the venom slides please") + # More matching cron rows than the whole scan window holds. + overflow = _DISCOVER_SCAN_LIMIT + 20 + db.create_session("cron_flood", source="cron") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", + (now - 1000, "cron_flood")) + for i in range(overflow): + db.append_message("cron_flood", role="assistant", + content=f"venom slides nightly digest {i}") + db._conn.commit() + + result = json.loads(session_search(query="venom slides", limit=3, db=db)) + assert result["success"] is True + sources = [r["source"] for r in result["results"]] + assert "telegram" in sources + assert result["results"][0]["session_id"] == "s_user" + def test_cron_still_reachable_when_only_match(self, db): """Demotion must not exclude cron — when only cron matches, it still comes back.""" @@ -891,12 +919,18 @@ def test_compacted_messages_still_surface_alongside_rewind(self, db): )) assert result_compact["count"] >= 1 - # Rewound content should NOT be discoverable + # Rewound content should NOT be discoverable. The OR-relaxation + # retry may legitimately surface OTHER messages sharing a term + # ("content" matches the compacted row), so assert on the rewound + # text itself rather than on an empty result set. result_rewind = json.loads(session_search( query="rewound content gamma", db=db, current_session_id="s_mixed", )) - assert result_rewind["count"] == 0 + results_text = json.dumps( + result_rewind.get("results", []), ensure_ascii=False + ) + assert "gamma" not in results_text class TestCompressionEndedHelper: diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index c5752f5ca4adf..e567f5a5a51cc 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -767,26 +767,40 @@ def _discover( current_lineage_root = _resolve_lineage(db, current_session_id) if current_session_id else None title_result = _title_match_result(db, query, current_lineage_root) + # Two-pass scan: interactive sources first, then top up with demoted + # (cron) ones. Post-hoc demotion alone (#19434) only reorders rows that + # made it into the scan window — a cron corpus with more than + # _DISCOVER_SCAN_LIMIT matching rows fills the window entirely and the + # user's own sessions never reach the demotion pass at all. try: raw_results = db.search_messages( query=query, role_filter=role_list, - exclude_sources=list(_HIDDEN_SESSION_SOURCES), + exclude_sources=list(_HIDDEN_SESSION_SOURCES) + + list(_DEMOTED_SESSION_SOURCES), limit=_DISCOVER_SCAN_LIMIT, # widen so dedup-by-lineage can find - # distinct sessions AND so interactive matches buried under a wall - # of cron rows are still in hand for the demotion pass below. + # distinct sessions among the interactive matches. offset=0, sort=sort, fields=_DISCOVER_SEARCH_FIELDS, ) + remaining = _DISCOVER_SCAN_LIMIT - len(raw_results) + if remaining > 0: + raw_results += db.search_messages( + query=query, + role_filter=role_list, + source_filter=list(_DEMOTED_SESSION_SOURCES), + limit=remaining, + offset=0, + sort=sort, + fields=_DISCOVER_SEARCH_FIELDS, + ) except Exception as e: logging.error("FTS5 search failed: %s", e, exc_info=True) return tool_error(f"Search failed: {e}", success=False) - # Demote automation (cron) rows below interactive ones before dedup, so a - # high-volume cron corpus can't starve the user's own sessions out of the - # top `limit` results (#19434). Stable — preserves BM25/recency order - # within each class. + # Keep the post-hoc demotion pass: it still covers rows routed here from + # other paths and keeps ordering stable if the source lists ever drift. raw_results = _order_for_recall(raw_results) if not raw_results and not title_result: