{"repo":"NousResearch/hermes-agent","public_id":"33381","display_label":"Honcho memory provider cache and setup hardening","summary":"These items address refactoring cache-busting logic into the memory provider layer, skipping unnecessary disk reads when Honcho is inactive, and hardening self-hosted Honcho setup paths and peer mapping.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30077},"resolution":{"state":"resolved","by":"pr-30077,pr-107547"},"freshness":{"state":"verified","last_verified_at":"2026-09-10T17:45:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":20688,"pr_number":35170,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":26459,"pr_number":35170,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":27314,"pr_number":30077,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28283,"pr_number":30077,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":29885,"pr_number":35170,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30246,"pr_number":35170,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33381,"pr_number":107547,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33382,"pr_number":30077,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":33381,"title":"memory: move provider-specific cache-busting keys out of gateway/run.py into a MemoryProvider hook","url":null,"state":"closed"},{"kind":"issue","number":33382,"title":"honcho: skip per-gateway-message disk read in _extract_cache_busting_config when honcho isn't the active memory provider","url":null,"state":"closed"},{"kind":"pr","number":30077,"title":"fix(honcho): runtime peer mapping — correctness follow-ups + setup wizard + docs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/memory_provider.py","gateway/run.py","plugins/memory/honcho/README.md","plugins/memory/honcho/__init__.py","plugins/memory/honcho/cli.py","plugins/memory/honcho/client.py","plugins/memory/honcho/session.py","run_agent.py","scripts/release.py","tests/gateway/test_agent_cache.py","tests/honcho_plugin/test_cli.py","tests/honcho_plugin/test_pin_peer_name.py","tests/honcho_plugin/test_session.py","tests/run_agent/test_memory_provider_init.py","tests/test_honcho_session_context.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -183,6 +183,7 @@ def init_agent(\n     prefill_messages: List[Dict[str, Any]] = None,\n     platform: str = None,\n     user_id: str = None,\n+    user_id_alt: str = None,\n     user_name: str = None,\n     chat_id: str = None,\n     chat_name: str = None,","@@ -265,6 +266,7 @@ def init_agent(\n     agent.ephemeral_system_prompt = ephemeral_system_prompt\n     agent.platform = platform  # \"cli\", \"telegram\", \"discord\", \"whatsapp\", etc.\n     agent._user_id = user_id  # Platform user identifier (gateway sessions)\n+    agent._user_id_alt = user_id_alt  # Optional stable alternate platform identifier\n     agent._user_name = user_name\n     agent._chat_id = chat_id\n     agent._chat_name = chat_name","@@ -1089,6 +1091,8 @@ def init_agent(\n                     # Thread gateway user identity for per-user memory scoping\n                     if agent._user_id:\n                         _init_kwargs[\"user_id\"] = agent._user_id\n+                    if agent._user_id_alt:\n+                        _init_kwargs[\"user_id_alt\"] = agent._user_id_alt\n                     if agent._user_name:\n                         _init_kwargs[\"user_name\"] = agent._user_name\n                     if agent._chat_id:"]},{"file":"agent/memory_provider.py","hunks":["@@ -78,6 +78,7 @@ def initialize(self, session_id: str, **kwargs) -> None:\n           - agent_workspace (str): Shared workspace name (e.g. \"hermes\").\n           - parent_session_id (str): For subagents, the parent's session_id.\n           - user_id (str): Platform user identifier (gateway sessions).\n+          - user_id_alt (str): Optional alternate stable platform user identifier.\n         \"\"\"\n \n     def system_prompt_block(self) -> str:"]},{"file":"gateway/run.py","hunks":["@@ -11436,6 +11436,7 @@ def run_sync():\n                     session_id=task_id,\n                     platform=platform_key,\n                     user_id=source.user_id,\n+                    user_id_alt=source.user_id_alt,\n                     user_name=source.user_name,\n                     chat_id=source.chat_id,\n                     chat_name=source.chat_name,","@@ -14672,6 +14673,29 @@ def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:\n             out[\"tools.registry_generation\"] = getattr(registry, \"_generation\", None)\n         except Exception:\n             out[\"tools.registry_generation\"] = None\n+\n+        # Honcho identity-mapping keys live in honcho.json, not user_config.\n+        # HonchoSessionManager freezes the resolved peer_name / ai_peer /\n+        # pin / aliases / prefix at construction; without busting here,\n+        # mid-flight honcho.json edits go unread until the next unrelated\n+        # cache eviction.\n+        try:\n+            from plugins.memory.honcho.client import HonchoClientConfig\n+\n+            hcfg = HonchoClientConfig.from_global_config()\n+            out[\"honcho.peer_name\"] = hcfg.peer_name\n+            out[\"honcho.ai_peer\"] = hcfg.ai_peer\n+            out[\"honcho.pin_peer_name\"] = bool(hcfg.pin_peer_name)\n+            out[\"honcho.runtime_peer_prefix\"] = hcfg.runtime_peer_prefix or \"\"\n+            aliases = hcfg.user_peer_aliases or {}\n+            out[\"honcho.user_peer_aliases\"] = sorted(aliases.items()) if isinstance(aliases, dict) else []\n+        except Exception:\n+            out[\"honcho.peer_name\"] = None\n+            out[\"honcho.ai_peer\"] = None\n+            out[\"honcho.pin_peer_name\"] = None\n+            out[\"honcho.runtime_peer_prefix\"] = None\n+            out[\"honcho.user_peer_aliases\"] = None\n+\n         return out\n \n     @staticmethod","@@ -14681,6 +14705,8 @@ def _agent_config_signature(\n         enabled_toolsets: list,\n         ephemeral_prompt: str,\n         cache_keys: dict | None = None,\n+        user_id: str | None = None,\n+        user_id_alt: str | None = None,\n     ) -> str:\n         \"\"\"Compute a stable string key from agent config values.\n ","@@ -14694,6 +14720,20 @@ def _agent_config_signature(\n         the output of ``_extract_cache_busting_config(user_config)`` so\n         edits to model.context_length / compression.* in config.yaml are\n         picked up on the next gateway message without a manual restart.\n+\n+        ``user_id`` and ``user_id_alt`` are the runtime user identities\n+        carried by the current message's gateway source.  They participate\n+        in the cache key because the Honcho memory provider freezes them\n+        into ``HonchoSessionManager`` at first-message init (see\n+        ``plugins/memory/honcho/__init__.py::_do_session_init``).  Without\n+        them in the signature, a shared-thread session_key (one in which\n+        ``build_session_key`` intentionally omits the participant ID,\n+        e.g. ``thread_sessions_per_user=False``) would reuse the cached\n+        AIAgent across distinct users, causing the second user's messages\n+        to be attributed to the first user's resolved Honcho peer.  This\n+        broke #27371's per-user-peer contract in multi-user gateways.\n+        Per-user agent rebuilds in shared threads trade prompt-cache\n+        warmth for correct memory attribution.\n         \"\"\"\n         import hashlib, json as _j\n ","@@ -14718,6 +14758,8 @@ def _agent_config_signature(\n                 # cached agent and doesn't affect system prompt or tools.\n                 ephemeral_prompt or \"\",\n                 _cache_keys_sorted,\n+                str(user_id or \"\"),\n+                str(user_id_alt or \"\"),\n             ],\n             sort_keys=True,\n             default=str,","@@ -16259,6 +16301,8 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                 enabled_toolsets,\n                 combined_ephemeral,\n                 cache_keys=self._extract_cache_busting_config(user_config),\n+                user_id=getattr(source, \"user_id\", None),\n+                user_id_alt=getattr(source, \"user_id_alt\", None),\n             )\n             agent = None\n             _cache_lock = getattr(self, \"_agent_cache_lock\", None)","@@ -16302,6 +16346,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n                     session_id=session_id,\n                     platform=platform_key,\n                     user_id=source.user_id,\n+                    user_id_alt=source.user_id_alt,\n                     user_name=source.user_name,\n                     chat_id=source.chat_id,\n                     chat_name=source.chat_name,"]},{"file":"plugins/memory/honcho/README.md","hunks":["@@ -127,6 +127,41 @@ For every key, resolution order is: **host block > root > env var > default**.\n | `peerName` | string | — | User peer identity |\n | `aiPeer` | string | host key | AI peer identity |\n \n+### Identity Mapping (Gateway Multi-User)\n+\n+In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a platform-native runtime ID (Telegram UID, Discord snowflake, Slack user). These three keys control how those runtime IDs map to Honcho peers. The resolver is config-driven and deterministic — no automatic merging or runtime inference.\n+\n+| Key | Type | Default | Description |\n+|-----|------|---------|-------------|\n+| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` |\n+| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect |\n+| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{\"86701400\": \"eri\"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |\n+| `runtimePeerPrefix` | string | `\"\"` | Prepended to unknown runtime IDs to namespace them (e.g. `\"telegram_\"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |\n+\n+**Resolver ladder** (first match wins):\n+\n+```\n+1. pinUserPeer / pinPeerName=true → return peerName (ignore runtime ID)\n+2. userPeerAliases[runtime_id]   → return aliased peer\n+3. userPeerAliases[runtime_id_alt] → check alt-ID too (Telegram UID + username, etc.)\n+4. runtimePeerPrefix + runtime_id → namespaced peer, with sha256 collision escalation\n+5. raw sanitized runtime_id      → fallback peer\n+6. peerName                      → no runtime ID at all (CLI/TUI)\n+7. session-key fallback          → no config either\n+```\n+\n+**Why no `pinAiPeer`?** The AI peer is already pinned by construction — `aiPeer` is the only AI-side identity setting and the resolver never overrides it. Only the user-side peer has the runtime-vs-config tension that `pinUserPeer` resolves.\n+\n+**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: \"\"`.\n+\n+**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):\n+\n+- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.\n+- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.\n+- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct.\n+\n+**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition.\n+\n ### Memory & Recall\n \n | Key | Type | Default | Description |"]},{"file":"plugins/memory/honcho/__init__.py","hunks":["@@ -321,10 +321,8 @@ def initialize(self, session_id: str, **kwargs) -> None:\n             except Exception as e:\n                 logger.debug(\"Honcho cost-awareness config parse error: %s\", e)\n \n-            # ----- Port #1969: aiPeer sync from SOUL.md — REMOVED -----\n-            # SOUL.md is persona content, not identity config. aiPeer should\n-            # only come from honcho.json (host block or root) or the default.\n-            # See scratch/memory-plugin-ux-specs.md #10 for rationale.\n+            # aiPeer comes from honcho.json (host block or root) only.\n+            # SOUL.md is persona content, not identity config.\n \n             # ----- Port #1957: lazy session init for tools-only mode -----\n             if self._recall_mode == \"tools\":","@@ -360,6 +358,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:\n             config=cfg,\n             context_tokens=[redacted:secret-assignment]\n             runtime_user_peer_name=kwargs.get(\"user_id\") or None,\n+            runtime_user_peer_name_alt=kwargs.get(\"user_id_alt\") or None,\n         )\n \n         # ----- B3: resolve_session_name -----"]},{"file":"plugins/memory/honcho/cli.py","hunks":["@@ -40,12 +40,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool:\n     if new_host in hosts:\n         return False  # already exists\n \n-    # Clone settings from default block, override identity fields\n+    # Clone settings from default block, override identity fields.\n+    # Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases,\n+    # runtimePeerPrefix) carry the operator's runtime-to-peer routing\n+    # intent from #27371.  Both pin keys are inherited because\n+    # HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving\n+    # the canonical key off this allowlist silently drops the pin on\n+    # cloned profiles when the default uses the newer name.\n     new_block = {}\n     for key in (\"recallMode\", \"writeFrequency\", \"sessionStrategy\",\n                 \"sessionPeerPrefix\", \"contextTokens\", \"dialecticReasoningLevel\",\n                 \"dialecticDynamic\", \"dialecticMaxChars\", \"messageMaxChars\",\n-                \"dialecticMaxInputChars\", \"saveMessages\", \"observation\"):\n+                \"dialecticMaxInputChars\", \"saveMessages\", \"observation\",\n+                \"pinPeerName\", \"pinUserPeer\", \"userPeerAliases\",\n+                \"runtimePeerPrefix\"):\n         val = default_block.get(key)\n         if val is not None:\n             new_block[key] = val","@@ -308,6 +316,72 @@ def _resolve_api_key(cfg: dict) -> str:\n     return key\n \n \n+_IDENTITY_MAPPING_KEYS = (\n+    \"pinPeerName\",\n+    \"pinUserPeer\",\n+    \"userPeerAliases\",\n+    \"runtimePeerPrefix\",\n+)\n+\n+\n+def _resolve_effective_identity_mapping(\n+    cfg: dict, hermes_host: dict\n+) -> tuple[bool, dict, str, bool, bool]:\n+    \"\"\"Resolve the effective identity-mapping state for the active host.\n+\n+    Matches the precedence used by ``HonchoClientConfig.from_global_config``\n+    so the wizard reads the same shape the gateway will actually run with.\n+    Without this, root-level overrides and ``pinUserPeer`` (which wins over\n+    ``pinPeerName`` at the same level) are invisible to detection, letting\n+    setup mis-classify the current shape and silently change effective\n+    routing on the next save.\n+\n+    Returns ``(pin, aliases, prefix, aliases_from_root, prefix_from_root)``.\n+    The ``*_from_root`` flags let the write step skip touching host keys\n+    whose value is actually inherited.\n+    \"\"\"\n+    pin = False\n+    for val in (\n+        hermes_host.get(\"pinUserPeer\"),\n+        hermes_host.get(\"pinPeerName\"),\n+        cfg.get(\"pinUserPeer\"),\n+        cfg.get(\"pinPeerName\"),\n+    ):\n+        if val is not None:\n+            pin = bool(val)\n+            break\n+\n+    if \"userPeerAliases\" in hermes_host:\n+        aliases_src = hermes_host.get(\"userPeerAliases\")\n+        aliases_from_root = False\n+    else:\n+        aliases_src = cfg.get(\"userPeerAliases\")\n+        aliases_from_root = aliases_src is not None\n+    aliases = aliases_src if isinstance(aliases_src, dict) else {}\n+\n+    if \"runtimePeerPrefix\" in hermes_host:\n+        prefix_src = hermes_host.get(\"runtimePeerPrefix\")\n+        prefix_from_root = False\n+    else:\n+        prefix_src = cfg.get(\"runtimePeerPrefix\")\n+        prefix_from_root = prefix_src is not None\n+    prefix = str(prefix_src or \"\")\n+\n+    return pin, aliases, prefix, aliases_from_root, prefix_from_root\n+\n+\n+def _scrub_identity_mapping(hermes_host: dict) -> None:\n+    \"\"\"Drop every peer-mapping key from the host block.\n+\n+    Called before the wizard writes a chosen shape so latent precedence\n+    conflicts can't survive — e.g. a stray host ``pinUserPeer: false``\n+    that would silently outrank a freshly written ``pinPeerName: true``\n+    (host ``pinUserPeer`` is first in the resolver ladder).\n+    \"\"\"\n+    for key in _IDENTITY_MAPPING_KEYS:\n+        hermes_host.pop(key, None)\n+\n+\n def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:\n     suffix = f\" [{default}]\" if default else \"\"\n     sys.stdout.write(f\"  {label}{suffix}: \")","@@ -435,6 +509,131 @@ def cmd_setup(args) -> None:\n     if new_workspace:\n         hermes_host[\"workspace\"] = new_workspace\n \n+    # --- 3b. Deployment shape ---\n+    # Determines how runtime user identities (Telegram UIDs, Discord\n+    # snowflakes, etc.) map to Honcho peers in gateway sessions.  Three\n+    # shapes cover the realistic deployments; each writes a different\n+    # combination of pinPeerName / userPeerAliases / runtimePeerPrefix.\n+    # See plugins/memory/honcho/README.md for the resolver ladder.\n+    #\n+    # Detection must mirror the gateway resolver: root-level config and\n+    # ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)\n+    # both affect effective routing, so reading host-only fields would\n+    # mis-classify a profile that inherits its mapping from root or uses\n+    # the newer canonical key.\n+    (\n+        current_pin,\n+        current_aliases,\n+        current_prefix,\n+        aliases_from_root,\n+        prefix_from_root,\n+    ) = _resolve_effective_identity_mapping(cfg, hermes_host)\n+\n+    if current_pin:\n+        current_shape = \"single\"\n+    elif current_aliases:\n+        current_shape = \"hybrid\"\n+    else:\n+        current_shape = \"multi\"\n+\n+    print(\"\\n  Deployment shape (how gateway users map to peers):\")\n+    print(\"    single -- all platforms route to your peer (recommended for personal use)\")\n+    print(\"    multi  -- each platform user gets their own peer (multi-user bots)\")\n+    print(\"    hybrid -- multi-user, but YOUR runtime IDs alias to your peer\")\n+    print(\"    skip   -- don't touch identity-mapping config\")\n+    new_shape = _prompt(\"Deployment shape\", default=current_shape).strip().lower()\n+\n+    # Transitioning single → multi orphans the peerName pool for runtime users\n+    # (their resolved peers go from peerName to runtime-derived IDs with empty\n+    # history).  Steer the operator toward hybrid so their own continuity is\n+    # preserved via alias mappings.\n+    if current_shape == \"single\" and new_shape == \"multi\":\n+        peer_target = hermes_host.get(\"peerName\") or current_peer or \"user\"\n+        print(\n+            f\"\\n  ⚠ Switching from single to multi will orphan memory accumulated\\n\"\n+            f\"    under peer '{peer_target}'.  Existing runtime users (Telegram,\\n\"\n+            f\"    Discord, etc.) will resolve to fresh, empty peers.\"\n+        )\n+        print(\"    To keep your own continuity, choose 'hybrid' and alias your\\n\"\n+              \"    runtime IDs back to peerName.\")\n+        confirm = _prompt(\"Continue with multi anyway? (yes/hybrid/no)\", default=\"hybrid\").strip().lower()\n+        if confirm in {\"hybrid\", \"h\"}:\n+            new_shape = \"hybrid\"\n+        elif confirm not in {\"yes\", \"y\"}:\n+            new_shape = \"skip\"\n+\n+    # Each shape branch scrubs every peer-mapping key before writing its own,\n+    # so a stale ``pinUserPeer`` left behind by an earlier setup run can't\n+    # outrank the freshly written ``pinPeerName`` via host-level precedence.\n+    if new_shape == \"single\":\n+        _scrub_identity_mapping(hermes_host)\n+        hermes_host[\"pinPeerName\"] = True\n+        print(f\"  pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.\")\n+    elif new_shape == \"multi\":\n+        # Preserve operator-curated, host-level aliases so multi → multi\n+        # re-runs don't drop them.  Root-sourced aliases are left to\n+        # cascade naturally and are NOT copied down into the host.\n+        prior_aliases = (\n+            dict(current_aliases)\n+            if isinstance(current_aliases, dict) and not aliases_from_root\n+            else {}\n+        )\n+        _scrub_identity_mapping(hermes_host)\n+        hermes_host[\"pinPeerName\"] = False\n+        # Do NOT auto-write ``userPeerAliases: {}``: an empty host map\n+        # would override any root-level ``userPeerAliases`` the operator\n+        # set as a cross-host baseline, silently disabling those aliases.\n+        # Absence is the right \"no host opinion\" signal.\n+        if prior_aliases:\n+            hermes_host[\"userPeerAliases\"] = prior_aliases\n+        _prefix_default = current_prefix or \"\"\n+        _new_prefix = _prompt(\n+            \"Runtime peer prefix (e.g. 'telegram_', blank for none)\",\n+            default=_prefix_default,\n+        ).strip()\n+        # Only write a host-level prefix when the operator typed one that\n+        # diverges from the inherited root value; otherwise let the root\n+        # cascade continue unmodified.\n+        if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):\n+            hermes_host[\"runtimePeerPrefix\"] = _new_prefix\n+        print(\"  Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.\")\n+    elif new_shape == \"hybrid\":\n+        # Hybrid encodes operator intent at the host level: collect existing\n+        # entries (host or root) so the wizard never silently drops a known\n+        # alias, then write the combined map.  Materialising root entries\n+        # into the host is the right move here — once the operator answers\n+        # the alias prompts for a host, they're declaring \"this host owns\n+        # the mapping\".\n+        existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}\n+        _scrub_identity_mapping(hermes_host)\n+        hermes_host[\"pinPeerName\"] = False\n+        peer_target = hermes_host.get(\"peerName\") or current_peer or \"user\"\n+        print(f\"\\n  Add runtime IDs that should alias to peer '{peer_target}'.\")\n+        print(\"  Leave blank to skip a platform.  Existing aliases are preserved.\")\n+        for platform_label, alias_hint in (\n+            (\"Telegram UID\", \"e.g. 86701400\"),\n+            (\"Discord snowflake\", \"e.g. 491827364\"),\n+            (\"Slack user ID\", \"e.g. U04ABCDEF\"),\n+            (\"Matrix MXID\", \"e.g. @you:matrix.org\"),\n+        ):\n+            entered = _prompt(f\"  {platform_label} ({alias_hint})\", default=\"\").strip()\n+            if entered:\n+                existing_aliases[entered] = peer_target\n+        if existing_aliases:\n+            hermes_host[\"userPeerAliases\"] = existing_aliases\n+        _prefix_default = current_prefix or \"\"\n+        _new_prefix = _prompt(\n+            \"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)\",\n+            default=_prefix_default,\n+        ).strip()\n+        if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):\n+            hermes_host[\"runtimePeerPrefix\"] = _new_prefix\n+        print(f\"  Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.\")\n+    elif new_shape == \"skip\":\n+        pass  # leave config untouched\n+    else:\n+        print(f\"  Unknown shape '{new_shape}' — leaving identity-mapping config untouched.\")\n+\n     # --- 4. Observation mode ---\n     current_obs = hermes_host.get(\"observationMode\") or cfg.get(\"observationMode\", \"directional\")\n     print(\"\\n  Observation mode:\")"]},{"file":"plugins/memory/honcho/client.py","hunks":["@@ -91,12 +91,17 @@ def _normalize_recall_mode(val: str) -> str:\n     return val if val in _VALID_RECALL_MODES else \"hybrid\"\n \n \n-def _resolve_bool(host_val, root_val, *, default: bool) -> bool:\n-    \"\"\"Resolve a bool config field: host wins, then root, then default.\"\"\"\n-    if host_val is not None:\n-        return bool(host_val)\n-    if root_val is not None:\n-        return bool(root_val)\n+def _resolve_bool(*vals, default: bool) -> bool:\n+    \"\"\"Resolve a bool config field: first non-None wins, else default.\n+\n+    Variadic to support aliased keys (e.g. ``pinUserPeer`` shadowing\n+    ``pinPeerName`` for backwards compatibility).  Pass values in\n+    precedence order: caller's preferred alias first, then fallback\n+    aliases, in (host, root) interleaving as needed.\n+    \"\"\"\n+    for val in vals:\n+        if val is not None:\n+            return bool(val)\n     return default\n \n ","@@ -122,6 +127,34 @@ def _parse_int_config(host_val, root_val, default: int) -> int:\n     return default\n \n \n+def _parse_string_map(host_obj: dict, root_obj: dict, key: str) -> dict[str, str]:\n+    \"\"\"Parse a string-to-string map with host-level whole-map override.\"\"\"\n+    source = host_obj[key] if key in host_obj else root_obj.get(key)\n+    if not isinstance(source, dict):\n+        return {}\n+\n+    result: dict[str, str] = {}\n+    for raw_key, raw_value in source.items():\n+        alias_key = str(raw_key).strip()\n+        alias_value = str(raw_value).strip() if raw_value is not None else \"\"\n+        if alias_key and alias_value:\n+            result[alias_key] = alias_value\n+    return result\n+\n+\n+def _parse_optional_string(\n+    host_obj: dict, root_obj: dict, key: str, default: str = \"\"\n+) -> str:\n+    \"\"\"Parse a string field where host-level empty string can override root.\"\"\"\n+    if key in host_obj:\n+        value = host_obj.get(key)\n+    else:\n+        value = root_obj.get(key, default)\n+    if value is None:\n+        return default\n+    return str(value).strip()\n+\n+\n def _parse_dialectic_depth(host_val, root_val) -> int:\n     \"\"\"Parse dialecticDepth: host wins, then root, then 1. Clamped to 1-3.\"\"\"\n     for val in (host_val, root_val):","@@ -259,6 +292,12 @@ class HonchoClientConfig:\n     # each platform would fork memory into its own peer (#14984).  Default\n     # ``False`` preserves existing multi-user behaviour.\n     pin_peer_name: bool = False\n+    # Map gateway runtime user IDs to stable Honcho user peers. Host-level\n+    # config replaces the root map as a whole so profiles can intentionally\n+    # own their identity mappings.\n+    user_peer_aliases: dict[str, str] = field(default_factory=dict)\n+    # Optional prefix for unknown gateway runtime user IDs, e.g. \"telegram_\".\n+    runtime_peer_prefix: str = \"\"\n     # Toggles\n     enabled: bool = False\n     save_messages: bool = True","@@ -454,10 +493,28 @@ def from_global_config(\n             peer_name=host_block.get(\"peerName\") or raw.get(\"peerName\"),\n             ai_peer=ai_peer,\n             pin_peer_name=_resolve_bool(\n+                # ``pinUserPeer`` is the clearer name (the resolver pins\n+                # the user-side peer to ``peerName``, ignoring runtime\n+                # identity).  ``pinPeerName`` is the original key from\n+                # #14984 and stays accepted for backward compatibility.\n+                # Host-level keys win over root-level; among same-level\n+                # keys, ``pinUserPeer`` wins over ``pinPeerName``.\n+                host_block.get(\"pinUserPeer\"),\n                 host_block.get(\"pinPeerName\"),\n+                raw.get(\"pinUserPeer\"),\n                 raw.get(\"pinPeerName\"),\n                 default=False,\n             ),\n+            user_peer_aliases=_parse_string_map(\n+                host_block,\n+                raw,\n+                \"userPeerAliases\",\n+            ),\n+            runtime_peer_prefix=_parse_optional_string(\n+                host_block,\n+                raw,\n+                \"runtimePeerPrefix\",\n+            ),\n             enabled=enabled,\n             save_messages=save_messages,\n             write_frequency=write_frequency,"]},{"file":"plugins/memory/honcho/session.py","hunks":["@@ -2,6 +2,7 @@\n \n from __future__ import annotations\n \n+import hashlib\n import queue\n import re\n import logging","@@ -19,6 +20,8 @@\n \n # Sentinel to signal the async writer thread to shut down\n _ASYNC_SHUTDOWN = object()\n+_PEER_ID_HASH_LEN = 8\n+_PEER_ID_HASH_ESCALATION_LENGTHS = (_PEER_ID_HASH_LEN, 12, 16, 24, 32, 64)\n \n \n @dataclass","@@ -79,6 +82,7 @@ def __init__(\n         context_tokens: int | None = None,\n         config: Any | None = None,\n         runtime_user_peer_name: str | None = None,\n+        runtime_user_peer_name_alt: str | None = None,\n     ):\n         \"\"\"\n         Initialize the session manager.","@@ -89,11 +93,13 @@ def __init__(\n             config: HonchoClientConfig from global config (provides peer_name, ai_peer,\n                     write_frequency, observation, etc.).\n             runtime_user_peer_name: Gateway user identity for per-user memory scoping.\n+            runtime_user_peer_name_alt: Optional stable alternate gateway identity.\n         \"\"\"\n         self._honcho = honcho\n         self._context_tokens = [redacted:secret-assignment]\n         self._config = config\n         self._runtime_user_peer_name = runtime_user_peer_name\n+        self._runtime_user_peer_name_alt = runtime_user_peer_name_alt\n         self._cache: dict[str, HonchoSession] = {}\n         self._cache_lock = threading.RLock()\n         self._peers_cache: dict[str, Any] = {}","@@ -267,6 +273,90 @@ def _sanitize_id(self, id_str: str) -> str:\n         \"\"\"Sanitize an ID to match Honcho's pattern: ^[a-zA-Z0-9_-]+\"\"\"\n         return re.sub(r'[^a-zA-Z0-9_-]', '-', id_str)\n \n+    def _runtime_user_ids(self) -> list[str]:\n+        \"\"\"Return runtime identity candidates in lookup order.\"\"\"\n+        candidates: list[str] = []\n+        for value in (self._runtime_user_peer_name, self._runtime_user_peer_name_alt):\n+            if value is None:\n+                continue\n+            candidate = str(value).strip()\n+            if candidate and candidate not in candidates:\n+                candidates.append(candidate)\n+        return candidates\n+\n+    def _session_key_fallback_peer_id(self, key: str) -> str:\n+        parts = key.split(\":\", 1)\n+        channel = parts[0] if len(parts) > 1 else \"default\"\n+        chat_id = parts[1] if len(parts) > 1 else key\n+        return self._sanitize_id(f\"user-{channel}-{chat_id}\")\n+\n+    def _explicit_user_peer_ids(self) -> set[str]:\n+        \"\"\"Return sanitized user peer IDs that came from explicit config.\"\"\"\n+        if self._config is None:\n+            return set()\n+\n+        explicit_ids: set[str] = set()\n+        peer_name = getattr(self._config, \"peer_name\", None)\n+        if peer_name:\n+            explicit_ids.add(self._sanitize_id(str(peer_name).strip()))\n+\n+        aliases = getattr(self._config, \"user_peer_aliases\", {})\n+        if isinstance(aliases, dict):\n+            for alias in aliases.values():\n+                if isinstance(alias, str) and alias.strip():\n+                    explicit_ids.add(self._sanitize_id(alias.strip()))\n+\n+        return explicit_ids\n+\n+    def _generated_runtime_peer_id(self, prefix: str, runtime_id: str) -> str:\n+        \"\"\"Return a stable peer ID for an unknown prefixed runtime user.\"\"\"\n+        raw_peer_id = f\"{prefix}{runtime_id}\"\n+        sanitized_peer_id = self._sanitize_id(raw_peer_id)\n+        explicit_ids = self._explicit_user_peer_ids()\n+        if (\n+            sanitized_peer_id != raw_peer_id\n+            or sanitized_peer_id in explicit_ids\n+        ):\n+            digest = hashlib.sha256(raw_peer_id.encode(\"utf-8\")).hexdigest()\n+            for hash_len in _PEER_ID_HASH_ESCALATION_LENGTHS:\n+                candidate = f\"{sanitized_peer_id}-{digest[:hash_len]}\"\n+                if candidate not in explicit_ids:\n+                    return candidate\n+            return f\"{sanitized_peer_id}-{digest}\"\n+        return sanitized_peer_id\n+\n+    def _resolve_user_peer_id(self, key: str) -> str:\n+        \"\"\"Resolve the Honcho user peer ID for this manager/session.\"\"\"\n+        pin_peer_name = (\n+            self._config is not None\n+            and bool(getattr(self._config, \"peer_name\", None))\n+            and getattr(self._config, \"pin_peer_name\", False) is True\n+        )\n+        if pin_peer_name:\n+            return self._sanitize_id(self._config.peer_name)\n+\n+        runtime_ids = self._runtime_user_ids()\n+        if runtime_ids:\n+            aliases = getattr(self._config, \"user_peer_aliases\", {}) if self._config else {}\n+            if not isinstance(aliases, dict):\n+                aliases = {}\n+            for runtime_id in runtime_ids:\n+                alias = aliases.get(runtime_id)\n+                if isinstance(alias, str) and alias.strip():\n+                    return self._sanitize_id(alias.strip())\n+\n+            primary_runtime_id = runtime_ids[0]\n+            prefix = getattr(self._config, \"runtime_peer_prefix\", \"\") if self._config else \"\"\n+            prefix = prefix.strip() if isinstance(prefix, str) else \"\"\n+            if prefix:\n+                return self._generated_runtime_peer_id(prefix, primary_runtime_id)\n+            return self._sanitize_id(primary_runtime_id)\n+\n+        if self._config and self._config.peer_name:\n+            return self._sanitize_id(self._config.peer_name)\n+\n+        return self._session_key_fallback_peer_id(key)\n+\n     def get_or_create(self, key: str) -> HonchoSession:\n         \"\"\"\n         Get an existing session or create a new one.","@@ -285,31 +375,11 @@ def get_or_create(self, key: str) -> HonchoSession:\n         # Determine peer IDs — no lock needed (read-only, no shared state mutation).\n         # Gateway sessions normally use the runtime user identity (the\n         # platform-native ID: Telegram UID, Discord snowflake, Slack user,\n-        # etc.) so multi-user bots scope memory per user.  For a single-user\n-        # deployment the config-supplied ``peer_name`` is an unambiguous\n-        # identity and we should keep it unified across platforms — see\n-        # #14984.  Opt into that with ``hosts.<host>.pinPeerName: true`` in\n-        # ``honcho.json`` (or root-level ``pinPeerName: true``).\n-        # `is True` (not `bool(...)`) is deliberate: several multi-user tests\n-        # pass a ``MagicMock`` for ``config`` where ``mock.pin_peer_name``\n-        # silently returns another MagicMock — truthy by default.  Requiring\n-        # strict ``True`` keeps pinning as opt-in even for callers that\n-        # haven't updated their mocks yet; real configs built via\n-        # ``from_global_config`` always produce a proper boolean.\n-        pin_peer_name = (\n-            self._config is not None\n-            and bool(getattr(self._config, \"peer_name\", None))\n-            and getattr(self._config, \"pin_peer_name\", False) is True\n-        )\n-        if self._runtime_user_peer_name and not pin_peer_name:\n-            user_peer_id = self._sanitize_id(self._runtime_user_peer_name)\n-        elif self._config and self._config.peer_name:\n-            user_peer_id = self._sanitize_id(self._config.peer_name)\n-        else:\n-            parts = key.split(\":\", 1)\n-            channel = parts[0] if len(parts) > 1 else \"default\"\n-            chat_id = parts[1] if len(parts) > 1 else key\n-            user_peer_id = self._sanitize_id(f\"user-{channel}-{chat_id}\")\n+        # etc.) so multi-user bots scope memory per user.  Config can alias\n+        # known runtime IDs or prefix unknown IDs.  For a single-user\n+        # deployment, ``pinPeerName`` still pins all runtime identities to\n+        # ``peerName`` (see #14984).\n+        user_peer_id = self._resolve_user_peer_id(key)\n \n         assistant_peer_id = self._sanitize_id(\n             self._config.ai_peer if self._config else \"hermes-assistant\"","@@ -937,11 +1007,11 @@ def get_session_context(self, session_key: str, peer: str = \"user\") -> dict[str,\n             return self._fetch_peer_context(peer_id, target=peer_id)\n \n         try:\n-            peer_id = self._resolve_peer_id(session, peer)\n+            observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)\n             ctx = honcho_session.context(\n                 summary=True,\n-                peer_target=peer_id,\n-                peer_perspective=session.user_peer_id if peer == \"user\" else session.assistant_peer_id,\n+                peer_target=target_peer_id or observer_peer_id,\n+                peer_perspective=observer_peer_id,\n             )\n \n             result: dict[str, Any] = {}","@@ -1017,7 +1087,14 @@ def get_peer_card(self, session_key: str, peer: str = \"user\") -> list[str]:\n \n         try:\n             observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)\n-            return self._fetch_peer_card(observer_peer_id, target=target_peer_id)\n+            card = self._fetch_peer_card(observer_peer_id, target=target_peer_id)\n+            if card:\n+                return card\n+            # Some backends store cards directly on the target peer, not the\n+            # observer-target slot. Fall back so honcho_profile still works.\n+            if target_peer_id:\n+                return self._fetch_peer_card(target_peer_id)\n+            return []\n         except Exception as e:\n             logger.debug(\"Failed to fetch peer card from Honcho: %s\", e)\n             return []","@@ -1164,13 +1241,22 @@ def set_peer_card(self, session_key: str, card: list[str], peer: str = \"user\") -\n         if not session:\n             return None\n         try:\n-            peer_id = self._resolve_peer_id(session, peer)\n-            if peer_id is None:\n+            observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)\n+            if observer_peer_id is None:\n                 logger.warning(\"Could not resolve peer '%s' for set_peer_card in session '%s'\", peer, session_key)\n                 return None\n-            peer_obj = self._get_or_create_peer(peer_id)\n-            result = peer_obj.set_card(card)\n-            logger.info(\"Updated peer card for %s (%d facts)\", peer_id, len(card))\n+            peer_obj = self._get_or_create_peer(observer_peer_id)\n+            result = (\n+                peer_obj.set_card(card, target=target_peer_id)\n+                if target_peer_id is not None\n+                else peer_obj.set_card(card)\n+            )\n+            logger.info(\n+                \"Updated peer card observer=%s target=%s (%d facts)\",\n+                observer_peer_id,\n+                target_peer_id or observer_peer_id,\n+                len(card),\n+            )\n             return result\n         except Exception as e:\n             logger.error(\"Failed to set peer card: %s\", e)"]},{"file":"run_agent.py","hunks":["@@ -393,6 +393,7 @@ def __init__(\n         prefill_messages: List[Dict[str, Any]] = None,\n         platform: str = None,\n         user_id: str = None,\n+        user_id_alt: str = None,\n         user_name: str = None,\n         chat_id: str = None,\n         chat_name: str = None,","@@ -462,6 +463,7 @@ def __init__(\n             prefill_messages=prefill_messages,\n             platform=platform,\n             user_id=user_id,\n+            user_id_alt=user_id_alt,\n             user_name=user_name,\n             chat_id=chat_id,\n             chat_name=chat_name,"]},{"file":"scripts/release.py","hunks":["@@ -213,6 +213,8 @@\n     \"[redacted:email]\": \"maciekczech\",\n     \"[redacted:email]\": \"LeonSGP43\",\n     \"[redacted:email]\": \"LeonSGP43\",\n+    \"[redacted:email]\": \"cyb0rgk1tty\",\n+    \"[redacted:email]\": \"cmullins70\",\n     \"[redacted:email]\": \"zeejaytan\",\n     \"[redacted:email]\": \"Aslaaen\",\n     \"[redacted:email]\": \"Tkander1715\","]},{"file":"tests/gateway/test_agent_cache.py","hunks":["@@ -1344,3 +1344,71 @@ def test_watchdog_accumulation_across_recursive_turns(self):\n             f\"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. \"\n             \"Inactivity timeout could not fire for a stuck interrupted turn.\"\n         )\n+\n+\n+class TestAgentConfigSignatureUserId:\n+    \"\"\"Shared-thread cache must not reuse an agent across users.\n+\n+    HonchoSessionManager freezes the resolved runtime user identity at\n+    first-message init.  When the gateway session_key omits the participant\n+    ID (``thread_sessions_per_user=False``), a cached AIAgent created by\n+    user A would otherwise be reused for user B, attributing B's writes to\n+    A's resolved peer.  Including ``user_id`` / ``user_id_alt`` in the\n+    signature forces per-user agent builds in shared threads.\n+\n+    Tradeoff: cold prompt cache for each user's first turn in a shared\n+    thread, in exchange for correct memory attribution.\n+    \"\"\"\n+\n+    def test_signature_changes_with_user_id(self):\n+        from gateway.run import GatewayRunner\n+        runtime = {\"provider\": \"anthropic\", \"api_key\": \"k\", \"base_url\": \"\", \"api_mode\": \"chat_completions\"}\n+        sig_a = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\", user_id=\"86701400\"\n+        )\n+        sig_b = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\", user_id=\"491827364\"\n+        )\n+        assert sig_a != sig_b\n+\n+    def test_signature_stable_with_same_user_id(self):\n+        from gateway.run import GatewayRunner\n+        runtime = {\"provider\": \"anthropic\", \"api_key\": \"k\", \"base_url\": \"\", \"api_mode\": \"chat_completions\"}\n+        sig_1 = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\", user_id=\"86701400\"\n+        )\n+        sig_2 = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\", user_id=\"86701400\"\n+        )\n+        assert sig_1 == sig_2\n+\n+    def test_signature_changes_with_user_id_alt(self):\n+        from gateway.run import GatewayRunner\n+        runtime = {\"provider\": \"anthropic\", \"api_key\": \"k\", \"base_url\": \"\", \"api_mode\": \"chat_completions\"}\n+        sig_a = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\",\n+            user_id=\"86701400\", user_id_alt=\"@igor_tg\",\n+        )\n+        sig_b = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\",\n+            user_id=\"86701400\", user_id_alt=\"@erosika_tg\",\n+        )\n+        assert sig_a != sig_b\n+\n+    def test_signature_omits_user_id_when_absent(self):\n+        \"\"\"Default-None user_id must not change signatures vs unset call.\n+\n+        Callers that pass no user_id kwarg must produce a signature\n+        byte-identical to ``user_id=None`` so in-flight caches survive\n+        the rollout of this fix.\n+        \"\"\"\n+        from gateway.run import GatewayRunner\n+        runtime = {\"provider\": \"anthropic\", \"api_key\": \"k\", \"base_url\": \"\", \"api_mode\": \"chat_completions\"}\n+        sig_implicit = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\",\n+        )\n+        sig_explicit_none = GatewayRunner._agent_config_signature(\n+            \"claude-sonnet-4\", runtime, [\"hermes-telegram\"], \"\",\n+            user_id=None, user_id_alt=None,\n+        )\n+        assert sig_implicit == sig_explicit_none"]},{"file":"tests/honcho_plugin/test_cli.py","hunks":["@@ -153,4 +153,424 @@ def _boom(hcfg, client):\n \n         out = capsys.readouterr().out\n         assert \"FAILED (Invalid API key)\" in out\n-        assert \"Connection... OK\" not in out\n\\ No newline at end of file\n+        assert \"Connection... OK\" not in out\n+\n+\n+class TestCloneHonchoForProfile:\n+    \"\"\"Identity-key carryover during profile cloning.\n+\n+    The host-scoped identity-mapping keys (``userPeerAliases``,\n+    ``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise\n+    the new profile silently fragments memory by resolving gateway users to\n+    raw runtime IDs instead of operator-declared peers.\n+    \"\"\"\n+\n+    def _setup_clone_env(self, monkeypatch, tmp_path, cfg):\n+        import plugins.memory.honcho.cli as honcho_cli\n+        cfg_path = tmp_path / \"config.json\"\n+        cfg_path.write_text(\"{}\")\n+        monkeypatch.setattr(honcho_cli, \"_read_config\", lambda: cfg)\n+        monkeypatch.setattr(honcho_cli, \"_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_local_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_ensure_peer_exists\", lambda host_key=None: True)\n+        written = {}\n+        def _write(c, path=None):\n+            written[\"cfg\"] = c\n+        monkeypatch.setattr(honcho_cli, \"_write_config\", _write)\n+        return honcho_cli, written\n+\n+    def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path):\n+        cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"userPeerAliases\": {\"86701400\": \"eri\", \"discord-491827364\": \"eri\"},\n+                    \"peerName\": \"eri\",\n+                },\n+            },\n+        }\n+        honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n+        ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n+        assert ok is True\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        assert new_block[\"userPeerAliases\"] == {\"86701400\": \"eri\", \"discord-491827364\": \"eri\"}\n+\n+    def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):\n+        cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"runtimePeerPrefix\": \"telegram_\",\n+                    \"peerName\": \"eri\",\n+                },\n+            },\n+        }\n+        honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n+        ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n+        assert ok is True\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        assert new_block[\"runtimePeerPrefix\"] == \"telegram_\"\n+\n+    def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):\n+        cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"pinPeerName\": True,\n+                    \"peerName\": \"eri\",\n+                },\n+            },\n+        }\n+        honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n+        ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n+        assert ok is True\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        assert new_block[\"pinPeerName\"] is True\n+\n+    def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):\n+        cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\"peerName\": \"eri\"}},\n+        }\n+        honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n+        ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n+        assert ok is True\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        assert \"userPeerAliases\" not in new_block\n+        assert \"runtimePeerPrefix\" not in new_block\n+        assert \"pinPeerName\" not in new_block\n+\n+\n+class TestSetupWizardDeploymentShape:\n+    \"\"\"The deployment-shape step writes pinPeerName / userPeerAliases /\n+    runtimePeerPrefix based on the operator's chosen shape.\n+\n+    Single-operator deployments collapse all platforms to peerName.\n+    Multi-user gateways leave the resolver to route per-runtime.\n+    Hybrid deployments alias the operator's own runtime IDs only.\n+\n+    These tests script the interactive _prompt calls and assert the\n+    resulting hermes_host block, so the wizard's deployment-shape\n+    semantics stay locked even as adjacent prompts are added.\n+    \"\"\"\n+\n+    def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):\n+        import plugins.memory.honcho.cli as honcho_cli\n+\n+        cfg_path = tmp_path / \"config.json\"\n+        cfg_path.write_text(\"{}\")\n+        cfg = initial_cfg if initial_cfg is not None else {\"apiKey\": \"***\"}\n+\n+        monkeypatch.setattr(honcho_cli, \"_read_config\", lambda: cfg)\n+        monkeypatch.setattr(honcho_cli, \"_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_local_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_host_key\", lambda: \"hermes\")\n+        monkeypatch.setattr(honcho_cli, \"_ensure_sdk_installed\", lambda: True)\n+        monkeypatch.setattr(honcho_cli, \"_write_config\", lambda *a, **k: None)\n+\n+        # Bypass config.yaml + connection test side effects.\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\", lambda: {\"memory\": {}}, raising=False,\n+        )\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.save_config\", lambda c: None, raising=False,\n+        )\n+\n+        class _FakeClientCfg:\n+            def resolve_session_name(self):\n+                return \"hermes-test\"\n+            workspace_id = \"hermes\"\n+            peer_name = \"eri\"\n+            ai_peer = \"hermetika\"\n+            observation_mode = \"directional\"\n+            write_frequency = \"async\"\n+            recall_mode = \"hybrid\"\n+            session_strategy = \"per-session\"\n+\n+        monkeypatch.setattr(\n+            \"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\",\n+            lambda host=None: _FakeClientCfg(),\n+        )\n+        monkeypatch.setattr(\n+            \"plugins.memory.honcho.client.reset_honcho_client\",\n+            lambda: None,\n+        )\n+        monkeypatch.setattr(\n+            \"plugins.memory.honcho.client.get_honcho_client\",\n+            lambda hcfg: object(),\n+        )\n+\n+        # Scripted _prompt: pop answers in order. Default-return for unconsumed prompts.\n+        answer_iter = iter(answers)\n+        def _scripted_prompt(label, default=None, secret=[redacted:secret-assignment]\n+            try:\n+                return next(answer_iter)\n+            except StopIteration:\n+                return default if default is not None else \"\"\n+        monkeypatch.setattr(honcho_cli, \"_prompt\", _scripted_prompt)\n+\n+        honcho_cli.cmd_setup(SimpleNamespace())\n+        return cfg[\"hosts\"][\"hermes\"]\n+\n+    def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):\n+        answers = [\n+            \"cloud\",           # deployment\n+            \"\",                # api key (keep)\n+            \"eri\",             # peer name\n+            \"hermetika\",       # ai peer\n+            \"hermes\",          # workspace\n+            \"single\",          # deployment shape ← key answer\n+            # remaining prompts fall through to defaults\n+        ]\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\n+                \"userPeerAliases\": {\"old\": \"stale\"},\n+                \"runtimePeerPrefix\": \"old_\",\n+            }},\n+        }\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is True\n+        assert \"userPeerAliases\" not in host\n+        assert \"runtimePeerPrefix\" not in host\n+\n+    def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):\n+        answers = [\n+            \"cloud\",           # deployment\n+            \"\",                # api key (keep)\n+            \"eri\",             # peer name\n+            \"hermetika\",       # ai peer\n+            \"hermes\",          # workspace\n+            \"multi\",           # deployment shape\n+            \"telegram_\",       # runtime peer prefix\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers)\n+        assert host[\"pinPeerName\"] is False\n+        # Multi must NOT auto-write ``userPeerAliases: {}``: an empty host\n+        # map would silently override a root-level baseline.  Absence is\n+        # the correct \"no host opinion\" signal.\n+        assert \"userPeerAliases\" not in host\n+        assert host[\"runtimePeerPrefix\"] == \"telegram_\"\n+\n+    def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):\n+        answers = [\n+            \"cloud\",           # deployment\n+            \"\",                # api key (keep)\n+            \"eri\",             # peer name\n+            \"hermetika\",       # ai peer\n+            \"hermes\",          # workspace\n+            \"hybrid\",          # deployment shape\n+            \"86701400\",        # telegram uid\n+            \"491827364\",       # discord snowflake\n+            \"\",                # slack (skip)\n+            \"\",                # matrix (skip)\n+            \"\",                # runtime peer prefix (skip)\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers)\n+        assert host[\"pinPeerName\"] is False\n+        assert host[\"userPeerAliases\"] == {\n+            \"86701400\": \"eri\",\n+            \"491827364\": \"eri\",\n+        }\n+        assert \"runtimePeerPrefix\" not in host\n+\n+    def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path):\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\n+                \"pinPeerName\": True,\n+                \"userPeerAliases\": {\"keep\": \"me\"},\n+                \"runtimePeerPrefix\": \"keep_\",\n+            }},\n+        }\n+        answers = [\n+            \"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\", \"skip\",\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is True\n+        assert host[\"userPeerAliases\"] == {\"keep\": \"me\"}\n+        assert host[\"runtimePeerPrefix\"] == \"keep_\"\n+\n+    def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):\n+        \"\"\"Flipping single → multi triggers a warning that auto-steers the\n+        operator to ``hybrid`` (default), so their own runtime IDs keep\n+        landing on peerName instead of orphaning the pinned-pool history.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\"pinPeerName\": True, \"peerName\": \"eri\"}},\n+        }\n+        answers = [\n+            \"cloud\",           # deployment\n+            \"\",                # api key (keep)\n+            \"eri\",             # peer name\n+            \"hermetika\",       # ai peer\n+            \"hermes\",          # workspace\n+            \"multi\",           # deployment shape — triggers the guard\n+            \"hybrid\",          # guard response: accept the steer\n+            \"86701400\",        # telegram uid\n+            \"\",                # discord (skip)\n+            \"\",                # slack (skip)\n+            \"\",                # matrix (skip)\n+            \"\",                # runtime prefix (skip)\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is False\n+        assert host[\"userPeerAliases\"] == {\"86701400\": \"eri\"}\n+\n+    def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):\n+        \"\"\"Operator can override the steer by answering ``yes`` and accept\n+        the orphaning consequences.  This is the explicit undo-the-pin path.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\"pinPeerName\": True, \"peerName\": \"eri\"}},\n+        }\n+        answers = [\n+            \"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\",\n+            \"multi\",           # deployment shape — triggers the guard\n+            \"yes\",             # guard response: confirm multi\n+            \"telegram_\",       # runtime peer prefix\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is False\n+        # See test_multi_shape_leaves_pin_false_and_accepts_prefix.\n+        assert \"userPeerAliases\" not in host\n+        assert host[\"runtimePeerPrefix\"] == \"telegram_\"\n+\n+    def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):\n+        \"\"\"Host-level ``pinUserPeer: true`` must classify as ``single``.\n+\n+        Pressing Enter at the shape prompt then preserves the pin instead\n+        of falling through to ``multi`` and orphaning the user's memory\n+        pool — the bug the wizard regressed when ``pinUserPeer`` landed\n+        as a higher-precedence alias.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\"pinUserPeer\": True, \"peerName\": \"eri\"}},\n+        }\n+        # Exhaust the iterator before the shape prompt so the scripted\n+        # mock falls through to the prompt's default (which is the\n+        # wizard-detected shape).  Scripting an explicit \"\" would NOT\n+        # exercise that fallthrough — the mock returns it literally.\n+        answers = [\"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\"]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        # Scrub-then-write normalises onto pinPeerName and drops the alias\n+        # so resolver precedence can't reintroduce ambiguity.\n+        assert host[\"pinPeerName\"] is True\n+        assert \"pinUserPeer\" not in host\n+\n+    def test_host_pin_user_peer_false_overrides_root_pin_peer_name(\n+        self, monkeypatch, tmp_path\n+    ):\n+        \"\"\"Host ``pinUserPeer: false`` outranks host ``pinPeerName`` in the\n+        resolver.  Detection must agree, otherwise the wizard would offer\n+        ``single`` as the default and silently re-pin a profile the\n+        operator explicitly unpinned via the newer key.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\n+                \"pinUserPeer\": False,\n+                \"pinPeerName\": True,\n+                \"peerName\": \"eri\",\n+            }},\n+        }\n+        answers = [\"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\"]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is False\n+        assert \"pinUserPeer\" not in host\n+\n+    def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path):\n+        \"\"\"Root-level ``userPeerAliases`` must classify as ``hybrid`` even\n+        when the host block has no aliases of its own.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"userPeerAliases\": {\"86701400\": \"eri\"},\n+            \"hosts\": {\"hermes\": {\"peerName\": \"eri\"}},\n+        }\n+        answers = [\"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\"]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is False\n+        # Hybrid materialises the root aliases into the host so subsequent\n+        # operator edits live on the host block they're inspecting.\n+        assert host[\"userPeerAliases\"] == {\"86701400\": \"eri\"}\n+\n+    def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):\n+        \"\"\"Explicit ``multi`` must leave the host ``userPeerAliases`` key\n+        absent, preserving any root-level aliases as a cross-host baseline.\n+\n+        Picking ``multi`` here is an active choice — detection would have\n+        defaulted to ``hybrid`` because root aliases exist — so the\n+        operator's intent is to drop the alias mapping for this host.\n+        We honor that by writing ``pinPeerName: false`` only, and rely\n+        on the host's absence of ``userPeerAliases`` to inherit root.\n+        That inheritance is intentional: a true wipe would require the\n+        operator to delete the root key explicitly.\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"userPeerAliases\": {\"baseline\": \"eri\"},\n+            \"hosts\": {\"hermes\": {\"peerName\": \"eri\"}},\n+        }\n+        answers = [\n+            \"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\",\n+            \"multi\",           # explicit multi override of detected hybrid\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is False\n+        assert \"userPeerAliases\" not in host\n+\n+    def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):\n+        \"\"\"Choosing ``single`` must drop any host-level ``pinUserPeer``,\n+        otherwise an existing ``pinUserPeer: false`` would outrank the\n+        freshly written ``pinPeerName: true`` and leave the profile\n+        effectively unpinned (the P1 latent-precedence regression).\n+        \"\"\"\n+        initial_cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\n+                \"pinUserPeer\": False,\n+                \"peerName\": \"eri\",\n+            }},\n+        }\n+        answers = [\n+            \"cloud\", \"\", \"eri\", \"hermetika\", \"hermes\",\n+            \"single\",\n+        ]\n+        host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)\n+        assert host[\"pinPeerName\"] is True\n+        assert \"pinUserPeer\" not in host\n+\n+\n+class TestCloneCarriesPinUserPeer:\n+    \"\"\"``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a\n+    profile clone.  Without this, a default profile that uses the newer\n+    key would silently produce cloned profiles without the pin even\n+    though the resolver prefers ``pinUserPeer`` over ``pinPeerName``.\n+    \"\"\"\n+\n+    def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path):\n+        import plugins.memory.honcho.cli as honcho_cli\n+\n+        cfg = {\n+            \"apiKey\": \"***\",\n+            \"hosts\": {\"hermes\": {\"pinUserPeer\": True, \"peerName\": \"eri\"}},\n+        }\n+        cfg_path = tmp_path / \"config.json\"\n+        cfg_path.write_text(\"{}\")\n+        monkeypatch.setattr(honcho_cli, \"_read_config\", lambda: cfg)\n+        monkeypatch.setattr(honcho_cli, \"_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_local_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_ensure_peer_exists\", lambda host_key=None: True)\n+        written = {}\n+        monkeypatch.setattr(\n+            honcho_cli, \"_write_config\", lambda c, path=None: written.setdefault(\"cfg\", c),\n+        )\n+\n+        ok = honcho_cli.clone_honcho_for_profile(\"partner\")\n+        assert ok is True\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes.partner\"]\n+        assert new_block[\"pinUserPeer\"] is True"]},{"file":"tests/honcho_plugin/test_pin_peer_name.py","hunks":["@@ -1,24 +1,20 @@\n-\"\"\"Tests for the ``pinPeerName`` config flag (#14984).\n-\n-By default, when Hermes runs under a gateway (Telegram, Discord, Slack, ...)\n-it passes the platform-native user ID as ``runtime_user_peer_name`` into\n-``HonchoSessionManager``.  That ID wins over any configured ``peer_name``\n-so multi-user bots scope memory per user.\n-\n-For a single-user personal deployment where the user connects over multiple\n-platforms, that default forks memory into one Honcho peer per platform\n-(Telegram UID, Discord snowflake, Slack user ID, ...).  The user asked for\n-an opt-in knob that pins the user peer to ``peer_name`` from ``honcho.json``\n-so the same person's memory stays unified regardless of which platform the\n-turn arrived on — ``hosts.<host>.pinPeerName: true`` (or root-level\n-``pinPeerName: true``).\n-\n-These tests exercise both the config parsing (``client.py::from_global_config``)\n-and the resolution order (``session.py::get_or_create``).  We stub the\n-Honcho API calls so we can assert the chosen ``user_peer_id`` without\n-touching the network.\n+\"\"\"Tests for the ``pinPeerName`` / ``pinUserPeer`` config flag.\n+\n+Under a gateway (Telegram, Discord, Slack, ...) Hermes passes the\n+platform-native user ID as ``runtime_user_peer_name`` into\n+``HonchoSessionManager``.  By default that ID wins over any configured\n+``peer_name`` so multi-user bots scope memory per user.\n+\n+For single-user deployments connecting over multiple platforms,\n+``pinUserPeer: true`` pins the user peer to ``peer_name`` so memory stays\n+unified across platforms.\n+\n+Tests cover config parsing (``client.py::from_global_config``) and resolver\n+order (``session.py::get_or_create``), stubbing Honcho API calls so the\n+chosen ``user_peer_id`` can be asserted without touching the network.\n \"\"\"\n \n+import hashlib\n import json\n from unittest.mock import MagicMock\n ","@@ -99,6 +95,90 @@ def test_explicit_false_parses(self, tmp_path, monkeypatch):\n         assert config.pin_peer_name is False\n \n \n+class TestRuntimePeerMappingConfigParsing:\n+    def test_defaults_are_empty(self):\n+        config = HonchoClientConfig()\n+        assert config.user_peer_aliases == {}\n+        assert config.runtime_peer_prefix == \"\"\n+\n+    def test_root_level_aliases_and_prefix_parse(self, tmp_path):\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"userPeerAliases\": {\n+                \" 86701400 \": \" Igor \",\n+                \"\": \"ignored\",\n+                \"empty-value\": \" \",\n+                \"null-value\": None,\n+            },\n+            \"runtimePeerPrefix\": \"telegram_\",\n+        }))\n+\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert config.user_peer_aliases == {\"86701400\": \"Igor\"}\n+        assert config.runtime_peer_prefix == \"telegram_\"\n+\n+    def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path):\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"userPeerAliases\": {\"root-user\": \"root-peer\"},\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"userPeerAliases\": {\"host-user\": \"host-peer\"},\n+                },\n+            },\n+        }))\n+\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert config.user_peer_aliases == {\"host-user\": \"host-peer\"}\n+\n+    def test_host_empty_aliases_disable_root_aliases(self, tmp_path):\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"userPeerAliases\": {\"root-user\": \"root-peer\"},\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"userPeerAliases\": {},\n+                },\n+            },\n+        }))\n+\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert config.user_peer_aliases == {}\n+\n+    def test_host_empty_prefix_disables_root_prefix(self, tmp_path):\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"runtimePeerPrefix\": \"telegram_\",\n+            \"hosts\": {\n+                \"hermes\": {\n+                    \"runtimePeerPrefix\": \"\",\n+                },\n+            },\n+        }))\n+\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert config.runtime_peer_prefix == \"\"\n+\n+    def test_malformed_alias_config_is_ignored(self, tmp_path):\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"userPeerAliases\": [\"not\", \"a\", \"map\"],\n+        }))\n+\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert config.user_peer_aliases == {}\n+\n+\n # ---------------------------------------------------------------------------\n # Peer resolution (the actual bug fix)\n # ---------------------------------------------------------------------------","@@ -119,13 +199,24 @@ def _patch_manager_for_resolution_test(mgr: HonchoSessionManager) -> None:\n class TestPeerResolutionOrder:\n     \"\"\"Matrix of (runtime_id, pin_peer_name, peer_name) → expected user_peer_id.\"\"\"\n \n-    def _config(self, *, peer_name: str | None, pin_peer_name: bool) -> HonchoClientConfig:\n+    def _config(\n+        self,\n+        *,\n+        peer_name: str | None,\n+        pin_peer_name: bool,\n+        user_peer_aliases: dict[str, str] | None = None,\n+        runtime_peer_prefix: str = \"\",\n+        session_peer_prefix: bool = False,\n+    ) -> HonchoClientConfig:\n         # The test doesn't need auth / Honcho — disable the provider so\n         # the manager doesn't try to open a real client.\n         return HonchoClientConfig(\n             api_key=\"[redacted:secret-assignment]\",\n             peer_name=peer_name,\n             pin_peer_name=pin_peer_name,\n+            user_peer_aliases=user_peer_aliases or {},\n+            runtime_peer_prefix=runtime_peer_prefix,\n+            session_peer_prefix=session_peer_prefix,\n             enabled=False,\n             write_frequency=\"turn\",  # avoid spawning the async writer thread\n         )","@@ -148,11 +239,177 @@ def test_runtime_wins_when_pin_is_false(self):\n             \"bot immediately merges memory across users.\"\n         )\n \n+    def test_alias_wins_for_known_runtime_id(self):\n+        \"\"\"Known platform IDs can preserve an existing stable Honcho peer.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=\"Igor\",\n+                pin_peer_name=False,\n+                user_peer_aliases={\"86701400\": \"Igor\"},\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == \"Igor\"\n+\n+    def test_unknown_runtime_id_uses_prefix(self):\n+        \"\"\"Unknown gateway users stay isolated but become platform-scoped.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=\"Igor\",\n+                pin_peer_name=False,\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == \"telegram_86701400\"\n+\n+    def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self):\n+        \"\"\"Generated prefixed IDs avoid merges caused by lossy sanitization.\"\"\"\n+        raw_peer_id = \"telegram_user:42\"\n+        expected_hash = hashlib.sha256(raw_peer_id.encode(\"utf-8\")).hexdigest()[:8]\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"user:42\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:user:42\")\n+        assert session.user_peer_id == f\"telegram_user-42-{expected_hash}\"\n+\n+    def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self):\n+        \"\"\"Unknown generated peers should not silently merge into peerName.\"\"\"\n+        raw_peer_id = \"telegram_86701400\"\n+        expected_hash = hashlib.sha256(raw_peer_id.encode(\"utf-8\")).hexdigest()[:8]\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=\"telegram_86701400\",\n+                pin_peer_name=False,\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == f\"telegram_86701400-{expected_hash}\"\n+\n+    def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self):\n+        \"\"\"Unknown generated peers should not silently merge into alias targets.\"\"\"\n+        raw_peer_id = \"telegram_86701400\"\n+        expected_hash = hashlib.sha256(raw_peer_id.encode(\"utf-8\")).hexdigest()[:8]\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\"known-user\": \"telegram_86701400\"},\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == f\"telegram_86701400-{expected_hash}\"\n+\n+    def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self):\n+        raw_peer_id = \"telegram_86701400\"\n+        digest = hashlib.sha256(raw_peer_id.encode(\"utf-8\")).hexdigest()\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\n+                    \"known-user\": \"telegram_86701400\",\n+                    \"reserved-user\": f\"telegram_86701400-{digest[:8]}\",\n+                },\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == f\"telegram_86701400-{digest[:12]}\"\n+\n+    def test_alias_value_is_sanitized_after_selection(self):\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\"86701400\": \"Alice Smith!\"},\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == \"Alice-Smith-\"\n+\n+    def test_alias_keys_match_raw_runtime_id_before_sanitization(self):\n+        \"\"\"Alias selection is exact on platform IDs before Honcho ID cleanup.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\n+                    \"user:42\": \"raw-match\",\n+                    \"user-42\": \"sanitized-match\",\n+                },\n+            ),\n+            runtime_user_peer_name=\"user:42\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:user:42\")\n+        assert session.user_peer_id == \"raw-match\"\n+\n+    def test_session_peer_prefix_is_orthogonal_to_runtime_peer_prefix(self):\n+        \"\"\"sessionPeerPrefix scopes session IDs; runtimePeerPrefix scopes user peers.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=\"Igor\",\n+                pin_peer_name=False,\n+                runtime_peer_prefix=\"telegram_\",\n+                session_peer_prefix=True,\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == \"telegram_86701400\"\n+        assert session.honcho_session_id == \"telegram-86701400\"\n+\n     def test_config_wins_when_pin_is_true(self):\n-        \"\"\"The #14984 fix: single-user deployments opt into config pinning.\"\"\"\n+        \"\"\"With pin enabled, configured peer_name beats runtime ID.\"\"\"\n         mgr = HonchoSessionManager(\n             honcho=MagicMock(),\n-            config=self._config(peer_name=\"Igor\", pin_peer_name=True),\n+            config=self._config(\n+                peer_name=\"Igor\",\n+                pin_peer_name=True,\n+                user_peer_aliases={\"86701400\": \"Alias\"},\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n             runtime_user_peer_name=\"86701400\",  # Telegram pushes this in\n         )\n         _patch_manager_for_resolution_test(mgr)","@@ -167,7 +424,23 @@ def test_config_wins_when_pin_is_true(self):\n     def test_pin_noop_when_peer_name_missing(self):\n         \"\"\"Safety: pinPeerName alone (no peer_name) must not silently drop\n         the runtime identity.  Without a configured peer_name there's\n-        nothing to pin to — fall back to runtime as before.\"\"\"\n+        nothing to pin to — fall through to runtime mapping.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=True,\n+                user_peer_aliases={\"86701400\": \"Igor\"},\n+                runtime_peer_prefix=\"telegram_\",\n+            ),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"telegram:86701400\")\n+        assert session.user_peer_id == \"Igor\"\n+\n+    def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self):\n         mgr = HonchoSessionManager(\n             honcho=MagicMock(),\n             config=self._config(peer_name=None, pin_peer_name=True),","@@ -176,11 +449,42 @@ def test_pin_noop_when_peer_name_missing(self):\n         _patch_manager_for_resolution_test(mgr)\n \n         session = mgr.get_or_create(\"telegram:86701400\")\n-        assert session.user_peer_id == \"86701400\", (\n-            \"pin_peer_name=True with no peer_name set must not strip the \"\n-            \"runtime ID — otherwise the user peer would collapse to the \"\n-            \"session-key fallback and lose per-user scoping entirely\"\n+        assert session.user_peer_id == \"86701400\"\n+\n+    def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self):\n+        \"\"\"Stable alternate IDs can map known users while primary ID fallback stays unchanged.\"\"\"\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\"union-user\": \"Igor\"},\n+                runtime_peer_prefix=\"feishu_\",\n+            ),\n+            runtime_user_peer_name=\"open-id\",\n+            runtime_user_peer_name_alt=\"union-user\",\n         )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"feishu:chat\")\n+        assert session.user_peer_id == \"Igor\"\n+\n+    def test_alt_runtime_id_does_not_replace_primary_prefix_fallback(self):\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._config(\n+                peer_name=None,\n+                pin_peer_name=False,\n+                user_peer_aliases={\"other-union\": \"Igor\"},\n+                runtime_peer_prefix=\"feishu_\",\n+            ),\n+            runtime_user_peer_name=\"open-id\",\n+            runtime_user_peer_name_alt=\"union-user\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+\n+        session = mgr.get_or_create(\"feishu:chat\")\n+        assert session.user_peer_id == \"feishu_open-id\"\n \n     def test_runtime_missing_falls_back_to_peer_name(self):\n         \"\"\"CLI-mode (no gateway runtime identity) uses config peer_name —","@@ -233,9 +537,8 @@ def test_pin_does_not_affect_assistant_peer(self):\n \n \n class TestCrossPlatformMemoryUnification:\n-    \"\"\"The user-visible outcome of the #14984 fix: the same physical user\n-    talking to Hermes via Telegram AND Discord should land on ONE peer\n-    (not two) when pinPeerName is opted in.\n+    \"\"\"The same physical user talking to Hermes via Telegram AND Discord\n+    lands on ONE peer when ``pinPeerName`` is opted in.\n     \"\"\"\n \n     def _config_pinned(self) -> HonchoClientConfig:","@@ -305,3 +608,277 @@ def test_multiuser_default_keeps_platforms_separate(self):\n             \"multi-user default MUST keep users separate — a regression \"\n             \"here would silently merge unrelated users' memory\"\n         )\n+\n+\n+class TestPinUserPeerAlias:\n+    \"\"\"``pinUserPeer`` and ``pinPeerName`` both resolve to the same internal\n+    ``pin_peer_name`` field.  Precedence when both appear: host pinUserPeer →\n+    host pinPeerName → root pinUserPeer → root pinPeerName → default.\n+    \"\"\"\n+\n+    def test_root_pinUserPeer_true_pins(self, tmp_path):\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+        import json\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"***\",\n+            \"peerName\": \"eri\",\n+            \"pinUserPeer\": True,\n+        }))\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+        assert config.pin_peer_name is True\n+\n+    def test_host_pinUserPeer_wins_over_root_pinPeerName(self, tmp_path):\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+        import json\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"***\",\n+            \"peerName\": \"eri\",\n+            \"pinPeerName\": False,\n+            \"hosts\": {\"hermes\": {\"pinUserPeer\": True}},\n+        }))\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+        assert config.pin_peer_name is True\n+\n+    def test_host_pinUserPeer_false_disables_root_pinPeerName(self, tmp_path):\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+        import json\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"***\",\n+            \"peerName\": \"eri\",\n+            \"pinPeerName\": True,\n+            \"hosts\": {\"hermes\": {\"pinUserPeer\": False}},\n+        }))\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+        assert config.pin_peer_name is False, (\n+            \"Host-level pinUserPeer=false must override root-level \"\n+            \"pinPeerName=true so a host can unpin a globally-pinned profile.\"\n+        )\n+\n+    def test_pinPeerName_still_works_unchanged(self, tmp_path):\n+        from plugins.memory.honcho.client import HonchoClientConfig\n+        import json\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"***\",\n+            \"peerName\": \"eri\",\n+            \"hosts\": {\"hermes\": {\"pinPeerName\": True}},\n+        }))\n+        config = HonchoClientConfig.from_global_config(config_path=config_file)\n+        assert config.pin_peer_name is True\n+\n+\n+class TestPinTransition:\n+    \"\"\"Behavior when honcho.json flips ``pinPeerName`` true → false.\n+\n+    Covers two contracts:\n+      1. A freshly-built manager picks up the flipped config and resolves\n+         the same runtime ID to a new peer (no resolver staleness).\n+      2. The gateway's agent-cache signature reflects honcho identity-mapping\n+         changes, so a config edit busts the cached AIAgent on the next turn.\n+    \"\"\"\n+\n+    def _pinned(self) -> HonchoClientConfig:\n+        return HonchoClientConfig(\n+            api_key=\"k\",\n+            peer_name=\"Igor\",\n+            pin_peer_name=True,\n+            enabled=False,\n+            write_frequency=\"turn\",\n+        )\n+\n+    def _unpinned(self) -> HonchoClientConfig:\n+        return HonchoClientConfig(\n+            api_key=\"k\",\n+            peer_name=\"Igor\",\n+            pin_peer_name=False,\n+            enabled=False,\n+            write_frequency=\"turn\",\n+        )\n+\n+    def test_fresh_manager_after_flip_resolves_to_runtime(self):\n+        pinned_mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._pinned(),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(pinned_mgr)\n+        before = pinned_mgr.get_or_create(\"telegram:86701400\")\n+        assert before.user_peer_id == \"Igor\"\n+\n+        unpinned_mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._unpinned(),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(unpinned_mgr)\n+        after = unpinned_mgr.get_or_create(\"telegram:86701400\")\n+        assert after.user_peer_id == \"86701400\", (\n+            \"After flipping pinPeerName off, the same runtime ID must resolve \"\n+            \"to its own peer — otherwise multi-user mode silently merges users.\"\n+        )\n+\n+    def test_cached_session_survives_config_flip_in_same_manager(self):\n+        mgr = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._pinned(),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr)\n+        first = mgr.get_or_create(\"telegram:86701400\")\n+        assert first.user_peer_id == \"Igor\"\n+\n+        mgr._config = self._unpinned()\n+        second = mgr.get_or_create(\"telegram:86701400\")\n+        assert second.user_peer_id == \"Igor\", (\n+            \"The per-key session cache is keyed by session-key, not by \"\n+            \"resolved peer.  In-process flips don't invalidate it — the \"\n+            \"gateway cache must bust the whole manager instead.\"\n+        )\n+\n+    def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypatch):\n+        \"\"\"Gateway agent cache must bust when honcho.json's pinPeerName flips.\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        cfg_path = tmp_path / \"honcho.json\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\", \"pinPeerName\": True}))\n+        sig_pinned = GatewayRunner._extract_cache_busting_config({})\n+\n+        cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\", \"pinPeerName\": False}))\n+        sig_unpinned = GatewayRunner._extract_cache_busting_config({})\n+\n+        assert sig_pinned[\"honcho.pin_peer_name\"] != sig_unpinned[\"honcho.pin_peer_name\"]\n+\n+    def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monkeypatch):\n+        from gateway.run import GatewayRunner\n+\n+        cfg_path = tmp_path / \"honcho.json\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\"}))\n+        sig_no_aliases = GatewayRunner._extract_cache_busting_config({})\n+\n+        cfg_path.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"peerName\": \"Igor\",\n+            \"userPeerAliases\": {\"86701400\": \"Igor\"},\n+        }))\n+        sig_with_aliases = GatewayRunner._extract_cache_busting_config({})\n+\n+        assert sig_no_aliases[\"honcho.user_peer_aliases\"] != sig_with_aliases[\"honcho.user_peer_aliases\"]\n+\n+    def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, monkeypatch):\n+        from gateway.run import GatewayRunner\n+\n+        cfg_path = tmp_path / \"honcho.json\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\"}))\n+        sig_no_prefix = GatewayRunner._extract_cache_busting_config({})\n+\n+        cfg_path.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"peerName\": \"Igor\",\n+            \"runtimePeerPrefix\": \"telegram_\",\n+        }))\n+        sig_with_prefix = GatewayRunner._extract_cache_busting_config({})\n+\n+        assert sig_no_prefix[\"honcho.runtime_peer_prefix\"] != sig_with_prefix[\"honcho.runtime_peer_prefix\"]\n+\n+    def test_cache_busting_signature_reflects_ai_peer(self, tmp_path, monkeypatch):\n+        \"\"\"Editing ``aiPeer`` mid-flight must invalidate the cached agent.\n+\n+        ``HonchoSessionManager`` freezes ``cfg.ai_peer`` at construction —\n+        without busting here, assistant writes keep landing on the old\n+        peer until an unrelated cache eviction.\n+        \"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        cfg_path = tmp_path / \"honcho.json\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        cfg_path.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"peerName\": \"Igor\",\n+            \"aiPeer\": \"hermes\",\n+        }))\n+        sig_before = GatewayRunner._extract_cache_busting_config({})\n+\n+        cfg_path.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"peerName\": \"Igor\",\n+            \"aiPeer\": \"hermetika\",\n+        }))\n+        sig_after = GatewayRunner._extract_cache_busting_config({})\n+\n+        assert sig_before[\"honcho.ai_peer\"] != sig_after[\"honcho.ai_peer\"]\n+\n+\n+class TestProfilePeerUniqueness:\n+    \"\"\"Each Hermes profile can pin to its own unique peerName.\n+\n+    Profile cloning copies host blocks, but operators routinely diverge them\n+    afterwards (e.g. `hermes -p partner` pinned to a different person's peer).\n+    The resolver must honor host-level ``peerName`` so two profiles in the\n+    same workspace stay scoped to different Honcho peers.\n+    \"\"\"\n+\n+    def _pinned_to(self, name: str) -> HonchoClientConfig:\n+        return HonchoClientConfig(\n+            api_key=\"k\",\n+            peer_name=name,\n+            pin_peer_name=True,\n+            enabled=False,\n+            write_frequency=\"turn\",\n+        )\n+\n+    def test_two_profiles_pinned_to_different_peer_names_resolve_distinctly(self):\n+        mgr_a = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._pinned_to(\"alice\"),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr_a)\n+        sess_a = mgr_a.get_or_create(\"telegram:86701400\")\n+\n+        mgr_b = HonchoSessionManager(\n+            honcho=MagicMock(),\n+            config=self._pinned_to(\"bob\"),\n+            runtime_user_peer_name=\"86701400\",\n+        )\n+        _patch_manager_for_resolution_test(mgr_b)\n+        sess_b = mgr_b.get_or_create(\"telegram:86701400\")\n+\n+        assert sess_a.user_peer_id == \"alice\"\n+        assert sess_b.user_peer_id == \"bob\"\n+        assert sess_a.user_peer_id != sess_b.user_peer_id, (\n+            \"Profiles pinned to distinct peer names must not collapse to \"\n+            \"the same Honcho peer — otherwise profile isolation is fictional.\"\n+        )\n+\n+    def test_host_peer_name_overrides_root_when_pinned(self, tmp_path, monkeypatch):\n+        \"\"\"Host-level peerName wins so each profile can pin uniquely while\n+        sharing a single root-level apiKey and workspace.\n+        \"\"\"\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"k\",\n+            \"peerName\": \"default-user\",\n+            \"hosts\": {\n+                \"hermes.partner\": {\n+                    \"peerName\": \"partner-user\",\n+                    \"pinPeerName\": True,\n+                },\n+            },\n+        }))\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"isolated\"))\n+\n+        cfg = HonchoClientConfig.from_global_config(\n+            host=\"hermes.partner\", config_path=config_file,\n+        )\n+        assert cfg.peer_name == \"partner-user\"\n+        assert cfg.pin_peer_name is True"]},{"file":"tests/honcho_plugin/test_session.py","hunks":["@@ -212,6 +212,39 @@ def test_get_peer_card_uses_direct_peer_lookup(self):\n         assert mgr.get_peer_card(session.key) == [\"Name: Robert\"]\n         assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id)\n \n+    def test_get_peer_card_falls_back_to_target_peer_own_card(self):\n+        # When the observer-target card slot is empty (returns None/[]), fall\n+        # back to the target peer's own card. Self-hosted Honcho v3 stores the\n+        # peer card on the peer itself; the observer-target slot is only\n+        # populated when writes also go through that path.\n+        mgr, session = self._make_cached_manager()\n+        assistant_peer = MagicMock()\n+        assistant_peer.get_card.return_value = None  # observer-target slot empty\n+        user_peer = MagicMock()\n+        user_peer.get_card.return_value = [\"Prefers: dark mode\"]\n+\n+        def _peer(peer_id: str) -> MagicMock:\n+            return assistant_peer if peer_id == session.assistant_peer_id else user_peer\n+\n+        mgr._get_or_create_peer = MagicMock(side_effect=_peer)\n+\n+        assert mgr.get_peer_card(session.key) == [\"Prefers: dark mode\"]\n+        assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id)\n+        user_peer.get_card.assert_called_once_with()\n+\n+    def test_set_peer_card_uses_observer_target_in_ai_observe_others_mode(self):\n+        # Writes must go to the same observer-target slot that reads check,\n+        # so that a subsequent honcho_profile read returns what was written.\n+        mgr, session = self._make_cached_manager()\n+        assistant_peer = MagicMock()\n+        assistant_peer.set_card.return_value = [\"Role: user\"]\n+        mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)\n+\n+        result = mgr.set_peer_card(session.key, [\"Role: user\"])\n+\n+        assert result == [\"Role: user\"]\n+        assistant_peer.set_card.assert_called_once_with([\"Role: user\"], target=session.user_peer_id)\n+\n     def test_search_context_uses_assistant_perspective_with_target(self):\n         mgr, session = self._make_cached_manager()\n         assistant_peer = MagicMock()","@@ -573,7 +606,7 @@ class TestToolsModeInitBehavior:\n     \"\"\"Verify initOnSessionStart controls session init timing in tools mode.\"\"\"\n \n     def _make_provider_with_config(self, recall_mode=\"tools\", init_on_session_start=False,\n-                                    peer_name=None, user_id=None):\n+                                    peer_name=None, user_id=None, user_id_alt=None):\n         \"\"\"Create a HonchoMemoryProvider with mocked config and dependencies.\"\"\"\n         from plugins.memory.honcho.client import HonchoClientConfig\n ","@@ -598,6 +631,8 @@ def _make_provider_with_config(self, recall_mode=\"tools\", init_on_session_start=\n         init_kwargs = {}\n         if user_id:\n             init_kwargs[\"user_id\"] = user_id\n+        if user_id_alt:\n+            init_kwargs[\"user_id_alt\"] = user_id_alt\n \n         with patch(\"plugins.memory.honcho.client.HonchoClientConfig.from_global_config\", return_value=cfg), \\\n              patch(\"plugins.memory.honcho.client.get_honcho_client\", return_value=MagicMock()), \\","@@ -655,6 +690,15 @@ def test_user_id_used_when_no_peer_name(self):\n         assert cfg.peer_name is None\n         assert mock_manager_cls.call_args.kwargs[\"runtime_user_peer_name\"] == \"8439114563\"\n \n+    def test_user_id_alt_is_passed_to_session_manager(self):\n+        \"\"\"Gateway alternate user IDs are available for Honcho alias matching.\"\"\"\n+        _, _, mock_manager_cls = self._make_provider_with_config(\n+            recall_mode=\"tools\", init_on_session_start=True,\n+            peer_name=None, user_id=\"open-id\", user_id_alt=\"union-id\",\n+        )\n+        assert mock_manager_cls.call_args.kwargs[\"runtime_user_peer_name\"] == \"open-id\"\n+        assert mock_manager_cls.call_args.kwargs[\"runtime_user_peer_name_alt\"] == \"union-id\"\n+\n \n class TestPerSessionMigrateGuard:\n     \"\"\"Verify migrate_memory_files is skipped under per-session strategy."]},{"file":"tests/run_agent/test_memory_provider_init.py","hunks":["@@ -4,6 +4,27 @@\n from unittest.mock import patch\n \n \n+class RecordingMemoryProvider:\n+    name = \"recording\"\n+\n+    def __init__(self):\n+        self.init_kwargs = None\n+        self.init_session_id = None\n+\n+    def is_available(self):\n+        return True\n+\n+    def initialize(self, session_id, **kwargs):\n+        self.init_session_id = session_id\n+        self.init_kwargs = dict(kwargs)\n+\n+    def get_tool_schemas(self):\n+        return []\n+\n+    def shutdown(self):\n+        pass\n+\n+\n def test_blank_memory_provider_does_not_auto_enable_honcho():\n     \"\"\"Blank memory.provider should remain opt-out even if Honcho fallback looks configured.\"\"\"\n     cfg = {\"memory\": {\"provider\": \"\"}, \"agent\": {}}","@@ -37,3 +58,35 @@ def test_blank_memory_provider_does_not_auto_enable_honcho():\n     load_memory_provider.assert_not_called()\n     save_config.assert_not_called()\n \n+\n+def test_aiagent_forwards_user_id_alt_to_memory_provider():\n+    provider = RecordingMemoryProvider()\n+    cfg = {\"memory\": {\"provider\": \"recording\"}, \"agent\": {}}\n+\n+    with (\n+        patch(\"hermes_cli.config.load_config\", return_value=cfg),\n+        patch(\"plugins.memory.load_memory_provider\", return_value=provider),\n+        patch(\"agent.model_metadata.get_model_context_length\", return_value=204_800),\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=False,\n+            session_id=\"sess-alt\",\n+            platform=\"feishu\",\n+            user_id=\"open-id\",\n+            user_id_alt=\"union-id\",\n+        )\n+\n+    assert agent._memory_manager is not None\n+    assert provider.init_session_id == \"sess-alt\"\n+    assert provider.init_kwargs[\"user_id\"] == \"open-id\"\n+    assert provider.init_kwargs[\"user_id_alt\"] == \"union-id\"\n+    assert provider.init_kwargs[\"platform\"] == \"feishu\""]},{"file":"tests/test_honcho_session_context.py","hunks":["@@ -0,0 +1,95 @@\n+\"\"\"Tests for Honcho session context peer resolution.\"\"\"\n+\n+from types import SimpleNamespace\n+\n+from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager\n+\n+\n+class _FakeSummary:\n+    content = \"summary\"\n+\n+\n+class _FakeContext:\n+    summary = _FakeSummary()\n+    peer_representation = \"representation\"\n+    peer_card = [\"fact\"]\n+    messages = []\n+\n+\n+class _RecordingHonchoSession:\n+    def __init__(self):\n+        self.calls = []\n+\n+    def context(self, **kwargs):\n+        self.calls.append(kwargs)\n+        return _FakeContext()\n+\n+\n+def _manager_with_cached_session(*, ai_observe_others=True):\n+    cfg = SimpleNamespace(\n+        write_frequency=\"turn\",\n+        dialectic_reasoning_level=\"low\",\n+        dialectic_dynamic=True,\n+        dialectic_max_chars=600,\n+        observation_mode=\"directional\",\n+        user_observe_me=True,\n+        user_observe_others=True,\n+        ai_observe_me=True,\n+        ai_observe_others=ai_observe_others,\n+        message_max_chars=25000,\n+        dialectic_max_input_chars=10000,\n+    )\n+    mgr = HonchoSessionManager(honcho=SimpleNamespace(), config=cfg)\n+    session = HonchoSession(\n+        key=\"test-session\",\n+        user_peer_id=\"chris\",\n+        assistant_peer_id=\"hermes\",\n+        honcho_session_id=\"test-session\",\n+    )\n+    fake_honcho_session = _RecordingHonchoSession()\n+    mgr._cache[session.key] = session\n+    mgr._sessions_cache[session.honcho_session_id] = fake_honcho_session\n+    return mgr, fake_honcho_session\n+\n+\n+def test_session_context_user_alias_uses_assistant_observer_when_ai_can_observe_others():\n+    mgr, fake = _manager_with_cached_session(ai_observe_others=True)\n+\n+    result = mgr.get_session_context(\"test-session\", peer=\"user\")\n+\n+    assert result[\"summary\"] == \"summary\"\n+    assert fake.calls == [\n+        {\n+            \"summary\": True,\n+            \"peer_target\": \"chris\",\n+            \"peer_perspective\": \"hermes\",\n+        }\n+    ]\n+\n+\n+def test_session_context_explicit_user_peer_matches_user_alias():\n+    mgr, fake = _manager_with_cached_session(ai_observe_others=True)\n+\n+    mgr.get_session_context(\"test-session\", peer=\"chris\")\n+\n+    assert fake.calls == [\n+        {\n+            \"summary\": True,\n+            \"peer_target\": \"chris\",\n+            \"peer_perspective\": \"hermes\",\n+        }\n+    ]\n+\n+\n+def test_session_context_user_alias_uses_user_self_observer_when_ai_cannot_observe_others():\n+    mgr, fake = _manager_with_cached_session(ai_observe_others=False)\n+\n+    mgr.get_session_context(\"test-session\", peer=\"user\")\n+\n+    assert fake.calls == [\n+        {\n+            \"summary\": True,\n+            \"peer_target\": \"chris\",\n+            \"peer_perspective\": \"chris\",\n+        }\n+    ]\n"]}]},{"kind":"pr","number":33954,"title":"fix(honcho): harden self-hosted setup paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"827ce602d","number":0},{"kind":"pr","value":"#35170","number":35170},{"kind":"pr","value":"#34851","number":34851}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35170,"title":"fix(honcho): harden self-hosted setup paths","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/main.py","hermes_cli/memory_setup.py","hermes_cli/profiles.py","optional-skills/autonomous-ai-agents/honcho/SKILL.md","plugins/memory/hindsight/__init__.py","plugins/memory/honcho/README.md","plugins/memory/honcho/__init__.py","plugins/memory/honcho/cli.py","plugins/memory/honcho/client.py","plugins/memory/mem0/__init__.py","plugins/memory/supermemory/__init__.py","tests/gateway/test_agent_cache.py","tests/hermes_cli/test_memory_setup_provider_arg.py","tests/hermes_cli/test_profiles.py","tests/honcho_plugin/test_cli.py","tests/honcho_plugin/test_client.py","tests/honcho_plugin/test_pin_peer_name.py","tests/plugins/memory/test_hindsight_provider.py","tests/plugins/memory/test_mem0_v2.py","tests/plugins/memory/test_supermemory_provider.py","tests/test_honcho_client_config.py","utils.py","website/docs/user-guide/features/honcho.md","website/docs/user-guide/features/memory-providers.md"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -15331,8 +15331,52 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n         (\"compression\", \"target_ratio\"),\n         (\"compression\", \"protect_last_n\"),\n         (\"agent\", \"disabled_toolsets\"),\n+        (\"memory\", \"provider\"),\n     )\n \n+    _HONCHO_CACHE_BUSTING_KEYS = (\n+        \"honcho.peer_name\",\n+        \"honcho.ai_peer\",\n+        \"honcho.pin_peer_name\",\n+        \"honcho.runtime_peer_prefix\",\n+        \"honcho.user_peer_aliases\",\n+    )\n+    _HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {}\n+\n+    @classmethod\n+    def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]:\n+        return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS}\n+\n+    @classmethod\n+    def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]:\n+        \"\"\"Extract Honcho identity keys, memoized by honcho.json mtime.\"\"\"\n+        try:\n+            from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n+\n+            path = resolve_config_path()\n+            try:\n+                mtime_ns = path.stat().st_mtime_ns\n+            except OSError:\n+                mtime_ns = None\n+            memo_key = (str(path), mtime_ns)\n+            cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key)\n+            if cached is not None:\n+                return dict(cached)\n+\n+            hcfg = HonchoClientConfig.from_global_config(config_path=path)\n+            aliases = hcfg.user_peer_aliases or {}\n+            values = {\n+                \"honcho.peer_name\": hcfg.peer_name,\n+                \"honcho.ai_peer\": hcfg.ai_peer,\n+                \"honcho.pin_peer_name\": bool(hcfg.pin_peer_name),\n+                \"honcho.runtime_peer_prefix\": hcfg.runtime_peer_prefix or \"\",\n+                \"honcho.user_peer_aliases\": sorted(aliases.items()) if isinstance(aliases, dict) else [],\n+            }\n+            cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values}\n+            return dict(values)\n+        except Exception:\n+            return cls._empty_honcho_cache_busting_config()\n+\n     @classmethod\n     def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:\n         \"\"\"Pull values that must bust the cached agent.","@@ -15363,26 +15407,12 @@ def _extract_cache_busting_config(cls, user_config: dict | None) -> dict:\n             out[\"tools.registry_generation\"] = None\n \n         # Honcho identity-mapping keys live in honcho.json, not user_config.\n-        # HonchoSessionManager freezes the resolved peer_name / ai_peer /\n-        # pin / aliases / prefix at construction; without busting here,\n-        # mid-flight honcho.json edits go unread until the next unrelated\n-        # cache eviction.\n-        try:\n-            from plugins.memory.honcho.client import HonchoClientConfig\n-\n-            hcfg = HonchoClientConfig.from_global_config()\n-            out[\"honcho.peer_name\"] = hcfg.peer_name\n-            out[\"honcho.ai_peer\"] = hcfg.ai_peer\n-            out[\"honcho.pin_peer_name\"] = bool(hcfg.pin_peer_name)\n-            out[\"honcho.runtime_peer_prefix\"] = hcfg.runtime_peer_prefix or \"\"\n-            aliases = hcfg.user_peer_aliases or {}\n-            out[\"honcho.user_peer_aliases\"] = sorted(aliases.items()) if isinstance(aliases, dict) else []\n-        except Exception:\n-            out[\"honcho.peer_name\"] = None\n-            out[\"honcho.ai_peer\"] = None\n-            out[\"honcho.pin_peer_name\"] = None\n-            out[\"honcho.runtime_peer_prefix\"] = None\n-            out[\"honcho.user_peer_aliases\"] = None\n+        # Only read that file when Honcho is the active memory provider.\n+        provider = cfg_get(cfg, \"memory\", \"provider\")\n+        if isinstance(provider, str) and provider.lower() == \"honcho\":\n+            out.update(cls._extract_honcho_cache_busting_config())\n+        else:\n+            out.update(cls._empty_honcho_cache_busting_config())\n \n         return out\n "]},{"file":"hermes_cli/main.py","hunks":["@@ -13029,9 +13029,15 @@ def cmd_plugins(args):\n         ),\n     )\n     memory_sub = memory_parser.add_subparsers(dest=\"memory_command\")\n-    memory_sub.add_parser(\n+    _setup_parser = memory_sub.add_parser(\n         \"setup\", help=\"Interactive provider selection and configuration\"\n     )\n+    _setup_parser.add_argument(\n+        \"provider\",\n+        nargs=\"?\",\n+        default=None,\n+        help=\"Provider to configure directly (e.g. honcho), skipping the picker\",\n+    )\n     memory_sub.add_parser(\"status\", help=\"Show current memory provider config\")\n     memory_sub.add_parser(\"off\", help=\"Disable external provider (built-in only)\")\n     _reset_parser = memory_sub.add_parser("]},{"file":"hermes_cli/memory_setup.py","hunks":["@@ -452,7 +452,11 @@ def memory_command(args) -> None:\n     \"\"\"Route memory subcommands.\"\"\"\n     sub = getattr(args, \"memory_command\", None)\n     if sub == \"setup\":\n-        cmd_setup(args)\n+        provider = getattr(args, \"provider\", None)\n+        if provider:\n+            cmd_setup_provider(provider)\n+        else:\n+            cmd_setup(args)\n     elif sub == \"status\":\n         cmd_status(args)\n     else:"]},{"file":"hermes_cli/profiles.py","hunks":["@@ -1471,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n \n def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:\n     \"\"\"Rename Honcho host blocks for a renamed profile without changing peers.\"\"\"\n-    old_host = f\"hermes.{old_name}\"\n-    new_host = f\"hermes.{new_name}\"\n+    old_host = f\"hermes_{old_name}\"\n+    legacy_old_host = f\"hermes.{old_name}\"\n+    new_host = f\"hermes_{new_name}\"\n \n     candidates = [\n         new_dir / \"honcho.json\",","@@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->\n             continue\n \n         hosts = raw.get(\"hosts\")\n-        if not isinstance(hosts, dict) or old_host not in hosts:\n+        if not isinstance(hosts, dict):\n+            continue\n+        source_host = old_host if old_host in hosts else legacy_old_host\n+        if source_host not in hosts:\n             continue\n \n         if new_host in hosts:\n             print(f\"⚠ Honcho host block not migrated: {new_host} already exists in {path}\")\n             continue\n \n-        block = hosts[old_host]\n+        block = hosts[source_host]\n         if isinstance(block, dict) and \"aiPeer\" not in block:\n-            bare = old_host.split(\".\", 1)[1] if \".\" in old_host else old_host\n+            if source_host.startswith(\"hermes_\"):\n+                bare = source_host.split(\"_\", 1)[1]\n+            else:\n+                bare = source_host.split(\".\", 1)[1] if \".\" in source_host else source_host\n             block[\"aiPeer\"] = bare\n-        hosts[new_host] = hosts.pop(old_host)\n+        hosts[new_host] = hosts.pop(source_host)\n         tmp = path.with_suffix(path.suffix + \".tmp\")\n         try:\n             tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + \"\\n\", encoding=\"utf-8\")","@@ -1519,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->\n                 pass\n             continue\n \n-        print(f\"✓ Honcho host updated: {old_host} → {new_host}\")\n+        print(f\"✓ Honcho host updated: {source_host} → {new_host}\")\n \n \n def rename_profile(old_name: str, new_name: str) -> Path:"]},{"file":"optional-skills/autonomous-ai-agents/honcho/SKILL.md","hunks":["@@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is\n ### Cloud (app.honcho.dev)\n \n ```bash\n-hermes honcho setup\n+hermes memory setup honcho\n # select \"cloud\", paste API key from https://app.honcho.dev\n ```\n \n ### Self-hosted\n \n ```bash\n-hermes honcho setup\n+hermes memory setup honcho\n # select \"local\", enter base URL (e.g. http://localhost:8000)\n ```\n "]},{"file":"plugins/memory/hindsight/__init__.py","hunks":["@@ -633,7 +633,8 @@ def save_config(self, values, hermes_home):\n             except Exception:\n                 pass\n         existing.update(values)\n-        config_path.write_text(json.dumps(existing, indent=2))\n+        from utils import atomic_json_write\n+        atomic_json_write(config_path, existing, mode=0o600)\n \n     def post_setup(self, hermes_home: str, config: dict) -> None:\n         \"\"\"Custom setup wizard — installs only the deps needed for the selected mode.\"\"\""]},{"file":"plugins/memory/honcho/README.md","hunks":["@@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi\n ## Setup\n \n ```bash\n-hermes honcho setup    # full interactive wizard (cloud or local)\n-hermes memory setup    # generic picker, also works\n+hermes memory setup honcho   # configure Honcho directly (works on a fresh install)\n+hermes memory setup          # generic picker, choose Honcho from the list\n ```\n \n Or manually:","@@ -22,6 +22,10 @@ hermes config set memory.provider honcho\n echo \"HONCHO_API_KEY=***\" >> ~/.hermes/.env\n ```\n \n+> `hermes honcho setup` also works, but only **after** Honcho is the active\n+> memory provider — the `honcho` subcommand is registered for the active\n+> provider only. On a fresh install, use `hermes memory setup honcho`.\n+\n ## Architecture Overview\n \n ### Two-Layer Context Injection","@@ -109,7 +113,7 @@ Config is read from the first file that exists:\n | 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |\n | 3 | `~/.honcho/config.json` | Global (cross-app interop) |\n \n-Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.\n+Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.\n \n For every key, resolution order is: **host block > root > env var > default**.\n ","@@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a\n \n **Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: \"\"`.\n \n-**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):\n+**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):\n \n - **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.\n - **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.","@@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i\n       \"recallMode\": \"hybrid\",\n       \"sessionStrategy\": \"per-directory\"\n     },\n-    \"hermes.coder\": {\n+    \"hermes_coder\": {\n       \"aiPeer\": \"coder\",\n       \"recallMode\": \"tools\",\n       \"sessionStrategy\": \"per-repo\"","@@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i\n \n Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.\n \n-Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` → host key `hermes.coder`).\n+Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.\n \n ### Dialectic & Reasoning\n ","@@ -307,7 +311,8 @@ Presets:\n \n | Command | Description |\n |---------|-------------|\n-| `hermes honcho setup` | Full interactive setup wizard |\n+| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |\n+| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |\n | `hermes honcho status` | Show resolved config for active profile |\n | `hermes honcho enable` / `disable` | Toggle Honcho for active profile |\n | `hermes honcho mode <mode>` | Change recall or observation mode |","@@ -344,7 +349,7 @@ Presets:\n       \"dialecticMaxChars\": 600,\n       \"saveMessages\": true\n     },\n-    \"hermes.coder\": {\n+    \"hermes_coder\": {\n       \"enabled\": true,\n       \"aiPeer\": \"coder\",\n       \"sessionStrategy\": \"per-repo\","]},{"file":"plugins/memory/honcho/__init__.py","hunks":["@@ -249,6 +249,7 @@ def is_available(self) -> bool:\n     def save_config(self, values, hermes_home):\n         \"\"\"Write config to $HERMES_HOME/honcho.json (Honcho SDK native format).\"\"\"\n         import json\n+        import os\n         from pathlib import Path\n         config_path = Path(hermes_home) / \"honcho.json\"\n         existing = {}","@@ -258,7 +259,8 @@ def save_config(self, values, hermes_home):\n             except Exception:\n                 pass\n         existing.update(values)\n-        config_path.write_text(json.dumps(existing, indent=2))\n+        from utils import atomic_json_write\n+        atomic_json_write(config_path, existing, mode=0o600)\n \n     def get_config_schema(self):\n         return ["]},{"file":"plugins/memory/honcho/cli.py","hunks":["@@ -11,7 +11,7 @@\n from pathlib import Path\n \n from hermes_constants import get_hermes_home\n-from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST\n+from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST\n from hermes_cli.config import cfg_get\n \n ","@@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool:\n     if not default_block and not has_key:\n         return False\n \n-    new_host = f\"{HOST}.{profile_name}\"\n+    new_host = profile_host_key(profile_name)\n     if new_host in hosts:\n         return False  # already exists\n ","@@ -192,7 +192,7 @@ def cmd_sync(args) -> None:\n         if p.name == \"default\":\n             continue\n         if clone_honcho_for_profile(p.name):\n-            print(f\"  + {p.name} -> hermes.{p.name}\")\n+            print(f\"  + {p.name} -> {profile_host_key(p.name)}\")\n             created += 1\n         else:\n             skipped += 1","@@ -243,7 +243,7 @@ def _host_key() -> str:\n     if _profile_override:\n         if _profile_override in {\"default\", \"custom\"}:\n             return HOST\n-        return f\"{HOST}.{_profile_override}\"\n+        return profile_host_key(_profile_override)\n     return resolve_active_host()\n \n ","@@ -275,10 +275,8 @@ def _read_config() -> dict:\n def _write_config(cfg: dict, path: Path | None = None) -> None:\n     path = path or _local_config_path()\n     path.parent.mkdir(parents=True, exist_ok=True)\n-    path.write_text(\n-        json.dumps(cfg, indent=2, ensure_ascii=False) + \"\\n\",\n-        encoding=\"utf-8\",\n-    )\n+    from utils import atomic_json_write\n+    atomic_json_write(path, cfg, mode=0o600)\n \n \n def _resolve_api_key(cfg: dict) -> str:","@@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str:\n     config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK\n     will reject them itself with a clearer error than ours.\n     \"\"\"\n-    host_key = ((cfg.get(\"hosts\") or {}).get(_host_key()) or {}).get(\"apiKey\")\n+    host_key = _host_block(cfg, _host_key()).get(\"apiKey\")\n     key = host_key or cfg.get(\"apiKey\", \"\") or os.environ.get(\"HONCHO_API_KEY\", \"\")\n     if not key:\n         base_url = cfg.get(\"baseUrl\") or cfg.get(\"base_url\") or os.environ.get(\"HONCHO_BASE_URL\", \"\")","@@ -462,21 +460,58 @@ def cmd_setup(args) -> None:\n     cfg.pop(\"base_url\", None)\n \n     if is_local:\n-        # --- Local: ask for base URL, skip or clear API key ---\n+        # --- Local: ask for base URL, optionally accept a JWT for auth ---\n         current_url = cfg.get(\"baseUrl\") or \"\"\n         new_url = _prompt(\"Base URL\", default=current_url or \"http://localhost:8000\")\n         if new_url:\n             cfg[\"baseUrl\"] = new_url\n \n-        # For local no-auth, the SDK must not send an API key.\n-        # We keep the key in config (for cloud switching later) but\n-        # the client should skip auth when baseUrl is local.\n-        current_key = cfg.get(\"apiKey\", \"\")\n-        if current_key:\n-            print(f\"\\n  API key present in config (kept for cloud/hybrid use).\")\n-            print(\"  Local connections will skip auth automatically.\")\n+        # Self-hosted Honcho can run with AUTH_USE_AUTH=true and an\n+        # AUTH_JWT_SECRET on the server side. In that case clients must\n+        # send a JWT signed with that secret as the bearer token (the\n+        # Honcho SDK takes it via ``api_key=``). Cloud users got prompted\n+        # for a key already; the local path historically skipped this and\n+        # forced users to disable auth on the server. Offer the prompt\n+        # here too. We store it under the host block (not the top-level\n+        # apiKey) so ``get_honcho_client`` recognises it as an explicit\n+        # local auth opt-in (see ``_host_has_key`` in client.py) and\n+        # cloud/hybrid switching is unaffected.\n+        current_host_key = hermes_host.get(\"apiKey\", \"\")\n+        masked = (\n+            f\"...{current_host_key[-8:]}\"\n+            if len(current_host_key) > 8\n+            else (\"set\" if current_host_key else \"not set\")\n+        )\n+        print(\n+            \"\\n  Local Honcho auth (JWT signed with the server's \"\n+            \"AUTH_JWT_SECRET).\"\n+        )\n+        print(\n+            \"  Leave blank if your server runs with AUTH_USE_AUTH=false. \"\n+            f\"Current: {masked}\"\n+        )\n+        new_local_key = _prompt(\n+            \"Local JWT / bearer token (blank to skip / keep current)\",\n+            secret=True,\n+        )\n+        if new_local_key:\n+            hermes_host[\"apiKey\"] = new_local_key\n+        elif current_host_key:\n+            print(\"  Keeping existing local JWT.\")\n         else:\n-            print(\"\\n  No API key set. Local no-auth ready.\")\n+            # Surface the top-level key situation for transparency.\n+            top_key = cfg.get(\"apiKey\", \"\")\n+            if top_key:\n+                print(\n+                    \"\\n  Top-level API key present in config (kept for \"\n+                    \"cloud/hybrid use).\"\n+                )\n+                print(\n+                    \"  Local connections will skip auth automatically \"\n+                    \"until a local JWT is set above.\"\n+                )\n+            else:\n+                print(\"\\n  No local JWT set. Local no-auth ready.\")\n     else:\n         # --- Cloud: set default base URL, require API key ---\n         cfg.pop(\"baseUrl\", None)  # cloud uses SDK default"]},{"file":"plugins/memory/honcho/client.py","hunks":["@@ -32,6 +32,24 @@\n HOST = \"hermes\"\n \n \n+def profile_host_key(profile: str | None) -> str:\n+    \"\"\"Return the safe Honcho host key for a Hermes profile.\"\"\"\n+    if not profile or profile in {\"default\", \"custom\"}:\n+        return HOST\n+    sanitized = \"\".join(c if c.isalnum() or c in \"_-\" else \"_\" for c in profile).strip(\"_\")\n+    return f\"{HOST}_{sanitized or 'profile'}\"\n+\n+\n+def _host_block(raw: dict, host: str) -> dict:\n+    \"\"\"Return host config, accepting legacy dot-form profile host keys.\"\"\"\n+    hosts = raw.get(\"hosts\") or {}\n+    block = hosts.get(host, {})\n+    if block or not host.startswith(f\"{HOST}_\"):\n+        return block\n+    legacy = f\"{HOST}.{host[len(HOST) + 1:]}\"\n+    return hosts.get(legacy, {})\n+\n+\n def resolve_active_host() -> str:\n     \"\"\"Derive the Honcho host key from the active Hermes profile.\n ","@@ -47,8 +65,7 @@ def resolve_active_host() -> str:\n     try:\n         from hermes_cli.profiles import get_active_profile_name\n         profile = get_active_profile_name()\n-        if profile and profile not in {\"default\", \"custom\"}:\n-            return f\"{HOST}.{profile}\"\n+        return profile_host_key(profile)\n     except Exception:\n         pass\n     return HOST","@@ -406,7 +423,7 @@ def from_global_config(\n             logger.warning(\"Failed to read %s: %s, falling back to env\", path, e)\n             return cls.from_env(host=resolved_host)\n \n-        host_block = (raw.get(\"hosts\") or {}).get(resolved_host, {})\n+        host_block = _host_block(raw, resolved_host)\n         # A hosts.hermes block or explicit enabled flag means the user\n         # intentionally configured Honcho for this host.\n         _explicitly_configured = bool(host_block) or raw.get(\"enabled\") is True","@@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:\n         or \"::1\" in resolved_base_url\n     )\n     if _is_local:\n-        # Check if the host block has its own apiKey (explicit local auth)\n+        # Check if the host block has its own apiKey (explicit local auth).\n+        # Auth-skipping is loopback-only: a stored key is likely a cloud key\n+        # that would break a no-auth local server, so we substitute the SDK's\n+        # required-non-empty placeholder unless the host block opts in.\n         _raw = config.raw or {}\n         _host_block = (_raw.get(\"hosts\") or {}).get(config.host, {})\n         _host_has_key = bool(_host_block.get(\"apiKey\"))","@@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:\n     else:\n         effective_api_key = [redacted:secret-assignment]\n \n+    # The Honcho SDK's route builders (e.g. routes.workspaces()) already\n+    # include the version prefix (e.g. \"/v3/workspaces\").  When a user-supplied\n+    # base_url already ends in a version segment (e.g.\n+    # \"http://localhost:38000/v3\", \"https://honcho.my.ts.net/v3\"), concatenating\n+    # the two produces \"/v3/v3/workspaces\" → 404 on every call.  This is a pure\n+    # routing concern independent of host, so strip a trailing version segment\n+    # from ANY base_url — loopback, LAN, custom domain, or cloud alike.  The\n+    # SDK then appends its own versioned paths correctly.\n+    if resolved_base_url:\n+        import re as _re\n+        resolved_base_url = _re.sub(r\"/v\\d+/*$\", \"\", resolved_base_url).rstrip(\"/\")\n+\n     kwargs: dict = {\n         \"workspace_id\": config.workspace_id,\n         \"api_key\": effective_api_key,"]},{"file":"plugins/memory/mem0/__init__.py","hunks":["@@ -155,7 +155,8 @@ def save_config(self, values, hermes_home):\n             except Exception:\n                 pass\n         existing.update(values)\n-        config_path.write_text(json.dumps(existing, indent=2))\n+        from utils import atomic_json_write\n+        atomic_json_write(config_path, existing, mode=0o600)\n \n     def get_config_schema(self):\n         return ["]},{"file":"plugins/memory/supermemory/__init__.py","hunks":["@@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:\n         except Exception:\n             existing = {}\n     existing.update(values)\n-    config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + \"\\n\", encoding=\"utf-8\")\n+    from utils import atomic_json_write\n+    atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)\n \n \n def _detect_category(text: str) -> str:"]},{"file":"tests/gateway/test_agent_cache.py","hunks":["@@ -276,6 +276,111 @@ def test_extract_includes_live_tool_registry_generation(self, monkeypatch):\n \n         assert out[\"tools.registry_generation\"] == 12345\n \n+\n+    def test_skips_honcho_config_read_when_provider_is_not_honcho(self, monkeypatch):\n+        \"\"\"Non-Honcho gateways must not read/parse honcho.json on every message.\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        called = False\n+\n+        def _boom():\n+            nonlocal called\n+            called = True\n+            raise AssertionError(\"should not read Honcho config\")\n+\n+        monkeypatch.setattr(GatewayRunner, \"_extract_honcho_cache_busting_config\", _boom)\n+\n+        out = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"mem0\"}})\n+\n+        assert called is False\n+        assert out[\"honcho.peer_name\"] is None\n+        assert out[\"honcho.user_peer_aliases\"] is None\n+\n+    def test_reads_honcho_config_only_when_provider_is_honcho(self, monkeypatch):\n+        from gateway.run import GatewayRunner\n+\n+        calls = []\n+\n+        def _fake():\n+            calls.append(True)\n+            return {\n+                \"honcho.peer_name\": \"eri\",\n+                \"honcho.ai_peer\": \"hermes\",\n+                \"honcho.pin_peer_name\": True,\n+                \"honcho.runtime_peer_prefix\": \"tg_\",\n+                \"honcho.user_peer_aliases\": [(\"123\", \"eri\")],\n+            }\n+\n+        monkeypatch.setattr(GatewayRunner, \"_extract_honcho_cache_busting_config\", _fake)\n+\n+        out = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n+\n+        assert calls == [True]\n+        assert out[\"honcho.peer_name\"] == \"eri\"\n+        assert out[\"honcho.user_peer_aliases\"] == [(\"123\", \"eri\")]\n+\n+    def test_memory_provider_change_busts_signature(self, monkeypatch):\n+        \"\"\"Switching memory.provider must itself change the cache-busting\n+        signature, so the agent is rebuilt when a user swaps providers\n+        mid-gateway (independent of the honcho.json identity keys).\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        # Neutralize honcho.json reads so the only varying input is the\n+        # provider value itself.\n+        monkeypatch.setattr(\n+            GatewayRunner,\n+            \"_extract_honcho_cache_busting_config\",\n+            classmethod(lambda cls: cls._empty_honcho_cache_busting_config()),\n+        )\n+\n+        sig_honcho = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n+        sig_mem0 = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"mem0\"}})\n+\n+        assert sig_honcho[\"memory.provider\"] == \"honcho\"\n+        assert sig_mem0[\"memory.provider\"] == \"mem0\"\n+        assert sig_honcho != sig_mem0\n+\n+    def test_honcho_cache_busting_config_memoized_by_mtime(self, monkeypatch, tmp_path):\n+        \"\"\"Repeated Honcho extraction for unchanged honcho.json should reuse parse result.\"\"\"\n+        from types import SimpleNamespace\n+        from gateway.run import GatewayRunner\n+\n+        config_path = tmp_path / \"honcho.json\"\n+        config_path.write_text(\"{}\")\n+        parse_calls = []\n+\n+        class FakeConfig:\n+            peer_name = \"eri\"\n+            ai_peer = \"hermes\"\n+            pin_peer_name = False\n+            runtime_peer_prefix = \"tg_\"\n+            user_peer_aliases = {\"123\": \"eri\"}\n+\n+            @classmethod\n+            def from_global_config(cls, config_path=None):\n+                parse_calls.append(config_path)\n+                return cls()\n+\n+        fake_client = SimpleNamespace(\n+            HonchoClientConfig=FakeConfig,\n+            resolve_config_path=lambda: config_path,\n+        )\n+        monkeypatch.setitem(__import__(\"sys\").modules, \"plugins.memory.honcho.client\", fake_client)\n+        monkeypatch.setattr(GatewayRunner, \"_HONCHO_CACHE_BUSTING_MEMO\", {})\n+\n+        first = GatewayRunner._extract_honcho_cache_busting_config()\n+        second = GatewayRunner._extract_honcho_cache_busting_config()\n+\n+        assert first == second\n+        assert first[\"honcho.user_peer_aliases\"] == [(\"123\", \"eri\")]\n+        assert parse_calls == [config_path]\n+\n+        config_path.write_text(\"{\\n  \\\"changed\\\": true\\n}\")\n+        third = GatewayRunner._extract_honcho_cache_busting_config()\n+\n+        assert third == first\n+        assert parse_calls == [config_path, config_path]\n+\n     def test_full_round_trip_busts_cache_on_real_edit(self):\n         \"\"\"End-to-end: simulate a config edit on main and verify the\n         extracted cache_keys change produces a new signature.\"\"\""]},{"file":"tests/hermes_cli/test_memory_setup_provider_arg.py","hunks":["@@ -0,0 +1,50 @@\n+\"\"\"Tests for `hermes memory setup [provider]` routing.\n+\n+The `memory setup` subcommand accepts an optional positional ``provider`` so a\n+fresh install can configure a specific provider directly (e.g.\n+``hermes memory setup honcho``) without the interactive picker — which matters\n+because the per-provider ``hermes <provider>`` subcommand is only registered\n+once that provider is active.\n+\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+from hermes_cli import memory_setup\n+\n+\n+class TestMemorySetupProviderRouting:\n+    def test_setup_with_provider_arg_skips_picker(self):\n+        \"\"\"`memory setup honcho` routes straight to cmd_setup_provider.\"\"\"\n+        args = SimpleNamespace(memory_command=\"setup\", provider=\"honcho\")\n+        with patch.object(memory_setup, \"cmd_setup_provider\") as direct, \\\n+             patch.object(memory_setup, \"cmd_setup\") as picker:\n+            memory_setup.memory_command(args)\n+        direct.assert_called_once_with(\"honcho\")\n+        picker.assert_not_called()\n+\n+    def test_setup_without_provider_runs_picker(self):\n+        \"\"\"`memory setup` (no provider) runs the interactive picker.\"\"\"\n+        args = SimpleNamespace(memory_command=\"setup\", provider=None)\n+        with patch.object(memory_setup, \"cmd_setup_provider\") as direct, \\\n+             patch.object(memory_setup, \"cmd_setup\") as picker:\n+            memory_setup.memory_command(args)\n+        picker.assert_called_once_with(args)\n+        direct.assert_not_called()\n+\n+    def test_setup_with_missing_provider_attr_runs_picker(self):\n+        \"\"\"A SimpleNamespace lacking `provider` must not crash — fall back to picker.\"\"\"\n+        args = SimpleNamespace(memory_command=\"setup\")\n+        with patch.object(memory_setup, \"cmd_setup_provider\") as direct, \\\n+             patch.object(memory_setup, \"cmd_setup\") as picker:\n+            memory_setup.memory_command(args)\n+        picker.assert_called_once_with(args)\n+        direct.assert_not_called()\n+\n+    def test_unknown_provider_reports_and_returns_early(self, capsys):\n+        \"\"\"An unknown provider name surfaces a helpful message and returns\n+        before any config load/save (the not-found guard precedes those imports).\"\"\"\n+        memory_setup.cmd_setup_provider(\"notaprovider\")\n+        out = capsys.readouterr().out\n+        assert \"not found\" in out\n+        assert \"hermes memory setup\" in out"]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -754,8 +754,8 @@ def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env):\n \n         cfg = json.loads(honcho_path.read_text())\n         assert \"hermes.ssi_health\" not in cfg[\"hosts\"]\n-        assert cfg[\"hosts\"][\"hermes.heimdall\"][\"aiPeer\"] == \"ssi_health\"\n-        assert cfg[\"hosts\"][\"hermes.heimdall\"][\"peerName\"] == \"user-peer\"\n+        assert cfg[\"hosts\"][\"hermes_heimdall\"][\"aiPeer\"] == \"ssi_health\"\n+        assert cfg[\"hosts\"][\"hermes_heimdall\"][\"peerName\"] == \"user-peer\"\n \n     def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):\n         tmp_path = profile_env","@@ -772,8 +772,8 @@ def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env):\n \n         cfg = json.loads(honcho_path.read_text())\n         assert \"hermes.ssi_health\" not in cfg[\"hosts\"]\n-        assert cfg[\"hosts\"][\"hermes.heimdall\"][\"aiPeer\"] == \"ssi_health\"\n-        assert cfg[\"hosts\"][\"hermes.heimdall\"][\"workspace\"] == \"hermes\"\n+        assert cfg[\"hosts\"][\"hermes_heimdall\"][\"aiPeer\"] == \"ssi_health\"\n+        assert cfg[\"hosts\"][\"hermes_heimdall\"][\"workspace\"] == \"hermes\"\n \n     def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):\n         tmp_path = profile_env","@@ -782,7 +782,7 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):\n         honcho_path.write_text(json.dumps({\n             \"hosts\": {\n                 \"hermes.ssi_health\": {\"aiPeer\": \"ssi_health\"},\n-                \"hermes.heimdall\": {\"aiPeer\": \"heimdall\"},\n+                \"hermes_heimdall\": {\"aiPeer\": \"heimdall\"},\n             }\n         }))\n ","@@ -791,7 +791,7 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env):\n \n         cfg = json.loads(honcho_path.read_text())\n         assert cfg[\"hosts\"][\"hermes.ssi_health\"][\"aiPeer\"] == \"ssi_health\"\n-        assert cfg[\"hosts\"][\"hermes.heimdall\"][\"aiPeer\"] == \"heimdall\"\n+        assert cfg[\"hosts\"][\"hermes_heimdall\"][\"aiPeer\"] == \"heimdall\"\n \n     def test_default_raises_value_error(self, profile_env):\n         with pytest.raises(ValueError, match=\"default\"):"]},{"file":"tests/honcho_plugin/test_cli.py","hunks":["@@ -1,6 +1,7 @@\n \"\"\"Tests for plugins/memory/honcho/cli.py.\"\"\"\n \n from types import SimpleNamespace\n+import json\n \n \n class TestResolveApiKey:","@@ -100,6 +101,84 @@ def test_accepts_legacy_schemeless_host(self, monkeypatch):\n                 f\"expected local sentinel for legacy schemeless {legacy!r}\"\n \n \n+class TestCmdSetupLocalJwt:\n+    \"\"\"Local-deployment setup must allow configuring a JWT for AUTH_JWT_SECRET-backed Honcho servers.\"\"\"\n+\n+    def _run_setup(self, monkeypatch, tmp_path, initial_cfg, prompt_answers):\n+        import plugins.memory.honcho.cli as honcho_cli\n+\n+        # Avoid touching real config / SDK / filesystem.\n+        cfg_path = tmp_path / \"honcho.json\"\n+        monkeypatch.setattr(honcho_cli, \"_read_config\", lambda: dict(initial_cfg))\n+        monkeypatch.setattr(honcho_cli, \"_local_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_config_path\", lambda: cfg_path)\n+        monkeypatch.setattr(honcho_cli, \"_host_key\", lambda: \"hermes\")\n+        monkeypatch.setattr(honcho_cli, \"_ensure_sdk_installed\", lambda: True)\n+\n+        written = {}\n+\n+        def _capture_write(cfg, path=None):\n+            written[\"cfg\"] = cfg\n+            written[\"path\"] = path\n+\n+        monkeypatch.setattr(honcho_cli, \"_write_config\", _capture_write)\n+\n+        # Feed scripted prompt answers in order.\n+        answers = list(prompt_answers)\n+\n+        def _fake_prompt(label, default=None, secret=[redacted:secret-assignment]\n+            if not answers:\n+                # Default-through any remaining prompts to keep the wizard moving.\n+                return default or \"\"\n+            return answers.pop(0)\n+\n+        monkeypatch.setattr(honcho_cli, \"_prompt\", _fake_prompt)\n+\n+        honcho_cli.cmd_setup(SimpleNamespace())\n+        return written.get(\"cfg\")\n+\n+    def test_local_setup_stores_jwt_under_host_block(self, monkeypatch, tmp_path):\n+        \"\"\"Self-hosted users supplying a JWT must have it written under hosts.<host>.apiKey,\n+        not as the top-level cloud apiKey, so cloud/hybrid switching is preserved and\n+        get_honcho_client treats it as an explicit local auth opt-in.\"\"\"\n+        cfg = self._run_setup(\n+            monkeypatch,\n+            tmp_path,\n+            initial_cfg={},\n+            prompt_answers=[\n+                \"local\",                       # deployment\n+                \"http://localhost:8000\",       # base URL\n+                \"my-local-jwt-token\",          # local JWT\n+            ],\n+        )\n+        assert cfg is not None\n+        assert cfg.get(\"baseUrl\") == \"http://localhost:8000\"\n+        # Top-level apiKey must remain unset (cloud field).\n+        assert not cfg.get(\"apiKey\")\n+        # The new local JWT belongs under the host block.\n+        host_block = (cfg.get(\"hosts\") or {}).get(\"hermes\") or {}\n+        assert host_block.get(\"apiKey\") == \"my-local-jwt-token\"\n+\n+    def test_local_setup_blank_jwt_keeps_local_no_auth(self, monkeypatch, tmp_path):\n+        \"\"\"Blank JWT prompt response on a fresh local config must not introduce an apiKey\n+        anywhere (local no-auth Honcho deployments must still work out of the box).\"\"\"\n+        cfg = self._run_setup(\n+            monkeypatch,\n+            tmp_path,\n+            initial_cfg={},\n+            prompt_answers=[\n+                \"local\",\n+                \"http://localhost:8000\",\n+                \"\",  # blank JWT\n+            ],\n+        )\n+        assert cfg is not None\n+        assert cfg.get(\"baseUrl\") == \"http://localhost:8000\"\n+        assert not cfg.get(\"apiKey\")\n+        host_block = (cfg.get(\"hosts\") or {}).get(\"hermes\") or {}\n+        assert not host_block.get(\"apiKey\")\n+\n+\n class TestCmdStatus:\n     def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):\n         import plugins.memory.honcho.cli as honcho_cli","@@ -192,7 +271,7 @@ def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path\n         honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n         ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n         assert ok is True\n-        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes_coder\"]\n         assert new_block[\"userPeerAliases\"] == {\"86701400\": \"eri\", \"discord-491827364\": \"eri\"}\n \n     def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):","@@ -208,7 +287,7 @@ def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_\n         honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n         ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n         assert ok is True\n-        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes_coder\"]\n         assert new_block[\"runtimePeerPrefix\"] == \"telegram_\"\n \n     def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):","@@ -224,7 +303,7 @@ def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):\n         honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n         ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n         assert ok is True\n-        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes_coder\"]\n         assert new_block[\"pinPeerName\"] is True\n \n     def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):","@@ -235,7 +314,7 @@ def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch,\n         honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg)\n         ok = honcho_cli.clone_honcho_for_profile(\"coder\")\n         assert ok is True\n-        new_block = written[\"cfg\"][\"hosts\"][\"hermes.coder\"]\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes_coder\"]\n         assert \"userPeerAliases\" not in new_block\n         assert \"runtimePeerPrefix\" not in new_block\n         assert \"pinPeerName\" not in new_block","@@ -572,5 +651,5 @@ def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path):\n \n         ok = honcho_cli.clone_honcho_for_profile(\"partner\")\n         assert ok is True\n-        new_block = written[\"cfg\"][\"hosts\"][\"hermes.partner\"]\n+        new_block = written[\"cfg\"][\"hosts\"][\"hermes_partner\"]\n         assert new_block[\"pinUserPeer\"] is True"]},{"file":"tests/honcho_plugin/test_client.py","hunks":["@@ -13,6 +13,7 @@\n from plugins.memory.honcho.client import (\n     HonchoClientConfig,\n     get_honcho_client,\n+    profile_host_key,\n     reset_honcho_client,\n     resolve_active_host,\n     resolve_config_path,","@@ -430,6 +431,10 @@ def test_from_global_config_uses_local_path(self, tmp_path):\n \n \n class TestResolveActiveHost:\n+    def test_profile_host_key_uses_honcho_safe_separator(self):\n+        assert profile_host_key(\"coder\") == \"hermes_coder\"\n+        assert profile_host_key(\"default\") == \"hermes\"\n+\n     def test_default_returns_hermes(self):\n         with patch.dict(os.environ, {}, clear=True):\n             os.environ.pop(\"HERMES_HONCHO_HOST\", None)","@@ -444,7 +449,7 @@ def test_profile_name_derives_host(self):\n         with patch.dict(os.environ, {}, clear=False):\n             os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n             with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"coder\"):\n-                assert resolve_active_host() == \"hermes.coder\"\n+                assert resolve_active_host() == \"hermes_coder\"\n \n     def test_default_profile_returns_hermes(self):\n         with patch.dict(os.environ, {}, clear=False):","@@ -477,10 +482,10 @@ def test_profiles_import_failure_falls_back(self):\n class TestProfileScopedConfig:\n     def test_from_env_uses_profile_host(self):\n         with patch.dict(os.environ, {\"HONCHO_API_KEY\": \"key\"}):\n-            config = HonchoClientConfig.from_env(host=\"hermes.coder\")\n-        assert config.host == \"hermes.coder\"\n+            config = HonchoClientConfig.from_env(host=\"hermes_coder\")\n+        assert config.host == \"hermes_coder\"\n         assert config.workspace_id == \"hermes\"  # shared workspace\n-        assert config.ai_peer == \"hermes.coder\"\n+        assert config.ai_peer == \"hermes_coder\"\n \n     def test_from_env_default_workspace_preserved_for_default_host(self):\n         with patch.dict(os.environ, {\"HONCHO_API_KEY\": \"key\"}):","@@ -494,19 +499,19 @@ def test_from_global_config_reads_profile_host_block(self, tmp_path):\n             \"apiKey\": \"shared-key\",\n             \"hosts\": {\n                 \"hermes\": {\"aiPeer\": \"hermes\", \"peerName\": \"alice\"},\n-                \"hermes.coder\": {\n-                    \"aiPeer\": \"hermes.coder\",\n+                \"hermes_coder\": {\n+                    \"aiPeer\": \"hermes_coder\",\n                     \"peerName\": \"alice-coder\",\n                     \"workspace\": \"coder-ws\",\n                 },\n             },\n         }))\n         config = HonchoClientConfig.from_global_config(\n-            host=\"hermes.coder\", config_path=config_file,\n+            host=\"hermes_coder\", config_path=config_file,\n         )\n-        assert config.host == \"hermes.coder\"\n+        assert config.host == \"hermes_coder\"\n         assert config.workspace_id == \"coder-ws\"\n-        assert config.ai_peer == \"hermes.coder\"\n+        assert config.ai_peer == \"hermes_coder\"\n         assert config.peer_name == \"alice-coder\"\n \n     def test_from_global_config_auto_resolves_host(self, tmp_path):","@@ -514,14 +519,30 @@ def test_from_global_config_auto_resolves_host(self, tmp_path):\n         config_file.write_text(json.dumps({\n             \"apiKey\": \"key\",\n             \"hosts\": {\n-                \"hermes.dreamer\": {\"peerName\": \"dreamer-user\"},\n+                \"hermes_dreamer\": {\"peerName\": \"dreamer-user\"},\n             },\n         }))\n-        with patch(\"plugins.memory.honcho.client.resolve_active_host\", return_value=\"hermes.dreamer\"):\n+        with patch(\"plugins.memory.honcho.client.resolve_active_host\", return_value=\"hermes_dreamer\"):\n             config = HonchoClientConfig.from_global_config(config_path=config_file)\n-        assert config.host == \"hermes.dreamer\"\n+        assert config.host == \"hermes_dreamer\"\n         assert config.peer_name == \"dreamer-user\"\n \n+    def test_from_global_config_reads_legacy_dot_profile_host_block(self, tmp_path):\n+        config_file = tmp_path / \"config.json\"\n+        config_file.write_text(json.dumps({\n+            \"apiKey\": \"key\",\n+            \"hosts\": {\n+                \"hermes.dreamer\": {\"peerName\": \"dreamer-user\"},\n+            },\n+        }))\n+        config = HonchoClientConfig.from_global_config(\n+            host=\"hermes_dreamer\",\n+            config_path=config_file,\n+        )\n+        assert config.host == \"hermes_dreamer\"\n+        assert config.peer_name == \"dreamer-user\"\n+        assert config.workspace_id == \"hermes_dreamer\"\n+\n \n class TestObservationModeMigration:\n     \"\"\"Existing configs without explicit observationMode keep 'unified' default.\"\"\"","@@ -890,3 +911,176 @@ def test_depth_levels_invalid_values_default_to_low(self, tmp_path):\n         }))\n         config = HonchoClientConfig.from_global_config(config_path=config_file)\n         assert config.dialectic_depth_levels == [\"low\", \"high\"]\n+\n+\n+class TestGetHonchoClientBaseUrlDoublePrefixFix:\n+    \"\"\"Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for\n+    self-hosted instances where base_url already contains a version path.\"\"\"\n+\n+    def teardown_method(self):\n+        reset_honcho_client()\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_local_base_url_with_v3_suffix_stripped(self):\n+        \"\"\"base_url 'http://localhost:38000/v3' must become 'http://localhost:38000'\n+        before passing to the Honcho SDK to avoid double '/v3/v3' prefixing.\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=None,\n+            base_url=\"http://localhost:38000/v3\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == \"http://localhost:38000\", (\n+            f\"Expected 'http://localhost:38000', got {passed_base_url!r}\"\n+        )\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_local_base_url_without_version_unchanged(self):\n+        \"\"\"base_url 'http://localhost:38000' (no version) must be passed unchanged.\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=None,\n+            base_url=\"http://localhost:38000\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == \"http://localhost:38000\", (\n+            f\"Expected 'http://localhost:38000', got {passed_base_url!r}\"\n+        )\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_cloud_base_url_without_version_unchanged(self):\n+        \"\"\"A cloud base_url with no version segment must pass through untouched.\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://api.honcho.dev\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == \"https://api.honcho.dev\", (\n+            f\"Expected 'https://api.honcho.dev', got {passed_base_url!r}\"\n+        )\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_cloud_base_url_with_version_stripped(self):\n+        \"\"\"A version segment double-prefixes regardless of host, so a cloud\n+        base_url that ends in '/v3' must also be stripped (the SDK re-adds it).\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://api.honcho.dev/v3\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == \"https://api.honcho.dev\", (\n+            f\"Expected 'https://api.honcho.dev', got {passed_base_url!r}\"\n+        )\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    @pytest.mark.parametrize(\n+        \"raw_url, expected\",\n+        [\n+            # LAN IP self-host\n+            (\"http://10.0.0.5:8000/v3\", \"http://10.0.0.5:8000\"),\n+            (\"http://192.168.1.20:38000/v3/\", \"http://192.168.1.20:38000\"),\n+            # Tailscale / custom-domain self-host\n+            (\"https://honcho.my.ts.net/v3\", \"https://honcho.my.ts.net\"),\n+            (\"https://honcho.lab.internal/v3\", \"https://honcho.lab.internal\"),\n+            (\"https://honcho.fly.dev/v3\", \"https://honcho.fly.dev\"),\n+            # higher version segments are also stripped\n+            (\"https://honcho.lab.internal/v12\", \"https://honcho.lab.internal\"),\n+            # self-host without a version segment is left unchanged\n+            (\"https://honcho.my.ts.net\", \"https://honcho.my.ts.net\"),\n+            (\"http://10.0.0.5:8000\", \"http://10.0.0.5:8000\"),\n+        ],\n+    )\n+    def test_self_hosted_base_url_version_stripped(self, raw_url, expected):\n+        \"\"\"Non-loopback self-hosted instances (LAN IPs, Tailscale, custom\n+        domains) must get the same version-segment stripping as localhost.\n+        Regression for #20688 recurring on any non-loopback self-host.\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=raw_url,\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == expected, (\n+            f\"Expected {expected!r}, got {passed_base_url!r}\"\n+        )\n+\n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_local_base_url_with_trailing_slash_stripped(self):\n+        \"\"\"base_url 'http://127.0.0.1:38000/v3/' must also be cleaned up.\"\"\"\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        cfg = HonchoClientConfig(\n+            api_key=None,\n+            base_url=\"http://127.0.0.1:38000/v3/\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho) as mock_honcho, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        passed_base_url = mock_honcho.call_args.kwargs.get(\"base_url\")\n+        assert passed_base_url == \"http://127.0.0.1:38000\", (\n+            f\"Expected 'http://127.0.0.1:38000', got {passed_base_url!r}\"\n+        )"]},{"file":"tests/honcho_plugin/test_pin_peer_name.py","hunks":["@@ -745,10 +745,10 @@ def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypa\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n \n         cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\", \"pinPeerName\": True}))\n-        sig_pinned = GatewayRunner._extract_cache_busting_config({})\n+        sig_pinned = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\", \"pinPeerName\": False}))\n-        sig_unpinned = GatewayRunner._extract_cache_busting_config({})\n+        sig_unpinned = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         assert sig_pinned[\"honcho.pin_peer_name\"] != sig_unpinned[\"honcho.pin_peer_name\"]\n ","@@ -759,14 +759,14 @@ def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monk\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n \n         cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\"}))\n-        sig_no_aliases = GatewayRunner._extract_cache_busting_config({})\n+        sig_no_aliases = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         cfg_path.write_text(json.dumps({\n             \"apiKey\": \"k\",\n             \"peerName\": \"Igor\",\n             \"userPeerAliases\": {\"86701400\": \"Igor\"},\n         }))\n-        sig_with_aliases = GatewayRunner._extract_cache_busting_config({})\n+        sig_with_aliases = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         assert sig_no_aliases[\"honcho.user_peer_aliases\"] != sig_with_aliases[\"honcho.user_peer_aliases\"]\n ","@@ -777,14 +777,14 @@ def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, mo\n         monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n \n         cfg_path.write_text(json.dumps({\"apiKey\": \"k\", \"peerName\": \"Igor\"}))\n-        sig_no_prefix = GatewayRunner._extract_cache_busting_config({})\n+        sig_no_prefix = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         cfg_path.write_text(json.dumps({\n             \"apiKey\": \"k\",\n             \"peerName\": \"Igor\",\n             \"runtimePeerPrefix\": \"telegram_\",\n         }))\n-        sig_with_prefix = GatewayRunner._extract_cache_busting_config({})\n+        sig_with_prefix = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         assert sig_no_prefix[\"honcho.runtime_peer_prefix\"] != sig_with_prefix[\"honcho.runtime_peer_prefix\"]\n ","@@ -805,14 +805,14 @@ def test_cache_busting_signature_reflects_ai_peer(self, tmp_path, monkeypatch):\n             \"peerName\": \"Igor\",\n             \"aiPeer\": \"hermes\",\n         }))\n-        sig_before = GatewayRunner._extract_cache_busting_config({})\n+        sig_before = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         cfg_path.write_text(json.dumps({\n             \"apiKey\": \"k\",\n             \"peerName\": \"Igor\",\n             \"aiPeer\": \"hermetika\",\n         }))\n-        sig_after = GatewayRunner._extract_cache_busting_config({})\n+        sig_after = GatewayRunner._extract_cache_busting_config({\"memory\": {\"provider\": \"honcho\"}})\n \n         assert sig_before[\"honcho.ai_peer\"] != sig_after[\"honcho.ai_peer\"]\n "]},{"file":"tests/plugins/memory/test_hindsight_provider.py","hunks":["@@ -6,7 +6,9 @@\n \"\"\"\n \n import json\n+import os\n import re\n+import stat\n import sys\n from types import SimpleNamespace\n from unittest.mock import AsyncMock, MagicMock","@@ -1570,3 +1572,13 @@ def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self,\n         assert embedded._client is None\n         assert provider._client is None\n \n+\n[redacted:email](os.name == \"nt\", reason=\"POSIX mode bits not enforced on Windows\")\n+def test_save_config_sets_owner_only_permissions(tmp_path):\n+    \"\"\"hindsight/config.json must be written with 0o600 so API key is not world-readable.\"\"\"\n+    provider = HindsightMemoryProvider()\n+    provider.save_config({\"api_key\": \"hd-test-key\"}, str(tmp_path))\n+    config_file = tmp_path / \"hindsight\" / \"config.json\"\n+    assert config_file.exists()\n+    mode = stat.S_IMODE(config_file.stat().st_mode)\n+    assert mode == 0o600, f\"Expected 0o600 (owner-only), got {oct(mode)}\""]},{"file":"tests/plugins/memory/test_mem0_v2.py","hunks":["@@ -4,6 +4,10 @@\n \"\"\"\n \n import json\n+import os\n+import stat\n+\n+import pytest\n \n from plugins.memory.mem0 import Mem0MemoryProvider\n ","@@ -202,6 +206,17 @@ def test_prefetch_dict_response(self, monkeypatch):\n # ---------------------------------------------------------------------------\n \n \n[redacted:email](os.name == \"nt\", reason=\"POSIX mode bits not enforced on Windows\")\n+def test_save_config_sets_owner_only_permissions(tmp_path):\n+    \"\"\"mem0.json must be written with 0o600 so API key is not world-readable.\"\"\"\n+    provider = Mem0MemoryProvider()\n+    provider.save_config({\"api_key\": \"m0-test-key\"}, str(tmp_path))\n+    config_file = tmp_path / \"mem0.json\"\n+    assert config_file.exists()\n+    mode = stat.S_IMODE(config_file.stat().st_mode)\n+    assert mode == 0o600, f\"Expected 0o600 (owner-only), got {oct(mode)}\"\n+\n+\n class TestMem0Defaults:\n     \"\"\"Ensure we don't break existing users' defaults.\"\"\"\n "]},{"file":"tests/plugins/memory/test_supermemory_provider.py","hunks":["@@ -1,4 +1,6 @@\n import json\n+import os\n+import stat\n import threading\n \n import pytest","@@ -409,3 +411,13 @@ def test_get_config_schema_minimal():\n     assert len(schema) == 1\n     assert schema[0][\"key\"] == \"api_key\"\n     assert schema[0][\"secret\"] is True\n+\n+\n[redacted:email](os.name == \"nt\", reason=\"POSIX mode bits not enforced on Windows\")\n+def test_save_config_sets_owner_only_permissions(tmp_path):\n+    \"\"\"supermemory.json must be written with 0o600 so API key is not world-readable.\"\"\"\n+    _save_supermemory_config({\"api_key\": \"sm-test-key\"}, str(tmp_path))\n+    config_file = tmp_path / \"supermemory.json\"\n+    assert config_file.exists()\n+    mode = stat.S_IMODE(config_file.stat().st_mode)\n+    assert mode == 0o600, f\"Expected 0o600 (owner-only), got {oct(mode)}\""]},{"file":"tests/test_honcho_client_config.py","hunks":["@@ -2,9 +2,13 @@\n \n import json\n import os\n+import stat\n+from pathlib import Path\n \n+import pytest\n \n from plugins.memory.honcho.client import HonchoClientConfig\n+from plugins.memory.honcho import HonchoMemoryProvider\n \n \n class TestHonchoClientConfigAutoEnable:","@@ -100,3 +104,24 @@ def test_falls_back_to_env_when_no_config_file(self, tmp_path, monkeypatch):\n \n         assert cfg.api_key == \"fallback-key\"\n         assert cfg.enabled is True  # from_env() sets enabled=True\n+\n+\n[redacted:email](os.name == \"nt\", reason=\"POSIX mode bits not enforced on Windows\")\n+def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch):\n+    \"\"\"honcho.json is created atomically with 0o600, not chmod-after-write.\"\"\"\n+    import utils\n+    calls = []\n+    real_atomic = utils.atomic_json_write\n+\n+    def spy(path, data, **kwargs):\n+        calls.append(kwargs.get(\"mode\"))\n+        return real_atomic(path, data, **kwargs)\n+\n+    monkeypatch.setattr(utils, \"atomic_json_write\", spy)\n+    provider = HonchoMemoryProvider()\n+    provider.save_config({\"api_key\": \"hc-test-key\"}, str(tmp_path))\n+    assert calls == [0o600]\n+    config_file = tmp_path / \"honcho.json\"\n+    assert config_file.exists()\n+    mode = stat.S_IMODE(config_file.stat().st_mode)\n+    assert mode == 0o600, f\"Expected 0o600 (owner-only), got {oct(mode)}\""]},{"file":"utils.py","hunks":["@@ -87,6 +87,7 @@ def atomic_json_write(\n     data: Any,\n     *,\n     indent: int = 2,\n+    mode: int | None = None,\n     **dump_kwargs: Any,\n ) -> None:\n     \"\"\"Write JSON data to a file atomically.","@@ -99,13 +100,16 @@ def atomic_json_write(\n         path: Target file path (will be created or overwritten).\n         data: JSON-serializable data to write.\n         indent: JSON indentation (default 2).\n+        mode: Optional final permission mode. When set, the temp file is\n+            created and replaced with this mode, avoiding chmod-after-write\n+            TOCTOU exposure for secret-bearing files.\n         **dump_kwargs: Additional keyword args forwarded to json.dump(), such\n             as default=str for non-native types.\n     \"\"\"\n     path = Path(path)\n     path.parent.mkdir(parents=True, exist_ok=True)\n \n-    original_mode = _preserve_file_mode(path)\n+    original_mode = None if mode is not None else _preserve_file_mode(path)\n \n     fd, tmp_path = tempfile.mkstemp(\n         dir=str(path.parent),","@@ -113,6 +117,8 @@ def atomic_json_write(\n         suffix=\".tmp\",\n     )\n     try:\n+        if mode is not None:\n+            os.fchmod(fd, mode)\n         with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n             json.dump(\n                 data,","@@ -125,7 +131,13 @@ def atomic_json_write(\n             os.fsync(f.fileno())\n         # Preserve symlinks — swap in-place on the real file (GitHub #16743).\n         real_path = atomic_replace(tmp_path, path)\n-        _restore_file_mode(real_path, original_mode)\n+        if mode is not None:\n+            try:\n+                os.chmod(real_path, mode)\n+            except OSError:\n+                pass\n+        else:\n+            _restore_file_mode(Path(real_path), original_mode)\n     except BaseException:\n         # Intentionally catch BaseException so temp-file cleanup still runs for\n         # KeyboardInterrupt/SystemExit before re-raising the original signal."]},{"file":"website/docs/user-guide/features/honcho.md","hunks":["@@ -106,6 +106,10 @@ The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1\n \n Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you.\n \n+### Self-Hosted Honcho with Authentication\n+\n+When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and `hermes memory setup`) ask for a **local JWT / bearer token** after the base URL. Paste a JWT signed with the server's `AUTH_JWT_SECRET` (the Honcho compose env var) to enable authenticated access; leave it blank for servers running with `AUTH_USE_AUTH=false`. The local token is stored under the host block (`hosts.<host>.apiKey` in `honcho.json`), separate from any cloud `apiKey`, so you can flip the `Cloud or local?` prompt back to `cloud` later without losing either credential.\n+\n ### Full Config Reference\n \n | Key | Default | Description |","@@ -199,11 +203,12 @@ When Honcho is active as the memory provider, five tools become available:\n \n ## CLI Commands\n \n-The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). Run `hermes memory setup` and pick Honcho first; the subcommand appears on the next invocation.\n+The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). On a fresh install, configure Honcho directly with `hermes memory setup honcho` (or run `hermes memory setup` and pick it from the list); the `hermes honcho` subcommand then appears on the next invocation.\n \n ```bash\n+hermes memory setup honcho    # Configure Honcho directly (works before activation)\n hermes honcho status          # Connection status, config, and key settings\n-hermes honcho setup           # Redirects to `hermes memory setup`\n+hermes honcho setup           # Redirects to `hermes memory setup` (post-activation alias)\n hermes honcho strategy        # Show or set session strategy (per-session/per-directory/per-repo/global)\n hermes honcho peer            # Show or update peer names + dialectic reasoning level\n hermes honcho mode            # Show or set recall mode (hybrid/context/tools)"]},{"file":"website/docs/user-guide/features/memory-providers.md","hunks":["@@ -66,7 +66,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c\n hermes memory setup        # select \"honcho\" — runs the Honcho-specific post-setup\n ```\n \n-The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider.\n+On a fresh install, configure Honcho directly with `hermes memory setup honcho`. The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider.\n \n **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).\n \n"]}]},{"kind":"pr","number":50260,"title":"refactor(memory): move Honcho cache signature into provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33381","number":33381}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":107547,"title":"feat(honcho): each turn is written under its author's peer; bot DMs get their own session (salvage #103890)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/turn_author.py","gateway/run.py","gateway/run_agent_cache.py","plugins/memory/honcho/README.md","plugins/memory/honcho/__init__.py","plugins/memory/honcho/client.py","plugins/memory/honcho/config_schema.py","plugins/memory/honcho/session.py","plugins/memory/honcho/session_peers.py","tests/honcho_plugin/test_a2a_sessions.py","tests/honcho_plugin/test_identity_signature.py","tests/honcho_plugin/test_pin_peer_name.py","tests/honcho_plugin/test_turn_author_peers.py","tests/plugins/memory/test_honcho_config_schema.py","tests/test_honcho_startup_fail_open.py","website/docs/user-guide/features/honcho.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}