"""`hermes memory setup` wizard for the Hindsight provider (``post_setup``).""" from __future__ import annotations import json 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, _MIN_CLIENT_VERSION, _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 so a Notepad BOM can't glue U+FEFF onto the first key and cause a duplicate 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 if key in env_writes: new_lines.append(f"{key}={env_writes[key]}") updated.add(key) else: new_lines.append(line) 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 _offer_starter_template(mode: str, provider_config: dict, env_writes: dict) -> None: """Seed the bank with a Hermes starter template (best-effort).""" import os 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, ) 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 = {} llm_provider = "" if mode == "local_embedded": providers = list(_PROVIDER_DEFAULT_MODELS) llm_items = [(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") for p in providers] llm_provider = _select(" Select LLM provider", llm_items, providers, provider_config.get("llm_provider")) if llm_provider is None: return provider_config["llm_provider"] = llm_provider print("\n Checking dependencies...") # Environment-aware install: sealed hosted venvs redirect to the durable # data-volume target instead of writing to /opt/hermes. from tools.lazy_deps import install_specs deps = ["hindsight-all"] if mode == "local_embedded" else [f"hindsight-client>={_MIN_CLIENT_VERSION}"] outcome = install_specs(deps, timeout=120) if outcome.ok: print(" ✓ Dependencies up to date") elif outcome.blocked: print(f" ⚠ Cannot install dependencies: {outcome.reason}") else: print(f" ⚠ Install failed:\n{(outcome.stderr or '').strip()}") print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps)}") if mode == "cloud": print("\n Get your API key at https://ui.hindsight.vectorize.io\n") existing_key = get_secret("HINDSIGHT_API_KEY", "") or "" if existing_key: masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set" api_key = _secret_prompt(f" API key (current: {masked}, blank to keep): ") else: api_key = _secret_prompt(" API key: ") if api_key: env_writes["HINDSIGHT_API_KEY"] = api_key val = input(f" API URL [{_DEFAULT_API_URL}]: ").strip() if val: 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 api_key = _secret_prompt(" API key (optional, blank to skip): ") if api_key: env_writes["HINDSIGHT_API_KEY"] = api_key else: # local_embedded 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)" if existing_base_url: prompt += f" [{existing_base_url}]" val = input(prompt + ": ").strip() if val: 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(Path(hermes_home) / ".env").get("HINDSIGHT_LLM_API_KEY", "") ) provider_config.setdefault("bank_id", "hermes") provider_config.setdefault("recall_budget", "mid") # Preserve explicit 0 timeouts instead of treating them as blank. timeout_val = provider_config.get("timeout") if timeout_val is None: timeout_val = _DEFAULT_TIMEOUT provider_config["timeout"] = timeout_val env_writes["HINDSIGHT_TIMEOUT"] = str(timeout_val) if mode == "local_embedded": idle_timeout_val = provider_config.get("idle_timeout") if idle_timeout_val is None: idle_timeout_val = _DEFAULT_IDLE_TIMEOUT provider_config["idle_timeout"] = idle_timeout_val env_writes["HINDSIGHT_IDLE_TIMEOUT"] = str(idle_timeout_val) config["memory"]["provider"] = "hindsight" save_config(config) provider.save_config(provider_config, hermes_home) if env_writes: _write_env(Path(hermes_home) / ".env", env_writes) # Starter template only for cloud / local_external — the API is reachable # now; local_embedded's daemon isn't up during setup. if _hs_templates.supported_for_mode(mode): _offer_starter_template(mode, provider_config, env_writes) if mode == "local_embedded": materialized_config = dict(provider_config) try: materialized_config = json.loads( (Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8") ) except Exception: pass llm_api_key = ( env_writes.get("HINDSIGHT_LLM_API_KEY", "") or _load_simple_env(Path(hermes_home) / ".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")