diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 17deb24237..e9fdb9d931 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -1058,23 +1058,18 @@ def _state_db_path() -> Path: def _seen_gateway_accounts(db_path: Path) -> list[dict]: - """Return gateway accounts recorded in state.db, most recent first. + """Gateway accounts recorded in state.db, most recent first; bot authors are skipped. - 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. + The gateway stamps each session row with its routing peer, so grouping rows by + (source, user_id) enumerates every account it has handled. """ if not db_path.exists(): return [] import sqlite3 - try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) - except sqlite3.Error: - return [] + from contextlib import closing # 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, + query = """SELECT source, user_id, MAX(COALESCE(display_name, '')), MAX(COALESCE(origin_json, '')), COUNT(*){profiles_col} @@ -1083,66 +1078,60 @@ def _seen_gateway_accounts(db_path: Path) -> list[dict]: 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()] + with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn: + try: + rows = conn.execute(query.format( + profiles_col=", GROUP_CONCAT(DISTINCT COALESCE(profile_name, 'default'))", + )).fetchall() + except sqlite3.OperationalError: + rows = [r + (None,) for r in conn.execute(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: + try: + origin = dict(json.loads(origin_json)) if origin_json else {} + except Exception: + origin = {} + if origin.get("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 "", + "user_id_alt": str(origin.get("user_id_alt") or ""), + "label": origin.get("user_name") or display_name or "", "sessions": count, "profiles": sorted(profiles.split(",")) if profiles else [], }) return accounts +def _sanitize_peer_id(s: str) -> str: + import re + return re.sub(r'[^a-zA-Z0-9_-]', '-', s) + + 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. + return f"{_sanitize_peer_id(peer_name)} (pinned)" + # The runtime resolver tries the alt ID (Signal UUID, Feishu union_id) after the primary. 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()) + return _sanitize_peer_id(alias.strip()) if prefix: - return f"{sanitize(prefix + user_id)} (prefixed)" - return sanitize(user_id) + return f"{_sanitize_peer_id(prefix + user_id)} (prefixed)" + return _sanitize_peer_id(user_id) + + +def _resolution_base(resolved: str) -> str: + """Strip display suffixes so the name can be checked against peer IDs.""" + return resolved.removesuffix(" (pinned)").removesuffix(" (prefixed)") # Workspaces holding thousands of peers (public bots) must not stall the CLI. @@ -1150,10 +1139,7 @@ _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. - """ + """(client, config) for the active host, or (None, None) offline. ``workspace`` overrides the configured one.""" try: from dataclasses import replace from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client @@ -1167,22 +1153,16 @@ def _peers_map_client(workspace: str | None = None): return None, None -def _api_workspace_peers(client) -> list[dict] | None: - """Fetch workspace peers (id + created date). None = API unavailable.""" +def _api_workspace_peers(client) -> list[str] | None: + """Workspace peer IDs, at most _PEERS_MAP_FETCH_CAP. None = API unavailable.""" if client is None: return None try: - peers: list[dict] = [] + peers: list[str] = [] 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], - }) + peers += [str(p.id) for p in batch] if len(batch) < 50: break page += 1 @@ -1213,11 +1193,6 @@ def _api_peer_detail(client, peer_id: str) -> str: 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, @@ -1248,8 +1223,7 @@ def _classify_workspace_peers( 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}'") + labels.setdefault(_sanitize_peer_id(val.strip()), f"{kind} of app '{hostk}'") for target in aliases.values(): if isinstance(target, str) and target.strip(): @@ -1257,20 +1231,13 @@ def _classify_workspace_peers( 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}") + for candidate in ([rid, prefix + rid] if prefix else [rid]): + labels.setdefault(_sanitize_peer_id(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 + return { + pid: labels.get(pid) or ("fallback peer (pre-identity traffic)" if pid.startswith("user-") else "unrecognized") + for pid in peer_ids + } def _sibling_resolutions(cfg: dict, acct: dict) -> dict[str, str]: @@ -1278,25 +1245,19 @@ def _sibling_resolutions(cfg: dict, acct: dict) -> dict[str, str]: 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( + out[name] = _resolution_base(_preview_peer_resolution( acct["user_id"], pin=pin, aliases=aliases, prefix=prefix, - peer_name=pn, user_id_alt=acct["user_id_alt"], - ) + peer_name=block.get("peerName") or cfg.get("peerName") or "", + 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, + workspace: str, ws_peers: list[str] | 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.") @@ -1305,14 +1266,11 @@ def _render_peers_map_view( 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'], '')}") + for i, pid in enumerate(ws_peers, 1): + print(f" p{i:<4} {pid:<30} {labels.get(pid, '')}") 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: + if ws_peers and not any(v.startswith(("your peer", "AI peer")) for v in labels.values()): 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.") @@ -1324,32 +1282,24 @@ def _render_peers_map_view( print(" by typing it at the prompt below.") return print(f" {'#':<4} {'Platform':<10} {'Runtime ID':<22} {'Name':<14} {'Resolves to'}") + known = set(ws_peers or []) 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"], + 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) + marker = "" if ws_peers is None else (" ✓" if mine in known else " ○ new") diverging = { - n: _resolution_base(v) for n, v in siblings.items() - if n != active_profile and _resolution_base(v) != mine + n: v for n, v in _sibling_resolutions(cfg, acct).items() + if n != active_profile and v != mine } - div = "" - if diverging: - div = " ≠ " + ", ".join(f"{n}→{v}" for n, v in sorted(diverging.items())) - via = "" + div = " ≠ " + ", ".join(f"{n}→{v}" for n, v in sorted(diverging.items())) if diverging else "" profiles = acct.get("profiles") or [] - if profiles and active_profile not in profiles: - via = f" (traffic → {', '.join(profiles)})" - label = acct["label"][:13] + via = f" (traffic → {', '.join(profiles)})" if profiles and active_profile not in profiles else "" print( f" {idx:<4} {acct['platform']:<10} {acct['user_id']:<22} " - f"{label:<14} {resolved}{marker}{div}{via}" + f"{acct['label'][:13]:<14} {resolved}{marker}{div}{via}" ) @@ -1364,8 +1314,7 @@ def _workspaces_flow(client, current_ws: str, cfg: dict, host: str): 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(f" {i:<4} {w}{' ← current' if w == current_ws else ''}") 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() @@ -1378,17 +1327,16 @@ def _workspaces_flow(client, current_ws: str, cfg: dict, host: str): 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']}") + for pid in b_peers[:30]: + print(f" {pid}") if len(b_peers) > 30: print(f" … and {len(b_peers) - 30} more") if target_ws == current_ws: return None - switch = _prompt( + if not _yes(_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) @@ -1401,23 +1349,21 @@ def _save_alias_map(cfg: dict, host: str, working: dict, aliases_from_root: bool 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"}: + scope = _prompt("Apply to all profiles (root) or only this profile? (all/this)", default="all") + if scope.strip().lower() 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.") + target = cfg if write_root else cfg.setdefault("hosts", {}).setdefault(host, {}) + if working: + target["userPeerAliases"] = working + else: + target.pop("userPeerAliases", None) + target_desc = f"host block [{host}]" 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 + active_ws = _host_block(cfg, 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 @@ -1426,13 +1372,6 @@ def _save_alias_map(cfg: dict, host: str, working: dict, aliases_from_root: bool 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 '{}'}") @@ -1444,38 +1383,33 @@ def cmd_peers_map(args) -> None: 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) + pin, aliases, prefix, aliases_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"}: + if not _yes(_prompt("Edit aliases anyway? (y/N)", default="n")): 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, - ) + def show() -> dict[str, str]: + labels = _classify_workspace_peers(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) + return labels + labels = show() print("\n Map: account number or a runtime ID · pN inspects a peer ·") print(" w lists workspaces · blank finishes.") changed = False @@ -1489,39 +1423,26 @@ def cmd_peers_map(args) -> None: 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, - ) + labels = show() 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"] + pid = ws_peers[n - 1] 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"], - )) + rid, alt = acct["user_id"], acct["user_id_alt"] + label = f"{acct['platform']} {rid}" + (f" ({acct['label']})" if acct["label"] else "") else: - rid = sel - label = sel - prev_resolved = _resolution_base(_preview_peer_resolution( - rid, pin=pin, aliases=working, prefix=prefix, peer_name=peer_name, - )) + rid, alt, label = sel, "", sel + prev_resolved = _resolution_base(_preview_peer_resolution( + rid, pin=pin, aliases=working, prefix=prefix, peer_name=peer_name, user_id_alt=alt, + )) current = working.get(rid, "") hint = " (pN from the peers table, a name, '-' clears)" if ws_peers else "" @@ -1532,18 +1453,15 @@ def cmd_peers_map(args) -> None: 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 ws_peers and entered[:1].lower() == "p" and entered[1:].isdigit() and 0 < int(entered[1:]) <= len(ws_peers): + entered = ws_peers[int(entered[1:]) - 1] 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: + if ws_peers is not None and _sanitize_peer_id(entered) not in ws_peers: 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): + if prev_resolved in (ws_peers or ()) and prev_resolved != _sanitize_peer_id(entered): print(f" peer '{prev_resolved}' keeps its existing history.") if not changed: diff --git a/tests/honcho_plugin/test_peers_map.py b/tests/honcho_plugin/test_peers_map.py index 0df0035d28..4ca96abdc6 100644 --- a/tests/honcho_plugin/test_peers_map.py +++ b/tests/honcho_plugin/test_peers_map.py @@ -186,10 +186,7 @@ class TestCmdPeersMap: ) 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 - ), + lambda client: list(ws_peers) if client is not None and ws_peers is not None else None, ) monkeypatch.setattr( honcho_cli, "_api_workspaces",