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"