Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 @<platform>`` 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.

Expand Down Expand Up @@ -3023,6 +3046,15 @@ def cmd_chat(args):
print(f" (later: hermes --resume {_imported_id})")
args.resume = _imported_id

# --resume @telegram / @whatsapp / @<any-platform>: 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:
Expand Down
13 changes: 12 additions & 1 deletion hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
58 changes: 58 additions & 0 deletions hermes_state_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions tests/hermes_cli/test_resume_platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Tests for `--resume @<platform>` — 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
46 changes: 46 additions & 0 deletions tests/hermes_state/test_get_messages_around.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
38 changes: 36 additions & 2 deletions tests/tools/test_session_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
Loading