From bb5cb3283898fdfb7f6c98f13598aedb10fbb2d9 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:07:53 -0400 Subject: [PATCH 1/8] refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup wizard wrote the legacy pinPeerName even though pinUserPeer is the canonical key that outranks it in the resolver — so it had to scrub the canonical key afterward to stop it winning. Write pinUserPeer directly and migrate any legacy pinPeerName onto it on touch (setup load + clone), which removes the precedence-fighting entirely. Resolver still reads pinPeerName as a back-compat alias; that's deferred. --- plugins/memory/honcho/cli.py | 50 ++++++++++++++-------- tests/honcho_plugin/test_cli.py | 73 ++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 092b7c823d3..bd74f42abd2 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool: return False # already exists # Clone settings from default block, override identity fields. - # Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases, - # runtimePeerPrefix) carry the operator's runtime-to-peer routing - # intent from #27371. Both pin keys are inherited because - # HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving - # the canonical key off this allowlist silently drops the pin on - # cloned profiles when the default uses the newer name. + # Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix) + # carry the operator's runtime-to-peer routing intent from #27371. new_block = {} for key in ("recallMode", "writeFrequency", "sessionStrategy", "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", "saveMessages", "observation", - "pinPeerName", "pinUserPeer", "userPeerAliases", - "runtimePeerPrefix"): + "pinUserPeer", "userPeerAliases", "runtimePeerPrefix"): val = default_block.get(key) if val is not None: new_block[key] = val + # Carry a legacy default-block pinPeerName forward under the canonical key. + if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None: + new_block["pinUserPeer"] = default_block["pinPeerName"] # Inherit peer name from default peer_name = default_block.get("peerName") or cfg.get("peerName") @@ -371,15 +369,28 @@ def _resolve_effective_identity_mapping( def _scrub_identity_mapping(hermes_host: dict) -> None: """Drop every peer-mapping key from the host block. - Called before the wizard writes a chosen shape so latent precedence - conflicts can't survive — e.g. a stray host ``pinUserPeer: false`` - that would silently outrank a freshly written ``pinPeerName: true`` - (host ``pinUserPeer`` is first in the resolver ladder). + Called before the wizard writes a chosen shape so a stale alias, prefix, + or pin from an earlier run can't bleed into the new mapping. """ for key in _IDENTITY_MAPPING_KEYS: hermes_host.pop(key, None) +def _migrate_pin_key(block: dict) -> bool: + """Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place. + + ``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes + only the canonical form and migrates on touch to stop configs carrying + both. Returns True if the block changed. + """ + if "pinPeerName" not in block: + return False + legacy = block.pop("pinPeerName") + if "pinUserPeer" not in block: + block["pinUserPeer"] = legacy + return True + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -446,6 +457,10 @@ def cmd_setup(args) -> None: hosts = cfg.setdefault("hosts", {}) hermes_host = hosts.setdefault(_host_key(), {}) + # Canonicalize any legacy pinPeerName before detection/writes. + _migrate_pin_key(cfg) + _migrate_pin_key(hermes_host) + # --- 1. Cloud or local? --- print(" Deployment:") print(" cloud -- Honcho cloud (api.honcho.dev)") @@ -599,12 +614,11 @@ def cmd_setup(args) -> None: new_shape = "skip" # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale ``pinUserPeer`` left behind by an earlier setup run can't - # outrank the freshly written ``pinPeerName`` via host-level precedence. + # so a stale alias/prefix/pin from an earlier run starts clean. if new_shape == "single": _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = True - print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") + hermes_host["pinUserPeer"] = True + print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") elif new_shape == "multi": # Preserve operator-curated, host-level aliases so multi → multi # re-runs don't drop them. Root-sourced aliases are left to @@ -615,7 +629,7 @@ def cmd_setup(args) -> None: else {} ) _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False # Do NOT auto-write ``userPeerAliases: {}``: an empty host map # would override any root-level ``userPeerAliases`` the operator # set as a cross-host baseline, silently disabling those aliases. @@ -642,7 +656,7 @@ def cmd_setup(args) -> None: # the mapping". existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False peer_target = hermes_host.get("peerName") or current_peer or "user" print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 74b7e1bc34e..fcbce52703b 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -239,7 +239,7 @@ class TestCloneHonchoForProfile: """Identity-key carryover during profile cloning. The host-scoped identity-mapping keys (``userPeerAliases``, - ``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise + ``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise the new profile silently fragments memory by resolving gateway users to raw runtime IDs instead of operator-declared peers. """ @@ -290,7 +290,7 @@ class TestCloneHonchoForProfile: new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["runtimePeerPrefix"] == "telegram_" - def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): + def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path): cfg = { "apiKey": "***", "hosts": { @@ -304,7 +304,8 @@ class TestCloneHonchoForProfile: ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["pinPeerName"] is True + assert new_block["pinUserPeer"] is True + assert "pinPeerName" not in new_block def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -317,6 +318,7 @@ class TestCloneHonchoForProfile: new_block = written["cfg"]["hosts"]["hermes_coder"] assert "userPeerAliases" not in new_block assert "runtimePeerPrefix" not in new_block + assert "pinUserPeer" not in new_block assert "pinPeerName" not in new_block @@ -409,7 +411,7 @@ class TestSetupWizardDeploymentShape: }}, } host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host @@ -424,7 +426,7 @@ class TestSetupWizardDeploymentShape: "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Multi must NOT auto-write ``userPeerAliases: {}``: an empty host # map would silently override a root-level baseline. Absence is # the correct "no host opinion" signal. @@ -446,7 +448,7 @@ class TestSetupWizardDeploymentShape: "", # runtime peer prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { "86701400": "eri", "491827364": "eri", @@ -454,6 +456,8 @@ class TestSetupWizardDeploymentShape: assert "runtimePeerPrefix" not in host def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path): + # Seeds the legacy ``pinPeerName``: skip must leave the mapping intact + # except for the on-load migration onto the canonical key. initial_cfg = { "apiKey": "***", "hosts": {"hermes": { @@ -466,7 +470,8 @@ class TestSetupWizardDeploymentShape: "cloud", "", "eri", "hermetika", "hermes", "skip", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" @@ -494,7 +499,7 @@ class TestSetupWizardDeploymentShape: "", # runtime prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): @@ -512,7 +517,7 @@ class TestSetupWizardDeploymentShape: "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" @@ -535,10 +540,9 @@ class TestSetupWizardDeploymentShape: # exercise that fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - # Scrub-then-write normalises onto pinPeerName and drops the alias - # so resolver precedence can't reintroduce ambiguity. - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + # Scrub-then-write normalises onto the canonical pinUserPeer. + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host def test_host_pin_user_peer_false_overrides_root_pin_peer_name( self, monkeypatch, tmp_path @@ -558,8 +562,8 @@ class TestSetupWizardDeploymentShape: } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is False + assert "pinPeerName" not in host def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): """Root-level ``userPeerAliases`` must classify as ``hybrid`` even @@ -572,7 +576,7 @@ class TestSetupWizardDeploymentShape: } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} @@ -584,7 +588,7 @@ class TestSetupWizardDeploymentShape: Picking ``multi`` here is an active choice — detection would have defaulted to ``hybrid`` because root aliases exist — so the operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinPeerName: false`` only, and rely + We honor that by writing ``pinUserPeer: false`` only, and rely on the host's absence of ``userPeerAliases`` to inherit root. That inheritance is intentional: a true wipe would require the operator to delete the root key explicitly. @@ -599,14 +603,12 @@ class TestSetupWizardDeploymentShape: "multi", # explicit multi override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert "userPeerAliases" not in host def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must drop any host-level ``pinUserPeer``, - otherwise an existing ``pinUserPeer: false`` would outrank the - freshly written ``pinPeerName: true`` and leave the profile - effectively unpinned (the P1 latent-precedence regression). + """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { "apiKey": "***", @@ -620,8 +622,7 @@ class TestSetupWizardDeploymentShape: "single", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is True class TestCloneCarriesPinUserPeer: @@ -653,3 +654,27 @@ class TestCloneCarriesPinUserPeer: assert ok is True new_block = written["cfg"]["hosts"]["hermes_partner"] assert new_block["pinUserPeer"] is True + + +class TestMigratePinKey: + """``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the + canonical ``pinUserPeer`` in place, without clobbering an existing + canonical value.""" + + def test_legacy_key_renamed_to_canonical(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": True} + + def test_canonical_key_wins_when_both_present(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True, "pinUserPeer": False} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": False} + + def test_noop_when_no_legacy_key(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinUserPeer": True} + assert honcho_cli._migrate_pin_key(block) is False + assert block == {"pinUserPeer": True} From d7dfeed6dc4218f51176dcd31ca2f4b926d5e89a Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:14:24 -0400 Subject: [PATCH 2/8] feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single/multi/hybrid 'deployment shape' was a misnomer: these keys only affect the gateway (the one entrypoint supplying a runtime user ID), and the three preset names stamped a lossy taxonomy onto three orthogonal knobs while hiding which keys got written. Replace it with an intent-led tree gated on gateway detection: - _gateway_platforms() lazily inspects the gateway config (best-effort, no hard dependency); the step auto-skips when no platform is connected. - 'who talks to this?' → just me / me+others (pooled?) / only others, deriving pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result. - [e] drops to a raw-knob editor for power users. - The single→multi orphan guard survives as a pooling steer. --- plugins/memory/honcho/cli.py | 310 +++++++++++++++++++++----------- tests/honcho_plugin/test_cli.py | 148 ++++++++++----- 2 files changed, 303 insertions(+), 155 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index bd74f42abd2..33edcf12dc0 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -391,6 +391,100 @@ def _migrate_pin_key(block: dict) -> bool: return True +def _gateway_platforms() -> list[str] | None: + """Connected gateway platforms, or None if undetectable. + + Identity mapping only affects gateway runtime users, so setup gates the + whole step on this. Best-effort and dependency-free: the memory plugin + must not hard-depend on the gateway package, so the import is lazy and + guarded (matching the idiom hermes_cli already uses for gateway refs). + """ + try: + from gateway.config import load_gateway_config + return [p.value for p in load_gateway_config().get_connected_platforms()] + except Exception: + return None + + +def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: + """Prompt for the operator's per-platform runtime IDs, aliasing each to + ``peer_target``. Existing entries are preserved.""" + aliases = dict(existing) + print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") + print(" Leave blank to skip a platform. Existing aliases are preserved.") + for platform_label, alias_hint in ( + ("Telegram UID", "e.g. 86701400"), + ("Discord snowflake", "e.g. 491827364"), + ("Slack user ID", "e.g. U04ABCDEF"), + ("Matrix MXID", "e.g. @you:matrix.org"), + ): + entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() + if entered: + aliases[entered] = peer_target + return aliases + + +def _apply_runtime_prefix( + hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str +) -> None: + """Write a host-level runtimePeerPrefix only when it diverges from an + inherited root value; otherwise let the root cascade stand.""" + new_prefix = _prompt(label, default=current_prefix or "").strip() + if new_prefix and not (prefix_from_root and new_prefix == current_prefix): + hermes_host["runtimePeerPrefix"] = new_prefix + + +def _echo_identity_mapping(hermes_host: dict) -> None: + """Show the resulting keys so the operator can verify what was written.""" + aliases = hermes_host.get("userPeerAliases") + prefix = hermes_host.get("runtimePeerPrefix") + print(" resolved →") + print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}") + print(f" userPeerAliases = {aliases if aliases else '{}'}") + print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}") + + +def _configure_raw_identity_mapping( + hermes_host: dict, + current_pin: bool, + current_aliases: dict, + current_prefix: str, + aliases_from_root: bool, + prefix_from_root: bool, +) -> None: + """Power-user escape hatch: set the three resolver knobs directly.""" + print("\n Raw identity-mapping keys (resolver tries them top-down):") + pin_in = _prompt( + "pinUserPeer — pin all gateway users to your peer? (true/false)", + default=str(bool(current_pin)).lower(), + ).strip().lower() + pin = pin_in in {"true", "t", "yes", "y", "1"} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = pin + if pin: + return + aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):") + while True: + entry = _prompt(" alias", default="").strip() + if not entry: + break + if "=" in entry: + rid, peer = (p.strip() for p in entry.split("=", 1)) + if rid and peer: + aliases[rid] = peer + if aliases: + hermes_host["userPeerAliases"] = aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "runtimePeerPrefix — namespace for unknown IDs (blank for none)", + ) + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -560,18 +654,15 @@ def cmd_setup(args) -> None: if new_workspace: hermes_host["workspace"] = new_workspace - # --- 3b. Deployment shape --- - # Determines how runtime user identities (Telegram UIDs, Discord - # snowflakes, etc.) map to Honcho peers in gateway sessions. Three - # shapes cover the realistic deployments; each writes a different - # combination of pinPeerName / userPeerAliases / runtimePeerPrefix. - # See plugins/memory/honcho/README.md for the resolver ladder. + # --- 3b. Gateway identity mapping --- + # These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...), + # the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP + # sessions have no runtime ID and fall through to peerName, so the step is + # moot off-gateway — gate it behind detection. # - # Detection must mirror the gateway resolver: root-level config and - # ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level) - # both affect effective routing, so reading host-only fields would - # mis-classify a profile that inherits its mapping from root or uses - # the newer canonical key. + # Detection mirrors the gateway resolver: root-level config and the + # canonical ``pinUserPeer`` both affect routing, so host-only reads would + # mis-classify a profile that inherits its mapping from root. ( current_pin, current_aliases, @@ -587,102 +678,109 @@ def cmd_setup(args) -> None: else: current_shape = "multi" - print("\n Deployment shape (how gateway users map to peers):") - print(" single -- all platforms route to your peer (recommended for personal use)") - print(" multi -- each platform user gets their own peer (multi-user bots)") - print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer") - print(" skip -- don't touch identity-mapping config") - new_shape = _prompt("Deployment shape", default=current_shape).strip().lower() - - # Transitioning single → multi orphans the peerName pool for runtime users - # (their resolved peers go from peerName to runtime-derived IDs with empty - # history). Steer the operator toward hybrid so their own continuity is - # preserved via alias mappings. - if current_shape == "single" and new_shape == "multi": - peer_target = hermes_host.get("peerName") or current_peer or "user" - print( - f"\n ⚠ Switching from single to multi will orphan memory accumulated\n" - f" under peer '{peer_target}'. Existing runtime users (Telegram,\n" - f" Discord, etc.) will resolve to fresh, empty peers." - ) - print(" To keep your own continuity, choose 'hybrid' and alias your\n" - " runtime IDs back to peerName.") - confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower() - if confirm in {"hybrid", "h"}: - new_shape = "hybrid" - elif confirm not in {"yes", "y"}: - new_shape = "skip" - - # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale alias/prefix/pin from an earlier run starts clean. - if new_shape == "single": - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = True - print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") - elif new_shape == "multi": - # Preserve operator-curated, host-level aliases so multi → multi - # re-runs don't drop them. Root-sourced aliases are left to - # cascade naturally and are NOT copied down into the host. - prior_aliases = ( - dict(current_aliases) - if isinstance(current_aliases, dict) and not aliases_from_root - else {} - ) - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - # Do NOT auto-write ``userPeerAliases: {}``: an empty host map - # would override any root-level ``userPeerAliases`` the operator - # set as a cross-host baseline, silently disabling those aliases. - # Absence is the right "no host opinion" signal. - if prior_aliases: - hermes_host["userPeerAliases"] = prior_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - # Only write a host-level prefix when the operator typed one that - # diverges from the inherited root value; otherwise let the root - # cascade continue unmodified. - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.") - elif new_shape == "hybrid": - # Hybrid encodes operator intent at the host level: collect existing - # entries (host or root) so the wizard never silently drops a known - # alias, then write the combined map. Materialising root entries - # into the host is the right move here — once the operator answers - # the alias prompts for a host, they're declaring "this host owns - # the mapping". - existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - peer_target = hermes_host.get("peerName") or current_peer or "user" - print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") - print(" Leave blank to skip a platform. Existing aliases are preserved.") - for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), - ("Discord snowflake", "e.g. 491827364"), - ("Slack user ID", "e.g. U04ABCDEF"), - ("Matrix MXID", "e.g. @you:matrix.org"), - ): - entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() - if entered: - existing_aliases[entered] = peer_target - if existing_aliases: - hermes_host["userPeerAliases"] = existing_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.") - elif new_shape == "skip": - pass # leave config untouched + gw_platforms = _gateway_platforms() + if gw_platforms is None: + print("\n Gateway identity mapping routes platform users to memory peers.") + run_mapping = _prompt( + "Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)", + default="n", + ).strip().lower() in {"y", "yes"} + elif not gw_platforms: + print("\n No gateway platforms connected — identity mapping only affects") + print(" gateway users, so this step doesn't apply here.") + run_mapping = _prompt( + "Configure gateway mapping anyway? (y/N)", default="n", + ).strip().lower() in {"y", "yes"} else: - print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.") + print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}") + run_mapping = True + + if run_mapping: + peer_target = hermes_host.get("peerName") or current_peer or "user" + default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") + print("\n How should gateway users map to memory peers?") + print(" [1] just me — everyone collapses to your peer") + print(" [2] me + other people — keep mine pooled, others separate") + print(" [3] only other people — everyone gets their own peer") + print(" [s] skip (leave untouched) [e] edit raw keys") + choice = _prompt("Choice", default=default_choice).strip().lower() + + if choice in {"2", "me+others", "both"}: + pooled = _prompt( + " Keep my own memory pooled across platforms? (Y/n)", default="y", + ).strip().lower() + shape = "hybrid" if pooled in {"y", "yes", ""} else "multi" + elif choice in {"1", "me", "just-me"}: + shape = "single" + elif choice in {"3", "others"}: + shape = "multi" + elif choice in {"e", "edit", "raw"}: + shape = "raw" + else: + shape = "skip" + + # Un-pinning a currently-pinned profile without aliasing strands the + # pooled peerName history; steer the operator toward pooling instead. + if current_pin and shape == "multi": + print( + f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n" + f" '{peer_target}'. Existing gateway users resolve to fresh,\n" + f" empty peers." + ) + confirm = _prompt( + " Pool my own memory instead (alias my IDs to peerName)? (Y/n)", + default="y", + ).strip().lower() + if confirm in {"y", "yes", ""}: + shape = "hybrid" + + # Each branch scrubs every peer-mapping key first so a stale alias, + # prefix, or pin from an earlier run starts clean. + if shape == "single": + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = True + print(f" All gateway users route to '{peer_target}'.") + _echo_identity_mapping(hermes_host) + elif shape == "multi": + # Preserve operator-curated host-level aliases across multi → multi + # re-runs. Root-sourced aliases cascade naturally and are NOT + # copied down — an empty host map would mask a root baseline. + prior_aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + if prior_aliases: + hermes_host["userPeerAliases"] = prior_aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix (e.g. 'telegram_', blank for none)", + ) + print(" Each gateway user → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "hybrid": + existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + merged = _collect_operator_aliases(existing_aliases, peer_target) + if merged: + hermes_host["userPeerAliases"] = merged + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", + ) + print(f" Your runtime IDs → '{peer_target}', others → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "raw": + _configure_raw_identity_mapping( + hermes_host, current_pin, current_aliases, current_prefix, + aliases_from_root, prefix_from_root, + ) + _echo_identity_mapping(hermes_host) + else: # skip + print(" Identity mapping left untouched.") # --- 4. Observation mode --- current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index fcbce52703b..afcc7af0779 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -323,19 +323,20 @@ class TestCloneHonchoForProfile: class TestSetupWizardDeploymentShape: - """The deployment-shape step writes pinPeerName / userPeerAliases / - runtimePeerPrefix based on the operator's chosen shape. + """The gateway identity-mapping tree writes pinUserPeer / userPeerAliases / + runtimePeerPrefix based on the operator's intent. - Single-operator deployments collapse all platforms to peerName. - Multi-user gateways leave the resolver to route per-runtime. - Hybrid deployments alias the operator's own runtime IDs only. + Choice [1] (just me) collapses all platforms to peerName. + Choice [3] (only other people) leaves the resolver to route per-runtime. + Choice [2] (me + others, pooled) aliases the operator's own runtime IDs. - These tests script the interactive _prompt calls and assert the - resulting hermes_host block, so the wizard's deployment-shape + These tests mock gateway detection and script the interactive _prompt + calls, asserting the resulting hermes_host block so the tree's routing semantics stay locked even as adjacent prompts are added. """ - def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): + def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None, + gateway_platforms=("telegram",)): import plugins.memory.honcho.cli as honcho_cli cfg_path = tmp_path / "config.json" @@ -348,6 +349,10 @@ class TestSetupWizardDeploymentShape: monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + # Gate detection is mocked so tests control whether the tree runs. + # None → undetectable; list (possibly empty) → connected platforms. + gw = None if gateway_platforms is None else list(gateway_platforms) + monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw) # Bypass config.yaml + connection test side effects. monkeypatch.setattr( @@ -393,14 +398,14 @@ class TestSetupWizardDeploymentShape: honcho_cli.cmd_setup(SimpleNamespace()) return cfg["hosts"]["hermes"] - def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path): + def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "single", # deployment shape ← key answer + "1", # tree: just me ← key answer # remaining prompts fall through to defaults ] initial_cfg = { @@ -415,14 +420,14 @@ class TestSetupWizardDeploymentShape: assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host - def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): + def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape + "3", # tree: only other people "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) @@ -433,14 +438,15 @@ class TestSetupWizardDeploymentShape: assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" - def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): + def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "hybrid", # deployment shape + "2", # tree: me + other people + "y", # keep my memory pooled? → hybrid "86701400", # telegram uid "491827364", # discord snowflake "", # slack (skip) @@ -467,7 +473,7 @@ class TestSetupWizardDeploymentShape: }}, } answers = [ - "cloud", "", "eri", "hermetika", "hermes", "skip", + "cloud", "", "eri", "hermetika", "hermes", "s", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True @@ -475,10 +481,10 @@ class TestSetupWizardDeploymentShape: assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" - def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path): - """Flipping single → multi triggers a warning that auto-steers the - operator to ``hybrid`` (default), so their own runtime IDs keep - landing on peerName instead of orphaning the pinned-pool history. + def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path): + """Choosing 'only other people' on a currently-pinned profile triggers + the orphan warning, which auto-steers to pooled (hybrid) so the + operator's own runtime IDs keep landing on peerName. """ initial_cfg = { "apiKey": "***", @@ -490,8 +496,8 @@ class TestSetupWizardDeploymentShape: "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape — triggers the guard - "hybrid", # guard response: accept the steer + "3", # tree: only others — triggers the orphan guard + "y", # pool my own memory instead? → hybrid "86701400", # telegram uid "", # discord (skip) "", # slack (skip) @@ -502,42 +508,40 @@ class TestSetupWizardDeploymentShape: assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} - def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): - """Operator can override the steer by answering ``yes`` and accept - the orphaning consequences. This is the explicit undo-the-pin path. - """ + def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): + """Operator can decline the steer ('n') and accept orphaning, ending + up with per-user peers (no aliases).""" initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}}, } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # deployment shape — triggers the guard - "yes", # guard response: confirm multi + "3", # tree: only others — triggers the orphan guard + "n", # decline pooling, accept orphaning "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path): """Host-level ``pinUserPeer: true`` must classify as ``single``. - Pressing Enter at the shape prompt then preserves the pin instead - of falling through to ``multi`` and orphaning the user's memory - pool — the bug the wizard regressed when ``pinUserPeer`` landed - as a higher-precedence alias. + Pressing Enter at the choice prompt then preserves the pin instead + of falling through to per-user routing and orphaning the user's + memory pool — the bug the wizard regressed when ``pinUserPeer`` + landed as a higher-precedence alias. """ initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}, } - # Exhaust the iterator before the shape prompt so the scripted - # mock falls through to the prompt's default (which is the - # wizard-detected shape). Scripting an explicit "" would NOT - # exercise that fallthrough — the mock returns it literally. + # Exhaust the iterator before the choice prompt so the scripted + # mock falls through to the prompt's default (the detected shape → + # choice "1"). Scripting an explicit "" would NOT exercise that + # fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) # Scrub-then-write normalises onto the canonical pinUserPeer. @@ -581,16 +585,16 @@ class TestSetupWizardDeploymentShape: # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} - def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): - """Explicit ``multi`` must leave the host ``userPeerAliases`` key - absent, preserving any root-level aliases as a cross-host baseline. + def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): + """Explicitly choosing 'only other people' must leave the host + ``userPeerAliases`` key absent, preserving any root-level aliases as a + cross-host baseline. - Picking ``multi`` here is an active choice — detection would have - defaulted to ``hybrid`` because root aliases exist — so the - operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinUserPeer: false`` only, and rely - on the host's absence of ``userPeerAliases`` to inherit root. - That inheritance is intentional: a true wipe would require the + Picking [3] here is an active choice — detection would have defaulted + to [2]/hybrid because root aliases exist — so the operator's intent is + to drop the alias mapping for this host. We honor that by writing + ``pinUserPeer: false`` only, relying on the host's absence of + ``userPeerAliases`` to inherit root. A true wipe would require the operator to delete the root key explicitly. """ initial_cfg = { @@ -600,14 +604,14 @@ class TestSetupWizardDeploymentShape: } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # explicit multi override of detected hybrid + "3", # explicit per-user override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False assert "userPeerAliases" not in host - def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): + """Choosing 'just me' must overwrite a stale ``pinUserPeer: false`` with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { @@ -619,11 +623,57 @@ class TestSetupWizardDeploymentShape: } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "single", + "1", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True + def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """With no gateway platforms connected, the tree is gated off; declining + the 'configure anyway?' prompt leaves identity mapping untouched.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=[], + ) + assert "pinUserPeer" not in host + assert "userPeerAliases" not in host + assert "runtimePeerPrefix" not in host + + def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """When the gateway package can't be inspected (None), the wizard asks + whether the gateway is running; 'no' skips the mapping step.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=None, + ) + assert "pinUserPeer" not in host + + def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path): + """The [e] escape hatch lets a power user set pinUserPeer + an alias + + prefix directly, bypassing the intent tree.""" + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "e", # tree: edit raw keys + "false", # pinUserPeer + "99887766=eri", # one alias pair + "", # finish aliases + "discord_", # runtimePeerPrefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinUserPeer"] is False + assert host["userPeerAliases"] == {"99887766": "eri"} + assert host["runtimePeerPrefix"] == "discord_" + class TestCloneCarriesPinUserPeer: """``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a From 99feb036077a2d6dc99e12d1902d05d28e13eb0a Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:15:17 -0400 Subject: [PATCH 3/8] docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree Drop pinPeerName from the key table (now a deprecated-alias note), and replace the single/multi/hybrid 'deployment shapes' section with the gateway-gated intent tree the wizard actually presents, including the [e] raw-edit hatch and the un-pin pooling steer. --- plugins/memory/honcho/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 3774747d05a..77270ffd2dd 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -137,11 +137,12 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `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` | -| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect | +| `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 | | `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 | | `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 | +> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. + **Resolver ladder** (first match wins): ``` @@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Host vs root semantics.** All three keys are accepted at both root and `hosts.` 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: ""`. -**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these): +**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: -- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. -- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. -- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct. +- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. +- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. -**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. +Pick **[e]** at the prompt to set the three keys directly instead of going through the tree. + +**Un-pinning (single → per-user).** 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, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile. ### Memory & Recall From 23a7458acfbea42e6c8ddf88bdba5c06152fe42c Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 14:58:19 -0400 Subject: [PATCH 4/8] docs(website): cover gateway identity mapping in Honcho feature page The identity-mapping keys never made it to the site docs. Add the three keys to the config reference and a Gateway Identity Mapping section: when it applies (gateway only, setup-gated), the intent tree, resolver order, the un-pin orphan warning, and the deprecated pinPeerName alias. --- website/docs/user-guide/features/honcho.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b971bea272d..b2493de7f53 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -129,6 +129,9 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `messageMaxChars` | `25000` | Max chars per message sent via `add_messages()`. Chunked if exceeded | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. @@ -154,6 +157,30 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`). +## Gateway Identity Mapping + +These settings only matter when you run the [Hermes gateway](../../developer-guide/gateway-internals.md) — the one entrypoint where users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user). CLI, TUI, and desktop sessions have no runtime ID and always resolve to `peerName`, so off-gateway these keys do nothing. + +The setup wizard detects whether a gateway platform is connected and skips this step entirely if not. When it runs, it asks one question — *who talks to this gateway?* — and derives the keys: + +| Answer | Result | +|--------|--------| +| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | +| **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | + +Pick `[e]` at the prompt to set the three keys directly instead. + +The resolver tries the keys top-down, first match wins: `pinUserPeer` → `userPeerAliases[id]` → `runtimePeerPrefix + id` → raw runtime ID → `peerName` → session-key fallback. + +:::warning Un-pinning orphans pooled memory +Flipping `pinUserPeer` from `true` to `false` does not migrate data — memory accumulated under `peerName` stays there, and platform users resolve to fresh, empty peers. To keep your own continuity, choose the **pooled** path so your runtime IDs alias back to `peerName`. The wizard offers this steer automatically when it detects the transition. +::: + +:::note Deprecated key +`pinPeerName` is a legacy alias for `pinUserPeer` — still read for back-compat (`pinUserPeer` wins where both are set), never written. Re-running setup migrates it onto the canonical key. +::: + ## Observation (Directional vs. Unified) Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`: From 2708c33c7570d5d3d53c19c80124b06d0d939c08 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:04:01 -0400 Subject: [PATCH 5/8] docs(honcho): anonymize example peer name to alice --- plugins/memory/honcho/README.md | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 77270ffd2dd..44c523be8bf 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,7 +138,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `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 | -| `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 | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). 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 | | `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 | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. @@ -208,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions. -If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`. +If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`. #### What each strategy produces diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b2493de7f53..4e8caa43a92 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,7 +130,7 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | | `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: From 1544813bfe5658c3b8b9c5e5506ef3692b5fb567 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:06:07 -0400 Subject: [PATCH 6/8] chore(honcho): replace example Telegram UID with placeholder --- plugins/memory/honcho/README.md | 4 +- plugins/memory/honcho/cli.py | 2 +- tests/gateway/test_agent_cache.py | 10 +- tests/honcho_plugin/test_cli.py | 16 +-- tests/honcho_plugin/test_pin_peer_name.py | 120 ++++++++++----------- website/docs/user-guide/features/honcho.md | 4 +- 6 files changed, 78 insertions(+), 78 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 44c523be8bf..70fe1fb5315 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,8 +138,8 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `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 | -| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). 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 | -| `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 | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). 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 | +| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 33edcf12dc0..25460989df2 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -413,7 +413,7 @@ def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), + ("Telegram UID", "e.g. 7654321"), ("Discord snowflake", "e.g. 491827364"), ("Slack user ID", "e.g. U04ABCDEF"), ("Matrix MXID", "e.g. @you:matrix.org"), diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 37f8b51a458..e3e14c70514 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -1466,7 +1466,7 @@ class TestAgentConfigSignatureUserId: from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364" @@ -1477,10 +1477,10 @@ class TestAgentConfigSignatureUserId: from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_1 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_2 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) assert sig_1 == sig_2 @@ -1489,11 +1489,11 @@ class TestAgentConfigSignatureUserId: runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@igor_tg", + user_id="7654321", user_id_alt="@igor_tg", ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@erosika_tg", + user_id="7654321", user_id_alt="@erosika_tg", ) assert sig_a != sig_b diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index afcc7af0779..c021cdb8cfe 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -263,7 +263,7 @@ class TestCloneHonchoForProfile: "apiKey": "***", "hosts": { "hermes": { - "userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"}, + "userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"}, "peerName": "eri", }, }, @@ -272,7 +272,7 @@ class TestCloneHonchoForProfile: ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"} + assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"} def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -447,7 +447,7 @@ class TestSetupWizardDeploymentShape: "hermes", # workspace "2", # tree: me + other people "y", # keep my memory pooled? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "491827364", # discord snowflake "", # slack (skip) "", # matrix (skip) @@ -456,7 +456,7 @@ class TestSetupWizardDeploymentShape: host = self._run_setup(monkeypatch, tmp_path, answers=answers) assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { - "86701400": "eri", + "7654321": "eri", "491827364": "eri", } assert "runtimePeerPrefix" not in host @@ -498,7 +498,7 @@ class TestSetupWizardDeploymentShape: "hermes", # workspace "3", # tree: only others — triggers the orphan guard "y", # pool my own memory instead? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "", # discord (skip) "", # slack (skip) "", # matrix (skip) @@ -506,7 +506,7 @@ class TestSetupWizardDeploymentShape: ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): """Operator can decline the steer ('n') and accept orphaning, ending @@ -575,7 +575,7 @@ class TestSetupWizardDeploymentShape: """ initial_cfg = { "apiKey": "***", - "userPeerAliases": {"86701400": "eri"}, + "userPeerAliases": {"7654321": "eri"}, "hosts": {"hermes": {"peerName": "eri"}}, } answers = ["cloud", "", "eri", "hermetika", "hermes"] @@ -583,7 +583,7 @@ class TestSetupWizardDeploymentShape: assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): """Explicitly choosing 'only other people' must leave the host diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index 1e72bc97d1a..1a6e2394a87 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -105,7 +105,7 @@ class TestRuntimePeerMappingConfigParsing: config_file.write_text(json.dumps({ "apiKey": "k", "userPeerAliases": { - " 86701400 ": " Igor ", + " 7654321 ": " Igor ", "": "ignored", "empty-value": " ", "null-value": None, @@ -115,7 +115,7 @@ class TestRuntimePeerMappingConfigParsing: config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.user_peer_aliases == {"86701400": "Igor"} + assert config.user_peer_aliases == {"7654321": "Igor"} assert config.runtime_peer_prefix == "telegram_" def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path): @@ -226,12 +226,12 @@ class TestPeerResolutionOrder: mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name="Igor", pin_peer_name=False), - runtime_user_peer_name="86701400", # e.g. Telegram UID + runtime_user_peer_name="7654321", # e.g. Telegram UID ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400", ( + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321", ( "pin_peer_name=False is the multi-user default — the gateway's " "platform-native user ID must win so each user gets their own " "peer scope. If this regresses, every Telegram/Discord/Slack " @@ -245,14 +245,14 @@ class TestPeerResolutionOrder: config=self._config( peer_name="Igor", pin_peer_name=False, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_unknown_runtime_id_uses_prefix(self): @@ -264,12 +264,12 @@ class TestPeerResolutionOrder: pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self): """Generated prefixed IDs avoid merges caused by lossy sanitization.""" @@ -291,43 +291,43 @@ class TestPeerResolutionOrder: def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self): """Unknown generated peers should not silently merge into peerName.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( - peer_name="telegram_86701400", + peer_name="telegram_7654321", pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self): """Unknown generated peers should not silently merge into alias targets.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"known-user": "telegram_86701400"}, + user_peer_aliases={"known-user": "telegram_7654321"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self): - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest() mgr = HonchoSessionManager( honcho=MagicMock(), @@ -335,17 +335,17 @@ class TestPeerResolutionOrder: peer_name=None, pin_peer_name=False, user_peer_aliases={ - "known-user": "telegram_86701400", - "reserved-user": f"telegram_86701400-{digest[:8]}", + "known-user": "telegram_7654321", + "reserved-user": f"telegram_7654321-{digest[:8]}", }, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{digest[:12]}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{digest[:12]}" def test_alias_value_is_sanitized_after_selection(self): mgr = HonchoSessionManager( @@ -353,13 +353,13 @@ class TestPeerResolutionOrder: config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"86701400": "Alice Smith!"}, + user_peer_aliases={"7654321": "Alice Smith!"}, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Alice-Smith-" def test_alias_keys_match_raw_runtime_id_before_sanitization(self): @@ -391,13 +391,13 @@ class TestPeerResolutionOrder: runtime_peer_prefix="telegram_", session_peer_prefix=True, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" - assert session.honcho_session_id == "telegram-86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" + assert session.honcho_session_id == "telegram-7654321" def test_config_wins_when_pin_is_true(self): """With pin enabled, configured peer_name beats runtime ID.""" @@ -406,14 +406,14 @@ class TestPeerResolutionOrder: config=self._config( peer_name="Igor", pin_peer_name=True, - user_peer_aliases={"86701400": "Alias"}, + user_peer_aliases={"7654321": "Alias"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", # Telegram pushes this in + runtime_user_peer_name="7654321", # Telegram pushes this in ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor", ( "With pinPeerName=true the user's configured peer_name must " "beat the platform-native runtime ID so memory stays unified " @@ -429,26 +429,26 @@ class TestPeerResolutionOrder: config=self._config( peer_name=None, pin_peer_name=True, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name=None, pin_peer_name=True), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321" def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self): """Stable alternate IDs can map known users while primary ID fallback stays unchanged.""" @@ -526,11 +526,11 @@ class TestPeerResolutionOrder: mgr = HonchoSessionManager( honcho=MagicMock(), config=cfg, - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" assert session.assistant_peer_id == "hermes-assistant" @@ -556,10 +556,10 @@ class TestCrossPlatformMemoryUnification: mgr_telegram = HonchoSessionManager( honcho=MagicMock(), config=self._config_pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_telegram) - telegram_session = mgr_telegram.get_or_create("telegram:86701400") + telegram_session = mgr_telegram.get_or_create("telegram:7654321") # Discord turn (separate manager instance — simulates a fresh # platform-adapter invocation) @@ -701,20 +701,20 @@ class TestPinTransition: pinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(pinned_mgr) - before = pinned_mgr.get_or_create("telegram:86701400") + before = pinned_mgr.get_or_create("telegram:7654321") assert before.user_peer_id == "Igor" unpinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._unpinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(unpinned_mgr) - after = unpinned_mgr.get_or_create("telegram:86701400") - assert after.user_peer_id == "86701400", ( + after = unpinned_mgr.get_or_create("telegram:7654321") + assert after.user_peer_id == "7654321", ( "After flipping pinPeerName off, the same runtime ID must resolve " "to its own peer — otherwise multi-user mode silently merges users." ) @@ -723,14 +723,14 @@ class TestPinTransition: mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - first = mgr.get_or_create("telegram:86701400") + first = mgr.get_or_create("telegram:7654321") assert first.user_peer_id == "Igor" mgr._config = self._unpinned() - second = mgr.get_or_create("telegram:86701400") + second = mgr.get_or_create("telegram:7654321") assert second.user_peer_id == "Igor", ( "The per-key session cache is keyed by session-key, not by " "resolved peer. In-process flips don't invalidate it — the " @@ -764,7 +764,7 @@ class TestPinTransition: cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", - "userPeerAliases": {"86701400": "Igor"}, + "userPeerAliases": {"7654321": "Igor"}, })) sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) @@ -839,18 +839,18 @@ class TestProfilePeerUniqueness: mgr_a = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("alice"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_a) - sess_a = mgr_a.get_or_create("telegram:86701400") + sess_a = mgr_a.get_or_create("telegram:7654321") mgr_b = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("bob"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_b) - sess_b = mgr_b.get_or_create("telegram:86701400") + sess_b = mgr_b.get_or_create("telegram:7654321") assert sess_a.user_peer_id == "alice" assert sess_b.user_peer_id == "bob" diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 4e8caa43a92..a692b26d96b 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,8 +130,8 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | -| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. From c7513df4f9e4af2d33a73a3a5256e2d4f346ee26 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:34:09 +0000 Subject: [PATCH 7/8] docs(honcho): clarify pinUserPeer pins only non-agent users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'everyone collapses to your peer' read as a promise about all traffic. pinUserPeer pins the user-side peer and is checked before userPeerAliases (session.py:335), so a pin overrides every alias — including agent peers. For a multi-agent operator that silently pools distinct agents onto one peer, the opposite of intent. Scopes the wording to 'every non-agent gateway user', notes the pin overrides aliases, and points agent-mesh operators at pinUserPeer:false + userPeerAliases instead. Same correction in the wizard menu/echo text, the plugin README, and the website Honcho page. --- plugins/memory/honcho/README.md | 2 +- plugins/memory/honcho/cli.py | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 70fe1fb5315..cb9b720bf56 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -161,7 +161,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: -- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor). - **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. - **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 25460989df2..cc19711e956 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -699,7 +699,7 @@ def cmd_setup(args) -> None: peer_target = hermes_host.get("peerName") or current_peer or "user" default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") print("\n How should gateway users map to memory peers?") - print(" [1] just me — everyone collapses to your peer") + print(" [1] just me — every non-agent user collapses to your peer") print(" [2] me + other people — keep mine pooled, others separate") print(" [3] only other people — everyone gets their own peer") print(" [s] skip (leave untouched) [e] edit raw keys") @@ -739,7 +739,7 @@ def cmd_setup(args) -> None: if shape == "single": _scrub_identity_mapping(hermes_host) hermes_host["pinUserPeer"] = True - print(f" All gateway users route to '{peer_target}'.") + print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).") _echo_identity_mapping(hermes_host) elif shape == "multi": # Preserve operator-curated host-level aliases across multi → multi diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index a692b26d96b..31d83913830 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -165,7 +165,7 @@ The setup wizard detects whether a gateway platform is connected and skips this | Answer | Result | |--------|--------| -| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **just me** | `pinUserPeer: true` — every non-agent gateway user collapses to your peer. Pin overrides all aliases, so pick this only when no user-side identity needs its own peer. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor) instead | | **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | | **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | From 6dde7d46574f7bd40e915217d96074c461791c4f Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:50:24 +0000 Subject: [PATCH 8/8] docs(memory-providers): cover gateway identity mapping for Honcho MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Honcho provider page documented the per-profile peer model (user peer / AI peer / observation) but never the gateway axis — how platform runtime IDs map to peers. Adds the three keys to the config table and a short Gateway identity mapping subsection that points at the Honcho page for the resolver ladder. Uses the corrected pinUserPeer wording (pins non-agent users, overrides aliases) so the provider-comparison reader gets the same accurate framing as the dedicated page. --- .../docs/user-guide/features/memory-providers.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 43b70334da6..476bd46696d 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -95,6 +95,9 @@ The legacy `hermes honcho setup` command still works (it now redirects to `herme | `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every non-agent gateway user collapses to `peerName`; the pin overrides all aliases | +| `userPeerAliases` | `{}` | Gateway only. Maps runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | @@ -199,6 +202,18 @@ Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win o See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference. +### Gateway identity mapping + +The peer model above covers CLI, TUI, and desktop sessions, where every conversation resolves to `peerName`. The [gateway](../../developer-guide/gateway-internals.md) adds a second axis: users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user), and three keys decide which peer each ID resolves to. + +| Key | Effect | +|-----|--------| +| `pinUserPeer: true` | Every non-agent gateway user collapses to `peerName`. The pin is checked first, so it overrides all aliases — pick it only when no user-side identity needs its own peer | +| `userPeerAliases` | Maps specific runtime IDs to peers (`{"7654321": "alice"}`). The home for routing distinct identities — including agents that each carry their own peer | +| `runtimePeerPrefix` | Namespaces any unmapped runtime ID (`telegram_7654321`) so platforms with same-shaped IDs don't collide | + +Off-gateway these keys do nothing. `hermes memory setup` only prompts for them when it detects a connected gateway platform. See the [Honcho page](./honcho.md#gateway-identity-mapping) for the resolver ladder and the setup flow. +
Full honcho.json example (multi-profile)