"""``hermes peer`` — bot-to-bot DMs across machines/gateways. A *peer* is another Hermes gateway (any machine: homelab, Spark, Hermes Cloud) running the ``api_server`` platform. Registering it here gives every bot on THIS machine a transport to message bots on THAT machine: hermes peer add spark --url http://spark.lan:8377 --key hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?" hermes peer dm spark/researcher "..." # named profile (multiplexed peer) ``dm`` resolves the remote agent's canonical "Bot Chat" session (by title, creating it when missing), runs ONE synchronous agent turn over the peer's existing ``POST /api/sessions/{id}/chat`` endpoint, and prints the reply on stdout — the exact cross-machine twin of the local ``hermes -p chat --in ~ -c "Bot Chat" ...`` bot-messaging command, so the Bot Mode protocol composes over it unchanged. Design notes: - No new server surface: the peer's stock api_server is the transport. - Peer labels/URLs live in config.yaml (``bot_peers``); the peer's API_SERVER_KEY is a credential and lives in ``~/.hermes/.env`` as ``HERMES_PEER__KEY``. - Named-profile targets use the peer's ``/p//`` multiplex mirror; the bare target is the peer gateway's own (launch) profile. """ from __future__ import annotations import argparse import json import re import sys import urllib.error import urllib.parse import urllib.request BOT_CHAT_TITLE = "Bot Chat" _PEER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _PROFILE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") # One synchronous agent turn can legitimately take minutes. DM_TIMEOUT_S = 600 LIST_TIMEOUT_S = 30 def _peer_key_env(name: str) -> str: return f"HERMES_PEER_{name.upper().replace('-', '_')}_KEY" def _load_peers() -> dict: from hermes_cli.config import load_config cfg = load_config() or {} peers = cfg.get("bot_peers") return peers if isinstance(peers, dict) else {} def _save_peers(peers: dict) -> None: from hermes_cli.config import load_config, save_config cfg = load_config() or {} cfg["bot_peers"] = peers save_config(cfg) def _peer_secret(name: str) -> str: """The peer's API key: profile-scoped secret store first, raw env fallback.""" env_name = _peer_key_env(name) try: from agent.secret_scope import get_secret return (get_secret(env_name, "") or "").strip() except Exception: import os return (os.environ.get(env_name) or "").strip() def _request(url: str, key: str, *, method: str = "GET", body: dict | None = None, timeout: int = LIST_TIMEOUT_S) -> dict: data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request( url, data=data, method=method, headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", "User-Agent": "hermes-peer-dm", }, ) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — user-registered peer URL payload = resp.read().decode("utf-8", "replace") try: parsed = json.loads(payload) except ValueError as exc: raise RuntimeError(f"Peer returned non-JSON response: {payload[:200]}") from exc if not isinstance(parsed, dict): raise RuntimeError("Peer returned a non-object JSON response") return parsed def _base_url(peer: dict, profile: str | None) -> str: url = str(peer.get("url") or "").rstrip("/") if profile: # Multiplex mirror: same handlers, scoped to the named profile. return f"{url}/p/{urllib.parse.quote(profile, safe='')}" return url def _find_bot_chat(base: str, key: str) -> str | None: """The remote canonical Bot Chat's session id, or None.""" listing = _request(f"{base}/api/sessions?limit=200", key) for session in listing.get("data") or []: if isinstance(session, dict) and (session.get("title") or "").strip() == BOT_CHAT_TITLE: return str(session.get("id") or "") or None return None def _ensure_bot_chat(base: str, key: str) -> str: existing = _find_bot_chat(base, key) if existing: return existing created = _request( f"{base}/api/sessions", key, method="POST", body={"title": BOT_CHAT_TITLE, "source": "bot_peer_dm"}, ) # Real api_server wraps the row: {"object": "hermes.session", "session": {...}}. session = created.get("session") if isinstance(created.get("session"), dict) else created session_id = str(session.get("id") or session.get("session_id") or "") if not session_id: raise RuntimeError("Peer did not return a session id for the new Bot Chat") return session_id def _parse_target(target: str) -> tuple[str, str | None]: """```` or ``/`` → (peer, profile|None).""" raw = (target or "").strip() peer, _, profile = raw.partition("/") peer = peer.strip() profile = profile.strip() or None if not peer: raise ValueError("Peer name required (hermes peer dm [/] ...)") if profile and not _PROFILE_RE.match(profile): raise ValueError(f"Invalid agent/profile name: {profile!r}") return peer, profile def _http_error_detail(exc: urllib.error.HTTPError) -> str: try: body = exc.read().decode("utf-8", "replace") parsed = json.loads(body) message = parsed.get("error", {}).get("message") if isinstance(parsed, dict) else None return message or body[:200] except Exception: return str(exc) def cmd_peer(args) -> int: action = getattr(args, "peer_action", None) if action in ("add", "set"): name = (args.name or "").strip().lower() if not _PEER_NAME_RE.match(name): print(f"Invalid peer name: {name!r} (lowercase, digits, -, _; max 64)", file=sys.stderr) return 2 url = (args.url or "").strip() if not url.lower().startswith(("http://", "https://")): print("Peer --url must be an http(s) gateway base URL, e.g. http://spark.lan:8377", file=sys.stderr) return 2 peers = _load_peers() peers[name] = {"url": url.rstrip("/"), **({"note": args.note.strip()} if getattr(args, "note", "") else {})} _save_peers(peers) key = (getattr(args, "key", "") or "").strip() if key: from hermes_cli.config import save_env_value save_env_value(_peer_key_env(name), key) print(f"Peer '{name}' saved ({url}) — key stored as {_peer_key_env(name)} in ~/.hermes/.env") else: print( f"Peer '{name}' saved ({url}). No key given — set the peer's API_SERVER_KEY with:\n" f" hermes peer add {name} --url {url} --key \n" f" (or add {_peer_key_env(name)}= to ~/.hermes/.env)" ) return 0 if action in ("remove", "rm"): name = (args.name or "").strip().lower() peers = _load_peers() if name not in peers: print(f"No peer named '{name}'.", file=sys.stderr) return 1 peers.pop(name) _save_peers(peers) print(f"Peer '{name}' removed (its {_peer_key_env(name)} entry in .env is kept; delete it manually if unused).") return 0 if action in ("list", "ls", None): peers = _load_peers() if not peers: print("No peers registered. Add one: hermes peer add --url http://host:port --key ") return 0 for name in sorted(peers): entry = peers[name] if isinstance(peers[name], dict) else {} has_key = "key set" if _peer_secret(name) else f"NO KEY ({_peer_key_env(name)} unset)" note = f" — {entry.get('note')}" if entry.get("note") else "" print(f"{name}\t{entry.get('url', '?')}\t[{has_key}]{note}") return 0 if action == "dm": try: peer_name, profile = _parse_target(args.target) except ValueError as exc: print(str(exc), file=sys.stderr) return 2 peers = _load_peers() peer = peers.get(peer_name) if not isinstance(peer, dict) or not peer.get("url"): print(f"No peer named '{peer_name}'. Run: hermes peer list", file=sys.stderr) return 1 key = _peer_secret(peer_name) if not key: print( f"No API key for peer '{peer_name}'. Set it: hermes peer add {peer_name} " f"--url --key (or add {_peer_key_env(peer_name)}= to ~/.hermes/.env)", file=sys.stderr, ) return 1 message = (args.message or "").strip() if not message and not sys.stdin.isatty(): message = sys.stdin.read().strip() if not message: print("Message required (argument or stdin).", file=sys.stderr) return 2 base = _base_url(peer, profile) try: session_id = _ensure_bot_chat(base, key) result = _request( f"{base}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat", key, method="POST", body={"message": message}, timeout=DM_TIMEOUT_S, ) except urllib.error.HTTPError as exc: print(f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}", file=sys.stderr) return 1 except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc: print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr) return 1 reply = "" msg = result.get("message") if isinstance(msg, dict): reply = str(msg.get("content") or "") if getattr(args, "json", False): print(json.dumps({"peer": peer_name, "profile": profile, "session_id": result.get("session_id") or session_id, "reply": reply})) else: print(reply or "(no reply)") return 0 print("Unknown peer action. See: hermes peer --help", file=sys.stderr) return 2 def build_peer_parser(subparsers) -> None: """Attach the ``peer`` subcommand to ``subparsers``.""" parser = subparsers.add_parser( "peer", help="Bot-to-bot DMs across machines (peer Hermes gateways)", description=( "Register other Hermes gateways as peers and message their agents. " "'hermes peer dm [/] \"...\"' delivers into the remote " "agent's canonical Bot Chat over the peer's API server and prints " "the reply — the cross-machine twin of 'hermes -p chat'. " "The peer must run the api_server platform; its API_SERVER_KEY is " "stored locally as a credential in ~/.hermes/.env." ), epilog=( "Examples:\n" " hermes peer add spark --url http://spark.lan:8377 --key \n" " hermes peer list\n" ' hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?"\n' ' hermes peer dm spark/researcher "..." # named profile on a multiplexed peer\n' " hermes peer remove spark\n" "\n" "Exit codes: 0 ok, 1 delivery/peer error, 2 usage error." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) peer_sub = parser.add_subparsers(dest="peer_action") add_p = peer_sub.add_parser("add", aliases=["set"], help="Register (or update) a peer gateway") add_p.add_argument("name", help="Peer name (lowercase slug, e.g. spark, homelab)") add_p.add_argument("--url", required=True, help="Peer gateway base URL, e.g. http://spark.lan:8377") add_p.add_argument("--key", default="", help="The peer's API_SERVER_KEY (stored in ~/.hermes/.env)") add_p.add_argument("--note", default="", help="Optional description") peer_sub.add_parser("list", aliases=["ls"], help="List registered peers") rm_p = peer_sub.add_parser("remove", aliases=["rm"], help="Remove a peer") rm_p.add_argument("name", help="Peer name") dm_p = peer_sub.add_parser( "dm", help="Message an agent on a peer gateway and print its reply", ) dm_p.add_argument("target", help=" or / (named profile on a multiplexed peer)") dm_p.add_argument("message", nargs="?", default=None, help="Message text (or stdin)") dm_p.add_argument("--json", action="store_true", default=False, help="Emit a JSON result") parser.set_defaults(func=cmd_peer)