Files
hermes-agent/plugins/memory/hindsight/setup.py
ethernet 80273b4507 refactor(pm): route Python tool installs through PM
Use PM-selected interpreters and tool entrypoints for Browser Use, Hindsight and Python language servers. Sync the declared Google Chat extras instead of changing the active environment with pip.

Verified the affected 11-file Nix test subset: 359 passed, 7 skipped. The full suite and real third-party package installation were not run.
2026-09-11 18:05:43 -04:00

214 lines
9.4 KiB
Python

"""`hermes memory setup` wizard for the Hindsight provider (``post_setup``)."""
from __future__ import annotations
import contextlib
import json
import os
import sys
from pathlib import Path
from agent.secret_scope import get_secret
from hermes_cli.secret_prompt import masked_secret_prompt
from . import templates as _hs_templates
from .embedded import _embedded_profile_env_path, _load_simple_env, _materialize_embedded_profile_env
from .settings import (
_DEFAULT_API_URL, _DEFAULT_IDLE_TIMEOUT, _DEFAULT_LOCAL_URL, _DEFAULT_TIMEOUT,
_PROVIDER_DEFAULT_MODELS,
)
_MODE_VALUES = ["cloud", "local_embedded", "local_external"]
_MODE_ITEMS = [
("Cloud", "Hindsight Cloud API (lightweight, just needs an API key)"),
("Local Embedded", "Run Hindsight locally (downloads ~200MB, needs LLM key)"),
("Local External", "Connect to an existing Hindsight instance"),
]
def _secret_prompt(label: str) -> str:
"""Masked prompt on a TTY; plain readline when stdin is piped."""
sys.stdout.write(label)
sys.stdout.flush()
return masked_secret_prompt("") if sys.stdin.isatty() else sys.stdin.readline().strip()
def _select(title: str, items: list, values: list, current) -> str | None:
"""Curses pick from *values*, defaulting to *current*; None when cancelled."""
from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup
default = values.index(current) if current in values else 0
idx = _curses_select(title, items, default=default, cancel_returns=_CANCELLED)
if idx == _CANCELLED:
_print_cancelled_setup()
return None
return values[idx]
def _write_env(env_path: Path, env_writes: dict) -> None:
"""Update keys in place (BOM-tolerant read: a Notepad BOM would glue U+FEFF onto
the first key and duplicate the line), append the rest."""
env_path.parent.mkdir(parents=True, exist_ok=True)
existing = env_path.read_text(encoding="utf-8-sig").splitlines() if env_path.exists() else []
updated = set()
new_lines = []
for line in existing:
key = line.split("=", 1)[0].strip() if "=" in line and not line.startswith("#") else None
new_lines.append(f"{key}={env_writes[key]}" if key in env_writes else line)
updated.add(key)
new_lines.extend(f"{k}={v}" for k, v in env_writes.items() if k not in updated)
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
def _prompt_embedded_llm(llm_provider: str, provider_config: dict, env_writes: dict, hermes_env: Path) -> None:
"""local_embedded wizard step: endpoint (openai_compatible only), model, LLM key."""
if llm_provider == "openai_compatible":
existing_base_url = provider_config.get("llm_base_url", "")
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)" + (f" [{existing_base_url}]" if existing_base_url else "")
if val := input(prompt + ": ").strip():
provider_config["llm_base_url"] = val
elif llm_provider == "openrouter":
provider_config["llm_base_url"] = "https://openrouter.ai/api/v1"
current_model = provider_config.get("llm_model") or _PROVIDER_DEFAULT_MODELS.get(llm_provider, "gpt-4o-mini")
val = input(f" LLM model [{current_model}]: ").strip()
provider_config["llm_model"] = val or current_model
llm_key = _secret_prompt(" LLM API key: ")
env_writes["HINDSIGHT_LLM_API_KEY"] = llm_key or _load_simple_env(hermes_env).get("HINDSIGHT_LLM_API_KEY", "")
def _sync_client_dependency() -> bool:
"""hindsight-client via the declared ``extra: hindsight`` (plugin.yaml).
pm's venv sync is the ONE install authority (uv.lock owns the pin) — no
pip against sys.executable, and no ``tools.lazy_deps`` (deleted)."""
try:
import pm
pm.sync_venv(["hindsight"], explicit=True)
return True
except Exception as e:
print(f" ⚠ Install failed: {e}")
print(" Run manually: hermes pm install")
return False
def _install_embedded_runtime() -> bool:
"""local_embedded: install the isolated side env (hindsight-embed + the heavy
hindsight-api-slim[all]) via PM's isolated environment operation — never the
shared main venv (the protobuf floors conflict). The daemon later runs from the
side env's own interpreter/binary; Hermes talks HTTP with hindsight-client."""
from .embedded_runtime import _local_runtime_hint, ensure_sideenv, sideenv_root
try:
ensure_sideenv()
except Exception as e:
print(f" ⚠ Isolated runtime install failed: {e}")
print(f" {_local_runtime_hint(str(e)).strip()}")
return False
print(f" ✓ Isolated Hindsight runtime installed at {sideenv_root()}")
print(" (separate environment — the Hermes venv is not modified; the daemon")
print(" runs from it and Hermes connects over HTTP)")
return True
def run_setup(provider, hermes_home: str, config: dict) -> None:
"""Interactive wizard — installs only the deps the selected mode needs."""
from hermes_cli.config import save_config
from . import _load_config
print("\n Configuring Hindsight memory:\n")
existing_config = provider._config if isinstance(provider._config, dict) else _load_config()
if not isinstance(existing_config, dict):
existing_config = {}
mode = _select(" Select mode", _MODE_ITEMS, _MODE_VALUES, existing_config.get("mode"))
if mode is None:
return
provider_config: dict = dict(existing_config, mode=mode)
env_writes: dict = {}
hermes_env = Path(hermes_home) / ".env"
llm_provider = ""
if mode == "local_embedded":
llm_items = [(p, f"default model: {m}") for p, m in _PROVIDER_DEFAULT_MODELS.items()]
llm_provider = _select(" Select LLM provider", llm_items, list(_PROVIDER_DEFAULT_MODELS),
provider_config.get("llm_provider"))
if llm_provider is None:
return
provider_config["llm_provider"] = llm_provider
print("\n Checking dependencies...")
# Environment-aware install: pm's venv sync is the ONE authority for the
# client (declared extra, uv.lock pin); tools.lazy_deps is deleted. The
# local_embedded heavy stack installs into its own isolated side env.
if not _sync_client_dependency():
return
if mode == "local_embedded" and not _install_embedded_runtime():
return
if mode == "cloud":
print("\n Get your API key at https://ui.hindsight.vectorize.io\n")
existing_key = get_secret("HINDSIGHT_API_KEY", "") or ""
masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set"
api_key = _secret_prompt(f" API key (current: {masked}, blank to keep): " if existing_key else " API key: ")
if api_key:
env_writes["HINDSIGHT_API_KEY"] = api_key
if val := input(f" API URL [{_DEFAULT_API_URL}]: ").strip():
provider_config["api_url"] = val
elif mode == "local_external":
val = input(f" Hindsight API URL [{_DEFAULT_LOCAL_URL}]: ").strip()
provider_config["api_url"] = val or _DEFAULT_LOCAL_URL
if api_key := _secret_prompt(" API key (optional, blank to skip): "):
env_writes["HINDSIGHT_API_KEY"] = api_key
else:
_prompt_embedded_llm(llm_provider, provider_config, env_writes, hermes_env)
provider_config.setdefault("bank_id", "hermes")
provider_config.setdefault("recall_budget", "mid")
# Preserve explicit 0 timeouts instead of treating them as blank.
timeouts = [("timeout", "HINDSIGHT_TIMEOUT", _DEFAULT_TIMEOUT)]
if mode == "local_embedded":
timeouts.append(("idle_timeout", "HINDSIGHT_IDLE_TIMEOUT", _DEFAULT_IDLE_TIMEOUT))
for key, env_key, default in timeouts:
value = provider_config.get(key)
provider_config[key] = value = default if value is None else value
env_writes[env_key] = str(value)
config["memory"]["provider"] = "hindsight"
save_config(config)
provider.save_config(provider_config, hermes_home)
if env_writes:
_write_env(hermes_env, env_writes)
# Starter template (best-effort) only where the API is reachable now
# (local_embedded's daemon isn't up).
if _hs_templates.supported_for_mode(mode):
from hermes_cli.memory_setup import _CANCELLED, _curses_select
default_url = _DEFAULT_LOCAL_URL if mode == "local_external" else _DEFAULT_API_URL
_hs_templates.run_template_step(
api_url=provider_config.get("api_url") or default_url,
bank_id=provider_config.get("bank_id", "hermes"),
api_key=env_writes.get("HINDSIGHT_API_KEY") or os.environ.get("HINDSIGHT_API_KEY", "") or None,
select=_curses_select, cancelled=_CANCELLED,
)
if mode == "local_embedded":
materialized_config = dict(provider_config)
with contextlib.suppress(Exception):
materialized_config = json.loads(
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8-sig")
)
llm_api_key = (
env_writes.get("HINDSIGHT_LLM_API_KEY", "")
or _load_simple_env(hermes_env).get("HINDSIGHT_LLM_API_KEY", "")
or _load_simple_env(_embedded_profile_env_path(materialized_config)).get("HINDSIGHT_API_LLM_API_KEY", "")
)
_materialize_embedded_profile_env(materialized_config, llm_api_key=llm_api_key or None)
print(f"\n ✓ Hindsight memory configured ({mode} mode)")
if env_writes:
print(" API keys saved to .env")
print("\n Start a new session to activate.\n")