From dc7c8673d1859012044daa8b631e36dee4b4bc2f Mon Sep 17 00:00:00 2001 From: Erosika Date: Tue, 11 Aug 2026 11:31:54 -0400 Subject: [PATCH] feat(honcho): add 'hermes honcho peers map' for interactive account-to-peer mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the read-only 'hermes honcho peers' view with a 'map' action that joins two sources: workspace peers fetched from the Honcho API, labeled from local config (your peer, each profile's AI peer, alias targets, runtime peers of seen accounts, user-* fallback peers, 'unrecognized' otherwise), and the gateway accounts recorded in state.db with what each currently resolves to. Targets are picked from the workspace list so a typo cannot silently create a peer; every assignment states its consequence (aliases move future messages only; a runtime peer left behind keeps its history). 'w' lists every workspace the key can reach — the wrong-workspace fallback — and can repoint the profile's workspace on explicit confirmation. With multiple profiles, saving a root-cascading map asks whether to write root or fork the host block, root writes warn when sibling profiles sit on other workspaces, and the accounts table marks siblings that resolve an account differently. Offline the command degrades to typed targets over the local account list. The setup wizard's gateway step closes by pointing at the command. --- plugins/memory/honcho/README.md | 6 + plugins/memory/honcho/cli.py | 519 +++++++++++++++++++++++++- tests/honcho_plugin/test_cli.py | 13 + tests/honcho_plugin/test_peers_map.py | 509 +++++++++++++++++++++++++ 4 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 tests/honcho_plugin/test_peers_map.py diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 51c84cdae2..341540dd60 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -233,6 +233,12 @@ Step 7 used to derive a peer from the session key (`user-default-`). That p Pick **[e]** at the prompt to set the three keys directly instead of going through the tree. +**Interactive mapping — `hermes honcho peers map`.** The setup tree covers the common shapes; `hermes honcho peers map` is the full identity view for a gateway with many users and agents. It joins two sources: the workspace's peers fetched from the Honcho API (labeled from local config — your peer, each profile's AI peer, alias targets, runtime peers of seen accounts, `user-*` fallback peers, honestly `unrecognized` otherwise), and the gateway accounts recorded in the local session store (platform, runtime ID, name, and what each currently resolves to, with `✓` when that peer already exists and `○ new` when it would be created on first message). + +Mapping targets are picked from the workspace list (`p3`) rather than typed, so a typo cannot silently create a fresh peer; typing a name stays available for the deliberate new-peer case, and `-` clears an alias. Every assignment states its consequence: aliases move future messages only, and a runtime peer left behind keeps its history. `p` alone peeks at a peer's card. `w` lists every workspace the key can reach — the wrong-workspace fallback when the peers shown aren't yours — and lets you browse one and, on explicit confirmation, repoint the profile's `workspace` at it. For a standalone workspace browser beyond mapping, [honcho-cli](https://pypi.org/project/honcho-cli/) is an optional companion (`uv tool install honcho-cli`). + +With multiple profiles: saving a root-cascading map asks whether the edit applies to all profiles (root) or forks this profile's host block; a root write with profiles on other workspaces warns that picked peers may not exist there; and the accounts table marks siblings that resolve the same account to a different peer (`≠ dreamer→bob`). Offline, the command degrades to typed targets over the local account list. `hermes honcho peers` without `map` stays a read-only view. + **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 diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 85a4613491..f0cff433f6 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -859,6 +859,8 @@ def _setup_wizard(args) -> None: hermes_host[key] = new _setup_identity_mapping(cfg, hermes_host, current_peer) + print("\n For a gateway with many users and agents, run\n" + " 'hermes honcho peers map' to map accounts interactively.") _setup_tuning(cfg, hermes_host) hermes_host["enabled"] = True @@ -1044,8 +1046,520 @@ def _cmd_status_all() -> None: print("\n * active profile\n") +def _state_db_path() -> Path: + """Return the state.db path for the targeted profile.""" + if _profile_override and _profile_override not in {"default", "custom"}: + try: + from hermes_cli.profiles import get_profile_dir + return get_profile_dir(_profile_override) / "state.db" + except Exception: + pass + return get_hermes_home() / "state.db" + + +def _seen_gateway_accounts(db_path: Path) -> list[dict]: + """Return gateway accounts recorded in state.db, most recent first. + + The gateway stamps each session row with its routing peer (source, + user_id, display_name, origin_json), so grouping rows by + (source, user_id) enumerates every account the gateway has handled. + Bot authors are skipped. + """ + if not db_path.exists(): + return [] + import sqlite3 + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + except sqlite3.Error: + return [] + # profile_name marks which profile a multiplexing gateway routed the + # session to; older state.db files predate the column. + base_query = """SELECT source, user_id, + MAX(COALESCE(display_name, '')), + MAX(COALESCE(origin_json, '')), + COUNT(*){profiles_col} + FROM sessions + WHERE user_id IS NOT NULL AND user_id != '' + AND session_key IS NOT NULL AND session_key != '' + GROUP BY source, user_id + ORDER BY MAX(COALESCE(started_at, 0)) DESC""" + try: + try: + rows = conn.execute(base_query.format( + profiles_col=",\n GROUP_CONCAT(DISTINCT COALESCE(profile_name, 'default'))", + )).fetchall() + except sqlite3.OperationalError: + rows = [r + (None,) for r in conn.execute( + base_query.format(profiles_col=""), + ).fetchall()] + except sqlite3.Error: + return [] + finally: + conn.close() + + accounts = [] + for source, user_id, display_name, origin_json, count, profiles in rows: + user_name = "" + user_id_alt = "" + is_bot = False + if origin_json: + try: + origin = json.loads(origin_json) + user_name = origin.get("user_name") or "" + user_id_alt = origin.get("user_id_alt") or "" + is_bot = bool(origin.get("is_bot")) + except Exception: + pass + if is_bot: + continue + accounts.append({ + "platform": source or "?", + "user_id": str(user_id), + "user_id_alt": str(user_id_alt), + "label": user_name or display_name or "", + "sessions": count, + "profiles": sorted(profiles.split(",")) if profiles else [], + }) + return accounts + + +def _preview_peer_resolution( + user_id: str, *, pin: bool, aliases: dict, prefix: str, peer_name: str, + user_id_alt: str = "", +) -> str: + """Mirror the runtime resolver ladder for display: pin → alias → prefix → raw.""" + import re + + def sanitize(s: str) -> str: + return re.sub(r'[^a-zA-Z0-9_-]', '-', s) + + if pin and peer_name: + return f"{sanitize(peer_name)} (pinned)" + # The runtime resolver checks the primary ID first, then the alt ID + # (Signal UUID, Feishu union_id) against the same alias map. + for rid in (user_id, user_id_alt): + alias = aliases.get(rid) if rid else None + if isinstance(alias, str) and alias.strip(): + return sanitize(alias.strip()) + if prefix: + return f"{sanitize(prefix + user_id)} (prefixed)" + return sanitize(user_id) + + +# Picker cap: workspaces holding thousands of peers (public bots) must not +# stall the CLI on an exhaustive listing. +_PEERS_MAP_FETCH_CAP = 200 + + +def _peers_map_client(workspace: str | None = None): + """Return (client, config) for the active host, or (None, None) offline. + + ``workspace`` overrides the configured workspace for browse mode. + """ + try: + from dataclasses import replace + from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client + hcfg = HonchoClientConfig.from_global_config(host=_host_key()) + if not (hcfg.api_key or hcfg.base_url): + return None, None + if workspace and workspace != hcfg.workspace_id: + hcfg = replace(hcfg, workspace_id=workspace) + return get_honcho_client(hcfg), hcfg + except Exception: + return None, None + + +def _api_workspace_peers(client) -> list[dict] | None: + """Fetch workspace peers (id + created date). None = API unavailable.""" + if client is None: + return None + try: + peers: list[dict] = [] + page = 1 + while len(peers) < _PEERS_MAP_FETCH_CAP: + batch = list(client.peers(page=page, size=50)) + if not batch: + break + for p in batch: + peers.append({ + "id": str(p.id), + "created": str(getattr(p, "created_at", "") or "")[:10], + }) + if len(batch) < 50: + break + page += 1 + return peers + except Exception: + return None + + +def _api_workspaces(client) -> list[str] | None: + """Workspace IDs this key can reach. None = API unavailable.""" + if client is None: + return None + try: + return [str(w) for w in client.workspaces(size=50)] + except Exception: + return None + + +def _api_peer_detail(client, peer_id: str) -> str: + """Short peek at a peer: its card, or a clear absence note.""" + try: + card = client.peer(peer_id).get_card() + if card: + text = str(card).strip() + return text[:400] + ("…" if len(text) > 400 else "") + return "(no peer card yet)" + except Exception as e: + return f"(peer detail unavailable: {e})" + + +def _sanitize_peer_id(s: str) -> str: + import re + return re.sub(r'[^a-zA-Z0-9_-]', '-', s) + + +def _classify_workspace_peers( + peer_ids: list[str], cfg: dict, accounts: list[dict], + aliases: dict, prefix: str, +) -> dict[str, str]: + """Label workspace peers from local config; 'unrecognized' when honest.""" + labels: dict[str, str] = {} + active_host = _host_key() + root_peer = cfg.get("peerName") or "" + + profile_rows = _all_profile_host_configs() + hermes_hosts = {hostk for _, hostk, _ in profile_rows} + for name, hostk, block in profile_rows: + pn = block.get("peerName") or root_peer + ai = block.get("aiPeer") or cfg.get("aiPeer") or hostk + if pn: + labels.setdefault( + _sanitize_peer_id(pn), + "your peer (peerName)" if hostk == active_host + else f"peerName of profile {name}", + ) + who = "this profile" if hostk == active_host else f"profile {name}" + labels.setdefault(_sanitize_peer_id(ai), f"AI peer · {who}") + + # Host blocks that are not Hermes profiles: other apps sharing the config. + for hostk, block in (cfg.get("hosts") or {}).items(): + if hostk in hermes_hosts or not isinstance(block, dict): + continue + for key, kind in (("peerName", "peer"), ("aiPeer", "AI peer")): + val = block.get(key) + if isinstance(val, str) and val.strip(): + labels.setdefault( + _sanitize_peer_id(val.strip()), f"{kind} of app '{hostk}'") + + for target in aliases.values(): + if isinstance(target, str) and target.strip(): + labels.setdefault(_sanitize_peer_id(target.strip()), "alias target") + + for acct in accounts: + rid = acct["user_id"] + candidates = [_sanitize_peer_id(rid)] + if prefix: + candidates.append(_sanitize_peer_id(prefix + rid)) + for candidate in candidates: + labels.setdefault( + candidate, f"runtime peer · {acct['platform']} {rid}") + + out = {} + for pid in peer_ids: + label = labels.get(pid) + if label is None and pid.startswith("user-"): + label = "fallback peer (pre-identity traffic)" + out[pid] = label or "unrecognized" + return out + + +def _sibling_resolutions(cfg: dict, acct: dict) -> dict[str, str]: + """Resolved peer per profile for one account (profile name → peer).""" + out = {} + for name, _hostk, block in _all_profile_host_configs(): + pin, aliases, prefix, _, _ = _resolve_effective_identity_mapping(cfg, block) + pn = block.get("peerName") or cfg.get("peerName") or "" + out[name] = _preview_peer_resolution( + acct["user_id"], pin=pin, aliases=aliases, prefix=prefix, + peer_name=pn, user_id_alt=acct["user_id_alt"], + ) + return out + + +def _resolution_base(resolved: str) -> str: + """Strip display suffixes so the name can be checked against peer IDs.""" + return resolved.removesuffix(" (pinned)").removesuffix(" (prefixed)") + + +def _render_peers_map_view( + workspace: str, ws_peers: list[dict] | None, labels: dict, + accounts: list[dict], cfg: dict, *, + pin: bool, working: dict, prefix: str, peer_name: str, +) -> None: + peer_id_set = {p["id"] for p in (ws_peers or [])} + if ws_peers is None: + print(f"\nWorkspace '{workspace}' — peers unavailable (offline or not configured)") + print(" Mapping still works; target peers are typed instead of picked.") + else: + print(f"\nWorkspace '{workspace}' — {len(ws_peers)} peers\n" + "─" * 62) + if not ws_peers: + print(" No peers here yet — peers appear after the first conversation.") + print(" Wrong workspace? 'w' lists the workspaces this key can see.") + for i, p in enumerate(ws_peers, 1): + print(f" p{i:<4} {p['id']:<30} {labels.get(p['id'], '')}") + if len(ws_peers) >= _PEERS_MAP_FETCH_CAP: + print(f" … listing capped at {_PEERS_MAP_FETCH_CAP} peers.") + recognized = any( + v.startswith(("your peer", "AI peer")) for v in labels.values() + ) + if ws_peers and not recognized: + print(f"\n None of these match your configured identity ('{peer_name or workspace}').") + print(" Wrong workspace? 'w' lists the workspaces this key can see.") + + active_profile = _active_profile_name() + print(f"\nGateway accounts seen on this machine ({len(accounts)})\n" + "─" * 62) + if not accounts: + print(" None recorded yet. Accounts appear here after the gateway") + print(" handles a message from them. You can still map a runtime ID") + print(" by typing it at the prompt below.") + return + print(f" {'#':<4} {'Platform':<10} {'Runtime ID':<22} {'Name':<14} {'Resolves to'}") + for idx, acct in enumerate(accounts, 1): + resolved = _preview_peer_resolution( + acct["user_id"], pin=pin, aliases=working, + prefix=prefix, peer_name=peer_name, + user_id_alt=acct["user_id_alt"], + ) + marker = "" + if ws_peers is not None: + marker = " ✓" if _resolution_base(resolved) in peer_id_set else " ○ new" + siblings = _sibling_resolutions(cfg, acct) + mine = _resolution_base(resolved) + diverging = { + n: _resolution_base(v) for n, v in siblings.items() + if n != active_profile and _resolution_base(v) != mine + } + div = "" + if diverging: + div = " ≠ " + ", ".join(f"{n}→{v}" for n, v in sorted(diverging.items())) + via = "" + profiles = acct.get("profiles") or [] + if profiles and active_profile not in profiles: + via = f" (traffic → {', '.join(profiles)})" + label = acct["label"][:13] + print( + f" {idx:<4} {acct['platform']:<10} {acct['user_id']:<22} " + f"{label:<14} {resolved}{marker}{div}{via}" + ) + + +def _workspaces_flow(client, current_ws: str, cfg: dict, host: str): + """List reachable workspaces; browse one; optionally repoint the profile. + + Returns (workspace, client, ws_peers) after a confirmed switch, else None. + """ + ws_list = _api_workspaces(client) + if not ws_list: + print(" Workspace list unavailable (offline or not configured).") + return None + print(f"\n Workspaces this key can see ({len(ws_list)}):") + for i, w in enumerate(ws_list, 1): + marker = " ← current" if w == current_ws else "" + print(f" {i:<4} {w}{marker}") + print("\n Tip: for a full workspace browser, install honcho-cli") + print(" (uv tool install honcho-cli).") + sel = _prompt("Browse a workspace (number, blank to go back)", default="").strip() + if not (sel.isdigit() and 1 <= int(sel) <= len(ws_list)): + return None + target_ws = ws_list[int(sel) - 1] + b_client, _ = _peers_map_client(workspace=target_ws) + b_peers = _api_workspace_peers(b_client) + if b_peers is None: + print(f" Could not list peers of '{target_ws}'.") + return None + print(f"\n Workspace '{target_ws}' — {len(b_peers)} peers") + for p in b_peers[:30]: + print(f" {p['id']}") + if len(b_peers) > 30: + print(f" … and {len(b_peers) - 30} more") + if target_ws == current_ws: + return None + switch = _prompt( + f"Point this profile at '{target_ws}'? Existing memory stays in '{current_ws}'. (y/N)", + default="n", + ).strip().lower() + if switch not in {"y", "yes"}: + return None + cfg.setdefault("hosts", {}).setdefault(host, {})["workspace"] = target_ws + _write_config(cfg) + print(f" workspace → '{target_ws}' (written to host block [{host}])") + return target_ws, b_client, b_peers + + +def _save_alias_map(cfg: dict, host: str, working: dict, aliases_from_root: bool) -> None: + """Persist the edited alias map, asking for scope when it is shared.""" + profiles = _all_profile_host_configs() + write_root = aliases_from_root + if aliases_from_root and len(profiles) > 1: + scope = _prompt( + "Apply to all profiles (root) or only this profile? (all/this)", + default="all", + ).strip().lower() + if scope in {"this", "t", "host", "only"}: + write_root = False + print(f" This forks [{host}] from the shared root map — future root") + print(" edits no longer reach this profile.") + + if write_root: + if working: + cfg["userPeerAliases"] = working + else: + cfg.pop("userPeerAliases", None) + target_desc = "root config (shared by all profiles)" + hermes_host = _host_block(cfg, host) + active_ws = hermes_host.get("workspace") or cfg.get("workspace") or host + other_ws: dict[str, list[str]] = {} + for name, hostk, block in profiles: + ws = block.get("workspace") or cfg.get("workspace") or hostk + if ws != active_ws: + other_ws.setdefault(ws, []).append(name) + for ws, names in sorted(other_ws.items()): + print(f" ⚠ root aliases also apply in workspace '{ws}' (profile") + print(f" {', '.join(names)}) — picked peers may not exist there.") + else: + block = cfg.setdefault("hosts", {}).setdefault(host, {}) + if working: + block["userPeerAliases"] = working + else: + block.pop("userPeerAliases", None) + target_desc = f"host block [{host}]" + + _write_config(cfg) + print(f"\n userPeerAliases = {working if working else '{}'}") + print(f" written to {target_desc} in {_local_config_path()}\n") + + +def cmd_peers_map(args) -> None: + """Interactively map gateway accounts to Honcho user peers.""" + cfg = _read_config() + host = _host_key() + hermes_host = _host_block(cfg, host) + ( + pin, aliases, prefix, aliases_from_root, _prefix_from_root, + ) = _resolve_effective_identity_mapping(cfg, hermes_host) + peer_name = hermes_host.get("peerName") or cfg.get("peerName") or "" + + if pin: + print("\n pinUserPeer is on: every gateway account resolves to peer") + print(f" '{peer_name or '(peerName not set)'}' and aliases have no effect.") + print(" Turn the pin off with 'hermes honcho setup' to use per-account peers.") + cont = _prompt("Edit aliases anyway? (y/N)", default="n").strip().lower() + if cont not in {"y", "yes"}: + print(" Nothing changed.\n") + return + + accounts = _seen_gateway_accounts(_state_db_path()) + working = dict(aliases) if isinstance(aliases, dict) else {} + + client, client_cfg = _peers_map_client() + workspace = ( + getattr(client_cfg, "workspace_id", None) + or hermes_host.get("workspace") or cfg.get("workspace") or host + ) + ws_peers = _api_workspace_peers(client) + labels = _classify_workspace_peers( + [p["id"] for p in (ws_peers or [])], cfg, accounts, working, prefix, + ) + + _render_peers_map_view( + workspace, ws_peers, labels, accounts, cfg, + pin=pin, working=working, prefix=prefix, peer_name=peer_name, + ) + + print("\n Map: account number or a runtime ID · pN inspects a peer ·") + print(" w lists workspaces · blank finishes.") + changed = False + while True: + sel = _prompt("Account (blank to finish)", default="").strip() + if not sel: + break + low = sel.lower() + + if low == "w": + switched = _workspaces_flow(client, workspace, cfg, host) + if switched: + workspace, client, ws_peers = switched + labels = _classify_workspace_peers( + [p["id"] for p in ws_peers], cfg, accounts, working, prefix, + ) + _render_peers_map_view( + workspace, ws_peers, labels, accounts, cfg, + pin=pin, working=working, prefix=prefix, peer_name=peer_name, + ) + continue + + if low.startswith("p") and low[1:].isdigit() and ws_peers: + n = int(low[1:]) + if 1 <= n <= len(ws_peers): + pid = ws_peers[n - 1]["id"] + print(f"\n {pid} — {labels.get(pid, '')}") + print(f" {_api_peer_detail(client, pid)}\n") + continue + + if sel.isdigit() and 1 <= int(sel) <= len(accounts): + acct = accounts[int(sel) - 1] + rid = acct["user_id"] + label = f"{acct['platform']} {rid}" + ( + f" ({acct['label']})" if acct["label"] else "" + ) + prev_resolved = _resolution_base(_preview_peer_resolution( + rid, pin=pin, aliases=working, prefix=prefix, + peer_name=peer_name, user_id_alt=acct["user_id_alt"], + )) + else: + rid = sel + label = sel + prev_resolved = _resolution_base(_preview_peer_resolution( + rid, pin=pin, aliases=working, prefix=prefix, peer_name=peer_name, + )) + + current = working.get(rid, "") + hint = " (pN from the peers table, a name, '-' clears)" if ws_peers else "" + entered = _prompt(f"Peer for {label}{hint}", default=current).strip() + if entered == "-": + if rid in working: + del working[rid] + changed = True + print(f" cleared: {rid}") + continue + if entered.lower().startswith("p") and entered[1:].isdigit() and ws_peers: + n = int(entered[1:]) + if 1 <= n <= len(ws_peers): + entered = ws_peers[n - 1]["id"] + if entered and entered != current: + working[rid] = entered + changed = True + peer_id_set = {p["id"] for p in (ws_peers or [])} + print(f" {rid} → {entered} — future messages resolve to '{entered}'") + if ws_peers is not None and _sanitize_peer_id(entered) not in peer_id_set: + print(f" '{entered}' is a new peer — created on first message.") + if prev_resolved in peer_id_set and prev_resolved != _sanitize_peer_id(entered): + print(f" peer '{prev_resolved}' keeps its existing history.") + + if not changed: + print(" Nothing changed.\n") + return + _save_alias_map(cfg, host, working, aliases_from_root) + + def cmd_peers(args) -> None: """Show peer identities across all profiles.""" + if getattr(args, "peers_action", None) == "map": + cmd_peers_map(args) + return + rows = _all_profile_host_configs() cfg = _read_config() print(f"\nHoncho peer identities ({len(rows)} profiles)\n{'─' * 50}\n" @@ -1414,7 +1928,10 @@ _SUBCOMMANDS = ( ("status", "Show current Honcho config and connection status", cmd_status, ( ("--all", dict(action="store_true", help="Show config overview across all profiles")), )), - ("peers", "Show peer identities across all profiles", cmd_peers, ()), + ("peers", "Show peer identities across all profiles ('peers map' to map gateway accounts)", cmd_peers, ( + ("peers_action", dict(nargs="?", default=None, choices=("map",), metavar="map", + help="'map': interactively map gateway accounts to user peers")), + )), ("sessions", "List known Honcho session mappings", cmd_sessions, ()), ("map", "Map current directory to a Honcho session name (no arg = list mappings)", cmd_map, ( ("session_name", dict(nargs="?", default=None, diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 15c1a01226..b4ff74fc1b 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -500,6 +500,19 @@ class TestSetupWizardDeploymentShape: assert host["userPeerAliases"] == {"7654321": "eri"} + def test_mapping_step_points_at_peers_map(self, monkeypatch, tmp_path, capsys): + answers = [ + "cloud", # deployment + "", # api key (keep) + "eri", # peer name + "hermetika", # ai peer + "hermes", # workspace + "s", # tree: skip + ] + self._run_setup(monkeypatch, tmp_path, answers=answers) + out = capsys.readouterr().out + assert "hermes honcho peers map" in out + def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path): """Host-level ``pinUserPeer: true`` must classify as ``single``. diff --git a/tests/honcho_plugin/test_peers_map.py b/tests/honcho_plugin/test_peers_map.py new file mode 100644 index 0000000000..d0289c0b1a --- /dev/null +++ b/tests/honcho_plugin/test_peers_map.py @@ -0,0 +1,509 @@ +"""Tests for ``hermes honcho peers map``. + +The command lists gateway accounts recorded in state.db (the gateway stamps +each session row with its routing peer) and interactively edits +``userPeerAliases``. Discovery, the resolution preview, and the write-level +rules (host block vs root cascade) are covered here. +""" + +import json +import sqlite3 +from types import SimpleNamespace + +import plugins.memory.honcho.cli as honcho_cli +from plugins.memory.honcho.cli import ( + _preview_peer_resolution, + _seen_gateway_accounts, +) + + +def _make_state_db(path, rows): + """Create a minimal sessions table with only the columns the query reads.""" + conn = sqlite3.connect(path) + conn.execute( + """CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + user_id TEXT, + session_key TEXT, + display_name TEXT, + origin_json TEXT, + started_at REAL + )""" + ) + conn.executemany( + "INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?)", rows, + ) + conn.commit() + conn.close() + + +def _origin(user_name=None, is_bot=False, user_id_alt=None): + return json.dumps({ + "user_name": user_name, "is_bot": is_bot, "user_id_alt": user_id_alt, + }) + + +class TestSeenGatewayAccounts: + def test_groups_rows_by_platform_and_user(self, tmp_path): + db = tmp_path / "state.db" + _make_state_db(db, [ + ("s1", "telegram", "111", "telegram:dm:111", "Eri", _origin("eri"), 100.0), + ("s2", "telegram", "111", "telegram:dm:111", "Eri", _origin("eri"), 200.0), + ("s3", "discord", "222", "discord:dm:222", "Tek", _origin("teknium"), 50.0), + ]) + accounts = _seen_gateway_accounts(db) + assert len(accounts) == 2 + assert accounts[0] == { + "platform": "telegram", "user_id": "111", "user_id_alt": "", + "label": "eri", "sessions": 2, "profiles": [], + } + assert accounts[1]["user_id"] == "222" + + def test_orders_most_recent_first(self, tmp_path): + db = tmp_path / "state.db" + _make_state_db(db, [ + ("s1", "telegram", "111", "k1", None, None, 100.0), + ("s2", "discord", "222", "k2", None, None, 900.0), + ]) + accounts = _seen_gateway_accounts(db) + assert [a["user_id"] for a in accounts] == ["222", "111"] + + def test_skips_bots_and_rows_without_identity(self, tmp_path): + db = tmp_path / "state.db" + _make_state_db(db, [ + ("s1", "discord", "333", "k1", "Bot", _origin("webhook", is_bot=True), 100.0), + ("s2", "cli", None, None, None, None, 100.0), + ("s3", "telegram", "111", "k2", None, _origin("eri"), 100.0), + ]) + accounts = _seen_gateway_accounts(db) + assert [a["user_id"] for a in accounts] == ["111"] + + def test_label_falls_back_to_display_name(self, tmp_path): + db = tmp_path / "state.db" + _make_state_db(db, [ + ("s1", "telegram", "111", "k1", "Eri DM", None, 100.0), + ]) + accounts = _seen_gateway_accounts(db) + assert accounts[0]["label"] == "Eri DM" + + def test_missing_db_returns_empty(self, tmp_path): + assert _seen_gateway_accounts(tmp_path / "absent.db") == [] + + def test_db_without_sessions_table_returns_empty(self, tmp_path): + db = tmp_path / "state.db" + sqlite3.connect(db).close() + assert _seen_gateway_accounts(db) == [] + + +class TestPreviewPeerResolution: + def test_pin_wins_over_alias(self): + out = _preview_peer_resolution( + "111", pin=True, aliases={"111": "alice"}, prefix="tg_", peer_name="eri", + ) + assert out == "eri (pinned)" + + def test_pin_without_peer_name_falls_through(self): + out = _preview_peer_resolution( + "111", pin=True, aliases={}, prefix="", peer_name="", + ) + assert out == "111" + + def test_alias_hit(self): + out = _preview_peer_resolution( + "111", pin=False, aliases={"111": "alice"}, prefix="tg_", peer_name="eri", + ) + assert out == "alice" + + def test_prefix_for_unknown_id(self): + out = _preview_peer_resolution( + "111", pin=False, aliases={}, prefix="tg_", peer_name="eri", + ) + assert out == "tg_111 (prefixed)" + + def test_raw_sanitized_fallback(self): + out = _preview_peer_resolution( + "@you:matrix.org", pin=False, aliases={}, prefix="", peer_name="", + ) + assert out == "-you-matrix-org" + + def test_alt_id_alias_hit(self): + out = _preview_peer_resolution( + "device-777", pin=False, aliases={"uuid-abc": "eri"}, prefix="", + peer_name="", user_id_alt="uuid-abc", + ) + assert out == "eri" + + def test_primary_alias_wins_over_alt(self): + out = _preview_peer_resolution( + "111", pin=False, aliases={"111": "alice", "uuid-abc": "bob"}, + prefix="", peer_name="", user_id_alt="uuid-abc", + ) + assert out == "alice" + + +class TestCmdPeersMap: + def _run(self, monkeypatch, tmp_path, *, answers, cfg, db_rows=(), + ws_peers=None, workspaces=None, profiles=None): + db = tmp_path / "state.db" + if db_rows: + _make_state_db(db, list(db_rows)) + + written = {} + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default") + monkeypatch.setattr(honcho_cli, "_state_db_path", lambda: db) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: tmp_path / "honcho.json") + monkeypatch.setattr( + honcho_cli, "_write_config", + lambda c, path=None: written.update({"cfg": c}), + ) + monkeypatch.setattr( + honcho_cli, "_all_profile_host_configs", + lambda: profiles if profiles is not None + else [("default", "hermes", (cfg.get("hosts") or {}).get("hermes", {}))], + ) + # API seams: offline by default; a fake client sentinel when ws_peers given. + fake_client = object() if ws_peers is not None else None + + class _FakeCfg: + workspace_id = "hermes" + monkeypatch.setattr( + honcho_cli, "_peers_map_client", + lambda workspace=None: (fake_client, _FakeCfg() if fake_client else None), + ) + monkeypatch.setattr( + honcho_cli, "_api_workspace_peers", + lambda client: ( + [{"id": p, "created": "2026-01-01"} for p in ws_peers] + if client is not None and ws_peers is not None else None + ), + ) + monkeypatch.setattr( + honcho_cli, "_api_workspaces", + lambda client: list(workspaces) if workspaces is not None else None, + ) + monkeypatch.setattr( + honcho_cli, "_api_peer_detail", + lambda client, pid: f"(card of {pid})", + ) + + 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_peers_map(SimpleNamespace()) + return written + + def test_maps_seen_account_to_host_block(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + rows = [("s1", "telegram", "111", "k1", None, _origin("eri"), 100.0)] + written = self._run( + monkeypatch, tmp_path, + answers=["1", "eri", ""], + cfg=cfg, db_rows=rows, + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"111": "eri"} + + def test_raw_runtime_id_entry(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {}}} + written = self._run( + monkeypatch, tmp_path, + answers=["7654321", "eri", ""], + cfg=cfg, + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"7654321": "eri"} + + def test_clear_alias_with_dash(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "hosts": {"hermes": {"userPeerAliases": {"111": "eri", "222": "tek"}}}, + } + written = self._run( + monkeypatch, tmp_path, + answers=["111", "-", ""], + cfg=cfg, + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"222": "tek"} + + def test_clearing_last_alias_removes_key(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"userPeerAliases": {"111": "eri"}}}} + written = self._run( + monkeypatch, tmp_path, + answers=["111", "-", ""], + cfg=cfg, + ) + assert "userPeerAliases" not in written["cfg"]["hosts"]["hermes"] + + def test_root_sourced_aliases_write_back_to_root(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "userPeerAliases": {"111": "eri"}, + "hosts": {"hermes": {}}, + } + written = self._run( + monkeypatch, tmp_path, + answers=["222", "tek", ""], + cfg=cfg, + ) + assert written["cfg"]["userPeerAliases"] == {"111": "eri", "222": "tek"} + assert "userPeerAliases" not in written["cfg"]["hosts"]["hermes"] + + def test_no_changes_no_write(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {}}} + written = self._run(monkeypatch, tmp_path, answers=[""], cfg=cfg) + assert written == {} + + def test_keeping_current_value_is_not_a_change(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"userPeerAliases": {"111": "eri"}}}} + written = self._run( + monkeypatch, tmp_path, + answers=["111", "eri", ""], + cfg=cfg, + ) + assert written == {} + + def test_pinned_declined_exits_without_write(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}} + written = self._run(monkeypatch, tmp_path, answers=["n"], cfg=cfg) + assert written == {} + + def test_pinned_accepted_still_edits_aliases(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}} + written = self._run( + monkeypatch, tmp_path, + answers=["y", "111", "eri", ""], + cfg=cfg, + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"111": "eri"} + + def test_peers_dispatches_map_action(self, monkeypatch): + called = {} + monkeypatch.setattr(honcho_cli, "cmd_peers_map", lambda a: called.update({"map": True})) + honcho_cli.cmd_peers(SimpleNamespace(peers_action="map")) + assert called == {"map": True} + + def test_target_picked_from_workspace_peers(self, monkeypatch, tmp_path): + """'p1' as the target resolves to the first workspace peer's id.""" + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + rows = [("s1", "telegram", "111", "k1", None, _origin("eri"), 100.0)] + written = self._run( + monkeypatch, tmp_path, + answers=["1", "p1", ""], + cfg=cfg, db_rows=rows, ws_peers=["eri", "hermes"], + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"111": "eri"} + + def test_new_peer_and_history_consequences_printed(self, monkeypatch, tmp_path, capsys): + """Mapping onto a name not in the workspace warns about peer creation, + and moving an account off its existing runtime peer names the history + that stays behind.""" + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + rows = [("s1", "telegram", "111", "k1", None, _origin("bob"), 100.0)] + self._run( + monkeypatch, tmp_path, + answers=["1", "fresh-name", ""], + cfg=cfg, db_rows=rows, ws_peers=["eri", "111"], + ) + out = capsys.readouterr().out + assert "'fresh-name' is a new peer" in out + assert "peer '111' keeps its existing history" in out + + def test_exists_markers_in_accounts_table(self, monkeypatch, tmp_path, capsys): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + rows = [ + ("s1", "telegram", "111", "k1", None, _origin("a"), 100.0), + ("s2", "discord", "222", "k2", None, _origin("b"), 50.0), + ] + self._run( + monkeypatch, tmp_path, answers=[""], + cfg=cfg, db_rows=rows, ws_peers=["eri", "111"], + ) + import re + out = capsys.readouterr().out + line_111 = next(ln for ln in out.splitlines() if re.match(r"\s+\d+\s+telegram\s", ln)) + line_222 = next(ln for ln in out.splitlines() if re.match(r"\s+\d+\s+discord\s", ln)) + assert "✓" in line_111 + assert "○ new" in line_222 + + def test_offline_degrades_to_typed_targets(self, monkeypatch, tmp_path, capsys): + cfg = {"apiKey": "***", "hosts": {"hermes": {}}} + written = self._run( + monkeypatch, tmp_path, + answers=["7654321", "eri", ""], + cfg=cfg, ws_peers=None, + ) + out = capsys.readouterr().out + assert "peers unavailable" in out + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == {"7654321": "eri"} + + def test_inspect_peer_prints_card(self, monkeypatch, tmp_path, capsys): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + self._run( + monkeypatch, tmp_path, answers=["p2", ""], + cfg=cfg, ws_peers=["eri", "meow"], + ) + out = capsys.readouterr().out + assert "(card of meow)" in out + + def test_empty_workspace_hints_at_workspace_list(self, monkeypatch, tmp_path, capsys): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + self._run(monkeypatch, tmp_path, answers=[""], cfg=cfg, ws_peers=[]) + out = capsys.readouterr().out + assert "No peers here yet" in out + assert "Wrong workspace?" in out + + def test_unrecognized_workspace_hints_wrong_workspace(self, monkeypatch, tmp_path, capsys): + """Peers exist but none match local identity → wrong-workspace hint.""" + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + self._run( + monkeypatch, tmp_path, answers=[""], + cfg=cfg, ws_peers=["stranger1", "stranger2"], + ) + out = capsys.readouterr().out + assert "None of these match your configured identity" in out + + +class TestWorkspaceSwitch: + _run = TestCmdPeersMap._run + + def test_browse_and_confirmed_switch_writes_workspace(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + written = self._run( + monkeypatch, tmp_path, + answers=["w", "2", "y", ""], + cfg=cfg, ws_peers=["eri"], workspaces=["hermes", "cosmania-dex"], + ) + assert written["cfg"]["hosts"]["hermes"]["workspace"] == "cosmania-dex" + + def test_declined_switch_leaves_config_untouched(self, monkeypatch, tmp_path): + cfg = {"apiKey": "***", "hosts": {"hermes": {"peerName": "eri"}}} + written = self._run( + monkeypatch, tmp_path, + answers=["w", "2", "n", ""], + cfg=cfg, ws_peers=["eri"], workspaces=["hermes", "cosmania-dex"], + ) + assert written == {} + + +class TestSaveScope: + _run = TestCmdPeersMap._run + + def _multi_profiles(self, cfg): + hosts = cfg.get("hosts") or {} + return [ + ("default", "hermes", hosts.get("hermes", {})), + ("dreamer", "hermes.dreamer", hosts.get("hermes.dreamer", {})), + ] + + def test_root_sourced_multi_profile_prompts_scope_all(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "userPeerAliases": {"111": "eri"}, + "hosts": {"hermes": {}, "hermes.dreamer": {}}, + } + written = self._run( + monkeypatch, tmp_path, + answers=["222", "tek", "", "all"], + cfg=cfg, profiles=self._multi_profiles(cfg), + ) + assert written["cfg"]["userPeerAliases"] == {"111": "eri", "222": "tek"} + assert "userPeerAliases" not in written["cfg"]["hosts"]["hermes"] + + def test_root_sourced_multi_profile_scope_this_forks_host(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "userPeerAliases": {"111": "eri"}, + "hosts": {"hermes": {}, "hermes.dreamer": {}}, + } + written = self._run( + monkeypatch, tmp_path, + answers=["222", "tek", "", "this"], + cfg=cfg, profiles=self._multi_profiles(cfg), + ) + assert written["cfg"]["hosts"]["hermes"]["userPeerAliases"] == { + "111": "eri", "222": "tek", + } + # Root map stays as the other profiles' baseline. + assert written["cfg"]["userPeerAliases"] == {"111": "eri"} + + def test_cross_workspace_root_write_warns(self, monkeypatch, tmp_path, capsys): + cfg = { + "apiKey": "***", + "userPeerAliases": {}, + "hosts": { + "hermes": {"workspace": "hermes"}, + "hermes.dreamer": {"workspace": "dreamland"}, + }, + } + self._run( + monkeypatch, tmp_path, + answers=["222", "tek", "", "all"], + cfg=cfg, profiles=self._multi_profiles(cfg), + ) + out = capsys.readouterr().out + assert "also apply in workspace 'dreamland'" in out + + def test_sibling_divergence_marked(self, monkeypatch, tmp_path, capsys): + """A sibling profile resolving the same account differently is shown.""" + cfg = { + "apiKey": "***", + "hosts": { + "hermes": {"peerName": "eri", "userPeerAliases": {"111": "eri"}}, + "hermes.dreamer": {"peerName": "eri", "userPeerAliases": {"111": "bob"}}, + }, + } + rows = [("s1", "telegram", "111", "k1", None, _origin("x"), 100.0)] + self._run( + monkeypatch, tmp_path, answers=[""], + cfg=cfg, db_rows=rows, profiles=self._multi_profiles(cfg), + ) + out = capsys.readouterr().out + assert "≠ dreamer→bob" in out + + +class TestClassifyWorkspacePeers: + def test_labels_from_local_config(self, monkeypatch): + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr( + honcho_cli, "_all_profile_host_configs", + lambda: [ + ("default", "hermes", {"peerName": "eri", "aiPeer": "hermetika"}), + ("dreamer", "hermes.dreamer", {"aiPeer": "dreamer-ai"}), + ], + ) + cfg = { + "peerName": "eri", + "hosts": {"claude_code": {"aiPeer": "clawd"}}, + } + accounts = [{"platform": "telegram", "user_id": "7654321", "user_id_alt": ""}] + labels = honcho_cli._classify_workspace_peers( + ["eri", "hermetika", "dreamer-ai", "clawd", "7654321", + "user-default-root", "meow"], + cfg, accounts, {"999": "friend"}, "", + ) + assert labels["eri"] == "your peer (peerName)" + assert labels["hermetika"] == "AI peer · this profile" + assert labels["dreamer-ai"] == "AI peer · profile dreamer" + assert labels["clawd"] == "AI peer of app 'claude_code'" + assert labels["7654321"] == "runtime peer · telegram 7654321" + assert labels["user-default-root"] == "fallback peer (pre-identity traffic)" + assert labels["meow"] == "unrecognized" + + def test_alias_targets_and_prefix_recognized(self, monkeypatch): + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr( + honcho_cli, "_all_profile_host_configs", + lambda: [("default", "hermes", {})], + ) + accounts = [{"platform": "telegram", "user_id": "42", "user_id_alt": ""}] + labels = honcho_cli._classify_workspace_peers( + ["friend", "tg_42"], {}, accounts, {"999": "friend"}, "tg_", + ) + assert labels["friend"] == "alias target" + assert labels["tg_42"] == "runtime peer · telegram 42"