Download plugins/memory/honcho/cli.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 92.3 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/plugins/memory/honcho/cli.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/plugins/memory/honcho/cli.py
-
curl -L -o cli.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/plugins/memory/honcho/cli.py
92.3 kB
| """``hermes honcho`` subcommands: setup wizard, status, peers, sessions, identity, migrate.""" | |
| from __future__ import annotations | |
| import copy | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| from hermes_constants import get_hermes_home | |
| from plugins.memory.honcho.client import _first_parsed, _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST | |
| from plugins.memory.honcho.session_peers import sanitize_peer_id | |
| from hermes_cli.config import cfg_get | |
| from utils import read_json_or_empty | |
| RULE = "β" * 40 | |
| REASONING_LEVELS = ("minimal", "low", "medium", "high", "max") | |
| _RETRY_HINT = " Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n" | |
| # Settings a new profile host block inherits from the default block. | |
| _INHERITED_KEYS = ( | |
| "recallMode", "writeFrequency", "sessionStrategy", "contextTokens", | |
| "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", | |
| "messageMaxChars", "dialecticMaxInputChars", "saveMessages", "observation", | |
| "recallSync", | |
| ) | |
| # clone_honcho_for_profile also carries the operator's runtime-to-peer routing intent. | |
| _CLONE_KEYS = _INHERITED_KEYS[:3] + ("sessionPeerPrefix", "sessionAiPeerPrefix") + _INHERITED_KEYS[3:] + ( | |
| "pinUserPeer", "userPeerAliases", "runtimePeerPrefix", | |
| ) | |
| _IDENTITY_MAPPING_KEYS = ("pinPeerName", "pinUserPeer", "userPeerAliases", "runtimePeerPrefix") | |
| # Setup-wizard answer -> identity-mapping shape ("2"/pooled answers are handled inline). | |
| _SHAPE_CHOICES = {"1": "single", "me": "single", "just-me": "single", "3": "multi", "others": "multi", | |
| "e": "raw", "edit": "raw", "raw": "raw"} | |
| _MODES = { | |
| "hybrid": "auto-injected context + Honcho tools available (default)", | |
| "context": "auto-injected context only, Honcho tools hidden", | |
| "tools": "Honcho tools only, no auto-injected context", | |
| } | |
| _STRATEGIES = { | |
| "per-session": "each run starts clean, Honcho injects context automatically", | |
| "per-directory": "reuses session per dir, prior context auto-injected each run", | |
| "per-repo": "one session per git repository", | |
| "global": "single session across all directories", | |
| } | |
| # ββ config access ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _profile_override: str | None = None | |
| def _host_key() -> str: | |
| """Active Honcho host key (``--target-profile`` override, else the active profile).""" | |
| if _profile_override: | |
| return HOST if _profile_override in {"default", "custom"} else profile_host_key(_profile_override) | |
| return resolve_active_host() | |
| def _config_path() -> Path: | |
| """Active Honcho config path for reading (instance-local, default profile, or global).""" | |
| return resolve_config_path() | |
| def _local_config_path() -> Path: | |
| """Instance-local write path; ~/.honcho/config.json is only a read fallback for cross-app interop.""" | |
| return get_hermes_home() / "honcho.json" | |
| class _ReadConfig(dict): | |
| """A command's config, with the ``snapshot`` and ``path`` _write_config() needs to apply only its edits.""" | |
| def __init__(self, raw: dict, path: Path): | |
| super().__init__(raw) | |
| self.snapshot, self.path = copy.deepcopy(raw), path | |
| def _read_config() -> dict: | |
| path = _config_path() | |
| return _ReadConfig(read_json_or_empty(path), path) | |
| class ConfigWriteRefused(Exception): | |
| """honcho.json exists on disk but does not parse, so no command may overwrite it.""" | |
| def _refuse_unparseable(path: Path) -> dict: | |
| """Return ``path``'s parsed content ({} when absent); raise ConfigWriteRefused when it exists but cannot | |
| be parsed, since writing back ``{}`` would drop every host.""" | |
| from plugins.memory.honcho.oauth import _read_config_strict | |
| try: | |
| return _read_config_strict(path) | |
| except (OSError, ValueError) as e: | |
| raise ConfigWriteRefused(f"{path} exists but could not be read as JSON ({e}). Nothing was written. " | |
| "Fix or move the file, then re-run.") from e | |
| def _apply_edits(base: dict, edited: dict, current: dict) -> dict: | |
| """Return ``current`` with the root keys and ``hosts.<h>.<k>`` the command changed (``base`` to ``edited``) | |
| applied. An untouched key keeps its on-disk value, so a rotation that landed while the command ran survives.""" | |
| out = copy.deepcopy(current) | |
| for key in (set(base) | set(edited)) - {"hosts"}: | |
| if key in edited and edited[key] != base.get(key): | |
| out[key] = copy.deepcopy(edited[key]) | |
| elif key not in edited and key in base: | |
| out.pop(key, None) | |
| base_hosts, edited_hosts = base.get("hosts") or {}, edited.get("hosts") or {} | |
| out_hosts = out.setdefault("hosts", {}) if (base_hosts or edited_hosts or "hosts" in current) else None | |
| for host in set(base_hosts) | set(edited_hosts): | |
| if host not in edited_hosts: | |
| out_hosts.pop(host, None) | |
| continue | |
| b, e = base_hosts.get(host) or {}, edited_hosts[host] | |
| block = out_hosts.setdefault(host, {}) | |
| for key in set(b) | set(e): | |
| if key in e and e[key] != b.get(key): | |
| block[key] = copy.deepcopy(e[key]) | |
| elif key not in e and key in b: | |
| block.pop(key, None) | |
| return out | |
| def _overlay_local(seed: dict, local: dict) -> dict: | |
| """Return ``seed`` with ``local``'s root keys and ``hosts.<h>.<k>`` keys on top. The local file wins, | |
| so a grant or rotation it already holds is what the command's edits land on.""" | |
| out = copy.deepcopy(seed) | |
| for key, value in local.items(): | |
| if key != "hosts": | |
| out[key] = copy.deepcopy(value) | |
| for host, block in (local.get("hosts") or {}).items(): | |
| out.setdefault("hosts", {}).setdefault(host, {}).update(copy.deepcopy(block)) | |
| return out | |
| def _write_config(cfg: dict, path: Path | None = None) -> None: | |
| """Persist ``cfg`` under the token refresh's cross-process lock. The object _read_config() returned | |
| has only its edits applied onto a fresh read of disk; a plain dict is written whole. A read that | |
| resolved to a seed file (~/.honcho or a profile) is written whole only while ``path`` does not exist.""" | |
| from plugins.memory.honcho.oauth import _config_refresh_lock, _refresh_lock | |
| from utils import atomic_json_write | |
| path = path or _local_config_path() | |
| # The file lock is best-effort; _refresh_lock is what keeps an in-process refresh thread out. | |
| with _refresh_lock, _config_refresh_lock(path): | |
| disk = _refuse_unparseable(path) | |
| out = cfg | |
| if isinstance(cfg, _ReadConfig): | |
| if cfg.path == path: | |
| out = _apply_edits(cfg.snapshot, cfg, disk) | |
| elif path.exists(): | |
| out = _apply_edits(cfg.snapshot, cfg, _overlay_local(cfg.snapshot, disk)) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| atomic_json_write(path, out, mode=0o600) | |
| if isinstance(cfg, _ReadConfig): # a later write on the same object applies only edits made after this one | |
| cfg.snapshot, cfg.path = copy.deepcopy(dict(cfg)), path | |
| def _label(host: str) -> str: | |
| return f"[{host}] " if host != "hermes" else "" | |
| def _mask(key: str) -> str: | |
| return f"...{key[-8:]}" if len(key) > 8 else ("set" if key else "not set") | |
| def _pref(block: dict, cfg: dict, key: str, default=None): | |
| """Host-block value, falling back to the root-level value (or ``default``).""" | |
| return block.get(key) or cfg.get(key, default) | |
| def _active_block(cfg: dict) -> dict: | |
| return (cfg.get("hosts") or {}).get(_host_key(), {}) | |
| def _set_field(cfg: dict, key: str, value, echo: str) -> None: | |
| """Write one key on the active host block and echo the change.""" | |
| host = _host_key() | |
| cfg.setdefault("hosts", {}).setdefault(host, {})[key] = value | |
| print(f" {_label(host)}{echo}") | |
| def _save(cfg: dict) -> None: | |
| _write_config(cfg) | |
| print(f" Saved to {_config_path()}\n") | |
| def _default_block_and_key(cfg: dict) -> tuple[dict, bool]: | |
| """(default host block, whether an API key is configured at root or env).""" | |
| return cfg_get(cfg, "hosts", HOST, default={}), bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) | |
| def _resolve_api_key(cfg: dict, block: dict | None = None, *, env: bool = True) -> str: | |
| """API key for ``block`` (default: the active host's block), host -> root -> env. A self-hosted | |
| http(s) or host:port ``baseUrl`` without a key yields "local" so credential guards accept it. | |
| ``env=False`` counts only what is on disk: a variable can vanish from the next process.""" | |
| block = _host_block(cfg, _host_key()) if block is None else block | |
| key = (block.get("apiKey") or cfg.get("apiKey", "") or (os.environ.get("HONCHO_API_KEY", "") if env else "")) | |
| if key: | |
| return key | |
| base_url = (block.get("baseUrl") or block.get("base_url") or cfg.get("baseUrl") or cfg.get("base_url") | |
| or (os.environ.get("HONCHO_BASE_URL", "") if env else "") or "").strip() | |
| if not base_url: | |
| return key | |
| from urllib.parse import urlparse | |
| try: | |
| parsed = urlparse(base_url) | |
| except (TypeError, ValueError): | |
| parsed = None | |
| if parsed and parsed.scheme in {"http", "https"} and parsed.netloc: | |
| return "local" | |
| lowered = base_url.lower() | |
| if lowered not in {"true", "false", "none", "null"} and any(c in base_url for c in ".:") and not base_url.isdigit(): | |
| return "local" | |
| return key | |
| def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: | |
| sys.stdout.write(f" {label}{f' [{default}]' if default else ''}: ") | |
| sys.stdout.flush() | |
| if secret and sys.stdin.isatty(): | |
| from hermes_cli.secret_prompt import masked_secret_prompt | |
| val = masked_secret_prompt("") | |
| else: # non-TTY (piped input, test runners) reads plaintext | |
| val = sys.stdin.readline().strip() | |
| return val or (default or "") | |
| def _yes(answer: str) -> bool: | |
| return answer.strip().lower() in {"y", "yes"} | |
| # ββ Honcho connection ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _connect(host: str | None, *, reset: bool = False): | |
| """(hcfg, client) for ``host``; lazy imports so tests can patch client.*.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client, reset_honcho_client | |
| if reset: | |
| reset_honcho_client() | |
| hcfg = HonchoClientConfig.from_global_config(host=host) | |
| return hcfg, get_honcho_client(hcfg) | |
| def _session_manager(hcfg, client): | |
| """(manager, session_key) with the session ensured (get_or_create is idempotent).""" | |
| from plugins.memory.honcho.session import HonchoSessionManager | |
| mgr = HonchoSessionManager(honcho=client, config=hcfg) | |
| session_key = hcfg.resolve_session_name() | |
| mgr.get_or_create(session_key) | |
| return mgr, session_key | |
| def _ensure_peer_exists(host_key: str | None = None) -> bool: | |
| """Create the AI (and user) peer in Honcho if missing. Idempotent; False on failure.""" | |
| try: | |
| from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client | |
| hcfg = HonchoClientConfig.from_global_config(host=host_key) | |
| if not hcfg.enabled or not (hcfg.api_key or hcfg.base_url): | |
| return False | |
| client = get_honcho_client(hcfg) | |
| client.peer(hcfg.ai_peer) | |
| if hcfg.peer_name: | |
| client.peer(hcfg.peer_name) | |
| return True | |
| except Exception: | |
| return False | |
| # ββ profile sync βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _inherit_defaults(block: dict, default_block: dict, cfg: dict, keys: tuple[str, ...]) -> None: | |
| """Copy ``keys`` (and peerName) from the default host block into ``block`` where unset.""" | |
| for key in keys: | |
| if (val := default_block.get(key)) is not None and key not in block: | |
| block[key] = val | |
| if (peer_name := _pref(default_block, cfg, "peerName")) and "peerName" not in block: | |
| block["peerName"] = peer_name | |
| def clone_honcho_for_profile(profile_name: str) -> bool: | |
| """Create a host block for a new profile, cloned from the default host block | |
| (called during profile creation). False if Honcho isn't configured or the block exists.""" | |
| cfg = _read_config() | |
| if not cfg: | |
| return False | |
| default_block, has_key = _default_block_and_key(cfg) | |
| new_host = profile_host_key(profile_name) | |
| if (not default_block and not has_key) or new_host in cfg.get("hosts", {}): | |
| return False | |
| new_block: dict = {} | |
| _inherit_defaults(new_block, default_block, cfg, _CLONE_KEYS) | |
| # Carry a legacy default-block pinPeerName forward under the canonical key. | |
| if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None: | |
| new_block["pinUserPeer"] = default_block["pinPeerName"] | |
| # AI peer is profile-specific (bare profile name: Honcho peer IDs allow no dots); | |
| # workspace is shared so all profiles see the same context. | |
| new_block.update(aiPeer=profile_name, workspace=_pref(default_block, cfg, "workspace") or HOST) | |
| # The default host's apiKey is not inherited; the block stays unenabled until this profile signs in. | |
| if _resolve_api_key(cfg, new_block, env=False): | |
| new_block["enabled"] = default_block.get("enabled", True) | |
| cfg.setdefault("hosts", {})[new_host] = new_block | |
| _write_config(cfg) | |
| _ensure_peer_exists(new_host) # eager so the peer exists before first message | |
| return True | |
| def _sync_profiles(verbose: bool) -> int: | |
| """Clone host blocks for profiles lacking one; returns the count created.""" | |
| say = print if verbose else (lambda *a: None) | |
| try: | |
| from hermes_cli.profiles import list_profiles | |
| profiles = list_profiles() | |
| except Exception as e: | |
| return say(f" Could not list profiles: {e}\n") or 0 | |
| cfg = _read_config() | |
| if not cfg: | |
| return say(" No Honcho config found. Run 'hermes honcho setup' first.\n") or 0 | |
| default_block, has_key = _default_block_and_key(cfg) | |
| if not default_block and not has_key: | |
| return say(" Honcho not configured on default profile. Run 'hermes honcho setup' first.\n") or 0 | |
| created = skipped = 0 | |
| for p in (p for p in profiles if p.name != "default"): | |
| try: | |
| cloned = clone_honcho_for_profile(p.name) | |
| except ConfigWriteRefused as e: | |
| return say(f" {e}\n") or created | |
| if cloned: | |
| say(f" + {p.name} -> {profile_host_key(p.name)}") | |
| created += 1 | |
| else: | |
| skipped += 1 | |
| say(f"\n {created} profile(s) synced." if created else " All profiles already have Honcho config.") | |
| if skipped: | |
| say(f" {skipped} profile(s) already configured (skipped).") | |
| say() | |
| return created | |
| def cmd_sync(args) -> None: | |
| """Sync Honcho config to all existing profiles (inherits from the default block).""" | |
| _sync_profiles(verbose=True) | |
| def sync_honcho_profiles_quiet() -> int: | |
| """Sync host blocks for all profiles from `hermes update`; no output, no exceptions.""" | |
| return _sync_profiles(verbose=False) | |
| def cmd_enable(args) -> None: | |
| """Enable Honcho for the active profile; refuses a block that cannot authenticate.""" | |
| cfg = _read_config() | |
| host = _host_key() | |
| label = _label(host) | |
| block = cfg.setdefault("hosts", {}).setdefault(host, {}) | |
| if not _resolve_api_key(cfg, block, env=False): | |
| profile = _active_profile_name() | |
| setup = "hermes honcho setup" + (f" --target-profile {profile}" if profile != "default" else "") | |
| return print(f" {label}Honcho stays disabled: no API key or base URL is configured for this profile, and the default " | |
| f"profile's key is not shared.\n Run '{setup}' to sign in, or set apiKey on hosts.{host} in {_config_path()}.\n") | |
| if block.get("enabled") is True: | |
| return print(f" {label}Honcho is already enabled.\n") | |
| block["enabled"] = True | |
| if not block.get("aiPeer"): # fresh profile block: clone settings from default | |
| default_block = cfg_get(cfg, "hosts", HOST, default={}) | |
| _inherit_defaults(block, default_block, cfg, _INHERITED_KEYS) | |
| block.setdefault("aiPeer", host.split(".", 1)[1] if "." in host else host) | |
| block.setdefault("workspace", _pref(default_block, cfg, "workspace") or HOST) | |
| _write_config(cfg) | |
| print(f" {label}Honcho enabled.") # before the (possibly slow) peer-creation round trip | |
| peer_state = f"Peer '{block.get('aiPeer', host)}' ready." if _ensure_peer_exists(host) else "Peer creation deferred (no connection)." | |
| print(f" {label}{peer_state}\n Saved to {_config_path()}\n") | |
| def cmd_disable(args) -> None: | |
| """Disable Honcho for the active profile.""" | |
| cfg = _read_config() | |
| host = _host_key() | |
| block = cfg_get(cfg, "hosts", host, default={}) | |
| if not block or block.get("enabled") is False: | |
| return print(f" {_label(host)}Honcho is already disabled.\n") | |
| block["enabled"] = False | |
| print(f" {_label(host)}Honcho disabled.") | |
| _save(cfg) | |
| # ββ identity mapping (setup wizard) ββββββββββββββββββββββββββββββββββββββββ | |
| def _resolve_effective_identity_mapping(cfg: dict, hermes_host: dict) -> tuple[bool, dict, str, bool, bool]: | |
| """``(pin, aliases, prefix, aliases_from_root, prefix_from_root)`` for the active host, | |
| mirroring ``from_global_config`` precedence (host over root; ``pinUserPeer`` beats | |
| ``pinPeerName``) so setup classifies the shape the gateway actually runs with. | |
| ``*_from_root`` lets writes skip inherited values.""" | |
| pin_sources = (hermes_host.get("pinUserPeer"), hermes_host.get("pinPeerName"), | |
| cfg.get("pinUserPeer"), cfg.get("pinPeerName")) | |
| pin = bool(next((v for v in pin_sources if v is not None), False)) | |
| def _inherit(key): | |
| if key in hermes_host: | |
| return hermes_host.get(key), False | |
| val = cfg.get(key) | |
| return val, val is not None | |
| aliases_src, aliases_from_root = _inherit("userPeerAliases") | |
| prefix_src, prefix_from_root = _inherit("runtimePeerPrefix") | |
| aliases = aliases_src if isinstance(aliases_src, dict) else {} | |
| return pin, aliases, str(prefix_src or ""), aliases_from_root, prefix_from_root | |
| def _scrub_identity_mapping(hermes_host: dict) -> None: | |
| """Drop every peer-mapping key so a stale alias/prefix/pin can't bleed into the new shape.""" | |
| for key in _IDENTITY_MAPPING_KEYS: | |
| hermes_host.pop(key, None) | |
| def _migrate_pin_key(block: dict) -> bool: | |
| """Rewrite legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place (the | |
| resolver prefers the canonical key). Returns True if the block changed.""" | |
| if "pinPeerName" not in block: | |
| return False | |
| block.setdefault("pinUserPeer", block.pop("pinPeerName")) | |
| return True | |
| def _gateway_platforms() -> list[str] | None: | |
| """Connected gateway platforms, or None if undetectable (lazy + guarded: the memory | |
| plugin must not hard-depend on the gateway package).""" | |
| try: | |
| from gateway.config import load_gateway_config | |
| return [p.value for p in load_gateway_config().get_connected_platforms()] | |
| except Exception: | |
| return None | |
| def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: | |
| """Prompt for the operator's per-platform runtime IDs, aliasing each to ``peer_target``.""" | |
| aliases = dict(existing) | |
| print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.\n" | |
| " Leave blank to skip a platform. Existing aliases are preserved.") | |
| for platform_label, alias_hint in (("Telegram UID", "e.g. 7654321"), ("Discord snowflake", "e.g. 491827364"), | |
| ("Slack user ID", "e.g. U04ABCDEF"), ("Matrix MXID", "e.g. @you:matrix.org")): | |
| entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() | |
| if entered: | |
| aliases[entered] = peer_target | |
| return aliases | |
| def _apply_runtime_prefix(hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str) -> None: | |
| """Write a host-level runtimePeerPrefix only when it diverges from an | |
| inherited root value; otherwise let the root cascade stand.""" | |
| new_prefix = _prompt(label, default=current_prefix or "").strip() | |
| if new_prefix and not (prefix_from_root and new_prefix == current_prefix): | |
| hermes_host["runtimePeerPrefix"] = new_prefix | |
| def _echo_identity_mapping(hermes_host: dict) -> None: | |
| print(f" resolved β\n pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}\n" | |
| f" userPeerAliases = {hermes_host.get('userPeerAliases') or '{}'}\n" | |
| f" runtimePeerPrefix = {hermes_host.get('runtimePeerPrefix') or '(none)'}") | |
| def _configure_raw_identity_mapping(hermes_host, current_pin, current_aliases, current_prefix, | |
| aliases_from_root, prefix_from_root) -> None: | |
| """Power-user escape hatch: set the three resolver knobs directly.""" | |
| print("\n Raw identity-mapping keys (resolver tries them top-down):") | |
| pin_in = _prompt("pinUserPeer β pin all gateway users to your peer? (true/false)", | |
| default=str(bool(current_pin)).lower()).strip().lower() | |
| pin = pin_in in {"true", "t", "yes", "y", "1"} | |
| _scrub_identity_mapping(hermes_host) | |
| hermes_host["pinUserPeer"] = pin | |
| if pin: | |
| return | |
| aliases = dict(current_aliases) if isinstance(current_aliases, dict) and not aliases_from_root else {} | |
| print(" userPeerAliases β 'runtime_id=peer' pairs (blank line to finish):") | |
| while entry := _prompt(" alias", default="").strip(): | |
| rid, _, peer = (p.strip() for p in entry.partition("=")) | |
| if rid and peer: | |
| aliases[rid] = peer | |
| if aliases: | |
| hermes_host["userPeerAliases"] = aliases | |
| _apply_runtime_prefix(hermes_host, current_prefix, prefix_from_root, | |
| "runtimePeerPrefix β namespace for unknown IDs (blank for none)") | |
| def _setup_identity_mapping(cfg: dict, hermes_host: dict, current_peer: str, new_host: bool) -> None: | |
| """Gateway identity mapping step. Only the gateway supplies a runtime user ID (CLI/TUI/ | |
| desktop fall through to peerName), so the step is gated on gateway detection.""" | |
| current_pin, current_aliases, current_prefix, aliases_from_root, prefix_from_root = ( | |
| _resolve_effective_identity_mapping(cfg, hermes_host)) | |
| current_shape = "single" if current_pin else "hybrid" if current_aliases else "multi" | |
| gw_platforms = _gateway_platforms() | |
| if gw_platforms: | |
| print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}") | |
| else: | |
| notice, question = ( | |
| ("\n Each gateway account (a Telegram user, a Discord user, ...)\n" | |
| " resolves to a peer. Honcho builds one representation per peer.", | |
| "Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)") if gw_platforms is None else | |
| ("\n No gateway platforms connected β nothing to map.", "Configure anyway? (y/N)")) | |
| print(notice) | |
| if not _yes(_prompt(question, default="n")): | |
| return | |
| peer_target = hermes_host.get("peerName") or current_peer or "user" | |
| ai_peer_label = hermes_host.get("aiPeer") or cfg.get("aiPeer") or "hermes" | |
| # Fresh configs default to the personal shape; configured ones keep their detected shape. | |
| identity_configured = not new_host or any(k in cfg for k in _IDENTITY_MAPPING_KEYS) | |
| default_choice = {"single": "1", "hybrid": "2", "multi": "3"}[current_shape] if identity_configured else "1" | |
| print("\n This step covers the HUMAN mapping only. Each account using the\n" | |
| " gateway resolves to a peer β the entity Honcho reasons about over\n" | |
| f" time. This agent is already its own peer ('{ai_peer_label}'), and each\n" | |
| " Hermes profile brings its own AI peer to the gateway.\n" | |
| "\n How should accounts resolve?\n" | |
| " [1] single peer β one person uses this agent; every account\n" | |
| f" resolves to '{peer_target}'. The common personal setup.\n" | |
| " Never for a gateway serving other people β their memory\n" | |
| " would merge into yours\n" | |
| " [2] your peer + one per other account β your accounts are\n" | |
| f" aliased to '{peer_target}'; each other account gets its own\n" | |
| " peer until you alias it. For a gateway you share\n" | |
| " [3] one peer per account β no aliases; every account is its\n" | |
| " own peer. For agents serving other people\n" | |
| " [s] skip (leave untouched) [e] edit raw keys\n" | |
| f"\n Tip: alias your Telegram UID and your Discord ID to '{peer_target}' β\n" | |
| " both accounts then resolve to one peer.") | |
| choice = _prompt("Choice", default=default_choice).strip().lower() | |
| if choice in {"2", "me+others", "both"}: | |
| pooled = _prompt(" Resolve all YOUR accounts to one peer? (Y/n)", default="y").strip().lower() | |
| shape = "hybrid" if pooled in {"y", "yes", ""} else "multi" | |
| else: | |
| shape = _SHAPE_CHOICES.get(choice, "skip") | |
| # Un-pinning without aliasing strands the pooled peerName history; steer toward pooling. | |
| if current_pin and shape == "multi": | |
| print(f"\n β The peer '{peer_target}' already has a representation built\n" | |
| f" from your messages. One peer per account means your accounts\n" | |
| f" resolve to new peers with no history.") | |
| if _prompt(f" Keep your accounts resolving to '{peer_target}' instead? (Y/n)", default="y").strip().lower() in {"y", "yes", ""}: | |
| shape = "hybrid" | |
| if shape == "skip": | |
| return print(" Identity mapping left untouched.") | |
| if shape == "raw": | |
| _configure_raw_identity_mapping(hermes_host, current_pin, current_aliases, current_prefix, | |
| aliases_from_root, prefix_from_root) | |
| else: | |
| # Preserve operator-curated host-level aliases across multi β multi re-runs. Root-sourced | |
| # aliases cascade naturally and are NOT copied down β an empty host map would mask a root baseline. | |
| prior_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} | |
| if shape == "multi" and aliases_from_root: | |
| prior_aliases = {} | |
| _scrub_identity_mapping(hermes_host) # each shape starts from a clean slate | |
| hermes_host["pinUserPeer"] = shape == "single" | |
| if shape == "single": | |
| print(f" Every gateway account resolves to peer '{peer_target}'.") | |
| else: | |
| aliases = prior_aliases if shape == "multi" else _collect_operator_aliases(prior_aliases, peer_target) | |
| if aliases: | |
| hermes_host["userPeerAliases"] = aliases | |
| _apply_runtime_prefix(hermes_host, current_prefix, prefix_from_root, | |
| "Runtime peer prefix (e.g. 'telegram_', blank for none)" if shape == "multi" else | |
| "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)") | |
| print(" Each gateway account resolves to its own peer." if shape == "multi" else | |
| f" Your accounts resolve to '{peer_target}'; each other account to its own peer.") | |
| _echo_identity_mapping(hermes_host) | |
| # ββ setup wizard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _ensure_sdk_installed() -> bool: | |
| """Check honcho-ai is importable; offer to install if not. Returns True if ready.""" | |
| try: | |
| import honcho # noqa: F401 | |
| return True | |
| except ImportError: | |
| pass | |
| print(" honcho-ai is not installed.") | |
| if not _yes(_prompt("Install it now? (honcho-ai==2.2.0)", default="y")): | |
| print(" Skipping install. Run: pip install 'honcho-ai==2.2.0'\n") | |
| return False | |
| print(" Installing honcho-ai...", flush=True) | |
| from tools.lazy_deps import install_specs # env-aware: sealed hosted venvs redirect to the data volume | |
| result = install_specs(["honcho-ai==2.2.0"]) | |
| if result.ok: | |
| print(" Installed.\n") | |
| return True | |
| print(f" Cannot install: {result.reason}\n" if result.blocked else | |
| f" Install failed:\n{(result.stderr or '').strip()}\n Run manually: uv pip install 'honcho-ai==2.2.0'\n") | |
| return False | |
| def _device_login_available() -> bool: | |
| """Whether the resolved host offers the RFC 8628 device grant. Fails closed.""" | |
| try: | |
| from plugins.memory.honcho.oauth_flow import resolve_endpoints, supports_device_login | |
| return supports_device_login(resolve_endpoints()) | |
| except Exception: | |
| return False | |
| def _headless() -> tuple[bool, bool]: | |
| """(is_remote, can_open_browser) β degrades safely if hermes_cli internals move.""" | |
| try: | |
| from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session | |
| return _is_remote_session(), _can_open_graphical_browser() | |
| except Exception: | |
| return False, True | |
| def _apply_grant_to_host(cfg: dict, hermes_host: dict, cred) -> None: | |
| """Store an OAuth grant on the host block and in ``cfg``'s snapshot. install_grant already wrote it to disk, | |
| so the final save must not copy it over a rotation that lands during the later prompts.""" | |
| hermes_host["apiKey"] = cred.access_token | |
| hermes_host["oauth"] = cred.oauth_block() | |
| if (snapshot := getattr(cfg, "snapshot", None)) is not None: | |
| snapshot.setdefault("hosts", {}).setdefault(_host_key(), {}).update(apiKey=cred.access_token, oauth=cred.oauth_block()) | |
| if cred.consent_peer_name: # default the peer prompt to the consent name | |
| hermes_host["peerName"] = cred.consent_peer_name | |
| print(" Authorized β token saved. Let's finish configuring.\n") | |
| def _setup_local_auth(cfg: dict, hermes_host: dict) -> None: | |
| """Self-hosted Honcho may run with AUTH_USE_AUTH=true; clients then send a JWT signed with | |
| the server's AUTH_JWT_SECRET as the bearer token. It is stored under the host block (not | |
| top-level apiKey) so ``get_honcho_client`` treats it as an explicit local auth opt-in and | |
| cloud/hybrid switching is unaffected.""" | |
| if new_url := _prompt("Base URL", default=cfg.get("baseUrl") or "http://localhost:8000"): | |
| cfg["baseUrl"] = new_url | |
| current_host_key = hermes_host.get("apiKey", "") | |
| print("\n Local Honcho auth (JWT signed with the server's AUTH_JWT_SECRET).\n" | |
| f" Leave blank if your server runs with AUTH_USE_AUTH=false. Current: {_mask(current_host_key)}") | |
| new_local_key = _prompt("Local JWT / bearer token (blank to skip / keep current)", secret=True) | |
| if new_local_key: | |
| hermes_host["apiKey"] = new_local_key | |
| elif current_host_key: | |
| print(" Keeping existing local JWT.") | |
| elif cfg.get("apiKey", ""): | |
| print("\n Top-level API key present in config (kept for cloud/hybrid use).\n" | |
| " Local connections will skip auth automatically until a local JWT is set above.") | |
| else: | |
| print("\n No local JWT set. Local no-auth ready.") | |
| def _setup_device_login(cfg: dict, hermes_host: dict, write_path: Path, *, open_browser: bool) -> bool: | |
| """RFC 8628 device-code sign-in. Returns False if setup must abort.""" | |
| from plugins.memory.honcho.oauth_flow import ( | |
| AccessDenied, AuthorizationTimeout, DeviceCode, DeviceCodeExpired, DeviceFlowError, authorize_via_device_code, | |
| ) | |
| def _show(device: DeviceCode) -> None: | |
| print(f"\n To connect, on any device with a browser:\n\n 1. Open {device.verification_uri}\n" | |
| f" 2. Enter {device.user_code}\n\n Or open directly:\n\n {device.verification_uri_complete}\n") | |
| mins = max(1, device.expires_in // 60) | |
| print(f" Waiting for approval (expires in {mins} min, Ctrl-C to cancel) ", end="", flush=True) | |
| print("\n Requesting device codeβ¦") | |
| import webbrowser | |
| try: | |
| cred = authorize_via_device_code( | |
| config_path=write_path, source="hermes-cli", apply_config=False, display=_show, | |
| open_url=webbrowser.open if open_browser else None, on_poll=lambda: print(".", end="", flush=True), | |
| ) | |
| except KeyboardInterrupt: | |
| print("\n Cancelled. Re-run 'hermes honcho setup' to try again.\n") | |
| except (AuthorizationTimeout, DeviceCodeExpired): | |
| print("\n Device code expired before approval.\n Re-run 'hermes honcho setup' to get a new code.\n") | |
| except AccessDenied: | |
| print("\n Sign-in was denied on the approval page.\n" + _RETRY_HINT) | |
| except Exception as e: | |
| print("\n Too many device-code requests β wait a minute and re-run setup.\n" | |
| if isinstance(e, DeviceFlowError) and e.error == "http_429" else f"\n Device sign-in failed: {e}\n" + _RETRY_HINT) | |
| else: | |
| print(" approved") | |
| _apply_grant_to_host(cfg, hermes_host, cred) | |
| return True | |
| return False | |
| def _setup_browser_login(cfg: dict, hermes_host: dict, write_path: Path) -> bool: | |
| """Loopback OAuth sign-in. Tokens merge into the in-memory cfg so the wizard's final save | |
| keeps them; settings stay wizard-owned (apply_config=False). Returns False on abort.""" | |
| from plugins.memory.honcho.oauth_flow import authorize_via_loopback | |
| import webbrowser | |
| def _open(url: str) -> None: | |
| print(f"\n Open this link to authorize (waiting up to 5 minutes):\n\n {url}\n") | |
| webbrowser.open(url) | |
| print("\n Starting browser sign-inβ¦") | |
| try: | |
| cred = authorize_via_loopback(config_path=write_path, source="hermes-cli", apply_config=False, open_url=_open) | |
| except Exception as e: | |
| print(f" OAuth sign-in failed: {e}\n" + _RETRY_HINT) | |
| return False | |
| _apply_grant_to_host(cfg, hermes_host, cred) | |
| return True | |
| def _setup_cloud_auth(cfg: dict, hermes_host: dict, write_path: Path) -> bool: | |
| """Cloud auth: OAuth (browser), device code, or API key. Returns False on abort.""" | |
| cfg.pop("baseUrl", None) # cloud uses SDK default | |
| from plugins.memory.honcho.oauth import OAuthCredential, is_oauth_access_token | |
| existing_oauth = OAuthCredential.from_host_block(hermes_host) | |
| device_available = _device_login_available() | |
| is_remote, can_browse = _headless() | |
| print("\n Auth method:") | |
| if existing_oauth is not None: | |
| print(f" (currently connected via OAuth β client {existing_oauth.client_id})") | |
| print(" oauth -- sign in via browser on this machine (recommended)") | |
| if device_available: | |
| print(" device -- device code: approve from a browser on another machine (SSH / headless)") | |
| print(" apikey -- paste an API key from https://app.honcho.dev") | |
| default_method = "oauth" | |
| if is_remote or not can_browse: | |
| default_method = "device" if device_available else "oauth" | |
| print(" (no usable local browser detected β device code recommended)" if device_available else | |
| " (no usable local browser detected β browser sign-in may need an SSH tunnel to 127.0.0.1:8765)") | |
| method = _prompt("oauth, device, or apikey?" if device_available else "OAuth or API key?", | |
| default=default_method).strip().lower() | |
| if device_available and method in {"device", "d"}: | |
| return _setup_device_login(cfg, hermes_host, write_path, open_browser=can_browse and not is_remote) | |
| if method in {"oauth", "o"}: | |
| return _setup_browser_login(cfg, hermes_host, write_path) | |
| # A leftover grant on the host block would shadow the pasted key. | |
| stale_grant = existing_oauth is not None or is_oauth_access_token(hermes_host.get("apiKey")) | |
| current = ("" if stale_grant else hermes_host.get("apiKey", "")) or cfg.get("apiKey", "") | |
| print(f"\n Current API key: {_mask(current)}") | |
| if new_key := _prompt("Honcho API key (leave blank to keep current)", secret=True): | |
| cfg["apiKey"] = new_key | |
| key = new_key or current | |
| if not key: | |
| print("\n No API key configured. Get yours at https://app.honcho.dev\n" | |
| " Run 'hermes honcho setup' again once you have a key.\n") | |
| return False | |
| hermes_host.pop("oauth", None) | |
| hermes_host["apiKey"] = key | |
| return True | |
| def _menu(header: str, *lines: str) -> None: | |
| print(f"\n {header}:\n" + "\n".join(f" {line}" for line in lines)) | |
| def _choice_step(hermes_host, key, current, label, valid, fallback=None) -> None: | |
| """Prompt for one of ``valid``; an invalid answer writes ``fallback`` (None = keep current).""" | |
| new = _prompt(label, default=current) | |
| if new in valid: | |
| hermes_host[key] = new | |
| elif fallback is not None: | |
| hermes_host[key] = fallback | |
| def _setup_tuning(cfg: dict, hermes_host: dict) -> None: | |
| """Wizard steps 4-8: observation, write frequency, recall, budgets, reasoning, strategy.""" | |
| _menu("Observation mode", | |
| "directional -- all observations on, each AI peer builds its own view (default)", | |
| "unified -- user observes self, AI observes others only") | |
| _choice_step(hermes_host, "observationMode", _pref(hermes_host, cfg, "observationMode", "directional"), | |
| "Observation mode", {"unified", "directional"}, "directional") | |
| _menu("Write frequency", | |
| "async -- background thread, no token cost (recommended)", | |
| "turn -- sync write after every turn", | |
| "session -- batch write at session end only", | |
| "N -- write every N turns (e.g. 5)") | |
| new_wf = _prompt("Write frequency", default=str(_pref(hermes_host, cfg, "writeFrequency", "async"))) | |
| hermes_host["writeFrequency"] = _first_parsed([new_wf], int, new_wf if new_wf in {"async", "turn", "session"} else "async") | |
| _menu("Recall mode", *(f"{m:<7} -- {desc}" for m, desc in _MODES.items())) | |
| raw_recall = _pref(hermes_host, cfg, "recallMode", "hybrid") | |
| _choice_step(hermes_host, "recallMode", raw_recall if raw_recall in _MODES else "hybrid", "Recall mode", _MODES) | |
| hermes_host["recallSync"] = _yes(_prompt( | |
| "Wait for current-query recall (bounded by request timeout, default 5s)? (y/N)", | |
| default="y" if hermes_host.get("recallSync", cfg.get("recallSync", False)) else "n")) | |
| current_ctx_tokens = _pref(hermes_host, cfg, "contextTokens") | |
| _menu("Context injection per turn (hybrid/context recall modes only)", | |
| "uncapped -- no limit (default)", | |
| "N -- token limit per turn (e.g. 1200)") | |
| new_ctx_tokens = _prompt("Context tokens", default=str(current_ctx_tokens) if current_ctx_tokens else "uncapped").strip() | |
| if new_ctx_tokens.lower() in {"none", "uncapped", "no limit"}: | |
| hermes_host.pop("contextTokens", None) | |
| elif new_ctx_tokens and (val := _first_parsed([new_ctx_tokens], int, -1)) >= 0: # non-numeric keeps current | |
| hermes_host["contextTokens"] = val | |
| _menu("Dialectic cadence", | |
| "How often Honcho rebuilds its user model (LLM call on Honcho backend).", | |
| "1 = every turn, 2 = every other turn, 3+ = sparser.", | |
| "Recommended: 1-5.") | |
| new_dialectic = _prompt("Dialectic cadence", default=str(_pref(hermes_host, cfg, "dialecticCadence") or "2")) | |
| if (val := _first_parsed([new_dialectic], int, None)) is None: | |
| hermes_host["dialecticCadence"] = 2 | |
| elif val >= 1: | |
| hermes_host["dialecticCadence"] = val | |
| _menu("Dialectic reasoning level", | |
| "Depth Honcho uses when synthesizing user context on auto-injected calls.", | |
| "minimal -- quick factual lookups", | |
| "low -- straightforward questions (default)", | |
| "medium -- multi-aspect synthesis", | |
| "high -- complex behavioral patterns", | |
| "max -- thorough audit-level analysis") | |
| _choice_step(hermes_host, "dialecticReasoningLevel", _pref(hermes_host, cfg, "dialecticReasoningLevel") or "low", | |
| "Reasoning level", REASONING_LEVELS, "low") | |
| _menu("Session strategy", *(f"{s:<13} -- {desc}" for s, desc in _STRATEGIES.items())) | |
| _choice_step(hermes_host, "sessionStrategy", _pref(hermes_host, cfg, "sessionStrategy", "per-session"), | |
| "Session strategy", _STRATEGIES) | |
| def cmd_setup(args) -> None: | |
| """Interactive Honcho setup wizard.""" | |
| try: | |
| _setup_wizard(args) | |
| except ConfigWriteRefused as e: | |
| print(f" {e}\n") | |
| def _setup_wizard(args) -> None: | |
| cfg = _read_config() | |
| write_path, read_path = _local_config_path(), _config_path() | |
| _refuse_unparseable(write_path) # before the questions, not after them | |
| print(f"\nHoncho memory setup\n{RULE}\n Honcho gives Hermes persistent cross-session memory.\n Config: {write_path}") | |
| if read_path != write_path and read_path.exists(): | |
| print(f" (seeding from existing config at {read_path})") | |
| print() | |
| if not _ensure_sdk_installed(): | |
| return | |
| hermes_host = cfg.setdefault("hosts", {}).setdefault(_host_key(), {}) | |
| _migrate_pin_key(cfg) # canonicalize legacy pinPeerName before detection/writes | |
| _migrate_pin_key(hermes_host) | |
| # Taken before the prompts populate the block: an existing install must not default to pinning every account. | |
| new_host = not any(k in hermes_host or k in cfg for k in (*_IDENTITY_MAPPING_KEYS, "peerName", "workspace", "enabled")) | |
| # --- 1. Cloud or local? --- | |
| print(" Deployment:\n cloud -- Honcho cloud (api.honcho.dev)\n local -- self-hosted Honcho server") | |
| current_url = cfg.get("baseUrl") or cfg.get("base_url") or "" | |
| current_deploy = "local" if any(h in current_url for h in ("localhost", "127.0.0.1", "::1")) else "cloud" | |
| is_local = _prompt("Cloud or local?", default=current_deploy).lower() in {"local", "l"} | |
| cfg.pop("base_url", None) # legacy snake_case key | |
| if is_local: | |
| _setup_local_auth(cfg, hermes_host) | |
| elif not _setup_cloud_auth(cfg, hermes_host, write_path): | |
| return | |
| # --- 3. Identity --- | |
| current_peer = hermes_host.get("peerName") or cfg.get("peerName", "") | |
| for key, label, default in ( | |
| ("peerName", "Your name (user peer)", current_peer or os.getenv("USER", "user")), | |
| ("aiPeer", "AI peer name", _pref(hermes_host, cfg, "aiPeer", "hermes")), | |
| ("workspace", "Workspace ID", _pref(hermes_host, cfg, "workspace", "hermes")), | |
| ): | |
| if new := _prompt(label, default=default): | |
| hermes_host[key] = new | |
| _setup_identity_mapping(cfg, hermes_host, current_peer, new_host) | |
| 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 | |
| hermes_host.setdefault("saveMessages", True) | |
| _write_config(cfg) | |
| print(f"\n Config written to {write_path}") | |
| try: # auto-enable Honcho as memory provider in config.yaml | |
| from hermes_cli.config import load_config, save_config | |
| hermes_config = load_config() | |
| hermes_config.setdefault("memory", {})["provider"] = "honcho" | |
| save_config(hermes_config) | |
| print(" Memory provider set to 'honcho' in config.yaml") | |
| except Exception as e: | |
| print(f" Could not auto-enable in config.yaml: {e}\n Run: hermes config set memory.provider honcho") | |
| print(" Testing connection... ", end="", flush=True) | |
| try: | |
| hcfg, _client = _connect(_host_key(), reset=True) | |
| print("OK") | |
| except Exception as e: | |
| return print(f"FAILED\n Error: {e}") | |
| print(f""" | |
| Honcho is ready. | |
| Session: {hcfg.resolve_session_name()} | |
| Workspace: {hcfg.workspace_id} | |
| User: {hcfg.peer_name} | |
| AI peer: {hcfg.ai_peer} | |
| Observe: {hcfg.observation_mode} | |
| Frequency: {hcfg.write_frequency} | |
| Recall: {hcfg.recall_mode} | |
| Sessions: {hcfg.session_strategy} | |
| Honcho tools available in chat: | |
| honcho_context -- session context: summary, representation, card, messages | |
| honcho_search -- semantic search over history | |
| honcho_profile -- peer card, key facts | |
| honcho_reasoning -- ask Honcho a question, synthesized answer | |
| honcho_conclude -- persist a user fact to memory | |
| Other commands: | |
| hermes honcho status -- show full config | |
| hermes honcho mode -- change recall/observation mode | |
| hermes honcho tokens -- tune context and dialectic budgets | |
| hermes honcho peer -- update peer names | |
| hermes honcho map <name> -- map this directory to a session name | |
| """) | |
| # ββ status / peers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _active_profile_name() -> str: | |
| """Active Hermes profile name (respects --target-profile override).""" | |
| if _profile_override: | |
| return _profile_override | |
| try: | |
| from hermes_cli.profiles import get_active_profile_name | |
| return get_active_profile_name() | |
| except Exception: | |
| return "default" | |
| def _all_profile_host_configs() -> list[tuple[str, str, dict]]: | |
| """(profile_name, host_key, host_block) for every known profile, reading honcho.json once.""" | |
| try: | |
| from hermes_cli.profiles import list_profiles | |
| profiles = list_profiles() | |
| except Exception: | |
| return [(_active_profile_name(), _host_key(), {})] | |
| cfg = _read_config() | |
| # _host_block (not hosts.get) keeps legacy dot-form keys ("hermes.work") readable. | |
| return [("default", HOST, cfg.get("hosts", {}).get(HOST, {}))] + [ | |
| (p.name, profile_host_key(p.name), _host_block(cfg, profile_host_key(p.name))) | |
| for p in profiles if p.name != "default" | |
| ] | |
| def cmd_status(args) -> None: | |
| """Show current Honcho config and connection status.""" | |
| if getattr(args, "all", False): | |
| _cmd_status_all() | |
| return | |
| try: | |
| import honcho # noqa: F401 | |
| except ImportError: | |
| print(" honcho-ai is not installed. Run: hermes honcho setup\n") | |
| return | |
| cfg = _read_config() | |
| active_path = _config_path() | |
| write_path = _local_config_path() | |
| from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client | |
| not_found = f" No Honcho config found at {active_path}\n Run 'hermes honcho setup' to configure.\n" | |
| try: | |
| hcfg = HonchoClientConfig.from_global_config(host=_host_key()) | |
| except Exception as e: | |
| return print(not_found if not cfg else f" Config error: {e}\n") | |
| if not cfg and not (hcfg.api_key or hcfg.base_url): # file missing and no env-var fallback either | |
| return print(not_found) | |
| # The OAuth access token is also stored under apiKey, so the auth line | |
| # distinguishes a refreshable grant from a static key explicitly. | |
| from plugins.memory.honcho.oauth import OAuthCredential | |
| raw = getattr(hcfg, "raw", None) or {} | |
| cred = OAuthCredential.from_host_block(raw.get("hosts", {}).get(hcfg.host) or {}) | |
| profile = _active_profile_name() | |
| if cred is not None: | |
| import time as _time | |
| remaining = int(cred.expires_at - _time.time()) | |
| token_state = f"valid {remaining // 60}m" if remaining > 0 else "expired β refreshes on next use" | |
| auth = f"OAuth ({cred.client_id}, token {token_state})" | |
| else: | |
| auth = f"API key ({_mask(hcfg.api_key or '')})" | |
| print(f"\nHoncho status{f' [{hcfg.host}]' if profile != 'default' else ''}\n" + RULE | |
| + (f"\n Profile: {profile}" if profile != "default" else "")) | |
| print(f" Host: {hcfg.host}\n Enabled: {hcfg.enabled}\n Auth: {auth}\n" | |
| f" Workspace: {hcfg.workspace_id}\n Config: {active_path}") | |
| global_path = Path.home() / ".honcho" / "config.json" | |
| if write_path != active_path: | |
| print(f" Write to: {write_path} (profile-local)") | |
| if active_path == global_path: | |
| print(" Fallback: (none β using global ~/.honcho/config.json)") | |
| elif global_path.exists(): | |
| print(f" Fallback: {global_path} (exists, cross-app interop)") | |
| dialectic_cadence = getattr(hcfg, "dialectic_cadence", None) or raw.get("dialecticCadence") or 1 | |
| reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap | |
| print(f""" AI peer: {hcfg.ai_peer} | |
| User peer: {hcfg.peer_name or 'not set'} | |
| Session key: {hcfg.resolve_session_name()} | |
| Session strat: {hcfg.session_strategy} | |
| Recall mode: {hcfg.recall_mode} | |
| Context budget: {hcfg.context_tokens or '(uncapped)'} tokens | |
| Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''} | |
| Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={'on' if hcfg.reasoning_heuristic else 'off'} | |
| Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others}) | |
| Write freq: {hcfg.write_frequency}""") | |
| if not (hcfg.enabled and (hcfg.api_key or hcfg.base_url)): | |
| return print(f"\n Not connected ({'disabled' if not hcfg.enabled else 'no API key or base URL'})\n") | |
| print("\n Connection... ", end="", flush=True) | |
| try: | |
| _show_peer_cards(hcfg, get_honcho_client(hcfg)) | |
| print("OK") | |
| except Exception as e: | |
| print(f"FAILED ({e})\n") | |
| def _show_peer_cards(hcfg, client) -> None: | |
| """Fetch and display peer cards for the active profile.""" | |
| try: | |
| mgr, session_key = _session_manager(hcfg, client) | |
| card = mgr.get_peer_card(session_key) | |
| if card: | |
| print(f"\n User peer card ({len(card)} facts):\n" + "\n".join(f" - {fact}" for fact in card[:10])) | |
| if len(card) > 10: | |
| print(f" ... and {len(card) - 10} more") | |
| ai_text = mgr.get_ai_representation(session_key).get("representation", "") | |
| if ai_text: | |
| print(f"\n AI peer representation:\n {ai_text[:200] + ('...' if len(ai_text) > 200 else '')}") | |
| print("" if card or ai_text else "\n No peer data yet (accumulates after first conversation)\n") | |
| except Exception as e: | |
| print(f"\n Peer data unavailable: {e}\n") | |
| def _cmd_status_all() -> None: | |
| """Show Honcho config overview across all profiles.""" | |
| rows = _all_profile_host_configs() | |
| cfg = _read_config() | |
| active = _active_profile_name() | |
| print(f"\nHoncho profiles ({len(rows)})\n{'β' * 55}\n" | |
| f" {'Profile':<14} {'Host':<22} {'Enabled':<9} {'Recall':<9} {'Write'}\n" | |
| f" {'β' * 14} {'β' * 22} {'β' * 9} {'β' * 9} {'β' * 9}") | |
| for name, host, block in rows: | |
| enabled = block.get("enabled", cfg.get("enabled")) | |
| if enabled is None: | |
| enabled = _default_block_and_key(cfg)[1] if block else False | |
| marker = " *" if name == active else "" | |
| print(f" {name + marker:<14} {host:<22} {'yes' if enabled else 'no':<9} " | |
| f"{_pref(block, cfg, 'recallMode', 'hybrid'):<9} {_pref(block, cfg, 'writeFrequency', 'async')}") | |
| 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]: | |
| """Gateway accounts recorded in state.db, most recent first. | |
| A session row keeps only its last routing peer, so a shared thread contributes its most recent | |
| author and not every participant. The row's origin does not record whether the author was a bot. | |
| """ | |
| if not db_path.exists(): | |
| return [] | |
| import sqlite3 | |
| from contextlib import closing | |
| # profile_name marks which profile a multiplexing gateway routed the | |
| # session to; older state.db files predate the column. | |
| 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 != '' | |
| GROUP BY source, user_id | |
| ORDER BY MAX(COALESCE(started_at, 0)) DESC""" | |
| try: | |
| 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 as e: | |
| print(f" (state.db unreadable: {e}; the accounts list is unavailable)", file=sys.stderr) | |
| return [] | |
| accounts = [] | |
| for source, user_id, display_name, origin_json, count, profiles in rows: | |
| try: | |
| origin = dict(json.loads(origin_json)) if origin_json else {} | |
| except Exception: | |
| origin = {} | |
| accounts.append({ | |
| "platform": source or "?", | |
| "user_id": str(user_id), | |
| "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 _preview_peer_resolution( | |
| user_id: str, *, pin: bool, aliases: dict, prefix: str, peer_name: str, | |
| user_id_alt: str = "", | |
| ) -> str: | |
| """Resolve through the runtime resolver and label the rung that decided: pin, alias, prefix, raw. | |
| Only the runtime knows when a prefixed id gets a hash suffix, so the CLI must not recompute it.""" | |
| from plugins.memory.honcho.client import HonchoClientConfig | |
| from plugins.memory.honcho.session import HonchoSessionManager | |
| config = HonchoClientConfig(peer_name=peer_name or None, pin_peer_name=bool(pin), | |
| user_peer_aliases=aliases, runtime_peer_prefix=prefix) | |
| manager = HonchoSessionManager(config=config, runtime_user_peer_name=user_id, | |
| runtime_user_peer_name_alt=user_id_alt or None) | |
| resolved = manager._resolve_user_peer_id("preview") | |
| if pin and peer_name: | |
| return f"{resolved} (pinned)" | |
| # The runtime resolver tries the alt ID (Signal UUID, Feishu union_id) after the primary. | |
| aliased = any(isinstance(aliases.get(rid), str) and aliases[rid].strip() for rid in (user_id, user_id_alt) if rid) | |
| if not aliased and prefix.strip(): | |
| return f"{resolved} (prefixed)" | |
| return resolved | |
| 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. | |
| _PEERS_MAP_FETCH_CAP = 200 | |
| def _peers_map_client(workspace: str | None = None): | |
| """(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 | |
| 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[str] | None: | |
| """Workspace peer IDs, at most _PEERS_MAP_FETCH_CAP. None = API unavailable.""" | |
| if client is None: | |
| return None | |
| try: | |
| peers: list[str] = [] | |
| page = client.peers(page=1, size=50) | |
| # Iterating a SyncPage walks every following page; .items is the one page asked for. | |
| while True: | |
| peers += [str(p.id) for p in page.items] | |
| if len(peers) >= _PEERS_MAP_FETCH_CAP or not page.has_next_page(): | |
| return peers[:_PEERS_MAP_FETCH_CAP] | |
| page = page.get_next_page() | |
| 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).items] | |
| 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 _classify_workspace_peers( | |
| peer_ids: list[str], cfg: dict, accounts: list[dict], | |
| aliases: dict, prefix: str, profile_rows: list[tuple[str, str, dict]], | |
| ) -> 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 "" | |
| 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"] | |
| for candidate in ([rid, prefix + rid] if prefix else [rid]): | |
| labels.setdefault(sanitize_peer_id(candidate), f"runtime peer Β· {acct['platform']} {rid}") | |
| 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, profile_rows: list[tuple[str, str, dict]]) -> dict[str, str]: | |
| """Resolved peer per profile for one account (profile name β peer).""" | |
| out = {} | |
| for name, _hostk, block in profile_rows: | |
| pin, aliases, prefix, _, _ = _resolve_effective_identity_mapping(cfg, block) | |
| out[name] = _resolution_base(_preview_peer_resolution( | |
| acct["user_id"], pin=pin, aliases=aliases, prefix=prefix, | |
| peer_name=block.get("peerName") or cfg.get("peerName") or "", | |
| user_id_alt=acct["user_id_alt"], | |
| )) | |
| return out | |
| def _render_peers_map_view( | |
| workspace: str, ws_peers: list[str] | None, labels: dict, | |
| accounts: list[dict], cfg: dict, profile_rows: list[tuple[str, str, dict]], *, | |
| pin: bool, working: dict, prefix: str, peer_name: str, | |
| ) -> None: | |
| 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, 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.") | |
| 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.") | |
| 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'}") | |
| 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"], | |
| ) | |
| mine = _resolution_base(resolved) | |
| marker = "" if ws_peers is None else (" β" if mine in known else " β new") | |
| diverging = { | |
| n: v for n, v in _sibling_resolutions(cfg, acct, profile_rows).items() | |
| if n != active_profile and v != mine | |
| } | |
| div = " β " + ", ".join(f"{n}β{v}" for n, v in sorted(diverging.items())) if diverging else "" | |
| profiles = acct.get("profiles") or [] | |
| 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"{acct['label'][:13]:<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): | |
| 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() | |
| 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 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 | |
| if not _yes(_prompt( | |
| f"Point this profile at '{target_ws}'? Existing memory stays in '{current_ws}'. (y/N)", | |
| default="n", | |
| )): | |
| 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") | |
| 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, {}) | |
| # An empty host map is an explicit override; popping the key would re-inherit the root aliases. | |
| target["userPeerAliases"] = working | |
| target_desc = f"host block [{host}]" | |
| if write_root: | |
| target_desc = "root config (shared by all profiles)" | |
| 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 | |
| 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.") | |
| _write_config(cfg) | |
| print(f"\n userPeerAliases = {working}") | |
| if not working and not write_root and cfg.get("userPeerAliases"): | |
| print(f" (empty host map: root aliases no longer apply to [{host}])") | |
| 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, _ = _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.") | |
| 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) | |
| # list_profiles() parses every profile's config.yaml; one scan serves every row and re-render. | |
| profile_rows = _all_profile_host_configs() | |
| def show() -> dict[str, str]: | |
| labels = _classify_workspace_peers(ws_peers or [], cfg, accounts, working, prefix, profile_rows) | |
| _render_peers_map_view(workspace, ws_peers, labels, accounts, cfg, profile_rows, | |
| 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 = repointed = 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 = show() | |
| repointed = True | |
| 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] | |
| 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, alt = acct["user_id"], acct["user_id_alt"] | |
| label = f"{acct['platform']} {rid}" + (f" ({acct['label']})" if acct["label"] else "") | |
| else: | |
| 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 "" | |
| 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 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 | |
| print(f" {rid} β {entered} β future messages resolve to '{entered}'") | |
| 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 (ws_peers or ()) and prev_resolved != sanitize_peer_id(entered): | |
| print(f" peer '{prev_resolved}' keeps its existing history.") | |
| if not changed: | |
| print(" Aliases unchanged.\n" if repointed else " 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" | |
| f" {'Profile':<14} {'User peer':<16} {'AI peer'}\n {'β' * 14} {'β' * 16} {'β' * 18}") | |
| for name, host, block in rows: | |
| user = _pref(block, cfg, "peerName") or "(not set)" | |
| print(f" {name:<14} {user:<16} {_pref(block, cfg, 'aiPeer') or host}") | |
| print() | |
| # ββ sessions / map βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def cmd_sessions(args) -> None: | |
| """List known directory β session name mappings.""" | |
| sessions = _read_config().get("sessions", {}) | |
| if not sessions: | |
| return print(f" No session mappings configured.\n\n Add one with: hermes honcho map <session-name>\n" | |
| f" Or edit {_config_path()} directly.\n") | |
| cwd = os.getcwd() | |
| print(f"\nHoncho session mappings ({len(sessions)})\n" + RULE) | |
| for path, name in sorted(sessions.items()): | |
| print(f" {name:<30} {path}{' β' if path == cwd else ''}") | |
| print() | |
| def cmd_map(args) -> None: | |
| """Map current directory to a Honcho session name.""" | |
| if not args.session_name: | |
| return cmd_sessions(args) | |
| session_name = args.session_name.strip() | |
| if not session_name: | |
| return print(" Session name cannot be empty.\n") | |
| import re | |
| if (sanitized := re.sub(r'[^a-zA-Z0-9_-]', '-', session_name).strip('-')) != session_name: | |
| print(f" Session name sanitized to: {sanitized}") | |
| session_name = sanitized | |
| cwd = os.getcwd() | |
| cfg = _read_config() | |
| cfg.setdefault("sessions", {})[cwd] = session_name | |
| _write_config(cfg) | |
| print(f" Mapped {cwd}\n β {session_name}\n") | |
| # ββ peer / mode / strategy / tokens ββββββββββββββββββββββββββββββββββββββββ | |
| def _show_or_set_fields(args, fields: tuple, show) -> None: | |
| """Shared body of ``peer`` / ``tokens``: with no flags, ``show(block, cfg)`` prints the | |
| current values; otherwise each ``(attr, key, echo, valid)`` flag that is set is written | |
| (an invalid value aborts before saving).""" | |
| cfg = _read_config() | |
| values = {attr: getattr(args, attr, None) for attr, _key, _echo, _valid in fields} | |
| if all(v is None for v in values.values()): | |
| return show(_active_block(cfg), cfg) | |
| for attr, key, echo, valid in fields: | |
| if (value := values[attr]) is None: | |
| continue | |
| if valid is not None and value not in valid: # only --reasoning carries a choice set | |
| return print(f" Invalid reasoning level '{value}'. Options: {', '.join(valid)}") | |
| value = value.strip() if isinstance(value, str) else value | |
| _set_field(cfg, key, value, echo.format(value)) | |
| _save(cfg) | |
| def cmd_peer(args) -> None: | |
| """Show or update peer names and dialectic reasoning level.""" | |
| def show(hermes, cfg): | |
| print(f""" | |
| Honcho peers | |
| {RULE} | |
| User peer: {_pref(hermes, cfg, 'peerName') or '(not set)'} | |
| Your identity in Honcho. Messages you send build this peer's card. | |
| AI peer: {_pref(hermes, cfg, 'aiPeer') or _host_key()} | |
| Hermes' identity in Honcho. Seed with 'hermes honcho identity <file>'. | |
| Dialectic calls ask this peer questions to warm session context. | |
| Dialectic reasoning: {_pref(hermes, cfg, 'dialecticReasoningLevel') or 'low'} ({', '.join(REASONING_LEVELS)}) | |
| Dialectic cap: {_pref(hermes, cfg, 'dialecticMaxChars') or 600} chars | |
| """) | |
| _show_or_set_fields(args, (("user", "peerName", "User peer -> {}", None), ("ai", "aiPeer", "AI peer -> {}", None), | |
| ("reasoning", "dialecticReasoningLevel", "Dialectic reasoning level -> {}", REASONING_LEVELS)), show) | |
| def _show_or_set_choice(args, *, attr: str, key: str, noun: str, title: str, choices: dict, | |
| default: str, width: int) -> None: | |
| """Shared body of ``mode`` / ``strategy``: list choices, or set one on the active host.""" | |
| cfg = _read_config() | |
| value = getattr(args, attr, None) | |
| if value is None: | |
| current = _pref(_active_block(cfg), cfg, key) or default | |
| print(f"\nHoncho {title}\n" + RULE) | |
| print("\n".join(f" {m:<{width}} {desc}{' <-' if m == current else ''}" for m, desc in choices.items())) | |
| return print(f"\n Set with: hermes honcho {attr} [{'|'.join(choices)}]\n") | |
| if value not in choices: | |
| return print(f" Invalid {noun} '{value}'. Options: {', '.join(choices)}\n") | |
| host = _host_key() | |
| cfg.setdefault("hosts", {}).setdefault(host, {})[key] = value | |
| _write_config(cfg) | |
| print(f" {_label(host)}{title[0].upper() + title[1:]} -> {value} ({choices[value]})\n") | |
| def cmd_mode(args) -> None: | |
| """Show or set the recall mode.""" | |
| _show_or_set_choice(args, attr="mode", key="recallMode", noun="mode", title="recall mode", | |
| choices=_MODES, default="hybrid", width=10) | |
| def cmd_strategy(args) -> None: | |
| """Show or set the session strategy.""" | |
| _show_or_set_choice(args, attr="strategy", key="sessionStrategy", noun="strategy", | |
| title="session strategy", choices=_STRATEGIES, default="per-session", width=15) | |
| def cmd_tokens(args) -> None: | |
| """Show or set token budget settings.""" | |
| def show(hermes, cfg): | |
| print(f""" | |
| Honcho budgets | |
| {RULE} | |
| Context {_pref(hermes, cfg, 'contextTokens') or '(Honcho default)'} tokens | |
| Raw memory retrieval. Honcho returns stored facts/history about | |
| the user and session, injected directly into the system prompt. | |
| Dialectic {_pref(hermes, cfg, 'dialecticMaxChars') or 600} chars, reasoning: {_pref(hermes, cfg, 'dialecticReasoningLevel') or 'low'} | |
| AI-to-AI inference. Hermes asks Honcho's AI peer a question | |
| (e.g. "what were we working on?") and Honcho runs its own model | |
| to synthesize an answer. Used for first-turn session continuity. | |
| Level controls how much reasoning Honcho spends on the answer. | |
| Set with: hermes honcho tokens [--context N] [--dialectic N] | |
| """) | |
| _show_or_set_fields(args, (("context", "contextTokens", "context tokens -> {}", None), | |
| ("dialectic", "dialecticMaxChars", "dialectic cap -> {} chars", None)), show) | |
| # ββ identity / migrate βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def cmd_identity(args) -> None: | |
| """Seed AI peer identity or show both peer representations.""" | |
| cfg = _read_config() | |
| if not _resolve_api_key(cfg): | |
| return print(" No API key configured. Run 'hermes honcho setup' first.\n") | |
| file_path = getattr(args, "file", None) | |
| try: | |
| hcfg, client = _connect(_host_key()) | |
| mgr, session_key = _session_manager(hcfg, client) | |
| except Exception as e: | |
| return print(f" Honcho connection failed: {e}\n") | |
| if getattr(args, "show", False): | |
| from plugins.memory.honcho.session import HonchoAuthError | |
| try: | |
| user_card = mgr.get_peer_card(session_key) | |
| ai_rep = mgr.get_ai_representation(session_key) | |
| except HonchoAuthError as e: | |
| return print(f" Honcho authentication failed: {e}\n") | |
| print(f"\nUser peer ({hcfg.peer_name or 'not set'})\n" + RULE) | |
| print("\n".join(f" {fact}" for fact in user_card) if user_card | |
| else " No user peer card yet. Send a few messages to build one.") | |
| print(f"\nAI peer ({hcfg.ai_peer})\n" + RULE) | |
| print(ai_rep.get("representation") or ai_rep.get("card") | |
| or " No representation built yet.\n Run 'hermes honcho identity <file>' to seed one.") | |
| print() | |
| return | |
| if not file_path: | |
| print(f""" | |
| Honcho identity management | |
| {RULE} | |
| User peer: {hcfg.peer_name or 'not set'} | |
| AI peer: {hcfg.ai_peer} | |
| hermes honcho identity --show β show both peer representations | |
| hermes honcho identity <file> β seed AI peer from SOUL.md or any .md/.txt | |
| """) | |
| return | |
| p = Path(file_path).expanduser() | |
| if not p.exists(): | |
| return print(f" File not found: {p}\n") | |
| content = p.read_text(encoding="utf-8").strip() | |
| if not content: | |
| return print(f" File is empty: {p}\n") | |
| if mgr.seed_ai_identity(session_key, content, source=p.name): | |
| print(f" Seeded AI peer identity from {p.name} into session '{session_key}'\n" | |
| f" Honcho will incorporate this into {hcfg.ai_peer}'s representation over time.\n") | |
| else: | |
| print(" Failed to seed identity. Check logs for details.\n") | |
| def _find_memory_files(names: list[str]) -> list[Path]: | |
| """Existing files named ``names`` in cwd then ~/.openclaw, deduplicated.""" | |
| candidates = (d / name for name in names for d in (Path(os.getcwd()), Path.home() / ".openclaw")) | |
| return list(dict.fromkeys(p for p in candidates if p.exists())) | |
| def _migrate_upload(mgr, session_key: str, user_files: list[Path]) -> None: | |
| dirs_with_files = set(str(f.parent) for f in user_files) | |
| # List (not generator) so every directory is attempted even after one succeeds. | |
| if any([mgr.migrate_memory_files(session_key, d) for d in dirs_with_files]): | |
| print(f" Uploaded user memory files from: {', '.join(dirs_with_files)}") | |
| else: | |
| print(" Nothing uploaded (files may already be migrated or empty).") | |
| def _migrate_seed(mgr, session_key: str, agent_files: list[Path]) -> None: | |
| for f in agent_files: | |
| content = f.read_text(encoding="utf-8").strip() | |
| if content: | |
| ok = mgr.seed_ai_identity(session_key, content, source=f.name) | |
| print(f" {f.name}: {'seeded' if ok else 'failed'}") | |
| def _offer(question: str, action, files: list[Path]) -> None: | |
| """Ask, then run ``action(mgr, session_key, files)`` against a fresh client.""" | |
| if not _yes(_prompt(question, default="y")): | |
| return | |
| try: | |
| action(*_session_manager(*_connect(None, reset=True)), files) | |
| except Exception as e: | |
| print(f" Failed: {e}") | |
| def cmd_migrate(args) -> None: | |
| """Step-by-step migration guide: OpenClaw native memory β Hermes + Honcho.""" | |
| user_files = _find_memory_files(["USER.md", "MEMORY.md"]) # facts about the user | |
| agent_files = _find_memory_files(["SOUL.md", "IDENTITY.md", "AGENTS.md", "TOOLS.md", "BOOTSTRAP.md"]) | |
| cfg = _read_config() | |
| has_key = bool(_resolve_api_key(cfg)) | |
| print("\nHoncho migration: OpenClaw native memory β Hermes\n" + "β" * 50) | |
| print(""" | |
| OpenClaw's native memory stores context in local markdown files | |
| (USER.md, MEMORY.md, SOUL.md, ...) and injects them via QMD search. | |
| Honcho replaces that with a cloud-backed, LLM-observable memory layer: | |
| context is retrieved semantically, injected automatically each turn, | |
| and enriched by a dialectic reasoning layer that builds over time. | |
| Step 1 Create a Honcho account | |
| """) | |
| if has_key: | |
| print(f" Honcho API key already configured: {_mask(cfg['apiKey'])}\n Skip to Step 2.") | |
| else: | |
| print(""" Honcho is a cloud memory service that gives Hermes persistent memory | |
| across sessions. You need an API key to use it. | |
| 1. Get your API key at https://app.honcho.dev | |
| 2. Run: hermes honcho setup | |
| Paste the key when prompted. | |
| """) | |
| if _yes(_prompt(" Run 'hermes honcho setup' now?", default="y")): | |
| cmd_setup(args) | |
| cfg = _read_config() | |
| has_key = bool(cfg.get("apiKey", "")) | |
| else: | |
| print("\n Run 'hermes honcho setup' when ready, then re-run this walkthrough.") | |
| print("\nStep 2 Detected OpenClaw memory files\n") | |
| if user_files or agent_files: | |
| for files, label in ((user_files, "User memory"), (agent_files, "Agent identity")): | |
| if files: | |
| peer = "user" if files is user_files else "AI" | |
| print(f" {label} ({len(files)} file(s)) β will go to Honcho {peer} peer:") | |
| print("\n".join(f" {f}" for f in files)) | |
| else: | |
| print(" No OpenClaw native memory files found in cwd or ~/.openclaw/.\n" | |
| " If your files are elsewhere, copy them here before continuing,\n" | |
| " or seed them manually: hermes honcho identity <path/to/file>") | |
| print(""" | |
| Step 3 Migrate user memory files β Honcho user peer | |
| USER.md and MEMORY.md contain facts about you that the agent should | |
| remember across sessions. Honcho will store these under your user peer | |
| and inject relevant excerpts into the system prompt automatically. | |
| """) | |
| if user_files: | |
| print(f" Found: {', '.join(f.name for f in user_files)}") | |
| print(""" | |
| These are picked up automatically the first time you run 'hermes' | |
| with Honcho configured and no prior session history. | |
| (Hermes calls migrate_memory_files() on first session init.) | |
| If you want to migrate them now without starting a session:""") | |
| print(" hermes honcho migrate β this step handles it interactively\n" * len(user_files), end="") | |
| if has_key: | |
| _offer(" Upload user memory files to Honcho now?", _migrate_upload, user_files) | |
| else: | |
| print(" Run 'hermes honcho setup' first, then re-run this step.") | |
| else: | |
| print(" No user memory files detected. Nothing to migrate here.") | |
| print(""" | |
| Step 4 Seed AI identity files β Honcho AI peer | |
| SOUL.md, IDENTITY.md, AGENTS.md, TOOLS.md, BOOTSTRAP.md define the | |
| agent's character, capabilities, and behavioral rules. In OpenClaw | |
| these are injected via file search at prompt-build time. | |
| In Hermes, they are seeded once into Honcho's AI peer through the | |
| observation pipeline. Honcho builds a representation from them and | |
| from every subsequent assistant message (observe_me=True). Over time | |
| the representation reflects actual behavior, not just declaration. | |
| """) | |
| if agent_files: | |
| print(f" Found: {', '.join(f.name for f in agent_files)}") | |
| print() | |
| if has_key: | |
| _offer(" Seed AI identity from all detected files now?", _migrate_seed, agent_files) | |
| else: | |
| print(" Run 'hermes honcho setup' first, then seed manually:") | |
| print("\n".join(f" hermes honcho identity {f}" for f in agent_files)) | |
| else: | |
| print(" No agent identity files detected.\n To seed manually: hermes honcho identity <path/to/SOUL.md>") | |
| print(""" | |
| Step 5 What changes vs. OpenClaw native memory | |
| Storage | |
| OpenClaw: markdown files on disk, searched via QMD at prompt-build time. | |
| Hermes: cloud-backed Honcho peers. Files can stay on disk as source | |
| of truth; Honcho holds the live representation. | |
| Context injection | |
| OpenClaw: file excerpts injected synchronously before each LLM call. | |
| Hermes: Honcho context fetched async at turn end, injected next turn. | |
| First turn has no Honcho context; subsequent turns are loaded. | |
| Memory growth | |
| OpenClaw: you edit files manually to update memory. | |
| Hermes: Honcho observes every message and updates representations | |
| automatically. Files become the seed, not the live store. | |
| Honcho tools (available to the agent during conversation) | |
| honcho_context β session context: summary, representation, card, messages | |
| honcho_search β semantic search over stored context | |
| honcho_profile β fast peer card snapshot | |
| honcho_reasoning β ask Honcho a question, synthesized answer | |
| honcho_conclude β write a conclusion/fact back to memory | |
| Session naming | |
| OpenClaw: no persistent session concept β files are global. | |
| Hermes: per-session by default β each run gets its own session | |
| Map a custom name: hermes honcho map <session-name> | |
| Step 6 Next steps | |
| """) | |
| if not has_key: | |
| print(" 1. hermes honcho setup β configure API key (required)\n" | |
| " 2. hermes honcho migrate β re-run this walkthrough") | |
| else: | |
| print(""" 1. hermes honcho status β verify Honcho connection | |
| 2. hermes β start a session | |
| (user memory files auto-uploaded on first turn if not done above) | |
| 3. hermes honcho identity --show β verify AI peer representation | |
| 4. hermes honcho tokens β tune context and dialectic budgets | |
| 5. hermes honcho mode β view or change memory mode""") | |
| print() | |
| # ββ dispatch / argparse ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # (subcommand, help, handler, ((arg, kwargs), ...)); order defines --help order. | |
| _SUBCOMMANDS = ( | |
| ("setup", "Initial Honcho setup (redirects to hermes memory setup)", None, ()), | |
| ("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 ('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, | |
| help="Session name to associate with this directory. Omit to list current mappings.")), | |
| )), | |
| ("peer", "Show or update peer names and dialectic reasoning level", cmd_peer, ( | |
| ("--user", dict(metavar="NAME", help="Set user peer name")), | |
| ("--ai", dict(metavar="NAME", help="Set AI peer name")), | |
| ("--reasoning", dict(metavar="LEVEL", choices=REASONING_LEVELS, | |
| help="Set default dialectic reasoning level (minimal/low/medium/high/max)")), | |
| )), | |
| ("mode", "Show or set recall mode (hybrid/context/tools)", cmd_mode, ( | |
| ("mode", dict(nargs="?", metavar="MODE", choices=tuple(_MODES), | |
| help="Recall mode to set (hybrid/context/tools). Omit to show current.")), | |
| )), | |
| ("strategy", "Show or set session strategy (per-session/per-directory/per-repo/global)", cmd_strategy, ( | |
| ("strategy", dict(nargs="?", metavar="STRATEGY", choices=tuple(_STRATEGIES), | |
| help="Session strategy to set. Omit to show current.")), | |
| )), | |
| ("tokens", "Show or set token budget for context and dialectic", cmd_tokens, ( | |
| ("--context", dict(type=int, metavar="N", help="Max tokens Honcho returns from session.context() per turn")), | |
| ("--dialectic", dict(type=int, metavar="N", help="Max chars of dialectic result to inject into system prompt")), | |
| )), | |
| ("identity", "Seed or show the AI peer's Honcho identity representation", cmd_identity, ( | |
| ("file", dict(nargs="?", default=None, help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.")), | |
| ("--show", dict(action="store_true", help="Show current AI peer representation from Honcho")), | |
| )), | |
| ("migrate", "Step-by-step migration guide from openclaw-honcho to Hermes Honcho", cmd_migrate, ()), | |
| ("enable", "Enable Honcho for the active profile", cmd_enable, ()), | |
| ("disable", "Disable Honcho for the active profile", cmd_disable, ()), | |
| ("sync", "Sync Honcho config to all existing profiles", cmd_sync, ()), | |
| ) | |
| _HANDLERS = {name: handler for name, _help, handler, _args in _SUBCOMMANDS if handler} | |
| def honcho_command(args) -> None: | |
| """Route honcho subcommands.""" | |
| global _profile_override | |
| _profile_override = getattr(args, "target_profile", None) | |
| sub = getattr(args, "honcho_command", None) | |
| if sub == "setup": # honcho setup goes through the unified memory-provider path | |
| print("\n Honcho is configured via the memory provider system.\n Running 'hermes memory setup'...\n") | |
| from hermes_cli.memory_setup import cmd_setup_provider | |
| return cmd_setup_provider("honcho") | |
| handler = cmd_status if sub is None else _HANDLERS.get(sub) | |
| if handler is None: | |
| return print(f" Unknown honcho command: {sub}\n" | |
| " Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n") | |
| try: | |
| handler(args) | |
| except ConfigWriteRefused as e: | |
| print(f" {e}\n") | |
| def register_cli(subparser) -> None: | |
| """Build the ``hermes honcho`` argparse subcommand tree on the ``hermes honcho`` parser.""" | |
| subparser.add_argument("--target-profile", metavar="NAME", dest="target_profile", | |
| help="Target a specific profile's Honcho config without switching") | |
| subs = subparser.add_subparsers(dest="honcho_command") | |
| for name, help_text, _handler, arguments in _SUBCOMMANDS: | |
| parser = subs.add_parser(name, help=help_text) | |
| for flag, kwargs in arguments: | |
| parser.add_argument(flag, **kwargs) | |
| subparser.set_defaults(func=honcho_command) | |