From 3e994e38f76309f48758f56ab59566c2fcfbfb64 Mon Sep 17 00:00:00 2001 From: bwjoke <1284814+bwjoke@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:06:26 +0800 Subject: [PATCH] [verified] fix: materialize hindsight profile env during setup --- plugins/memory/hindsight/__init__.py | 142 +++++++++++++----- .../plugins/memory/test_hindsight_provider.py | 83 +++++++++- 2 files changed, 182 insertions(+), 43 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 26c60bb9e..56274efd0 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -233,6 +233,72 @@ def _utc_timestamp() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") +def _embedded_profile_name(config: dict[str, Any]) -> str: + """Return the Hindsight embedded profile name for this Hermes config.""" + profile = config.get("profile", "hermes") + return str(profile or "hermes") + + +def _load_simple_env(path) -> dict[str, str]: + """Parse a simple KEY=VALUE env file, ignoring comments and blank lines.""" + if not path.exists(): + return {} + + values: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def _build_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | None = None) -> dict[str, str]: + """Build the profile-scoped env file that standalone hindsight-embed consumes.""" + current_key = llm_api_key + if current_key is None: + current_key = ( + config.get("llmApiKey") + or config.get("llm_api_key") + or os.environ.get("HINDSIGHT_LLM_API_KEY", "") + ) + + current_provider = config.get("llm_provider", "") + current_model = config.get("llm_model", "") + current_base_url = config.get("llm_base_url") or os.environ.get("HINDSIGHT_API_LLM_BASE_URL", "") + + # The embedded daemon expects OpenAI wire format for these providers. + daemon_provider = "openai" if current_provider in ("openai_compatible", "openrouter") else current_provider + + env_values = { + "HINDSIGHT_API_LLM_PROVIDER": str(daemon_provider), + "HINDSIGHT_API_LLM_API_KEY": str(current_key or ""), + "HINDSIGHT_API_LLM_MODEL": str(current_model), + "HINDSIGHT_API_LOG_LEVEL": "info", + } + if current_base_url: + env_values["HINDSIGHT_API_LLM_BASE_URL"] = str(current_base_url) + return env_values + + +def _embedded_profile_env_path(config: dict[str, Any]): + from pathlib import Path + + return Path.home() / ".hindsight" / "profiles" / f"{_embedded_profile_name(config)}.env" + + +def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | None = None): + """Write the profile-scoped env file that standalone hindsight-embed uses.""" + profile_env = _embedded_profile_env_path(config) + profile_env.parent.mkdir(parents=True, exist_ok=True) + env_values = _build_embedded_profile_env(config, llm_api_key=llm_api_key) + profile_env.write_text( + "".join(f"{key}={value}\n" for key, value in env_values.items()), + encoding="utf-8", + ) + return profile_env + + # --------------------------------------------------------------------------- # MemoryProvider implementation # --------------------------------------------------------------------------- @@ -367,7 +433,7 @@ class HindsightMemoryProvider(MemoryProvider): else: deps_to_install = [cloud_dep] - print(f"\n Checking dependencies...") + print("\n Checking dependencies...") uv_path = shutil.which("uv") if not uv_path: print(" ⚠ uv not found — install it: curl -LsSf https://astral.sh/uv/install.sh | sh") @@ -378,14 +444,14 @@ class HindsightMemoryProvider(MemoryProvider): [uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install, check=True, timeout=120, capture_output=True, ) - print(f" ✓ Dependencies up to date") + print(" ✓ Dependencies up to date") except Exception as e: print(f" ⚠ Install failed: {e}") print(f" Run manually: uv pip install --python {sys.executable} {' '.join(deps_to_install)}") # Step 3: Mode-specific config if mode == "cloud": - print(f"\n Get your API key at https://ui.hindsight.vectorize.io\n") + print("\n Get your API key at https://ui.hindsight.vectorize.io\n") existing_key = os.environ.get("HINDSIGHT_API_KEY", "") if existing_key: masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set" @@ -446,7 +512,6 @@ class HindsightMemoryProvider(MemoryProvider): # Step 4: Save everything provider_config["bank_id"] = "hermes" provider_config["recall_budget"] = "mid" - bank_id = "hermes" config["memory"]["provider"] = "hindsight" save_config(config) @@ -472,10 +537,32 @@ class HindsightMemoryProvider(MemoryProvider): new_lines.append(f"{k}={v}") env_path.write_text("\n".join(new_lines) + "\n") + if mode == "local_embedded": + materialized_config = dict(provider_config) + config_path = Path(hermes_home) / "hindsight" / "config.json" + try: + materialized_config = json.loads(config_path.read_text(encoding="utf-8")) + except Exception: + pass + + llm_api_key = env_writes.get("HINDSIGHT_LLM_API_KEY", "") + if not llm_api_key: + llm_api_key = _load_simple_env(Path(hermes_home) / ".env").get("HINDSIGHT_LLM_API_KEY", "") + if not llm_api_key: + llm_api_key = _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(f" API keys saved to .env") - print(f"\n Start a new session to activate.\n") + print(" API keys saved to .env") + print("\n Start a new session to activate.\n") def get_config_schema(self): return [ @@ -554,7 +641,9 @@ class HindsightMemoryProvider(MemoryProvider): if Version(installed) < Version(_MIN_CLIENT_VERSION): logger.warning("hindsight-client %s is outdated (need >=%s), attempting upgrade...", installed, _MIN_CLIENT_VERSION) - import shutil, subprocess, sys + import shutil + import subprocess + import sys uv_path = shutil.which("uv") if uv_path: try: @@ -675,42 +764,13 @@ class HindsightMemoryProvider(MemoryProvider): # Update the profile .env to match our current config so # the daemon always starts with the right settings. # If the config changed and the daemon is running, stop it. - from pathlib import Path as _Path - profile_env = _Path.home() / ".hindsight" / "profiles" / f"{profile}.env" - current_key = self._config.get("llm_api_key") or os.environ.get("HINDSIGHT_LLM_API_KEY", "") - current_provider = self._config.get("llm_provider", "") - current_model = self._config.get("llm_model", "") - current_base_url = self._config.get("llm_base_url") or os.environ.get("HINDSIGHT_API_LLM_BASE_URL", "") - # Map openai_compatible/openrouter → openai for the daemon (OpenAI wire format) - daemon_provider = "openai" if current_provider in ("openai_compatible", "openrouter") else current_provider - - # Read saved profile config - saved = {} - if profile_env.exists(): - for line in profile_env.read_text().splitlines(): - if "=" in line and not line.startswith("#"): - k, v = line.split("=", 1) - saved[k.strip()] = v.strip() - - config_changed = ( - saved.get("HINDSIGHT_API_LLM_PROVIDER") != daemon_provider or - saved.get("HINDSIGHT_API_LLM_MODEL") != current_model or - saved.get("HINDSIGHT_API_LLM_API_KEY") != current_key or - saved.get("HINDSIGHT_API_LLM_BASE_URL", "") != current_base_url - ) + profile_env = _embedded_profile_env_path(self._config) + expected_env = _build_embedded_profile_env(self._config) + saved = _load_simple_env(profile_env) + config_changed = saved != expected_env if config_changed: - # Write updated profile .env - profile_env.parent.mkdir(parents=True, exist_ok=True) - env_lines = ( - f"HINDSIGHT_API_LLM_PROVIDER={daemon_provider}\n" - f"HINDSIGHT_API_LLM_API_KEY={current_key}\n" - f"HINDSIGHT_API_LLM_MODEL={current_model}\n" - f"HINDSIGHT_API_LOG_LEVEL=info\n" - ) - if current_base_url: - env_lines += f"HINDSIGHT_API_LLM_BASE_URL={current_base_url}\n" - profile_env.write_text(env_lines) + profile_env = _materialize_embedded_profile_env(self._config) if client._manager.is_running(profile): with open(log_path, "a") as f: f.write("\n=== Config changed, restarting daemon ===\n") diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 45fbab3de..829a988ae 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -7,9 +7,8 @@ turn counting, tags), and schema completeness. import json import re -import threading from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -251,6 +250,86 @@ class TestConfig: assert cfg["banks"]["hermes"]["budget"] == "high" +class TestPostSetup: + def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + user_home = tmp_path / "user-home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + + selections = iter([1, 0]) # local_embedded, openai + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setattr("builtins.input", lambda prompt="": "") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("getpass.getpass", lambda prompt="": "sk-local-test") + saved_configs = [] + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_configs.append(cfg.copy())) + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {}}) + + assert saved_configs[-1]["memory"]["provider"] == "hindsight" + assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=sk-local-test\n" + + profile_env = user_home / ".hindsight" / "profiles" / "hermes.env" + assert profile_env.exists() + assert profile_env.read_text() == ( + "HINDSIGHT_API_LLM_PROVIDER=openai\n" + "HINDSIGHT_API_LLM_API_KEY=sk-local-test\n" + "HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n" + "HINDSIGHT_API_LOG_LEVEL=info\n" + ) + + def test_local_embedded_setup_respects_existing_profile_name(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + user_home = tmp_path / "user-home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + + selections = iter([1, 0]) # local_embedded, openai + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setattr("builtins.input", lambda prompt="": "") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("getpass.getpass", lambda prompt="": "sk-local-test") + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) + + provider = HindsightMemoryProvider() + provider.save_config({"profile": "coder"}, str(hermes_home)) + provider.post_setup(str(hermes_home), {"memory": {}}) + + coder_env = user_home / ".hindsight" / "profiles" / "coder.env" + hermes_env = user_home / ".hindsight" / "profiles" / "hermes.env" + assert coder_env.exists() + assert not hermes_env.exists() + + def test_local_embedded_setup_preserves_existing_key_when_input_left_blank(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + user_home = tmp_path / "user-home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + + selections = iter([1, 0]) # local_embedded, openai + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setattr("builtins.input", lambda prompt="": "") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("getpass.getpass", lambda prompt="": "") + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) + + env_path = hermes_home / ".env" + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.write_text("HINDSIGHT_LLM_API_KEY=existing-key\n") + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {}}) + + profile_env = user_home / ".hindsight" / "profiles" / "hermes.env" + assert profile_env.exists() + assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text() + + # --------------------------------------------------------------------------- # Tool handler tests # ---------------------------------------------------------------------------