{"repo":"NousResearch/hermes-agent","public_id":"6602","display_label":"Hindsight plugin transcript duplication and session bugs","summary":"Multiple reports and proposed fixes around the Hindsight plugin resending full session transcripts on every retain in append mode, along with related session-switch and document-scoping issues.","subsystem":"agent","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15079},"resolution":{"state":"partially_resolved","by":"pr-15079"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T00:41:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17155555555555554},"fix_edges":[{"issue_number":6602,"pr_number":15079,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23724,"pr_number":40605,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":40503,"pr_number":40605,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6602,"title":"[Bug]: Hindsight memory overwrites/loses old session data on /resume","url":null,"state":"closed"},{"kind":"issue","number":23724,"title":"Hindsight plugin: sync_turn resends full session transcript on every retain, duplicating content with update_mode=append","url":null,"state":"closed"},{"kind":"issue","number":42821,"title":"hindsight: per-turn auto-retain re-sends full session transcript on update_mode=append (O(n²) extraction)","url":null,"state":"closed"},{"kind":"pr","number":6654,"title":"fix(hindsight): scope document_id per process + optional bank_id_template","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":15079,"title":"fix(hindsight): scope document_id per process + optional bank_id_template","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/hindsight/README.md","plugins/memory/hindsight/__init__.py","tests/plugins/memory/test_hindsight_provider.py"],"fix_hunks":[{"file":"plugins/memory/hindsight/README.md","hunks":["@@ -59,7 +59,8 @@ Config file: `~/.hermes/hindsight/config.json`\n \n | Key | Default | Description |\n |-----|---------|-------------|\n-| `bank_id` | `hermes` | Memory bank name |\n+| `bank_id` | `hermes` | Memory bank name (static fallback used when `bank_id_template` is unset or resolves empty) |\n+| `bank_id_template` | — | Optional template to derive the bank name dynamically. Placeholders: `{profile}`, `{workspace}`, `{platform}`, `{user}`, `{session}`. Example: `hermes-{profile}` isolates memory per active Hermes profile. Empty placeholders collapse cleanly (e.g. `hermes-{user}` with no user becomes `hermes`). |\n | `bank_mission` | — | Reflect mission (identity/framing for reflect reasoning). Applied via Banks API. |\n | `bank_retain_mission` | — | Retain mission (steers what gets extracted). Applied via Banks API. |\n "]},{"file":"plugins/memory/hindsight/__init__.py","hunks":["@@ -324,6 +324,60 @@ def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: st\n     )\n     return profile_env\n \n+def _sanitize_bank_segment(value: str) -> str:\n+    \"\"\"Sanitize a bank_id_template placeholder value.\n+\n+    Bank IDs should be safe for URL paths and filesystem use. Replaces any\n+    character that isn't alphanumeric, dash, or underscore with a dash, and\n+    collapses runs of dashes.\n+    \"\"\"\n+    if not value:\n+        return \"\"\n+    out = []\n+    prev_dash = False\n+    for ch in str(value):\n+        if ch.isalnum() or ch == \"-\" or ch == \"_\":\n+            out.append(ch)\n+            prev_dash = False\n+        else:\n+            if not prev_dash:\n+                out.append(\"-\")\n+                prev_dash = True\n+    return \"\".join(out).strip(\"-_\")\n+\n+\n+def _resolve_bank_id_template(template: str, fallback: str, **placeholders: str) -> str:\n+    \"\"\"Resolve a bank_id template string with the given placeholders.\n+\n+    Supported placeholders (each is sanitized before substitution):\n+      {profile}   — active Hermes profile name (from agent_identity)\n+      {workspace} — Hermes workspace name (from agent_workspace)\n+      {platform}  — \"cli\", \"telegram\", \"discord\", etc.\n+      {user}      — platform user id (gateway sessions)\n+      {session}   — current session id\n+\n+    Missing/empty placeholders are rendered as the empty string and then\n+    collapsed — e.g. ``hermes-{user}`` with no user becomes ``hermes``.\n+\n+    If the template is empty, resolution falls back to *fallback*.\n+    Returns the sanitized bank id.\n+    \"\"\"\n+    if not template:\n+        return fallback\n+    sanitized = {k: _sanitize_bank_segment(v) for k, v in placeholders.items()}\n+    try:\n+        rendered = template.format(**sanitized)\n+    except (KeyError, IndexError) as exc:\n+        logger.warning(\"Invalid bank_id_template %r: %s — using fallback %r\",\n+                       template, exc, fallback)\n+        return fallback\n+    while \"--\" in rendered:\n+        rendered = rendered.replace(\"--\", \"-\")\n+    while \"__\" in rendered:\n+        rendered = rendered.replace(\"__\", \"_\")\n+    rendered = rendered.strip(\"-_\")\n+    return rendered or fallback\n+\n \n # ---------------------------------------------------------------------------\n # MemoryProvider implementation","@@ -354,6 +408,7 @@ def __init__(self):\n         self._chat_type = \"\"\n         self._thread_id = \"\"\n         self._agent_identity = \"\"\n+        self._agent_workspace = \"\"\n         self._turn_index = 0\n         self._client = None\n         self._timeout = _DEFAULT_TIMEOUT","@@ -362,6 +417,8 @@ def __init__(self):\n         self._prefetch_thread = None\n         self._sync_thread = None\n         self._session_id = \"\"\n+        self._parent_session_id = \"\"\n+        self._document_id = \"\"\n \n         # Tags\n         self._tags: list[str] | None = None","@@ -386,6 +443,7 @@ def __init__(self):\n         # Bank\n         self._bank_mission = \"\"\n         self._bank_retain_mission: str | None = None\n+        self._bank_id_template = \"\"\n \n     @property\n     def name(self) -> str:","@@ -613,7 +671,8 @@ def get_config_schema(self):\n             {\"key\": \"llm_base_url\", \"description\": \"Endpoint URL (e.g. http://192.168.1.10:8080/v1)\", \"default\": \"\", \"when\": {\"mode\": \"local_embedded\", \"llm_provider\": \"openai_compatible\"}},\n             {\"key\": \"llm_api_key\", \"description\": \"LLM API key (optional for openai_compatible)\", \"secret\": True, \"env_var\": \"HINDSIGHT_LLM_API_KEY\", \"when\": {\"mode\": \"local_embedded\"}},\n             {\"key\": \"llm_model\", \"description\": \"LLM model\", \"default\": \"gpt-4o-mini\", \"default_from\": {\"field\": \"llm_provider\", \"map\": _PROVIDER_DEFAULT_MODELS}, \"when\": {\"mode\": \"local_embedded\"}},\n-            {\"key\": \"bank_id\", \"description\": \"Memory bank name\", \"default\": \"hermes\"},\n+            {\"key\": \"bank_id\", \"description\": \"Memory bank name (static fallback when bank_id_template is unset)\", \"default\": \"hermes\"},\n+            {\"key\": \"bank_id_template\", \"description\": \"Optional template to derive bank_id dynamically. Placeholders: {profile}, {workspace}, {platform}, {user}, {session}. Example: hermes-{profile}\", \"default\": \"\"},\n             {\"key\": \"bank_mission\", \"description\": \"Mission/purpose description for the memory bank\"},\n             {\"key\": \"bank_retain_mission\", \"description\": \"Custom extraction prompt for memory retention\"},\n             {\"key\": \"recall_budget\", \"description\": \"Recall thoroughness\", \"default\": \"mid\", \"choices\": [\"low\", \"mid\", \"high\"]},","@@ -679,6 +738,15 @@ def _run_sync(self, coro):\n \n     def initialize(self, session_id: str, **kwargs) -> None:\n         self._session_id = str(session_id or \"\").strip()\n+        self._parent_session_id = str(kwargs.get(\"parent_session_id\", \"\") or \"\").strip()\n+\n+        # Each process lifecycle gets its own document_id. Reusing session_id\n+        # alone caused overwrites on /resume — the reloaded session starts\n+        # with an empty _session_turns, so the next retain would replace the\n+        # previously stored content. session_id stays in tags so processes\n+        # for the same session remain filterable together.\n+        start_ts = datetime.now().strftime(\"%Y%m%d_%H%M%S_%f\")\n+        self._document_id = f\"{self._session_id}-{start_ts}\"\n \n         # Check client version and auto-upgrade if needed\n         try:","@@ -717,6 +785,7 @@ def initialize(self, session_id: str, **kwargs) -> None:\n         self._chat_type = str(kwargs.get(\"chat_type\") or \"\").strip()\n         self._thread_id = str(kwargs.get(\"thread_id\") or \"\").strip()\n         self._agent_identity = str(kwargs.get(\"agent_identity\") or \"\").strip()\n+        self._agent_workspace = str(kwargs.get(\"agent_workspace\") or \"\").strip()\n         self._turn_index = 0\n         self._session_turns = []\n         self._mode = self._config.get(\"mode\", \"cloud\")","@@ -740,7 +809,17 @@ def initialize(self, session_id: str, **kwargs) -> None:\n         self._llm_base_url = self._config.get(\"llm_base_url\", \"\")\n \n         banks = self._config.get(\"banks\", {}).get(\"hermes\", {})\n-        self._bank_id = self._config.get(\"bank_id\") or banks.get(\"bankId\", \"hermes\")\n+        static_bank_id = self._config.get(\"bank_id\") or banks.get(\"bankId\", \"hermes\")\n+        self._bank_id_template = self._config.get(\"bank_id_template\", \"\") or \"\"\n+        self._bank_id = _resolve_bank_id_template(\n+            self._bank_id_template,\n+            fallback=static_bank_id,\n+            profile=self._agent_identity,\n+            workspace=self._agent_workspace,\n+            platform=self._platform,\n+            user=self._user_id,\n+            session=self._session_id,\n+        )\n         budget = self._config.get(\"recall_budget\") or self._config.get(\"budget\") or banks.get(\"budget\", \"mid\")\n         self._budget = budget if budget in _VALID_BUDGETS else \"mid\"\n ","@@ -793,6 +872,10 @@ def initialize(self, session_id: str, **kwargs) -> None:\n             pass\n         logger.info(\"Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s\",\n                      self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version)\n+        if self._bank_id_template:\n+            logger.debug(\"Hindsight bank resolved from template %r: profile=%s workspace=%s platform=%s user=%s -> bank=%s\",\n+                         self._bank_id_template, self._agent_identity, self._agent_workspace,\n+                         self._platform, self._user_id, self._bank_id)\n         logger.debug(\"Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, \"\n                      \"retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s\",\n                      self._auto_retain, self._auto_recall, self._retain_every_n_turns,","@@ -1026,6 +1109,12 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n                      len(self._session_turns), sum(len(t) for t in self._session_turns))\n         content = \"[\" + \",\".join(self._session_turns) + \"]\"\n \n+        lineage_tags: list[str] = []\n+        if self._session_id:\n+            lineage_tags.append(f\"session:{self._session_id}\")\n+        if self._parent_session_id:\n+            lineage_tags.append(f\"parent:{self._parent_session_id}\")\n+\n         def _sync():\n             try:\n                 client = self._get_client()","@@ -1036,15 +1125,16 @@ def _sync():\n                         message_count=len(self._session_turns) * 2,\n                         turn_index=self._turn_index,\n                     ),\n+                    tags=lineage_tags or None,\n                 )\n                 item.pop(\"bank_id\", None)\n                 item.pop(\"retain_async\", None)\n                 logger.debug(\"Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d\",\n-                             self._bank_id, self._session_id, self._retain_async, len(content), len(self._session_turns))\n+                             self._bank_id, self._document_id, self._retain_async, len(content), len(self._session_turns))\n                 self._run_sync(client.aretain_batch(\n                     bank_id=self._bank_id,\n                     items=[item],\n-                    document_id=self._session_id,\n+                    document_id=self._document_id,\n                     retain_async=self._retain_async,\n                 ))\n                 logger.debug(\"Hindsight retain succeeded\")"]},{"file":"tests/plugins/memory/test_hindsight_provider.py","hunks":["@@ -19,6 +19,8 @@\n     RETAIN_SCHEMA,\n     _load_config,\n     _normalize_retain_tags,\n+    _resolve_bank_id_template,\n+    _sanitize_bank_segment,\n )\n \n ","@@ -549,12 +551,12 @@ def test_sync_turn_retains_metadata_rich_turn(self, provider_with_config):\n         p._client.aretain_batch.assert_called_once()\n         call_kwargs = p._client.aretain_batch.call_args.kwargs\n         assert call_kwargs[\"bank_id\"] == \"test-bank\"\n-        assert call_kwargs[\"document_id\"] == \"session-1\"\n+        assert call_kwargs[\"document_id\"].startswith(\"session-1-\")\n         assert call_kwargs[\"retain_async\"] is True\n         assert len(call_kwargs[\"items\"]) == 1\n         item = call_kwargs[\"items\"][0]\n         assert item[\"context\"] == \"conversation between Hermes Agent and the User\"\n-        assert item[\"tags\"] == [\"conv\", \"session1\"]\n+        assert item[\"tags\"] == [\"conv\", \"session1\", \"session:session-1\"]\n         content = json.loads(item[\"content\"])\n         assert len(content) == 1\n         assert content[0][0][\"role\"] == \"user\"","@@ -582,6 +584,36 @@ def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config):\n         assert p._sync_thread is None\n         p._client.aretain_batch.assert_not_called()\n \n+    def test_sync_turn_with_tags(self, provider_with_config):\n+        p = provider_with_config(retain_tags=[\"conv\", \"session1\"])\n+        p.sync_turn(\"hello\", \"hi\")\n+        if p._sync_thread:\n+            p._sync_thread.join(timeout=5.0)\n+        item = p._client.aretain_batch.call_args.kwargs[\"items\"][0]\n+        assert \"conv\" in item[\"tags\"]\n+        assert \"session1\" in item[\"tags\"]\n+        assert \"session:test-session\" in item[\"tags\"]\n+\n+    def test_sync_turn_uses_aretain_batch(self, provider):\n+        \"\"\"sync_turn should use aretain_batch with retain_async.\"\"\"\n+        provider.sync_turn(\"hello\", \"hi\")\n+        if provider._sync_thread:\n+            provider._sync_thread.join(timeout=5.0)\n+        provider._client.aretain_batch.assert_called_once()\n+        call_kwargs = provider._client.aretain_batch.call_args.kwargs\n+        assert call_kwargs[\"document_id\"].startswith(\"test-session-\")\n+        assert call_kwargs[\"retain_async\"] is True\n+        assert len(call_kwargs[\"items\"]) == 1\n+        assert call_kwargs[\"items\"][0][\"context\"] == \"conversation between Hermes Agent and the User\"\n+\n+    def test_sync_turn_custom_context(self, provider_with_config):\n+        p = provider_with_config(retain_context=\"my-agent\")\n+        p.sync_turn(\"hello\", \"hi\")\n+        if p._sync_thread:\n+            p._sync_thread.join(timeout=5.0)\n+        item = p._client.aretain_batch.call_args.kwargs[\"items\"][0]\n+        assert item[\"context\"] == \"my-agent\"\n+\n     def test_sync_turn_every_n_turns(self, provider_with_config):\n         p = provider_with_config(retain_every_n_turns=3, retain_async=False)\n         p.sync_turn(\"turn1-user\", \"turn1-asst\")","@@ -592,7 +624,7 @@ def test_sync_turn_every_n_turns(self, provider_with_config):\n         p._sync_thread.join(timeout=5.0)\n         p._client.aretain_batch.assert_called_once()\n         call_kwargs = p._client.aretain_batch.call_args.kwargs\n-        assert call_kwargs[\"document_id\"] == \"test-session\"\n+        assert call_kwargs[\"document_id\"].startswith(\"test-session-\")\n         assert call_kwargs[\"retain_async\"] is False\n         item = call_kwargs[\"items\"][0]\n         content = json.loads(item[\"content\"])","@@ -604,6 +636,95 @@ def test_sync_turn_every_n_turns(self, provider_with_config):\n         assert item[\"metadata\"][\"turn_index\"] == \"3\"\n         assert item[\"metadata\"][\"message_count\"] == \"6\"\n \n+    def test_sync_turn_accumulates_full_session(self, provider_with_config):\n+        \"\"\"Each retain sends the ENTIRE session, not just the latest batch.\"\"\"\n+        p = provider_with_config(retain_every_n_turns=2)\n+\n+        p.sync_turn(\"turn1-user\", \"turn1-asst\")\n+        p.sync_turn(\"turn2-user\", \"turn2-asst\")\n+        if p._sync_thread:\n+            p._sync_thread.join(timeout=5.0)\n+\n+        p._client.aretain_batch.reset_mock()\n+\n+        p.sync_turn(\"turn3-user\", \"turn3-asst\")\n+        p.sync_turn(\"turn4-user\", \"turn4-asst\")\n+        if p._sync_thread:\n+            p._sync_thread.join(timeout=5.0)\n+\n+        content = p._client.aretain_batch.call_args.kwargs[\"items\"][0][\"content\"]\n+        # Should contain ALL turns from the session\n+        assert \"turn1-user\" in content\n+        assert \"turn2-user\" in content\n+        assert \"turn3-user\" in content\n+        assert \"turn4-user\" in content\n+\n+    def test_sync_turn_passes_document_id(self, provider):\n+        \"\"\"sync_turn should pass document_id (session_id + per-startup ts).\"\"\"\n+        provider.sync_turn(\"hello\", \"hi\")\n+        if provider._sync_thread:\n+            provider._sync_thread.join(timeout=5.0)\n+        call_kwargs = provider._client.aretain_batch.call_args.kwargs\n+        # Format: {session_id}-{YYYYMMDD_HHMMSS_microseconds}\n+        assert call_kwargs[\"document_id\"].startswith(\"test-session-\")\n+        assert call_kwargs[\"document_id\"] == provider._document_id\n+\n+    def test_resume_creates_new_document(self, tmp_path, monkeypatch):\n+        \"\"\"Resuming a session (re-initializing) gets a new document_id\n+        so previously stored content is not overwritten.\"\"\"\n+        config = {\"mode\": \"cloud\", \"apiKey\": \"k\", \"api_url\": \"http://x\", \"bank_id\": \"b\"}\n+        config_path = tmp_path / \"hindsight\" / \"config.json\"\n+        config_path.parent.mkdir(parents=True, exist_ok=True)\n+        config_path.write_text(json.dumps(config))\n+        monkeypatch.setattr(\"plugins.memory.hindsight.get_hermes_home\", lambda: tmp_path)\n+\n+        p1 = HindsightMemoryProvider()\n+        p1.initialize(session_id=\"resumed-session\", hermes_home=str(tmp_path), platform=\"cli\")\n+\n+        # Sleep just enough that the microsecond timestamp differs\n+        import time\n+        time.sleep(0.001)\n+\n+        p2 = HindsightMemoryProvider()\n+        p2.initialize(session_id=\"resumed-session\", hermes_home=str(tmp_path), platform=\"cli\")\n+\n+        # Same session, but each process gets its own document_id\n+        assert p1._document_id != p2._document_id\n+        assert p1._document_id.startswith(\"resumed-session-\")\n+        assert p2._document_id.startswith(\"resumed-session-\")\n+\n+    def test_sync_turn_session_tag(self, provider):\n+        \"\"\"Each retain should be tagged with session:<id> for filtering.\"\"\"\n+        provider.sync_turn(\"hello\", \"hi\")\n+        if provider._sync_thread:\n+            provider._sync_thread.join(timeout=5.0)\n+        item = provider._client.aretain_batch.call_args.kwargs[\"items\"][0]\n+        assert \"session:test-session\" in item[\"tags\"]\n+\n+    def test_sync_turn_parent_session_tag(self, tmp_path, monkeypatch):\n+        \"\"\"When initialized with parent_session_id, parent tag is added.\"\"\"\n+        config = {\"mode\": \"cloud\", \"apiKey\": \"k\", \"api_url\": \"http://x\", \"bank_id\": \"b\"}\n+        config_path = tmp_path / \"hindsight\" / \"config.json\"\n+        config_path.parent.mkdir(parents=True, exist_ok=True)\n+        config_path.write_text(json.dumps(config))\n+        monkeypatch.setattr(\"plugins.memory.hindsight.get_hermes_home\", lambda: tmp_path)\n+\n+        p = HindsightMemoryProvider()\n+        p.initialize(\n+            session_id=\"child-session\",\n+            hermes_home=str(tmp_path),\n+            platform=\"cli\",\n+            parent_session_id=\"parent-session\",\n+        )\n+        p._client = _make_mock_client()\n+        p.sync_turn(\"hello\", \"hi\")\n+        if p._sync_thread:\n+            p._sync_thread.join(timeout=5.0)\n+\n+        item = p._client.aretain_batch.call_args.kwargs[\"items\"][0]\n+        assert \"session:child-session\" in item[\"tags\"]\n+        assert \"parent:parent-session\" in item[\"tags\"]\n+\n     def test_sync_turn_error_does_not_raise(self, provider):\n         provider._client.aretain_batch.side_effect = RuntimeError(\"network error\")\n         provider.sync_turn(\"hello\", \"hi\")","@@ -663,7 +784,7 @@ def test_schema_has_all_new_fields(self, provider):\n         keys = {f[\"key\"] for f in schema}\n         expected_keys = {\n             \"mode\", \"api_url\", \"api_key\", \"llm_provider\", \"llm_api_key\",\n-            \"llm_model\", \"bank_id\", \"bank_mission\", \"bank_retain_mission\",\n+            \"llm_model\", \"bank_id\", \"bank_id_template\", \"bank_mission\", \"bank_retain_mission\",\n             \"recall_budget\", \"memory_mode\", \"recall_prefetch_method\",\n             \"retain_tags\", \"retain_source\",\n             \"retain_user_prefix\", \"retain_assistant_prefix\",","@@ -676,6 +797,150 @@ def test_schema_has_all_new_fields(self, provider):\n         assert expected_keys.issubset(keys), f\"Missing: {expected_keys - keys}\"\n \n \n+# ---------------------------------------------------------------------------\n+# bank_id_template tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBankIdTemplate:\n+    def test_sanitize_bank_segment_passthrough(self):\n+        assert _sanitize_bank_segment(\"hermes\") == \"hermes\"\n+        assert _sanitize_bank_segment(\"my-agent_1\") == \"my-agent_1\"\n+\n+    def test_sanitize_bank_segment_strips_unsafe(self):\n+        assert _sanitize_bank_segment(\"[redacted:email]\") == \"josh-example-com\"\n+        assert _sanitize_bank_segment(\"chat:#general\") == \"chat-general\"\n+        assert _sanitize_bank_segment(\"  spaces  \") == \"spaces\"\n+\n+    def test_sanitize_bank_segment_empty(self):\n+        assert _sanitize_bank_segment(\"\") == \"\"\n+        assert _sanitize_bank_segment(None) == \"\"\n+\n+    def test_resolve_empty_template_uses_fallback(self):\n+        result = _resolve_bank_id_template(\n+            \"\", fallback=\"hermes\", profile=\"coder\"\n+        )\n+        assert result == \"hermes\"\n+\n+    def test_resolve_with_profile(self):\n+        result = _resolve_bank_id_template(\n+            \"hermes-{profile}\", fallback=\"hermes\",\n+            profile=\"coder\", workspace=\"\", platform=\"\", user=\"\", session=\"\",\n+        )\n+        assert result == \"hermes-coder\"\n+\n+    def test_resolve_with_multiple_placeholders(self):\n+        result = _resolve_bank_id_template(\n+            \"{workspace}-{profile}-{platform}\",\n+            fallback=\"hermes\",\n+            profile=\"coder\", workspace=\"myorg\", platform=\"cli\",\n+            user=\"\", session=\"\",\n+        )\n+        assert result == \"myorg-coder-cli\"\n+\n+    def test_resolve_collapses_empty_placeholders(self):\n+        # When user is empty, \"hermes-{user}\" becomes \"hermes-\" -> trimmed to \"hermes\"\n+        result = _resolve_bank_id_template(\n+            \"hermes-{user}\", fallback=\"default\",\n+            profile=\"\", workspace=\"\", platform=\"\", user=\"\", session=\"\",\n+        )\n+        assert result == \"hermes\"\n+\n+    def test_resolve_collapses_double_dashes(self):\n+        # Two empty placeholders with a dash between them should collapse\n+        result = _resolve_bank_id_template(\n+            \"{workspace}-{profile}-{user}\", fallback=\"fallback\",\n+            profile=\"coder\", workspace=\"\", platform=\"\", user=\"\", session=\"\",\n+        )\n+        assert result == \"coder\"\n+\n+    def test_resolve_empty_rendered_falls_back(self):\n+        result = _resolve_bank_id_template(\n+            \"{user}-{profile}\", fallback=\"fallback\",\n+            profile=\"\", workspace=\"\", platform=\"\", user=\"\", session=\"\",\n+        )\n+        assert result == \"fallback\"\n+\n+    def test_resolve_sanitizes_placeholder_values(self):\n+        result = _resolve_bank_id_template(\n+            \"user-{user}\", fallback=\"hermes\",\n+            profile=\"\", workspace=\"\", platform=\"\",\n+            user=\"[redacted:email]\", session=\"\",\n+        )\n+        assert result == \"user-josh-example-com\"\n+\n+    def test_resolve_invalid_template_returns_fallback(self):\n+        # Unknown placeholder should fall back without raising\n+        result = _resolve_bank_id_template(\n+            \"hermes-{unknown}\", fallback=\"hermes\",\n+            profile=\"\", workspace=\"\", platform=\"\", user=\"\", session=\"\",\n+        )\n+        assert result == \"hermes\"\n+\n+    def test_provider_uses_bank_id_template_from_config(self, tmp_path, monkeypatch):\n+        config = {\n+            \"mode\": \"cloud\",\n+            \"apiKey\": \"k\",\n+            \"api_url\": \"http://x\",\n+            \"bank_id\": \"fallback-bank\",\n+            \"bank_id_template\": \"hermes-{profile}\",\n+        }\n+        config_path = tmp_path / \"hindsight\" / \"config.json\"\n+        config_path.parent.mkdir(parents=True, exist_ok=True)\n+        config_path.write_text(json.dumps(config))\n+        monkeypatch.setattr(\"plugins.memory.hindsight.get_hermes_home\", lambda: tmp_path)\n+\n+        p = HindsightMemoryProvider()\n+        p.initialize(\n+            session_id=\"s1\",\n+            hermes_home=str(tmp_path),\n+            platform=\"cli\",\n+            agent_identity=\"coder\",\n+            agent_workspace=\"hermes\",\n+        )\n+        assert p._bank_id == \"hermes-coder\"\n+        assert p._bank_id_template == \"hermes-{profile}\"\n+\n+    def test_provider_without_template_uses_static_bank_id(self, tmp_path, monkeypatch):\n+        config = {\n+            \"mode\": \"cloud\",\n+            \"apiKey\": \"k\",\n+            \"api_url\": \"http://x\",\n+            \"bank_id\": \"my-static-bank\",\n+        }\n+        config_path = tmp_path / \"hindsight\" / \"config.json\"\n+        config_path.parent.mkdir(parents=True, exist_ok=True)\n+        config_path.write_text(json.dumps(config))\n+        monkeypatch.setattr(\"plugins.memory.hindsight.get_hermes_home\", lambda: tmp_path)\n+\n+        p = HindsightMemoryProvider()\n+        p.initialize(\n+            session_id=\"s1\",\n+            hermes_home=str(tmp_path),\n+            platform=\"cli\",\n+            agent_identity=\"coder\",\n+        )\n+        assert p._bank_id == \"my-static-bank\"\n+\n+    def test_provider_template_with_missing_profile_falls_back(self, tmp_path, monkeypatch):\n+        config = {\n+            \"mode\": \"cloud\",\n+            \"apiKey\": \"k\",\n+            \"api_url\": \"http://x\",\n+            \"bank_id\": \"hermes-fallback\",\n+            \"bank_id_template\": \"hermes-{profile}\",\n+        }\n+        config_path = tmp_path / \"hindsight\" / \"config.json\"\n+        config_path.parent.mkdir(parents=True, exist_ok=True)\n+        config_path.write_text(json.dumps(config))\n+        monkeypatch.setattr(\"plugins.memory.hindsight.get_hermes_home\", lambda: tmp_path)\n+\n+        p = HindsightMemoryProvider()\n+        # No agent_identity passed — template renders to \"hermes-\" which collapses to \"hermes\"\n+        p.initialize(session_id=\"s1\", hermes_home=str(tmp_path), platform=\"cli\")\n+        assert p._bank_id == \"hermes\"\n+\n+\n # ---------------------------------------------------------------------------\n # Availability tests\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":20664,"title":"Replace cumulative Hindsight transcript snapshots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"09d66037f8f7bc5bd879ed8128273fb6780a009f","number":0},{"kind":"pr","value":"#40605","number":40605}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23755,"title":"fix(hindsight): clear _session_turns after sync_turn to send only deltas on append mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23686","number":23686}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":27280,"title":"fix(hindsight): clear _session_turns after building retain content (#23724)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"09d66037f8f7bc5bd879ed8128273fb6780a009f","number":0},{"kind":"pr","value":"#40605","number":40605},{"kind":"pr","value":"#23724","number":23724}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":29555,"title":"fix(hindsight): clear _session_turns after retain to stop transcript duplication","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27280","number":27280},{"kind":"pr","value":"#23755","number":23755},{"kind":"pr","value":"#27472","number":27472},{"kind":"pr","value":"#23724","number":23724}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":40605,"title":"fix(hindsight): send only new-turn delta on append retains instead of whole session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/hindsight/__init__.py","tests/plugins/memory/test_hindsight_provider.py"],"fix_hunks":[{"file":"plugins/memory/hindsight/__init__.py","hunks":["@@ -575,6 +575,10 @@ def __init__(self):\n         self._retain_context = \"conversation between Hermes Agent and the User\"\n         self._turn_counter = 0\n         self._session_turns: list[str] = []  # accumulates ALL turns for the session\n+        # How many turns the last append-mode retain already shipped. Used to\n+        # send only the new delta on subsequent retains when the API supports\n+        # update_mode='append' (legacy/overwrite path still sends everything).\n+        self._last_retained_turn_count = 0\n \n         # Recall controls\n         self._auto_recall = True","@@ -1119,6 +1123,7 @@ def initialize(self, session_id: str, **kwargs) -> None:\n         self._agent_workspace = str(kwargs.get(\"agent_workspace\") or \"\").strip()\n         self._turn_index = 0\n         self._session_turns = []\n+        self._last_retained_turn_count = 0\n         self._mode = self._config.get(\"mode\", \"cloud\")\n         # Read timeout from config or env var, fall back to default\n         self._timeout = _parse_int_setting(","@@ -1461,9 +1466,24 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n                          self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns))\n             return\n \n-        logger.debug(\"sync_turn: retaining %d turns, total session content %d chars\",\n-                     len(self._session_turns), sum(len(t) for t in self._session_turns))\n-        content = \"[\" + \",\".join(self._session_turns) + \"]\"\n+        document_id, update_mode = self._resolve_retain_target(self._document_id)\n+\n+        # On append-capable APIs each retain only needs to ship the turns\n+        # accumulated since the last retain — the server appends them to the\n+        # existing document. On legacy/overwrite APIs we must resend the whole\n+        # session because each retain replaces the document.\n+        if update_mode == \"append\":\n+            turns_to_retain = self._session_turns[self._last_retained_turn_count:]\n+            if not turns_to_retain:\n+                logger.debug(\"sync_turn: skipped append retain; no new turns since last retain\")\n+                return\n+        else:\n+            turns_to_retain = list(self._session_turns)\n+\n+        logger.debug(\"sync_turn: retaining %d/%d turns, payload %d chars\",\n+                     len(turns_to_retain), len(self._session_turns),\n+                     sum(len(t) for t in turns_to_retain))\n+        content = \"[\" + \",\".join(turns_to_retain) + \"]\"\n \n         lineage_tags: list[str] = []\n         if self._session_id:","@@ -1474,11 +1494,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n         # Snapshot the state needed for the retain. The writer may run after\n         # _session_turns / _turn_index are mutated by a later sync_turn().\n         metadata_snapshot = self._build_metadata(\n-            message_count=len(self._session_turns) * 2,\n+            message_count=len(turns_to_retain) * 2,\n             turn_index=self._turn_index,\n         )\n-        num_turns = len(self._session_turns)\n-        document_id, update_mode = self._resolve_retain_target(self._document_id)\n+        num_turns = len(turns_to_retain)\n         bank_id = self._bank_id\n         retain_async_flag = self._retain_async\n         retain_context = self._retain_context","@@ -1509,6 +1528,10 @@ def _do_retain() -> None:\n         self._ensure_writer()\n         self._register_atexit()\n         self._retain_queue.put(_do_retain)\n+        # Advance the append watermark only after the delta is queued, so a\n+        # later retain doesn't re-ship turns we've already handed to the writer.\n+        if update_mode == \"append\":\n+            self._last_retained_turn_count = len(self._session_turns)\n \n     def get_tool_schemas(self) -> List[Dict[str, Any]]:\n         if self._memory_mode == \"context\":","@@ -1706,6 +1729,7 @@ def _flush():\n         self._session_turns = []\n         self._turn_counter = 0\n         self._turn_index = 0\n+        self._last_retained_turn_count = 0\n         logger.debug(\n             \"Hindsight on_session_switch: new_session=%s parent=%s reset=%s doc=%s\",\n             self._session_id, self._parent_session_id, reset, self._document_id,"]},{"file":"tests/plugins/memory/test_hindsight_provider.py","hunks":["@@ -780,8 +780,8 @@ def test_sync_turn_every_n_turns(self, provider_with_config):\n         assert item[\"metadata\"][\"turn_index\"] == \"3\"\n         assert item[\"metadata\"][\"message_count\"] == \"6\"\n \n-    def test_sync_turn_accumulates_full_session(self, provider_with_config):\n-        \"\"\"Each retain sends the ENTIRE session, not just the latest batch.\"\"\"\n+    def test_sync_turn_accumulates_full_session_without_append_support(self, provider_with_config):\n+        \"\"\"Legacy/overwrite APIs (no update_mode=append) resend the ENTIRE session each retain.\"\"\"\n         p = provider_with_config(retain_every_n_turns=2)\n \n         p.sync_turn(\"turn1-user\", \"turn1-asst\")","@@ -795,12 +795,59 @@ def test_sync_turn_accumulates_full_session(self, provider_with_config):\n         p._retain_queue.join()\n \n         content = p._client.aretain_batch.call_args.kwargs[\"items\"][0][\"content\"]\n-        # Should contain ALL turns from the session\n+        # Without append support the document is overwritten, so it must\n+        # contain ALL turns from the session.\n         assert \"turn1-user\" in content\n         assert \"turn2-user\" in content\n         assert \"turn3-user\" in content\n         assert \"turn4-user\" in content\n \n+    def test_sync_turn_appends_only_delta_when_append_supported(self, provider_with_config, monkeypatch):\n+        \"\"\"On append-capable APIs each retain ships only the new turns, not the whole session.\"\"\"\n+        monkeypatch.setattr(\n+            \"plugins.memory.hindsight._fetch_hindsight_api_version\",\n+            lambda *a, **kw: \"0.5.6\",\n+        )\n+        from plugins.memory.hindsight import _append_capability_cache, _append_capability_lock\n+        # Clear before AND after: the capability cache is module-global and keyed\n+        # per api_url, so a stale entry would leak into other tests.\n+        with _append_capability_lock:\n+            _append_capability_cache.clear()\n+        try:\n+            p = provider_with_config(retain_every_n_turns=2)\n+\n+            p.sync_turn(\"turn1-user\", \"turn1-asst\")\n+            p.sync_turn(\"turn2-user\", \"turn2-asst\")\n+            p._retain_queue.join()\n+\n+            first = p._client.aretain_batch.call_args.kwargs\n+            first_item = first[\"items\"][0]\n+            assert first[\"document_id\"] == \"test-session\"\n+            assert first_item[\"update_mode\"] == \"append\"\n+            assert \"turn1-user\" in first_item[\"content\"]\n+            assert \"turn2-user\" in first_item[\"content\"]\n+\n+            p._client.aretain_batch.reset_mock()\n+\n+            p.sync_turn(\"turn3-user\", \"turn3-asst\")\n+            p.sync_turn(\"turn4-user\", \"turn4-asst\")\n+            p._retain_queue.join()\n+\n+            second = p._client.aretain_batch.call_args.kwargs\n+            second_item = second[\"items\"][0]\n+            assert second[\"document_id\"] == \"test-session\"\n+            assert second_item[\"update_mode\"] == \"append\"\n+            # Only the delta — the already-retained turns must NOT be resent.\n+            assert \"turn1-user\" not in second_item[\"content\"]\n+            assert \"turn2-user\" not in second_item[\"content\"]\n+            assert \"turn3-user\" in second_item[\"content\"]\n+            assert \"turn4-user\" in second_item[\"content\"]\n+            # message_count reflects only the delta (2 turns -> 4 messages).\n+            assert second_item[\"metadata\"][\"message_count\"] == \"4\"\n+        finally:\n+            with _append_capability_lock:\n+                _append_capability_cache.clear()\n+\n     def test_sync_turn_passes_document_id(self, provider):\n         \"\"\"sync_turn should pass document_id (session_id + per-startup ts).\"\"\"\n         provider.sync_turn(\"hello\", \"hi\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}