Port from earendil-works/pi#8337 (UTF-8 BOM normalization in text inputs): sibling sites the merged #81967 BOM sweep missed. json.loads hard-fails on a leading U+FEFF and every one of these loaders swallows the exception and silently falls back to defaults — a user who edited mem0.json, honcho.json, hindsight/config.json, or supermemory.json in Notepad lost their whole config with no error, and Qwen CLI OAuth creds saved with a BOM raised qwen_auth_read_failed. - plugins/memory/{honcho,mem0,hindsight,supermemory}: 13 read sites -> utf-8-sig - hermes_cli/auth.py: _read_qwen_cli_tokens -> utf-8-sig - tests: BOM regression tests per loader (sabotage-proven) + plain-UTF-8 guard
214 lines
9.4 KiB
Python
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")
|