1492 lines
65 KiB
Python
1492 lines
65 KiB
Python
"""CLI commands for Honcho integration management.
|
|
|
|
Handles: hermes honcho setup | status | sessions | map | peer
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
|
|
from hermes_cli.config import cfg_get
|
|
|
|
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",
|
|
)
|
|
# clone_honcho_for_profile also carries the operator's runtime-to-peer routing intent.
|
|
_CLONE_KEYS = _INHERITED_KEYS[:3] + ("sessionPeerPrefix",) + _INHERITED_KEYS[3:] + (
|
|
"pinUserPeer", "userPeerAliases", "runtimePeerPrefix",
|
|
)
|
|
_IDENTITY_MAPPING_KEYS = ("pinPeerName", "pinUserPeer", "userPeerAliases", "runtimePeerPrefix")
|
|
_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:
|
|
"""Return the active Honcho host key, derived from the current Hermes profile."""
|
|
if _profile_override:
|
|
if _profile_override in {"default", "custom"}:
|
|
return HOST
|
|
return profile_host_key(_profile_override)
|
|
return resolve_active_host()
|
|
|
|
|
|
def _config_path() -> Path:
|
|
"""Return the active Honcho config path for reading (instance-local or global)."""
|
|
return resolve_config_path()
|
|
|
|
|
|
def _local_config_path() -> Path:
|
|
"""Instance-local write path ($HERMES_HOME/honcho.json). The global
|
|
~/.honcho/config.json is only a read fallback for cross-app interop."""
|
|
return get_hermes_home() / "honcho.json"
|
|
|
|
|
|
def _read_config() -> dict:
|
|
path = _config_path()
|
|
if path.exists():
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
|
path = path or _local_config_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
from utils import atomic_json_write
|
|
atomic_json_write(path, cfg, mode=0o600)
|
|
|
|
|
|
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)."""
|
|
default_block = cfg_get(cfg, "hosts", HOST, default={})
|
|
has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
|
|
return default_block, has_key
|
|
|
|
|
|
def _resolve_api_key(cfg: dict) -> str:
|
|
"""Resolve API key with host -> root -> env fallback. A self-hosted ``baseUrl``
|
|
without a key yields ``"local"`` so credential guards accept it: the URL is
|
|
scheme-validated (http/https) so ``baseUrl: true`` can't pass, while schemeless
|
|
host:port shapes (legacy ``localhost:8000``) still pass — the SDK rejects them."""
|
|
host_key = _host_block(cfg, _host_key()).get("apiKey")
|
|
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
|
if key:
|
|
return key
|
|
base_url = (cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") 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:
|
|
suffix = f" [{default}]" if default else ""
|
|
sys.stdout.write(f" {label}{suffix}: ")
|
|
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``; imports are lazy 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:
|
|
val = default_block.get(key)
|
|
if val is not None and key not in block:
|
|
block[key] = val
|
|
peer_name = _pref(default_block, cfg, "peerName")
|
|
if peer_name 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
|
|
hosts = cfg.get("hosts", {})
|
|
default_block, has_key = _default_block_and_key(cfg)
|
|
if not default_block and not has_key:
|
|
return False
|
|
new_host = profile_host_key(profile_name)
|
|
if new_host in 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["aiPeer"] = profile_name
|
|
new_block["workspace"] = _pref(default_block, cfg, "workspace") or HOST
|
|
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:
|
|
say(f" Could not list profiles: {e}\n")
|
|
return 0
|
|
cfg = _read_config()
|
|
if not cfg:
|
|
say(" No Honcho config found. Run 'hermes honcho setup' first.\n")
|
|
return 0
|
|
default_block, has_key = _default_block_and_key(cfg)
|
|
if not default_block and not has_key:
|
|
say(" Honcho not configured on default profile. Run 'hermes honcho setup' first.\n")
|
|
return 0
|
|
|
|
created = skipped = 0
|
|
for p in profiles:
|
|
if p.name == "default":
|
|
continue
|
|
if clone_honcho_for_profile(p.name):
|
|
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."""
|
|
cfg = _read_config()
|
|
host = _host_key()
|
|
label = _label(host)
|
|
block = cfg.setdefault("hosts", {}).setdefault(host, {})
|
|
if block.get("enabled") is True:
|
|
print(f" {label}Honcho is already enabled.\n")
|
|
return
|
|
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.")
|
|
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:
|
|
print(f" {_label(host)}Honcho is already disabled.\n")
|
|
return
|
|
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 ``HonchoClientConfig.from_global_config`` precedence (root-level
|
|
overrides; ``pinUserPeer`` beats ``pinPeerName``) so setup classifies the shape
|
|
the gateway actually runs with. ``*_from_root`` lets writes skip inherited values."""
|
|
pin = False
|
|
for val in (hermes_host.get("pinUserPeer"), hermes_host.get("pinPeerName"),
|
|
cfg.get("pinUserPeer"), cfg.get("pinPeerName")):
|
|
if val is not None:
|
|
pin = bool(val)
|
|
break
|
|
|
|
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
|
|
legacy = block.pop("pinPeerName")
|
|
if "pinUserPeer" not in block:
|
|
block["pinUserPeer"] = legacy
|
|
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:
|
|
aliases = hermes_host.get("userPeerAliases")
|
|
prefix = hermes_host.get("runtimePeerPrefix")
|
|
print(f" resolved →\n pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}\n"
|
|
f" userPeerAliases = {aliases if aliases else '{}'}\n"
|
|
f" runtimePeerPrefix = {prefix if prefix else '(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 True:
|
|
entry = _prompt(" alias", default="").strip()
|
|
if not entry:
|
|
break
|
|
if "=" in entry:
|
|
rid, peer = (p.strip() for p in entry.split("=", 1))
|
|
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) -> 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 is None:
|
|
print("\n Gateway identity mapping routes platform users to memory peers.")
|
|
run_mapping = _yes(_prompt("Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)", default="n"))
|
|
elif not gw_platforms:
|
|
print("\n No gateway platforms connected — identity mapping only affects\n"
|
|
" gateway users, so this step doesn't apply here.")
|
|
run_mapping = _yes(_prompt("Configure gateway mapping anyway? (y/N)", default="n"))
|
|
else:
|
|
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
|
|
run_mapping = True
|
|
if not run_mapping:
|
|
return
|
|
|
|
peer_target = hermes_host.get("peerName") or current_peer or "user"
|
|
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3")
|
|
print("\n How should gateway users map to memory peers?\n"
|
|
" [1] just me — every non-agent user collapses to your peer\n"
|
|
" [2] me + other people — keep mine pooled, others separate\n"
|
|
" [3] only other people — everyone gets their own peer\n"
|
|
" [s] skip (leave untouched) [e] edit raw keys")
|
|
choice = _prompt("Choice", default=default_choice).strip().lower()
|
|
|
|
if choice in {"2", "me+others", "both"}:
|
|
pooled = _prompt(" Keep my own memory pooled across platforms? (Y/n)", default="y").strip().lower()
|
|
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
|
|
else:
|
|
shape = {"1": "single", "me": "single", "just-me": "single", "3": "multi", "others": "multi",
|
|
"e": "raw", "edit": "raw", "raw": "raw"}.get(choice, "skip")
|
|
|
|
# Un-pinning without aliasing strands the pooled peerName history; steer toward pooling.
|
|
if current_pin and shape == "multi":
|
|
print(f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
|
|
f" '{peer_target}'. Existing gateway users resolve to fresh,\n"
|
|
f" empty peers.")
|
|
confirm = _prompt(" Pool my own memory instead (alias my IDs to peerName)? (Y/n)", default="y").strip().lower()
|
|
if confirm in {"y", "yes", ""}:
|
|
shape = "hybrid"
|
|
|
|
if shape == "skip":
|
|
print(" Identity mapping left untouched.")
|
|
return
|
|
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" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).")
|
|
elif shape == "multi":
|
|
if prior_aliases:
|
|
hermes_host["userPeerAliases"] = prior_aliases
|
|
_apply_runtime_prefix(hermes_host, current_prefix, prefix_from_root,
|
|
"Runtime peer prefix (e.g. 'telegram_', blank for none)")
|
|
print(" Each gateway user → own peer.")
|
|
else: # hybrid
|
|
merged = _collect_operator_aliases(prior_aliases, peer_target)
|
|
if merged:
|
|
hermes_host["userPeerAliases"] = merged
|
|
_apply_runtime_prefix(hermes_host, current_prefix, prefix_from_root,
|
|
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)")
|
|
print(f" Your runtime IDs → '{peer_target}', others → 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
|
|
if result.blocked:
|
|
print(f" Cannot install: {result.reason}\n")
|
|
else:
|
|
print(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(hermes_host: dict, cred) -> None:
|
|
"""Store an OAuth grant on the host block; the wizard's final save persists it."""
|
|
hermes_host["apiKey"] = cred.access_token
|
|
hermes_host["oauth"] = cred.oauth_block()
|
|
if cred.consent_peer_name: # default the peer prompt to the consent name
|
|
hermes_host["peerName"] = cred.consent_peer_name
|
|
|
|
|
|
def _open_in_browser(url: str) -> None:
|
|
import webbrowser
|
|
webbrowser.open(url)
|
|
|
|
|
|
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."""
|
|
new_url = _prompt("Base URL", default=cfg.get("baseUrl") or "http://localhost:8000")
|
|
if new_url:
|
|
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(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("\n To connect, on any device with a browser:")
|
|
print(f"\n 1. Open {device.verification_uri}")
|
|
print(f" 2. Enter {device.user_code}")
|
|
print(f"\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…")
|
|
try:
|
|
cred = authorize_via_device_code(
|
|
config_path=write_path, source="hermes-cli", apply_config=False, display=_show,
|
|
open_url=_open_in_browser 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:
|
|
if isinstance(e, DeviceFlowError) and e.error == "http_429":
|
|
print("\n Too many device-code requests — wait a minute and re-run setup.\n")
|
|
else:
|
|
print(f"\n Device sign-in failed: {e}\n" + _RETRY_HINT)
|
|
else:
|
|
print(" approved")
|
|
_apply_grant_to_host(hermes_host, cred)
|
|
print(" Authorized — token saved. Let's finish configuring.\n")
|
|
return True
|
|
return False
|
|
|
|
|
|
def _setup_browser_login(hermes_host: dict, write_path: Path) -> bool:
|
|
"""Loopback OAuth sign-in, up front — the browser link is the whole point.
|
|
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
|
|
|
|
def _open(url: str) -> None:
|
|
print(f"\n Open this link to authorize (waiting up to 5 minutes):\n\n {url}\n")
|
|
_open_in_browser(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(hermes_host, cred)
|
|
print(" Authorized — token saved. Let's finish configuring.\n")
|
|
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
|
|
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:
|
|
if device_available:
|
|
print(" (no usable local browser detected — device code recommended)")
|
|
default_method = "device"
|
|
else:
|
|
print(" (no usable local browser detected — browser sign-in may need an SSH tunnel to 127.0.0.1:8765)")
|
|
prompt_label = "oauth, device, or apikey?" if device_available else "OAuth or API key?"
|
|
method = _prompt(prompt_label, default=default_method).strip().lower()
|
|
|
|
if device_available and method in {"device", "d"}:
|
|
return _setup_device_login(hermes_host, write_path, open_browser=can_browse and not is_remote)
|
|
if method in {"oauth", "o"}:
|
|
return _setup_browser_login(hermes_host, write_path)
|
|
print(f"\n Current API key: {_mask(cfg.get('apiKey', ''))}")
|
|
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
|
|
if new_key:
|
|
cfg["apiKey"] = new_key
|
|
if not cfg.get("apiKey"):
|
|
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
|
|
return True
|
|
|
|
|
|
def _menu(header: str, *lines: str) -> None:
|
|
print(f"\n {header}:")
|
|
for line in lines:
|
|
print(f" {line}")
|
|
|
|
|
|
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")))
|
|
try:
|
|
hermes_host["writeFrequency"] = int(new_wf)
|
|
except (ValueError, TypeError):
|
|
hermes_host["writeFrequency"] = 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)
|
|
|
|
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")
|
|
if new_ctx_tokens.strip().lower() in {"none", "uncapped", "no limit"}:
|
|
hermes_host.pop("contextTokens", None)
|
|
elif new_ctx_tokens.strip():
|
|
try:
|
|
val = int(new_ctx_tokens)
|
|
if val >= 0:
|
|
hermes_host["contextTokens"] = val
|
|
except (ValueError, TypeError):
|
|
pass # keep current
|
|
|
|
_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"))
|
|
try:
|
|
val = int(new_dialectic)
|
|
if val >= 1:
|
|
hermes_host["dialecticCadence"] = val
|
|
except (ValueError, TypeError):
|
|
hermes_host["dialecticCadence"] = 2
|
|
|
|
_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."""
|
|
cfg = _read_config()
|
|
write_path = _local_config_path()
|
|
read_path = _config_path()
|
|
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)
|
|
|
|
# --- 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")),
|
|
):
|
|
new = _prompt(label, default=default)
|
|
if new:
|
|
hermes_host[key] = new
|
|
|
|
_setup_identity_mapping(cfg, hermes_host, current_peer)
|
|
|
|
_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:
|
|
print(f"FAILED\n Error: {e}")
|
|
return
|
|
|
|
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()
|
|
results = [("default", HOST, cfg.get("hosts", {}).get(HOST, {}))]
|
|
for p in profiles:
|
|
if p.name != "default":
|
|
h = profile_host_key(p.name)
|
|
# _host_block (not hosts.get) keeps legacy dot-form keys ("hermes.work") readable.
|
|
results.append((p.name, h, _host_block(cfg, h)))
|
|
return results
|
|
|
|
|
|
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
|
|
if not cfg: # config file missing — the env-var fallback may still yield a usable config
|
|
try:
|
|
_env_cfg = HonchoClientConfig.from_global_config(host=_host_key())
|
|
cfg = bool(_env_cfg.api_key or _env_cfg.base_url)
|
|
except Exception:
|
|
cfg = False
|
|
if not cfg:
|
|
print(f" No Honcho config found at {active_path}\n Run 'hermes honcho setup' to configure.\n")
|
|
return
|
|
try:
|
|
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
|
|
except Exception as e:
|
|
print(f" Config error: {e}\n")
|
|
return
|
|
|
|
# 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)
|
|
if profile != "default":
|
|
print(f" Profile: {profile}")
|
|
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 hcfg.enabled and (hcfg.api_key or hcfg.base_url):
|
|
print("\n Connection... ", end="", flush=True)
|
|
try:
|
|
client = get_honcho_client(hcfg)
|
|
_show_peer_cards(hcfg, client)
|
|
print("OK")
|
|
except Exception as e:
|
|
print(f"FAILED ({e})\n")
|
|
else:
|
|
print(f"\n Not connected ({'disabled' if not hcfg.enabled else 'no API key or base URL'})\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):")
|
|
for fact in card[:10]:
|
|
print(f" - {fact}")
|
|
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 '')}")
|
|
if not card and not ai_text:
|
|
print("\n No peer data yet (accumulates after first conversation)")
|
|
print()
|
|
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
|
|
recall = _pref(block, cfg, "recallMode", "hybrid")
|
|
write = _pref(block, cfg, "writeFrequency", "async")
|
|
marker = " *" if name == active else ""
|
|
print(f" {name + marker:<14} {host:<22} {'yes' if enabled else 'no':<9} {recall:<9} {write}")
|
|
print("\n * active profile\n")
|
|
|
|
|
|
def cmd_peers(args) -> None:
|
|
"""Show peer identities across all profiles."""
|
|
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."""
|
|
cfg = _read_config()
|
|
sessions = cfg.get("sessions", {})
|
|
if not sessions:
|
|
print(f" No session mappings configured.\n\n Add one with: hermes honcho map <session-name>\n"
|
|
f" Or edit {_config_path()} directly.\n")
|
|
return
|
|
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:
|
|
cmd_sessions(args)
|
|
return
|
|
session_name = args.session_name.strip()
|
|
if not session_name:
|
|
print(" Session name cannot be empty.\n")
|
|
return
|
|
import re
|
|
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', session_name).strip('-')
|
|
if sanitized != 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 cmd_peer(args) -> None:
|
|
"""Show or update peer names and dialectic reasoning level."""
|
|
cfg = _read_config()
|
|
user_name = getattr(args, "user", None)
|
|
ai_name = getattr(args, "ai", None)
|
|
reasoning = getattr(args, "reasoning", None)
|
|
|
|
if user_name is None and ai_name is None and reasoning is None:
|
|
hermes = _active_block(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
|
|
""")
|
|
return
|
|
|
|
if user_name is not None:
|
|
_set_field(cfg, "peerName", user_name.strip(), f"User peer -> {user_name.strip()}")
|
|
if ai_name is not None:
|
|
_set_field(cfg, "aiPeer", ai_name.strip(), f"AI peer -> {ai_name.strip()}")
|
|
if reasoning is not None:
|
|
if reasoning not in REASONING_LEVELS:
|
|
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
|
|
return
|
|
_set_field(cfg, "dialecticReasoningLevel", reasoning, f"Dialectic reasoning level -> {reasoning}")
|
|
_save(cfg)
|
|
|
|
|
|
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)
|
|
for m, desc in choices.items():
|
|
print(f" {m:<{width}} {desc}{' <-' if m == current else ''}")
|
|
print(f"\n Set with: hermes honcho {attr} [{'|'.join(choices)}]\n")
|
|
return
|
|
if value not in choices:
|
|
print(f" Invalid {noun} '{value}'. Options: {', '.join(choices)}\n")
|
|
return
|
|
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."""
|
|
cfg = _read_config()
|
|
context = getattr(args, "context", None)
|
|
dialectic = getattr(args, "dialectic", None)
|
|
if context is None and dialectic is None:
|
|
hermes = _active_block(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]
|
|
""")
|
|
return
|
|
|
|
if context is not None:
|
|
_set_field(cfg, "contextTokens", context, f"context tokens -> {context}")
|
|
if dialectic is not None:
|
|
_set_field(cfg, "dialecticMaxChars", dialectic, f"dialectic cap -> {dialectic} chars")
|
|
_save(cfg)
|
|
|
|
|
|
# ── 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):
|
|
print(" No API key configured. Run 'hermes honcho setup' first.\n")
|
|
return
|
|
file_path = getattr(args, "file", None)
|
|
try:
|
|
hcfg, client = _connect(_host_key())
|
|
mgr, session_key = _session_manager(hcfg, client)
|
|
except Exception as e:
|
|
print(f" Honcho connection failed: {e}\n")
|
|
return
|
|
|
|
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:
|
|
print(f" Honcho authentication failed: {e}\n")
|
|
return
|
|
print(f"\nUser peer ({hcfg.peer_name or 'not set'})\n" + RULE)
|
|
if user_card:
|
|
for fact in user_card:
|
|
print(f" {fact}")
|
|
else:
|
|
print(" 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():
|
|
print(f" File not found: {p}\n")
|
|
return
|
|
content = p.read_text(encoding="utf-8").strip()
|
|
if not content:
|
|
print(f" File is empty: {p}\n")
|
|
return
|
|
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."""
|
|
found: list[Path] = []
|
|
for name in names:
|
|
for d in (Path(os.getcwd()), Path.home() / ".openclaw"):
|
|
p = d / name
|
|
if p.exists() and p not in found:
|
|
found.append(p)
|
|
return found
|
|
|
|
|
|
def _migrate_upload(mgr, session_key: str, user_files: list[Path]) -> None:
|
|
dirs_with_files = set(str(f.parent) for f in user_files)
|
|
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 _yes(_prompt(question, default="y")):
|
|
try:
|
|
hcfg, client = _connect(None, reset=True)
|
|
action(*_session_manager(hcfg, client), 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:
|
|
if user_files:
|
|
print(f" User memory ({len(user_files)} file(s)) — will go to Honcho user peer:")
|
|
for f in user_files:
|
|
print(f" {f}")
|
|
if agent_files:
|
|
print(f" Agent identity ({len(agent_files)} file(s)) — will go to Honcho AI peer:")
|
|
for f in agent_files:
|
|
print(f" {f}")
|
|
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:""")
|
|
for _f in user_files:
|
|
print(" hermes honcho migrate — this step handles it interactively")
|
|
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:")
|
|
for f in agent_files:
|
|
print(f" hermes honcho identity {f}")
|
|
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", cmd_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
|
|
cmd_setup_provider("honcho")
|
|
return
|
|
handler = cmd_status if sub is None else _HANDLERS.get(sub)
|
|
if handler is None:
|
|
print(f" Unknown honcho command: {sub}\n"
|
|
" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n")
|
|
return
|
|
handler(args)
|
|
|
|
|
|
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)
|