{"repo":"NousResearch/hermes-agent","public_id":"5087","display_label":"Matrix auto-thread feature and thread reply behavior","summary":"These members concern adding MATRIX_AUTO_THREAD and MATRIX_REQUIRE_MENTION configuration support, along with a reported issue where thread-scoped sessions do not correctly attach the m.thread relation when posting replies.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5106},"resolution":{"state":"partially_resolved","by":"pr-5106"},"freshness":{"state":"stale","last_verified_at":"2026-04-04T20:09:27+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":3891,"pr_number":5106,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5087,"pr_number":5106,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5087,"title":"feat(matrix): add auto_thread support for group rooms","url":null,"state":"closed"},{"kind":"issue","number":67352,"title":"[Bug]: Matrix auto_thread / session_scope=thread isolates sessions correctly but replies still post at room level (no m.thread relation)","url":null,"state":"closed"},{"kind":"pr","number":5090,"title":"feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5106","number":5106}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5106,"title":"feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/matrix.py","hermes_cli/config.py","tests/gateway/test_matrix_mention.py","website/docs/reference/environment-variables.md","website/docs/user-guide/messaging/matrix.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -575,6 +575,20 @@ def load_gateway_config() -> GatewayConfig:\n                     if isinstance(frc, list):\n                         frc = \",\".join(str(v) for v in frc)\n                     os.environ[\"WHATSAPP_FREE_RESPONSE_CHATS\"] = str(frc)\n+\n+            # Matrix settings → env vars (env vars take precedence)\n+            matrix_cfg = yaml_cfg.get(\"matrix\", {})\n+            if isinstance(matrix_cfg, dict):\n+                if \"require_mention\" in matrix_cfg and not os.getenv(\"MATRIX_REQUIRE_MENTION\"):\n+                    os.environ[\"MATRIX_REQUIRE_MENTION\"] = str(matrix_cfg[\"require_mention\"]).lower()\n+                frc = matrix_cfg.get(\"free_response_rooms\")\n+                if frc is not None and not os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\"):\n+                    if isinstance(frc, list):\n+                        frc = \",\".join(str(v) for v in frc)\n+                    os.environ[\"MATRIX_FREE_RESPONSE_ROOMS\"] = str(frc)\n+                if \"auto_thread\" in matrix_cfg and not os.getenv(\"MATRIX_AUTO_THREAD\"):\n+                    os.environ[\"MATRIX_AUTO_THREAD\"] = str(matrix_cfg[\"auto_thread\"]).lower()\n+\n     except Exception as e:\n         logger.warning(\n             \"Failed to process config.yaml — falling back to .env / gateway.json values. \""]},{"file":"gateway/platforms/matrix.py","hunks":["@@ -5,13 +5,16 @@\n when installed with ``pip install \"matrix-nio[e2e]\"``.\n \n Environment variables:\n-    MATRIX_HOMESERVER       Homeserver URL (e.g. https://matrix.example.org)\n-    MATRIX_ACCESS_TOKEN     Access token (preferred auth method)\n-    MATRIX_USER_ID          Full user ID (@bot:server) — required for password login\n-    MATRIX_PASSWORD         Password (alternative to access token)\n-    MATRIX_ENCRYPTION       Set \"true\" to enable E2EE\n-    MATRIX_ALLOWED_USERS    Comma-separated Matrix user IDs (@user:server)\n-    MATRIX_HOME_ROOM        Room ID for cron/notification delivery\n+    MATRIX_HOMESERVER           Homeserver URL (e.g. https://matrix.example.org)\n+    MATRIX_ACCESS_TOKEN         Access token (preferred auth method)\n+    MATRIX_USER_ID              Full user ID (@bot:server) — required for password login\n+    MATRIX_PASSWORD             Password (alternative to access token)\n+    MATRIX_ENCRYPTION           Set \"true\" to enable E2EE\n+    MATRIX_ALLOWED_USERS        Comma-separated Matrix user IDs (@user:server)\n+    MATRIX_HOME_ROOM            Room ID for cron/notification delivery\n+    MATRIX_REQUIRE_MENTION      Require @mention in rooms (default: true)\n+    MATRIX_FREE_RESPONSE_ROOMS  Comma-separated room IDs exempt from mention requirement\n+    MATRIX_AUTO_THREAD          Auto-create threads for room messages (default: true)\n \"\"\"\n \n from __future__ import annotations","@@ -123,6 +126,10 @@ def __init__(self, config: PlatformConfig):\n         # Each entry: (room, event, timestamp)\n         self._pending_megolm: list = []\n \n+        # Thread participation tracking (for require_mention bypass)\n+        self._bot_participated_threads: set = self._load_participated_threads()\n+        self._MAX_TRACKED_THREADS = 500\n+\n     def _is_duplicate_event(self, event_id) -> bool:\n         \"\"\"Return True if this event was already processed. Tracks the ID otherwise.\"\"\"\n         if not event_id:","@@ -902,6 +909,30 @@ async def _on_room_message(self, room: Any, event: Any) -> None:\n         if relates_to.get(\"rel_type\") == \"m.thread\":\n             thread_id = relates_to.get(\"event_id\")\n \n+        # Require-mention gating.\n+        if not is_dm:\n+            free_rooms_raw = os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\", \"\")\n+            free_rooms = {r.strip() for r in free_rooms_raw.split(\",\") if r.strip()}\n+            require_mention = os.getenv(\"MATRIX_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\")\n+            is_free_room = room.room_id in free_rooms\n+            in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)\n+\n+            formatted_body = source_content.get(\"formatted_body\")\n+            if require_mention and not is_free_room and not in_bot_thread:\n+                if not self._is_bot_mentioned(body, formatted_body):\n+                    return\n+\n+        # Strip mention from body when present (including in DMs).\n+        if self._is_bot_mentioned(body, source_content.get(\"formatted_body\")):\n+            body = self._strip_mention(body)\n+\n+        # Auto-thread: create a thread for non-DM, non-threaded messages.\n+        if not is_dm and not thread_id:\n+            auto_thread = os.getenv(\"MATRIX_AUTO_THREAD\", \"true\").lower() in (\"true\", \"1\", \"yes\")\n+            if auto_thread:\n+                thread_id = event.event_id\n+                self._track_thread(thread_id)\n+\n         # Reply-to detection.\n         reply_to = None\n         in_reply_to = relates_to.get(\"m.in_reply_to\", {})","@@ -946,6 +977,9 @@ async def _on_room_message(self, room: Any, event: Any) -> None:\n             reply_to_message_id=reply_to,\n         )\n \n+        if thread_id:\n+            self._track_thread(thread_id)\n+\n         await self.handle_message(msg_event)\n \n     async def _on_room_message_media(self, room: Any, event: Any) -> None:","@@ -1031,6 +1065,30 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n         if relates_to.get(\"rel_type\") == \"m.thread\":\n             thread_id = relates_to.get(\"event_id\")\n \n+        # Require-mention gating (media messages).\n+        if not is_dm:\n+            free_rooms_raw = os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\", \"\")\n+            free_rooms = {r.strip() for r in free_rooms_raw.split(\",\") if r.strip()}\n+            require_mention = os.getenv(\"MATRIX_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\")\n+            is_free_room = room.room_id in free_rooms\n+            in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)\n+\n+            if require_mention and not is_free_room and not in_bot_thread:\n+                formatted_body = source_content.get(\"formatted_body\")\n+                if not self._is_bot_mentioned(body, formatted_body):\n+                    return\n+\n+        # Strip mention from body when present (including in DMs).\n+        if self._is_bot_mentioned(body, source_content.get(\"formatted_body\")):\n+            body = self._strip_mention(body)\n+\n+        # Auto-thread: create a thread for non-DM, non-threaded messages.\n+        if not is_dm and not thread_id:\n+            auto_thread = os.getenv(\"MATRIX_AUTO_THREAD\", \"true\").lower() in (\"true\", \"1\", \"yes\")\n+            if auto_thread:\n+                thread_id = event.event_id\n+                self._track_thread(thread_id)\n+\n         # For voice messages, cache audio locally for transcription tools.\n         # Use the authenticated nio client to download (Matrix requires auth for media).\n         media_urls = [http_url] if http_url else None","@@ -1079,6 +1137,9 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None:\n             media_types=media_types,\n         )\n \n+        if thread_id:\n+            self._track_thread(thread_id)\n+\n         await self.handle_message(msg_event)\n \n     async def _on_invite(self, room: Any, event: Any) -> None:","@@ -1166,6 +1227,82 @@ async def _refresh_dm_cache(self) -> None:\n             for rid in self._joined_rooms\n         }\n \n+    # ------------------------------------------------------------------\n+    # Thread participation tracking\n+    # ------------------------------------------------------------------\n+\n+    @staticmethod\n+    def _thread_state_path() -> Path:\n+        \"\"\"Path to the persisted thread participation set.\"\"\"\n+        from hermes_cli.config import get_hermes_home\n+        return get_hermes_home() / \"matrix_threads.json\"\n+\n+    @classmethod\n+    def _load_participated_threads(cls) -> set:\n+        \"\"\"Load persisted thread IDs from disk.\"\"\"\n+        path = cls._thread_state_path()\n+        try:\n+            if path.exists():\n+                data = json.loads(path.read_text(encoding=\"utf-8\"))\n+                if isinstance(data, list):\n+                    return set(data)\n+        except Exception as e:\n+            logger.debug(\"Could not load matrix thread state: %s\", e)\n+        return set()\n+\n+    def _save_participated_threads(self) -> None:\n+        \"\"\"Persist the current thread set to disk (best-effort).\"\"\"\n+        path = self._thread_state_path()\n+        try:\n+            thread_list = list(self._bot_participated_threads)\n+            if len(thread_list) > self._MAX_TRACKED_THREADS:\n+                thread_list = thread_list[-self._MAX_TRACKED_THREADS:]\n+                self._bot_participated_threads = set(thread_list)\n+            path.parent.mkdir(parents=True, exist_ok=True)\n+            path.write_text(json.dumps(thread_list), encoding=\"utf-8\")\n+        except Exception as e:\n+            logger.debug(\"Could not save matrix thread state: %s\", e)\n+\n+    def _track_thread(self, thread_id: str) -> None:\n+        \"\"\"Add a thread to the participation set and persist.\"\"\"\n+        if thread_id not in self._bot_participated_threads:\n+            self._bot_participated_threads.add(thread_id)\n+            self._save_participated_threads()\n+\n+    # ------------------------------------------------------------------\n+    # Mention detection helpers\n+    # ------------------------------------------------------------------\n+\n+    def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool:\n+        \"\"\"Return True if the bot is mentioned in the message.\"\"\"\n+        if not body and not formatted_body:\n+            return False\n+        # Check for full @user:server in body\n+        if self._user_id and self._user_id in body:\n+            return True\n+        # Check for localpart with word boundaries (case-insensitive)\n+        if self._user_id and \":\" in self._user_id:\n+            localpart = self._user_id.split(\":\")[0].lstrip(\"@\")\n+            if localpart and re.search(r'\\b' + re.escape(localpart) + r'\\b', body, re.IGNORECASE):\n+                return True\n+        # Check formatted_body for Matrix pill\n+        if formatted_body and self._user_id:\n+            if f\"matrix.to/#/{self._user_id}\" in formatted_body:\n+                return True\n+        return False\n+\n+    def _strip_mention(self, body: str) -> str:\n+        \"\"\"Remove bot mention from message body.\"\"\"\n+        # Remove full @user:server\n+        if self._user_id:\n+            body = body.replace(self._user_id, \"\")\n+        # If still contains localpart mention, remove it\n+        if self._user_id and \":\" in self._user_id:\n+            localpart = self._user_id.split(\":\")[0].lstrip(\"@\")\n+            if localpart:\n+                body = re.sub(r'\\b' + re.escape(localpart) + r'\\b', '', body, flags=re.IGNORECASE)\n+        return body.strip()\n+\n     def _get_display_name(self, room: Any, user_id: str) -> str:\n         \"\"\"Get a user's display name in a room, falling back to user_id.\"\"\"\n         if room and hasattr(room, \"users\"):"]},{"file":"hermes_cli/config.py","hunks":["@@ -42,6 +42,7 @@\n     \"WHATSAPP_MODE\", \"WHATSAPP_ENABLED\",\n     \"MATTERMOST_HOME_CHANNEL\", \"MATTERMOST_REPLY_MODE\",\n     \"MATRIX_PASSWORD\", \"MATRIX_ENCRYPTION\", \"MATRIX_HOME_ROOM\",\n+    \"MATRIX_REQUIRE_MENTION\", \"MATRIX_FREE_RESPONSE_ROOMS\", \"MATRIX_AUTO_THREAD\",\n })\n import yaml\n ","@@ -1008,6 +1009,30 @@ def ensure_hermes_home():\n         \"password\": False,\n         \"category\": \"messaging\",\n     },\n+    \"MATRIX_REQUIRE_MENTION\": {\n+        \"description\": \"Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.\",\n+        \"prompt\": \"Require @mention in rooms (true/false)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"messaging\",\n+        \"advanced\": True,\n+    },\n+    \"MATRIX_FREE_RESPONSE_ROOMS\": {\n+        \"description\": \"Comma-separated Matrix room IDs where bot responds without @mention\",\n+        \"prompt\": \"Free-response room IDs (comma-separated)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"messaging\",\n+        \"advanced\": True,\n+    },\n+    \"MATRIX_AUTO_THREAD\": {\n+        \"description\": \"Auto-create threads for messages in Matrix rooms (default: true)\",\n+        \"prompt\": \"Auto-create threads in rooms (true/false)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"messaging\",\n+        \"advanced\": True,\n+    },\n     \"GATEWAY_ALLOW_ALL_USERS\": {\n         \"description\": \"Allow all users to interact with messaging bots (true/false). Default: false.\",\n         \"prompt\": \"Allow all users (true/false)\","]},{"file":"tests/gateway/test_matrix_mention.py","hunks":["@@ -0,0 +1,492 @@\n+\"\"\"Tests for Matrix require-mention gating and auto-thread features.\"\"\"\n+\n+import json\n+import sys\n+import time\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_nio_mock():\n+    \"\"\"Install a mock nio module when matrix-nio isn't available.\"\"\"\n+    if \"nio\" in sys.modules and hasattr(sys.modules[\"nio\"], \"__file__\"):\n+        return\n+    nio_mod = MagicMock()\n+    nio_mod.MegolmEvent = type(\"MegolmEvent\", (), {})\n+    nio_mod.RoomMessageText = type(\"RoomMessageText\", (), {})\n+    nio_mod.RoomMessageImage = type(\"RoomMessageImage\", (), {})\n+    nio_mod.RoomMessageAudio = type(\"RoomMessageAudio\", (), {})\n+    nio_mod.RoomMessageVideo = type(\"RoomMessageVideo\", (), {})\n+    nio_mod.RoomMessageFile = type(\"RoomMessageFile\", (), {})\n+    nio_mod.DownloadResponse = type(\"DownloadResponse\", (), {})\n+    nio_mod.MemoryDownloadResponse = type(\"MemoryDownloadResponse\", (), {})\n+    nio_mod.InviteMemberEvent = type(\"InviteMemberEvent\", (), {})\n+    sys.modules.setdefault(\"nio\", nio_mod)\n+\n+\n+_ensure_nio_mock()\n+\n+\n+def _make_adapter(tmp_path=None):\n+    \"\"\"Create a MatrixAdapter with mocked config.\"\"\"\n+    from gateway.platforms.matrix import MatrixAdapter\n+\n+    config = PlatformConfig(\n+        enabled=True,\n+        token=\"[redacted:secret-assignment]\",\n+        extra={\n+            \"homeserver\": \"https://matrix.example.org\",\n+            \"user_id\": \"@hermes:example.org\",\n+        },\n+    )\n+    adapter = MatrixAdapter(config)\n+    adapter.handle_message = AsyncMock()\n+    adapter._startup_ts = time.time() - 10  # avoid startup grace filter\n+    return adapter\n+\n+\n+def _make_room(room_id=\"!room1:example.org\", member_count=5, is_dm=False):\n+    \"\"\"Create a fake Matrix room.\"\"\"\n+    room = SimpleNamespace(\n+        room_id=room_id,\n+        member_count=member_count,\n+        users={},\n+    )\n+    return room\n+\n+\n+def _make_event(\n+    body,\n+    sender=\"@alice:example.org\",\n+    event_id=\"$evt1\",\n+    formatted_body=None,\n+    thread_id=None,\n+):\n+    \"\"\"Create a fake RoomMessageText event.\"\"\"\n+    content = {\"body\": body, \"msgtype\": \"m.text\"}\n+    if formatted_body:\n+        content[\"formatted_body\"] = formatted_body\n+        content[\"format\"] = \"org.matrix.custom.html\"\n+\n+    relates_to = {}\n+    if thread_id:\n+        relates_to[\"rel_type\"] = \"m.thread\"\n+        relates_to[\"event_id\"] = thread_id\n+    if relates_to:\n+        content[\"m.relates_to\"] = relates_to\n+\n+    return SimpleNamespace(\n+        sender=sender,\n+        event_id=event_id,\n+        server_timestamp=int(time.time() * 1000),\n+        body=body,\n+        source={\"content\": content},\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Mention detection helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestIsBotMentioned:\n+    def setup_method(self):\n+        self.adapter = _make_adapter()\n+\n+    def test_full_user_id_in_body(self):\n+        assert self.adapter._is_bot_mentioned(\"hey @hermes:example.org help\")\n+\n+    def test_localpart_in_body(self):\n+        assert self.adapter._is_bot_mentioned(\"hermes can you help?\")\n+\n+    def test_localpart_case_insensitive(self):\n+        assert self.adapter._is_bot_mentioned(\"HERMES can you help?\")\n+\n+    def test_matrix_pill_in_formatted_body(self):\n+        html = '<a href=\"https://matrix.to/#/@hermes:example.org\">Hermes</a> help'\n+        assert self.adapter._is_bot_mentioned(\"Hermes help\", html)\n+\n+    def test_no_mention(self):\n+        assert not self.adapter._is_bot_mentioned(\"hello everyone\")\n+\n+    def test_empty_body(self):\n+        assert not self.adapter._is_bot_mentioned(\"\")\n+\n+    def test_partial_localpart_no_match(self):\n+        # \"hermesbot\" should not match word-boundary check for \"hermes\"\n+        assert not self.adapter._is_bot_mentioned(\"hermesbot is here\")\n+\n+\n+class TestStripMention:\n+    def setup_method(self):\n+        self.adapter = _make_adapter()\n+\n+    def test_strip_full_user_id(self):\n+        result = self.adapter._strip_mention(\"@hermes:example.org help me\")\n+        assert result == \"help me\"\n+\n+    def test_strip_localpart(self):\n+        result = self.adapter._strip_mention(\"hermes help me\")\n+        assert result == \"help me\"\n+\n+    def test_strip_returns_empty_for_mention_only(self):\n+        result = self.adapter._strip_mention(\"@hermes:example.org\")\n+        assert result == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Require-mention gating in _on_room_message\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_require_mention_default_ignores_unmentioned(monkeypatch):\n+    \"\"\"Default (require_mention=true): messages without mention are ignored.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"hello everyone\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_require_mention_default_processes_mentioned(monkeypatch):\n+    \"\"\"Default: messages with mention are processed, mention stripped.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"@hermes:example.org help me\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"help me\"\n+\n+\n[redacted:email]\n+async def test_require_mention_html_pill(monkeypatch):\n+    \"\"\"Bot mentioned via HTML pill should be processed.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    formatted = '<a href=\"https://matrix.to/#/@hermes:example.org\">Hermes</a> help'\n+    event = _make_event(\"Hermes help\", formatted_body=formatted)\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_require_mention_dm_always_responds(monkeypatch):\n+    \"\"\"DMs always respond regardless of mention setting.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    # member_count=2 triggers DM detection\n+    room = _make_room(member_count=2)\n+    event = _make_event(\"hello without mention\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_dm_strips_mention(monkeypatch):\n+    \"\"\"DMs strip mention from body, matching Discord behavior.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room(member_count=2)\n+    event = _make_event(\"@hermes:example.org help me\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"help me\"\n+\n+\n[redacted:email]\n+async def test_bare_mention_passes_empty_string(monkeypatch):\n+    \"\"\"A message that is only a mention should pass through as empty, not be dropped.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"@hermes:example.org\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"\"\n+\n+\n[redacted:email]\n+async def test_require_mention_free_response_room(monkeypatch):\n+    \"\"\"Free-response rooms bypass mention requirement.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_FREE_RESPONSE_ROOMS\", \"!room1:example.org,!room2:example.org\")\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room(room_id=\"!room1:example.org\")\n+    event = _make_event(\"hello without mention\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_require_mention_bot_participated_thread(monkeypatch):\n+    \"\"\"Threads with prior bot participation bypass mention requirement.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    adapter._bot_participated_threads.add(\"$thread1\")\n+\n+    room = _make_room()\n+    event = _make_event(\"hello without mention\", thread_id=\"$thread1\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_require_mention_disabled(monkeypatch):\n+    \"\"\"MATRIX_REQUIRE_MENTION=false: all messages processed.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"hello without mention\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"hello without mention\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Auto-thread in _on_room_message\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_auto_thread_default_creates_thread(monkeypatch):\n+    \"\"\"Default (auto_thread=true): sets thread_id to event.event_id.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"hello\", event_id=\"$msg1\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.source.thread_id == \"$msg1\"\n+\n+\n[redacted:email]\n+async def test_auto_thread_preserves_existing_thread(monkeypatch):\n+    \"\"\"If message is already in a thread, thread_id is not overridden.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+    adapter = _make_adapter()\n+    adapter._bot_participated_threads.add(\"$thread_root\")\n+    room = _make_room()\n+    event = _make_event(\"reply in thread\", thread_id=\"$thread_root\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.source.thread_id == \"$thread_root\"\n+\n+\n[redacted:email]\n+async def test_auto_thread_skips_dm(monkeypatch):\n+    \"\"\"DMs should not get auto-threaded.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+    adapter = _make_adapter()\n+    room = _make_room(member_count=2)\n+    event = _make_event(\"hello dm\", event_id=\"$dm1\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.source.thread_id is None\n+\n+\n[redacted:email]\n+async def test_auto_thread_disabled(monkeypatch):\n+    \"\"\"MATRIX_AUTO_THREAD=false: thread_id stays None.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"hello\", event_id=\"$msg1\")\n+\n+    await adapter._on_room_message(room, event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.source.thread_id is None\n+\n+\n[redacted:email]\n+async def test_auto_thread_tracks_participation(monkeypatch):\n+    \"\"\"Auto-created threads are tracked in _bot_participated_threads.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+    adapter = _make_adapter()\n+    room = _make_room()\n+    event = _make_event(\"hello\", event_id=\"$msg1\")\n+\n+    with patch.object(adapter, \"_save_participated_threads\"):\n+        await adapter._on_room_message(room, event)\n+\n+    assert \"$msg1\" in adapter._bot_participated_threads\n+\n+\n+# ---------------------------------------------------------------------------\n+# Thread persistence\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestThreadPersistence:\n+    def test_empty_state_file(self, tmp_path, monkeypatch):\n+        \"\"\"No state file → empty set.\"\"\"\n+        monkeypatch.setattr(\n+            \"gateway.platforms.matrix.MatrixAdapter._thread_state_path\",\n+            staticmethod(lambda: tmp_path / \"matrix_threads.json\"),\n+        )\n+        adapter = _make_adapter()\n+        loaded = adapter._load_participated_threads()\n+        assert loaded == set()\n+\n+    def test_track_thread_persists(self, tmp_path, monkeypatch):\n+        \"\"\"_track_thread writes to disk.\"\"\"\n+        state_path = tmp_path / \"matrix_threads.json\"\n+        monkeypatch.setattr(\n+            \"gateway.platforms.matrix.MatrixAdapter._thread_state_path\",\n+            staticmethod(lambda: state_path),\n+        )\n+        adapter = _make_adapter()\n+        adapter._track_thread(\"$thread_abc\")\n+\n+        data = json.loads(state_path.read_text())\n+        assert \"$thread_abc\" in data\n+\n+    def test_threads_survive_reload(self, tmp_path, monkeypatch):\n+        \"\"\"Persisted threads are loaded by a new adapter instance.\"\"\"\n+        state_path = tmp_path / \"matrix_threads.json\"\n+        state_path.write_text(json.dumps([\"$t1\", \"$t2\"]))\n+        monkeypatch.setattr(\n+            \"gateway.platforms.matrix.MatrixAdapter._thread_state_path\",\n+            staticmethod(lambda: state_path),\n+        )\n+        adapter = _make_adapter()\n+        assert \"$t1\" in adapter._bot_participated_threads\n+        assert \"$t2\" in adapter._bot_participated_threads\n+\n+    def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):\n+        \"\"\"Thread set is trimmed to _MAX_TRACKED_THREADS.\"\"\"\n+        state_path = tmp_path / \"matrix_threads.json\"\n+        monkeypatch.setattr(\n+            \"gateway.platforms.matrix.MatrixAdapter._thread_state_path\",\n+            staticmethod(lambda: state_path),\n+        )\n+        adapter = _make_adapter()\n+        adapter._MAX_TRACKED_THREADS = 5\n+\n+        for i in range(10):\n+            adapter._bot_participated_threads.add(f\"$t{i}\")\n+        adapter._save_participated_threads()\n+\n+        data = json.loads(state_path.read_text())\n+        assert len(data) == 5\n+\n+\n+# ---------------------------------------------------------------------------\n+# YAML config bridge\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestMatrixConfigBridge:\n+    def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):\n+        \"\"\"Matrix YAML config should bridge to env vars.\"\"\"\n+        monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+        monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+        monkeypatch.delenv(\"MATRIX_AUTO_THREAD\", raising=False)\n+\n+        yaml_content = {\n+            \"matrix\": {\n+                \"require_mention\": False,\n+                \"free_response_rooms\": [\"!room1:example.org\", \"!room2:example.org\"],\n+                \"auto_thread\": False,\n+            }\n+        }\n+\n+        import os\n+        import yaml\n+\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(yaml.dump(yaml_content))\n+\n+        # Simulate the bridge logic from gateway/config.py\n+        yaml_cfg = yaml.safe_load(config_file.read_text())\n+        matrix_cfg = yaml_cfg.get(\"matrix\", {})\n+        if isinstance(matrix_cfg, dict):\n+            if \"require_mention\" in matrix_cfg and not os.getenv(\"MATRIX_REQUIRE_MENTION\"):\n+                monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower())\n+            frc = matrix_cfg.get(\"free_response_rooms\")\n+            if frc is not None and not os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\"):\n+                if isinstance(frc, list):\n+                    frc = \",\".join(str(v) for v in frc)\n+                monkeypatch.setenv(\"MATRIX_FREE_RESPONSE_ROOMS\", str(frc))\n+            if \"auto_thread\" in matrix_cfg and not os.getenv(\"MATRIX_AUTO_THREAD\"):\n+                monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", str(matrix_cfg[\"auto_thread\"]).lower())\n+\n+        assert os.getenv(\"MATRIX_REQUIRE_MENTION\") == \"false\"\n+        assert os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\") == \"!room1:example.org,!room2:example.org\"\n+        assert os.getenv(\"MATRIX_AUTO_THREAD\") == \"false\"\n+\n+    def test_env_vars_take_precedence_over_yaml(self, monkeypatch):\n+        \"\"\"Env vars should not be overwritten by YAML values.\"\"\"\n+        monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"true\")\n+\n+        import os\n+        yaml_cfg = {\"matrix\": {\"require_mention\": False}}\n+        matrix_cfg = yaml_cfg.get(\"matrix\", {})\n+        if \"require_mention\" in matrix_cfg and not os.getenv(\"MATRIX_REQUIRE_MENTION\"):\n+            monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower())\n+\n+        assert os.getenv(\"MATRIX_REQUIRE_MENTION\") == \"true\""]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -232,6 +232,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI\n | `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) |\n | `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) |\n | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) |\n+| `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. |\n+| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |\n+| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |\n | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |\n | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |\n | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |"]},{"file":"website/docs/user-guide/messaging/matrix.md","hunks":["@@ -17,8 +17,9 @@ Before setup, here's the part most people want to know: how Hermes behaves once\n | Context | Behavior |\n |---------|----------|\n | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |\n-| **Rooms** | Hermes responds to all messages in rooms it has joined. Room invites are auto-accepted. |\n-| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. |\n+| **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. |\n+| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |\n+| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. |\n | **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. |\n \n :::tip","@@ -51,6 +52,30 @@ Shared sessions can be useful for a collaborative room, but they also mean:\n - one person's long tool-heavy task can bloat everyone else's context\n - one person's in-flight run can interrupt another person's follow-up in the same room\n \n+### Mention and Threading Configuration\n+\n+You can configure mention and auto-threading behavior via environment variables or `config.yaml`:\n+\n+```yaml\n+matrix:\n+  require_mention: true           # Require @mention in rooms (default: true)\n+  free_response_rooms:            # Rooms exempt from mention requirement\n+    - \"!abc123:matrix.org\"\n+  auto_thread: true               # Auto-create threads for responses (default: true)\n+```\n+\n+Or via environment variables:\n+\n+```bash\n+MATRIX_REQUIRE_MENTION=true\n+MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org\n+MATRIX_AUTO_THREAD=true\n+```\n+\n+:::note\n+If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`.\n+:::\n+\n This guide walks you through the full setup process — from creating your bot account to sending your first message.\n \n ## Step 1: Create a Bot Account\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}