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"