feat(honcho): add 'hermes honcho peers map' for interactive account-to-peer mapping

Extends the read-only 'hermes honcho peers' view with a 'map' action
that joins two sources: workspace peers fetched from the Honcho API,
labeled from local config (your peer, each profile's AI peer, alias
targets, runtime peers of seen accounts, user-* fallback peers,
'unrecognized' otherwise), and the gateway accounts recorded in
state.db with what each currently resolves to. Targets are picked
from the workspace list so a typo cannot silently create a peer;
every assignment states its consequence (aliases move future
messages only; a runtime peer left behind keeps its history).

'w' lists every workspace the key can reach — the wrong-workspace
fallback — and can repoint the profile's workspace on explicit
confirmation. With multiple profiles, saving a root-cascading map
asks whether to write root or fork the host block, root writes warn
when sibling profiles sit on other workspaces, and the accounts
table marks siblings that resolve an account differently. Offline
the command degrades to typed targets over the local account list.
The setup wizard's gateway step closes by pointing at the command.
This commit is contained in:
Erosika
2026-08-11 11:31:54 -04:00
committed by kshitij
parent 55dfb517e4
commit dc7c8673d1
4 changed files with 1046 additions and 1 deletions
+6
View File
@@ -233,6 +233,12 @@ Step 7 used to derive a peer from the session key (`user-default-<dir>`). 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<N>` 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
+518 -1
View File
@@ -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,