diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 105b08a3fb0..61b52c309e5 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -441,6 +441,86 @@ 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. + current_pin = bool(hermes_host.get("pinPeerName", False)) + current_aliases = hermes_host.get("userPeerAliases", {}) + current_prefix = hermes_host.get("runtimePeerPrefix", "") + + if current_pin: + current_shape = "single" + elif current_aliases: + current_shape = "hybrid" + 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() + + if new_shape == "single": + hermes_host["pinPeerName"] = True + hermes_host.pop("userPeerAliases", None) + hermes_host.pop("runtimePeerPrefix", None) + print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") + elif new_shape == "multi": + hermes_host["pinPeerName"] = False + # Preserve any existing operator-curated aliases / prefix. + if "userPeerAliases" not in hermes_host: + hermes_host["userPeerAliases"] = {} + _prefix_default = current_prefix or "" + _new_prefix = _prompt( + "Runtime peer prefix (e.g. 'telegram_', blank for none)", + default=_prefix_default, + ).strip() + if _new_prefix: + hermes_host["runtimePeerPrefix"] = _new_prefix + else: + hermes_host.pop("runtimePeerPrefix", None) + print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.") + elif new_shape == "hybrid": + hermes_host["pinPeerName"] = False + peer_target = hermes_host.get("peerName") or current_peer or "user" + existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} + 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 + elif "userPeerAliases" in hermes_host: + # No aliases entered and none pre-existing — leave the key absent. + if not hermes_host["userPeerAliases"]: + hermes_host.pop("userPeerAliases", None) + _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: + hermes_host["runtimePeerPrefix"] = _new_prefix + else: + hermes_host.pop("runtimePeerPrefix", None) + print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.") + elif new_shape == "skip": + pass # leave config untouched + else: + print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.") + # --- 4. Observation mode --- current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") print("\n Observation mode:") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 2b485373f77..073efe4eda2 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -238,4 +238,152 @@ class TestCloneHonchoForProfile: new_block = written["cfg"]["hosts"]["hermes.coder"] assert "userPeerAliases" not in new_block assert "runtimePeerPrefix" not in new_block - assert "pinPeerName" not in new_block \ No newline at end of file + assert "pinPeerName" not in new_block + + +class TestSetupWizardDeploymentShape: + """The deployment-shape step writes pinPeerName / userPeerAliases / + runtimePeerPrefix based on the operator's chosen shape. + + 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. + + These tests script the interactive _prompt calls and assert the + resulting hermes_host block, so the wizard's deployment-shape + semantics stay locked even as adjacent prompts are added. + """ + + def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): + import plugins.memory.honcho.cli as honcho_cli + + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}") + cfg = initial_cfg if initial_cfg is not None else {"apiKey": "***"} + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + 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) + + # Bypass config.yaml + connection test side effects. + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"memory": {}}, raising=False, + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda c: None, raising=False, + ) + + class _FakeClientCfg: + def resolve_session_name(self): + return "hermes-test" + workspace_id = "hermes" + peer_name = "eri" + ai_peer = "hermetika" + observation_mode = "directional" + write_frequency = "async" + recall_mode = "hybrid" + session_strategy = "per-session" + + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: _FakeClientCfg(), + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.reset_honcho_client", + lambda: None, + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.get_honcho_client", + lambda hcfg: object(), + ) + + # Scripted _prompt: pop answers in order. Default-return for unconsumed prompts. + answer_iter = iter(answers) + def _scripted_prompt(label, default=None, secret=False): + try: + return next(answer_iter) + except StopIteration: + return default if default is not None else "" + monkeypatch.setattr(honcho_cli, "_prompt", _scripted_prompt) + + honcho_cli.cmd_setup(SimpleNamespace()) + return cfg["hosts"]["hermes"] + + def test_single_shape_sets_pin_peer_name_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 + # remaining prompts fall through to defaults + ] + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "userPeerAliases": {"old": "stale"}, + "runtimePeerPrefix": "old_", + }}, + } + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is True + assert "userPeerAliases" not in host + assert "runtimePeerPrefix" not in host + + def test_multi_shape_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 + "telegram_", # runtime peer prefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinPeerName"] is False + assert host["userPeerAliases"] == {} + assert host["runtimePeerPrefix"] == "telegram_" + + def test_hybrid_shape_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 + "86701400", # telegram uid + "491827364", # discord snowflake + "", # slack (skip) + "", # matrix (skip) + "", # runtime peer prefix (skip) + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinPeerName"] is False + assert host["userPeerAliases"] == { + "86701400": "eri", + "491827364": "eri", + } + assert "runtimePeerPrefix" not in host + + def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path): + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "pinPeerName": True, + "userPeerAliases": {"keep": "me"}, + "runtimePeerPrefix": "keep_", + }}, + } + answers = [ + "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["userPeerAliases"] == {"keep": "me"} + assert host["runtimePeerPrefix"] == "keep_" \ No newline at end of file