{"repo":"NousResearch/hermes-agent","public_id":"11430","display_label":"Per-user and per-chat memory scoping across components","summary":"These issues and PRs address propagating user identity through agents, gateways, APIs, and memory plugins to isolate long-term memory per user, per chat, or per session. Work spans bug fixes for identity bleed in multi-user contexts and features for opt-in scoping headers and store-level isolation.","subsystem":"agent","counts":{"members":24,"open_issues":6,"open_issues_distinct":6,"open_prs":4,"closed_issues":5,"merged_prs":1,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60745},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-16T23:48:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.060144927536231886},"fix_edges":[{"issue_number":61284,"pr_number":60745,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":64154,"pr_number":60745,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":11430,"title":"Feature: Per-user memory isolation in group chats to prevent identity contamination","url":null,"state":"open"},{"kind":"issue","number":14162,"title":"Feature: Per-conversation context isolation for multi-chat deployments","url":null,"state":"open"},{"kind":"issue","number":25084,"title":"[Feature]: Add force_user_id config to Mem0 plugin to prevent gateway namespace fragmentation","url":null,"state":"closed"},{"kind":"issue","number":27182,"title":"Per-user USER.md isolation for multi-user platform deployments","url":null,"state":"open"},{"kind":"issue","number":28279,"title":"Feature: Per-Chat Memory Isolation (Scoped Memory)","url":null,"state":"open"},{"kind":"issue","number":34605,"title":"Feature request: opt-in X-Hermes-User-Id header to scope per-user long-term memory over the HTTP API","url":null,"state":"open"},{"kind":"issue","number":35408,"title":"[Feature]: Dashboard authenticated user identity should propagate to the agent session","url":null,"state":"closed"},{"kind":"issue","number":42321,"title":"TUI: AIAgent missing user_id — Hindsight bank_id_template renders empty user segment","url":null,"state":"closed"},{"kind":"issue","number":52900,"title":"[Bug]: Cross-user long-term memory bleed on multi-user messaging platforms","url":null,"state":"closed"},{"kind":"issue","number":62549,"title":"[Bug]: Dashboard auth identity (`Session.user_id`) never reaches agent construction — memory providers silently fall back to the default user, breaking multi-user isolation","url":null,"state":"open"},{"kind":"issue","number":64340,"title":"mem0 plugin: group chat memory stored under wrong user_id due to agent cache reuse","url":null,"state":"closed"},{"kind":"pr","number":11452,"title":"feat: per-user memory isolation in group chats (#11430)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":27183,"title":"Add per-user USER.md isolation in MemoryStore","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23784","number":23784}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":34609,"title":"feat(gateway): opt-in X-Hermes-User-Id header for per-user memory scope","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42322,"title":"fix(cli): pass user_id to AIAgent for correct Hindsight bank_id_template rendering","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":52903,"title":"fix(memory): isolate long-term memory per user and per group","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8a384628a","number":0},{"kind":"commit","value":"6bd43111d","number":0},{"kind":"pr","value":"#4845","number":4845},{"kind":"pr","value":"#29006","number":29006},{"kind":"pr","value":"#4845","number":4845}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":60458,"title":"Propagate dashboard auth identity to TUI agents","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60745,"title":"fix(dashboard): scope chat attach tokens by session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/test_pty_keepalive_ws.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -15149,7 +15149,8 @@ async def pty_ws(ws: WebSocket) -> None:\n         return\n \n     # --- spawn PTY ------------------------------------------------------\n-    resume = ws.query_params.get(\"resume\") or None\n+    raw_resume = ws.query_params.get(\"resume\") or None\n+    resume = raw_resume\n     profile = ws.query_params.get(\"profile\") or None\n     channel = _channel_or_close_code(ws)\n     sidecar_url = _build_sidecar_url(channel) if channel else None","@@ -15192,6 +15193,12 @@ async def pty_ws(ws: WebSocket) -> None:\n \n \n     attach_token = [redacted:secret-assignment]\"attach\") or None\n+    registry_resume = raw_resume\n+    if raw_resume and env:\n+        registry_resume = env.get(\"HERMES_TUI_RESUME\") or raw_resume\n+    if attach_token is not None and (registry_resume or profile):\n+        # Key explicit resumes on their canonical target, never the active-session fallback.\n+        attach_token = f\"{attach_token}\\0{profile or ''}\\0{registry_resume or ''}\"\n \n     def _spawn():\n         return PtyBridge.spawn(argv, cwd=cwd, env=env)"]},{"file":"tests/test_pty_keepalive_ws.py","hunks":["@@ -1,53 +1,136 @@\n+import json\n+\n import pytest\n \n from hermes_cli import web_server\n \n \n[redacted:email]\n-async def test_attach_token_reuses_same_session(monkeypatch):\n-    \"\"\"Two connects with the same ?attach= token hit one spawned bridge.\"\"\"\n-    spawned = []\n+class FakeBridge:\n+    def __init__(self):\n+        self.alive = True\n+\n+    def read(self, timeout):\n+        return b\"\"        # idle forever\n \n-    class FakeBridge:\n-        def __init__(self):\n-            self.alive = True\n+    def write(self, data):\n+        pass\n \n-        def read(self, timeout):\n-            return b\"\"        # idle forever\n+    def resize(self, cols, rows):\n+        pass\n \n-        def write(self, data):\n-            pass\n+    def close(self):\n+        self.alive = False\n \n-        def resize(self, cols, rows):\n-            pass\n \n-        def close(self):\n-            self.alive = False\n[redacted:email]\n+def pty_keepalive_harness(monkeypatch):\n+    spawned = []\n \n     def fake_spawn(argv, cwd=None, env=None):\n         b = FakeBridge()\n-        spawned.append(b)\n+        spawned.append(argv)\n         return b\n \n     monkeypatch.setattr(web_server.PtyBridge, \"spawn\", staticmethod(fake_spawn))\n-    # bypass auth + argv resolution for the test\n     monkeypatch.setattr(web_server, \"_ws_auth_reason\", lambda ws: (None, \"test\"))\n     monkeypatch.setattr(web_server, \"_ws_host_origin_reason\", lambda ws: None)\n     monkeypatch.setattr(web_server, \"_ws_client_reason\", lambda ws: None)\n \n     async def fake_argv(**kw):\n-        return ([\"x\"], \"/tmp\", {})\n+        resume = \"child\" if kw.get(\"resume\") == \"parent\" else kw.get(\"resume\")\n+        env = {\"HERMES_TUI_RESUME\": resume} if resume else {}\n+        return ([\"x\", resume or \"fresh\"], \"/tmp\", env)\n \n     monkeypatch.setattr(web_server, \"_resolve_chat_argv_async\", fake_argv)\n \n-    from starlette.testclient import TestClient\n-\n     try:\n-        client = TestClient(web_server.app)\n-        with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws1:\n-            ws1.send_bytes(b\"hi\")\n-        with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws2:\n-            ws2.send_bytes(b\"again\")\n-        assert len(spawned) == 1                # reattached, did not respawn\n+        yield spawned\n     finally:\n         web_server.PTY_REGISTRY._sessions.clear()\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_same_session(pty_keepalive_harness):\n+    \"\"\"Two connects with the same ?attach= token hit one spawned bridge.\"\"\"\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert len(pty_keepalive_harness) == 1                # reattached, did not respawn\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_same_resume(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=same\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=same\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"same\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_does_not_reuse_different_resume(pty_keepalive_harness):\n+    \"\"\"A keep-alive token must not pin /chat to an old selected session.\"\"\"\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=old\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=new\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"old\"], [\"x\", \"new\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_canonical_resume(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=parent\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&resume=child\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert pty_keepalive_harness == [[\"x\", \"child\"]]\n+\n+\n[redacted:email]\n+async def test_attach_token_does_not_reuse_different_profile(pty_keepalive_harness):\n+    from starlette.testclient import TestClient\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&profile=alpha\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&profile=beta\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+    assert len(pty_keepalive_harness) == 2\n+\n+\n[redacted:email]\n+async def test_attach_token_reuses_default_chat_after_active_session_fallback(\n+    pty_keepalive_harness, tmp_path, monkeypatch\n+):\n+    from starlette.testclient import TestClient\n+\n+    active_session_file = tmp_path / \"active-session.json\"\n+    monkeypatch.setattr(\n+        web_server,\n+        \"_active_session_file_for_channel\",\n+        lambda app, channel: active_session_file,\n+    )\n+\n+    client = TestClient(web_server.app)\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&channel=CHAT\") as ws1:\n+        ws1.send_bytes(b\"hi\")\n+\n+    active_session_file.write_text(json.dumps({\"session_id\": \"existing\"}))\n+\n+    with client.websocket_connect(\"/api/pty?attach=TOK1&channel=CHAT\") as ws2:\n+        ws2.send_bytes(b\"again\")\n+\n+    assert pty_keepalive_harness == [[\"x\", \"fresh\"]]\n"]}]},{"kind":"pr","number":62563,"title":"fix: propagate dashboard auth user identity to agent construction (#62549)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62549","number":62549},{"kind":"pr","value":"#60458","number":60458},{"kind":"pr","value":"#62563","number":62563}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62629,"title":"fix(dashboard): propagate authenticated user_id through PTY spawn to agent (#62549)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62563","number":62563},{"kind":"pr","value":"#62549","number":62549},{"kind":"pr","value":"#62563","number":62563},{"kind":"pr","value":"#62549","number":62549},{"kind":"pr","value":"#60458","number":60458},{"kind":"pr","value":"#62563","number":62563},{"kind":"pr","value":"#60458","number":60458}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62877,"title":"fix(dashboard): propagate auth identity to agent construction","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63217,"title":"fix(dashboard): propagate auth user identity to agent construction (#62549)","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":64398,"title":"fix(mem0): use per-turn user identity for group chat memory (#64340)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"46e87b14f","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":71224,"title":"feat(memory): per-chat memory isolation via optional entry-level `scope` (#28279)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"924bc67ee","number":0},{"kind":"commit","value":"9ca954a27","number":0},{"kind":"commit","value":"83ffe4dfa321","number":0},{"kind":"pr","value":"#2933","number":2933},{"kind":"pr","value":"#60462","number":60462},{"kind":"pr","value":"#71224","number":71224},{"kind":"pr","value":"#2933","number":2933},{"kind":"pr","value":"#2942","number":2942},{"kind":"pr","value":"#4623","number":4623},{"kind":"pr","value":"#5423","number":5423},{"kind":"pr","value":"#60462","number":60462},{"kind":"pr","value":"#60462","number":60462},{"kind":"pr","value":"#71224","number":71224},{"kind":"pr","value":"#28279","number":28279},{"kind":"pr","value":"#71224","number":71224},{"kind":"pr","value":"#2933","number":2933},{"kind":"pr","value":"#60462","number":60462},{"kind":"pr","value":"#71224","number":71224},{"kind":"pr","value":"#60462","number":60462},{"kind":"pr","value":"#71224","number":71224},{"kind":"pr","value":"#2933","number":2933},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#28279","number":28279},{"kind":"pr","value":"#71224","number":71224}],"category":"partial_fix","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}