diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 2707c77f4ff..8b076da2884 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -15,24 +15,40 @@ from pathlib import Path from hermes_constants import get_hermes_home from hermes_cli.secret_prompt import masked_secret_prompt +_CANCELLED = -1 + # --------------------------------------------------------------------------- # Curses-based interactive picker (same pattern as hermes tools) # --------------------------------------------------------------------------- -def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int: +def _curses_select( + title: str, + items: list[tuple[str, str]], + default: int = 0, + *, + cancel_returns: int | None = None, +) -> int: """Interactive single-select with arrow keys. items: list of (label, description) tuples. - Returns selected index, or default on escape/quit. + Returns selected index, or cancel_returns/default on escape/quit. """ from hermes_cli.curses_ui import curses_radiolist + + if cancel_returns is None: + cancel_returns = default + # Format (label, desc) tuples into display strings display_items = [ f"{label} {desc}" if desc else label for label, desc in items ] - return curses_radiolist(title, display_items, selected=default, cancel_returns=default) + return curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns) + + +def _print_cancelled_setup() -> None: + print("\n Cancelled. No changes saved.\n") def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: @@ -241,14 +257,17 @@ def cmd_setup(args) -> None: items.append(("Built-in only", "— MEMORY.md / USER.md (default)")) builtin_idx = len(items) - 1 - selected = _curses_select("Memory provider setup", items, default=builtin_idx) + selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED) + if selected == _CANCELLED: + _print_cancelled_setup() + return config = load_config() if not isinstance(config.get("memory"), dict): config["memory"] = {} # Built-in only - if selected >= len(providers) or selected < 0: + if selected >= len(providers): config["memory"]["provider"] = "" save_config(config) print("\n ✓ Memory provider: built-in only") @@ -309,7 +328,10 @@ def cmd_setup(args) -> None: current_idx = 0 if current and current in choices: current_idx = choices.index(current) - sel = _curses_select(f" {desc}", choice_items, default=current_idx) + sel = _curses_select(f" {desc}", choice_items, default=current_idx, cancel_returns=_CANCELLED) + if sel == _CANCELLED: + _print_cancelled_setup() + return provider_config[key] = choices[sel] elif is_secret: # Prompt for secret diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index c26e45a0e11..03ebda28eca 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -702,7 +702,7 @@ class HindsightMemoryProvider(MemoryProvider): from hermes_cli.config import save_config from hermes_cli.secret_prompt import masked_secret_prompt - from hermes_cli.memory_setup import _curses_select + from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup print("\n Configuring Hindsight memory:\n") @@ -719,7 +719,10 @@ class HindsightMemoryProvider(MemoryProvider): ] existing_mode = existing_config.get("mode") mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0 - mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx) + mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx, cancel_returns=_CANCELLED) + if mode_idx == _CANCELLED: + _print_cancelled_setup() + return mode = mode_values[mode_idx] provider_config: dict = dict(existing_config) @@ -737,6 +740,27 @@ class HindsightMemoryProvider(MemoryProvider): else: deps_to_install = [cloud_dep] + llm_provider = "" + if mode == "local_embedded": + providers_list = list(_PROVIDER_DEFAULT_MODELS.keys()) + llm_items = [ + (p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") + for p in providers_list + ] + existing_llm_provider = provider_config.get("llm_provider") + llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0 + llm_idx = _curses_select( + " Select LLM provider", + llm_items, + default=llm_default_idx, + cancel_returns=_CANCELLED, + ) + if llm_idx == _CANCELLED: + _print_cancelled_setup() + return + llm_provider = providers_list[llm_idx] + provider_config["llm_provider"] = llm_provider + print("\n Checking dependencies...") uv_path = shutil.which("uv") if not uv_path: @@ -785,18 +809,6 @@ class HindsightMemoryProvider(MemoryProvider): env_writes["HINDSIGHT_API_KEY"] = api_key else: # local_embedded - providers_list = list(_PROVIDER_DEFAULT_MODELS.keys()) - llm_items = [ - (p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}") - for p in providers_list - ] - existing_llm_provider = provider_config.get("llm_provider") - llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0 - llm_idx = _curses_select(" Select LLM provider", llm_items, default=llm_default_idx) - llm_provider = providers_list[llm_idx] - - provider_config["llm_provider"] = llm_provider - 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)" diff --git a/plugins/memory/openviking/README.md b/plugins/memory/openviking/README.md index 07e9484d4dd..0b6be37c0a7 100644 --- a/plugins/memory/openviking/README.md +++ b/plugins/memory/openviking/README.md @@ -14,6 +14,10 @@ Context database by Volcengine (ByteDance) with filesystem-style knowledge hiera hermes memory setup # select "openviking" ``` +The setup can link to an existing `~/.openviking/ovcli.conf`, copy its current +connection values into Hermes, or create a minimal `ovcli.conf` when one does +not exist. + Or manually: ```bash hermes config set memory.provider openviking @@ -28,6 +32,9 @@ All config via environment variables in `.env`: |---------|---------|-------------| | `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL | | `OPENVIKING_API_KEY` | (none) | API key (optional) | +| `OPENVIKING_ACCOUNT` | (none) | Tenant account override | +| `OPENVIKING_USER` | (none) | Tenant user override | +| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace | ## Tools diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 810f2db43e0..07df0e7d888 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -7,12 +7,13 @@ automatic memory extraction, and session management. Original PR #3369 by Mibayy, rewritten to use the full OpenViking session lifecycle instead of read-only search endpoints. -Config via environment variables (profile-scoped via each profile's .env): +Config via environment variables (profile-scoped via each profile's .env) +or a linked OpenViking CLI config: OPENVIKING_ENDPOINT — Server URL (default: http://127.0.0.1:1933) OPENVIKING_API_KEY — API key (required for authenticated servers) - OPENVIKING_ACCOUNT — Tenant account (default: default) - OPENVIKING_USER — Tenant user (default: default) - OPENVIKING_AGENT — Tenant agent (default: hermes) + OPENVIKING_ACCOUNT — Optional tenant account override + OPENVIKING_USER — Optional tenant user override + OPENVIKING_AGENT — Tenant agent (default: hermes) Capabilities: - Automatic memory extraction on session commit (6 categories) @@ -44,6 +45,18 @@ from tools.registry import tool_error logger = logging.getLogger(__name__) _DEFAULT_ENDPOINT = "http://127.0.0.1:1933" +_DEFAULT_ACCOUNT = "" +_DEFAULT_USER = "" +_DEFAULT_AGENT = "hermes" +_OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE" +_OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf" +_OPENVIKING_ENV_KEYS = ( + "OPENVIKING_ENDPOINT", + "OPENVIKING_API_KEY", + "OPENVIKING_ACCOUNT", + "OPENVIKING_USER", + "OPENVIKING_AGENT", +) _TIMEOUT = 30.0 _REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://") @@ -108,27 +121,21 @@ class _VikingClient: """Thin HTTP client for the OpenViking REST API.""" def __init__(self, endpoint: str, api_key: str = "", - account: str = "", user: str = "", agent: str = ""): + account: Optional[str] = None, user: Optional[str] = None, + agent: Optional[str] = None): self._endpoint = endpoint.rstrip("/") self._api_key = api_key - self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") - self._user = user or os.environ.get("OPENVIKING_USER", "default") - self._agent = agent or os.environ.get("OPENVIKING_AGENT", "hermes") + self._account = account if account is not None else os.environ.get("OPENVIKING_ACCOUNT", _DEFAULT_ACCOUNT) + self._user = user if user is not None else os.environ.get("OPENVIKING_USER", _DEFAULT_USER) + self._agent = agent if agent is not None else os.environ.get("OPENVIKING_AGENT", _DEFAULT_AGENT) self._httpx = _get_httpx() if self._httpx is None: raise ImportError("httpx is required for OpenViking: pip install httpx") def _headers(self) -> dict: - # Always send tenant headers when account/user are configured. - # OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User - # for ROOT API key requests to tenant-scoped APIs — omitting them - # causes INVALID_ARGUMENT errors even when account="default". - # User-level keys can omit them (server derives tenancy from the key), - # but ROOT keys must always include them explicitly. - h = { - "Content-Type": "application/json", - "X-OpenViking-Agent": self._agent, - } + h = {"Content-Type": "application/json"} + if self._agent: + h["X-OpenViking-Agent"] = self._agent if self._account: h["X-OpenViking-Account"] = self._account if self._user: @@ -405,6 +412,156 @@ def _path_from_file_uri(uri: str) -> Path | str: return Path(url2pathname(parsed.path)).expanduser() +def _clean_config_value(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _default_ovcli_config_path() -> Path: + return Path.home() / _OVCLI_DEFAULT_RELATIVE_PATH + + +def _resolve_ovcli_config_path(config_path: str = "") -> Path: + if config_path: + return Path(config_path).expanduser() + env_path = os.environ.get(_OVCLI_CONFIG_ENV, "").strip() + if env_path: + return Path(env_path).expanduser() + return _default_ovcli_config_path() + + +def _load_ovcli_config(path: Optional[Path] = None) -> dict: + config_path = path or _resolve_ovcli_config_path() + if not config_path.exists(): + return {} + with config_path.open(encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError(f"OpenViking CLI config must be a JSON object: {config_path}") + return data + + +def _connection_values_from_ovcli(data: dict) -> dict: + return { + "endpoint": _clean_config_value(data.get("url")) or _DEFAULT_ENDPOINT, + "api_key": _clean_config_value(data.get("api_key")), + "account": _clean_config_value(data.get("account") or data.get("account_id")), + "user": _clean_config_value(data.get("user") or data.get("user_id")), + "agent": _clean_config_value(data.get("agent_id")), + } + + +def _load_hermes_openviking_config() -> dict: + try: + from hermes_cli.config import load_config + + config = load_config() + memory_config = config.get("memory", {}) if isinstance(config, dict) else {} + provider_config = memory_config.get("openviking", {}) if isinstance(memory_config, dict) else {} + return dict(provider_config) if isinstance(provider_config, dict) else {} + except Exception: + return {} + + +def _env_value(name: str) -> Optional[str]: + return os.environ[name].strip() if name in os.environ else None + + +def _first_nonempty(*values: Optional[str], default: str = "") -> str: + for value in values: + if value: + return value + return default + + +def _resolve_connection_settings(provider_config: Optional[dict] = None) -> dict: + provider_config = dict(provider_config or {}) + ovcli_values: dict = {} + if provider_config.get("use_ovcli_config"): + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + ovcli_values = _connection_values_from_ovcli(_load_ovcli_config(ovcli_path)) + + endpoint_env = _env_value("OPENVIKING_ENDPOINT") + api_key_env = _env_value("OPENVIKING_API_KEY") + account_env = _env_value("OPENVIKING_ACCOUNT") + user_env = _env_value("OPENVIKING_USER") + agent_env = _env_value("OPENVIKING_AGENT") + + return { + "endpoint": _first_nonempty(endpoint_env, ovcli_values.get("endpoint"), default=_DEFAULT_ENDPOINT), + "api_key": api_key_env if api_key_env is not None else ovcli_values.get("api_key", ""), + "account": account_env if account_env is not None else ovcli_values.get("account", ""), + "user": user_env if user_env is not None else ovcli_values.get("user", ""), + "agent": _first_nonempty(agent_env, ovcli_values.get("agent"), default=_DEFAULT_AGENT), + } + + +def _env_writes_from_connection_values(values: dict) -> dict: + writes = {} + mapping = { + "OPENVIKING_ENDPOINT": "endpoint", + "OPENVIKING_API_KEY": "api_key", + "OPENVIKING_ACCOUNT": "account", + "OPENVIKING_USER": "user", + "OPENVIKING_AGENT": "agent", + } + for env_key, value_key in mapping.items(): + value = _clean_config_value(values.get(value_key)) + if value: + writes[env_key] = value + return writes + + +def _write_env_vars(env_path: Path, env_writes: dict, remove_keys: tuple[str, ...] = ()) -> None: + env_path.parent.mkdir(parents=True, exist_ok=True) + remove_set = set(remove_keys) - set(env_writes) + existing_lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else [] + updated_keys = set() + new_lines = [] + for line in existing_lines: + key_match = line.split("=", 1)[0].strip() if "=" in line else "" + if key_match in remove_set: + continue + if key_match in env_writes: + new_lines.append(f"{key_match}={env_writes[key_match]}") + updated_keys.add(key_match) + else: + new_lines.append(line) + for key, val in env_writes.items(): + if key not in updated_keys: + new_lines.append(f"{key}={val}") + env_path.write_text("\n".join(new_lines) + ("\n" if new_lines else ""), encoding="utf-8") + + +def _remember_ovcli_path(provider_config: dict, ovcli_path: Path) -> None: + default_path = _default_ovcli_config_path().expanduser() + if os.environ.get(_OVCLI_CONFIG_ENV, "").strip() or ovcli_path.expanduser() != default_path: + provider_config["ovcli_config_path"] = str(ovcli_path) + else: + provider_config.pop("ovcli_config_path", None) + + +def _ovcli_data_from_connection_values(values: dict) -> dict: + data = {"url": _clean_config_value(values.get("endpoint")) or _DEFAULT_ENDPOINT} + api_key = _clean_config_value(values.get("api_key")) + account = _clean_config_value(values.get("account")) + user = _clean_config_value(values.get("user")) + agent = _clean_config_value(values.get("agent")) or _DEFAULT_AGENT + if api_key: + data["api_key"] = api_key + if account: + data["account"] = account + if user: + data["user"] = user + if agent: + data["agent_id"] = agent + return data + + +def _write_ovcli_config(path: Path, values: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(_ovcli_data_from_connection_values(values), indent=2) + "\n", encoding="utf-8") + + # --------------------------------------------------------------------------- # MemoryProvider implementation # --------------------------------------------------------------------------- @@ -429,7 +586,16 @@ class OpenVikingMemoryProvider(MemoryProvider): def is_available(self) -> bool: """Check if OpenViking endpoint is configured. No network calls.""" - return bool(os.environ.get("OPENVIKING_ENDPOINT")) + if os.environ.get("OPENVIKING_ENDPOINT"): + return True + provider_config = _load_hermes_openviking_config() + if not provider_config.get("use_ovcli_config"): + return False + try: + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + return bool(_connection_values_from_ovcli(_load_ovcli_config(ovcli_path)).get("endpoint")) + except Exception: + return False def get_config_schema(self): return [ @@ -448,14 +614,12 @@ class OpenVikingMemoryProvider(MemoryProvider): }, { "key": "account", - "description": "OpenViking tenant account ID ([default], used when local mode, OPENVIKING_API_KEY is empty)", - "default": "default", + "description": "OpenViking tenant account ID (blank for user API keys)", "env_var": "OPENVIKING_ACCOUNT", }, { "key": "user", - "description": "OpenViking user ID within the account ([default], used when local mode, OPENVIKING_API_KEY is empty)", - "default": "default", + "description": "OpenViking user ID within the account (blank for user API keys)", "env_var": "OPENVIKING_USER", }, { @@ -466,12 +630,132 @@ class OpenVikingMemoryProvider(MemoryProvider): }, ] + def post_setup(self, hermes_home: str, config: dict) -> None: + """Custom setup that can reuse OpenViking's shared CLI config.""" + from hermes_cli.config import save_config + from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup, _prompt + + hermes_home_path = Path(hermes_home) + env_path = hermes_home_path / ".env" + if not isinstance(config.get("memory"), dict): + config["memory"] = {} + provider_config = config["memory"].get("openviking", {}) + if not isinstance(provider_config, dict): + provider_config = {} + + ovcli_path = _resolve_ovcli_config_path(str(provider_config.get("ovcli_config_path") or "")) + + print("\n Configuring OpenViking memory:\n") + + if ovcli_path.exists(): + try: + ovcli_values = _connection_values_from_ovcli(_load_ovcli_config(ovcli_path)) + except Exception as e: + print(f"\n Could not read OpenViking CLI config: {e}") + print(" No changes saved.\n") + return + + setup_options = [ + ("Link to ovcli.conf", "Hermes follows the active OpenViking CLI config"), + ("Copy once", "Hermes won't follow future ovcli.conf changes"), + ] + choice = _curses_select( + " OpenViking config source", + setup_options, + default=0, + cancel_returns=_CANCELLED, + ) + if choice == _CANCELLED: + _print_cancelled_setup() + return + + if choice == 0: + provider_config["use_ovcli_config"] = True + _remember_ovcli_path(provider_config, ovcli_path) + _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) + config["memory"]["provider"] = "openviking" + config["memory"]["openviking"] = provider_config + save_config(config) + print(f"\n Memory provider: openviking") + print(f" Linked config: {ovcli_path}") + print(" Start a new session to activate.\n") + return + + provider_config["use_ovcli_config"] = False + provider_config.pop("ovcli_config_path", None) + config["memory"]["provider"] = "openviking" + config["memory"]["openviking"] = provider_config + save_config(config) + _write_env_vars( + env_path, + _env_writes_from_connection_values(ovcli_values), + remove_keys=_OPENVIKING_ENV_KEYS, + ) + print(f"\n Memory provider: openviking") + print(" Connection saved to .env") + print(" Start a new session to activate.\n") + return + + setup_options = [ + ("Create ovcli.conf and link", "Recommended"), + ("Configure Hermes only", "Write OpenViking values to Hermes .env"), + ] + choice = _curses_select( + " OpenViking config source", + setup_options, + default=0, + cancel_returns=_CANCELLED, + ) + if choice == _CANCELLED: + _print_cancelled_setup() + return + + defaults = { + "endpoint": _DEFAULT_ENDPOINT, + "api_key": "", + "account": "", + "user": "", + "agent": _DEFAULT_AGENT, + } + values = { + "endpoint": _prompt("OpenViking server URL", default=defaults["endpoint"]), + "api_key": _prompt("OpenViking API key", secret=True), + "account": _prompt("OpenViking account", default=defaults["account"]), + "user": _prompt("OpenViking user", default=defaults["user"]), + "agent": _prompt("OpenViking agent", default=defaults["agent"]), + } + + config["memory"]["provider"] = "openviking" + if choice == 0: + _write_ovcli_config(ovcli_path, values) + provider_config["use_ovcli_config"] = True + _remember_ovcli_path(provider_config, ovcli_path) + config["memory"]["openviking"] = provider_config + save_config(config) + _write_env_vars(env_path, {}, remove_keys=_OPENVIKING_ENV_KEYS) + print(f"\n Memory provider: openviking") + print(f" Created config: {ovcli_path}") + else: + provider_config["use_ovcli_config"] = False + provider_config.pop("ovcli_config_path", None) + config["memory"]["openviking"] = provider_config + save_config(config) + _write_env_vars( + env_path, + _env_writes_from_connection_values(values), + remove_keys=_OPENVIKING_ENV_KEYS, + ) + print(f"\n Memory provider: openviking") + print(" Connection saved to .env") + print(" Start a new session to activate.\n") + def initialize(self, session_id: str, **kwargs) -> None: - self._endpoint = os.environ.get("OPENVIKING_ENDPOINT", _DEFAULT_ENDPOINT) - self._api_key = os.environ.get("OPENVIKING_API_KEY", "") - self._account = os.environ.get("OPENVIKING_ACCOUNT", "default") - self._user = os.environ.get("OPENVIKING_USER", "default") - self._agent = os.environ.get("OPENVIKING_AGENT", "hermes") + settings = _resolve_connection_settings(_load_hermes_openviking_config()) + self._endpoint = settings["endpoint"] + self._api_key = settings["api_key"] + self._account = settings["account"] + self._user = settings["user"] + self._agent = settings["agent"] self._session_id = session_id self._turn_count = 0 diff --git a/plugins/memory/openviking/plugin.yaml b/plugins/memory/openviking/plugin.yaml index 714877f9763..18b8ea78741 100644 --- a/plugins/memory/openviking/plugin.yaml +++ b/plugins/memory/openviking/plugin.yaml @@ -3,7 +3,6 @@ version: 2.0.0 description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing." pip_dependencies: - httpx -requires_env: - - OPENVIKING_ENDPOINT +requires_env: [] hooks: - on_session_end diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py new file mode 100644 index 00000000000..b458a1d2d6a --- /dev/null +++ b/tests/hermes_cli/test_memory_setup.py @@ -0,0 +1,109 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import hermes_cli.memory_setup as memory_setup +from hermes_cli.memory_setup import _CANCELLED, _curses_select + + +def test_curses_select_cancel_defaults_to_selected(monkeypatch): + captured = {} + + def fake_radiolist(title, items, selected=0, *, cancel_returns=None): + captured.update({ + "title": title, + "items": items, + "selected": selected, + "cancel_returns": cancel_returns, + }) + return cancel_returns + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist) + + result = _curses_select("Pick one", [("first", "desc"), ("second", "")], default=1) + + assert result == 1 + assert captured == { + "title": "Pick one", + "items": ["first desc", "second"], + "selected": 1, + "cancel_returns": 1, + } + + +def test_curses_select_accepts_explicit_cancel_value(monkeypatch): + captured = {} + + def fake_radiolist(title, items, selected=0, *, cancel_returns=None): + captured["cancel_returns"] = cancel_returns + return cancel_returns + + monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist) + + result = _curses_select("Pick one", [("first", "")], default=0, cancel_returns=_CANCELLED) + + assert result == _CANCELLED + assert captured["cancel_returns"] == _CANCELLED + + +def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch): + save_config = MagicMock() + load_config = MagicMock(side_effect=AssertionError("cancel should not load config")) + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", object())]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: kwargs["cancel_returns"]) + monkeypatch.setattr("hermes_cli.config.load_config", load_config) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + load_config.assert_not_called() + save_config.assert_not_called() + + +def test_cmd_setup_builtin_selection_still_saves_builtin(monkeypatch): + save_config = MagicMock() + config = {"memory": {"provider": "openviking"}} + providers = [("fake", "local", object())] + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: len(providers)) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: config) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + assert config["memory"]["provider"] == "" + save_config.assert_called_once_with(config) + + +def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch): + class ChoiceProvider: + def __init__(self): + self.save_config = MagicMock() + + def get_config_schema(self): + return [{ + "key": "mode", + "description": "Mode", + "default": "one", + "choices": ["one", "two"], + }] + + provider = ChoiceProvider() + selections = iter([0, _CANCELLED]) + save_config = MagicMock() + install_dependencies = MagicMock() + + monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", provider)]) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr(memory_setup, "_install_dependencies", install_dependencies) + monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: tmp_path) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}}) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + memory_setup.cmd_setup(SimpleNamespace()) + + install_dependencies.assert_called_once_with("fake") + save_config.assert_not_called() + provider.save_config.assert_not_called() + assert not (tmp_path / ".env").exists() diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index b121a2bb20b..bbcb151baa9 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from hermes_cli.memory_setup import _CANCELLED from plugins.memory.hindsight import ( HindsightMemoryProvider, RECALL_SCHEMA, @@ -376,6 +377,61 @@ class TestConfig: class TestPostSetup: + def test_setup_cancel_at_mode_picker_writes_nothing(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)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home) + + save_config = MagicMock() + which = MagicMock(return_value="/usr/bin/uv") + run = MagicMock() + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: _CANCELLED) + monkeypatch.setattr("shutil.which", which) + monkeypatch.setattr("subprocess.run", run) + monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}}) + + save_config.assert_not_called() + which.assert_not_called() + run.assert_not_called() + assert not (hermes_home / ".env").exists() + assert not (hermes_home / "hindsight" / "config.json").exists() + assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists() + + def test_local_embedded_setup_cancel_at_llm_picker_writes_nothing(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)) + monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home) + + selections = iter([1, _CANCELLED]) # local_embedded, then cancel LLM picker + save_config = MagicMock() + which = MagicMock(return_value="/usr/bin/uv") + run = MagicMock() + monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections)) + monkeypatch.setattr("shutil.which", which) + monkeypatch.setattr("subprocess.run", run) + monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run"))) + monkeypatch.setattr("hermes_cli.config.save_config", save_config) + + provider = HindsightMemoryProvider() + provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}}) + + save_config.assert_not_called() + which.assert_not_called() + run.assert_not_called() + assert not (hermes_home / ".env").exists() + assert not (hermes_home / "hindsight" / "config.json").exists() + assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists() + def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch): hermes_home = tmp_path / "hermes-home" user_home = tmp_path / "user-home" diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 3f609cd1d67..754f7ff617c 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -8,6 +8,281 @@ import pytest from plugins.memory.openviking import OpenVikingMemoryProvider, _VikingClient +def _clear_openviking_env(monkeypatch): + for key in ( + "OPENVIKING_ENDPOINT", + "OPENVIKING_API_KEY", + "OPENVIKING_ACCOUNT", + "OPENVIKING_USER", + "OPENVIKING_AGENT", + "OPENVIKING_CLI_CONFIG_FILE", + ): + monkeypatch.delenv(key, raising=False) + + +def test_linked_ovcli_config_is_read_at_runtime(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking-one.local", + "api_key": "key-one", + "account": "acct-one", + "user": "alice", + "agent_id": "agent-one", + }), + encoding="utf-8", + ) + provider_config = {"use_ovcli_config": True, "ovcli_config_path": str(ovcli_path)} + + settings = openviking_module._resolve_connection_settings(provider_config) + + assert settings == { + "endpoint": "http://openviking-one.local", + "api_key": "key-one", + "account": "acct-one", + "user": "alice", + "agent": "agent-one", + } + + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking-two.local", + "api_key": "key-two", + "agent_id": "agent-two", + }), + encoding="utf-8", + ) + + settings = openviking_module._resolve_connection_settings(provider_config) + + assert settings == { + "endpoint": "http://openviking-two.local", + "api_key": "key-two", + "account": "", + "user": "", + "agent": "agent-two", + } + + +def test_openviking_env_overrides_linked_ovcli_config(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking.local", + "api_key": "file-key", + "account": "file-account", + "user": "file-user", + "agent_id": "file-agent", + }), + encoding="utf-8", + ) + monkeypatch.setenv("OPENVIKING_ENDPOINT", "http://env.local") + monkeypatch.setenv("OPENVIKING_API_KEY", "env-key") + monkeypatch.setenv("OPENVIKING_ACCOUNT", "env-account") + monkeypatch.setenv("OPENVIKING_USER", "env-user") + monkeypatch.setenv("OPENVIKING_AGENT", "env-agent") + + settings = openviking_module._resolve_connection_settings({ + "use_ovcli_config": True, + "ovcli_config_path": str(ovcli_path), + }) + + assert settings == { + "endpoint": "http://env.local", + "api_key": "env-key", + "account": "env-account", + "user": "env-user", + "agent": "env-agent", + } + + +def test_post_setup_link_existing_ovcli_clears_hermes_env(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + env_path = hermes_home / ".env" + env_path.write_text( + "OPENVIKING_ENDPOINT=http://old.local\n" + "OPENVIKING_ACCOUNT=old-account\n" + "OTHER_KEY=keep\n", + encoding="utf-8", + ) + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text(json.dumps({"url": "http://openviking.local"}), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import memory_setup + + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 0) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"]["use_ovcli_config"] is True + assert config["memory"]["openviking"]["ovcli_config_path"] == str(ovcli_path) + env_text = env_path.read_text(encoding="utf-8") + assert "OPENVIKING_" not in env_text + assert "OTHER_KEY=keep" in env_text + + +def test_post_setup_copy_existing_ovcli_writes_hermes_env(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text( + json.dumps({ + "url": "http://openviking.local", + "api_key": "test-key", + "account": "acct", + "user": "alice", + "agent_id": "agent", + }), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import memory_setup + + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 1) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"]["use_ovcli_config"] is False + env_text = (hermes_home / ".env").read_text(encoding="utf-8") + assert "OPENVIKING_ENDPOINT=http://openviking.local" in env_text + assert "OPENVIKING_API_KEY=test-key" in env_text + assert "OPENVIKING_ACCOUNT=acct" in env_text + assert "OPENVIKING_USER=alice" in env_text + assert "OPENVIKING_AGENT=agent" in env_text + + +def test_post_setup_cancel_existing_ovcli_writes_nothing(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + env_path = hermes_home / ".env" + original_env = "OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n" + env_path.write_text(original_env, encoding="utf-8") + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text(json.dumps({"url": "http://openviking.local"}), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import config as hermes_config + from hermes_cli import memory_setup + + save_config = MagicMock() + monkeypatch.setattr(hermes_config, "save_config", save_config) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: -1) + config = {"memory": {"provider": "builtin"}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + save_config.assert_not_called() + assert config == {"memory": {"provider": "builtin"}} + assert env_path.read_text(encoding="utf-8") == original_env + + +def test_post_setup_invalid_existing_ovcli_writes_nothing(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + env_path = hermes_home / ".env" + original_env = "OPENVIKING_ENDPOINT=http://old.local\nOTHER_KEY=keep\n" + env_path.write_text(original_env, encoding="utf-8") + ovcli_path = tmp_path / "ovcli.conf" + ovcli_path.write_text("{", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import config as hermes_config + from hermes_cli import memory_setup + + save_config = MagicMock() + monkeypatch.setattr(hermes_config, "save_config", save_config) + monkeypatch.setattr( + memory_setup, + "_curses_select", + MagicMock(side_effect=AssertionError("picker should not open for invalid ovcli.conf")), + ) + config = {"memory": {"provider": "builtin"}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + save_config.assert_not_called() + assert config == {"memory": {"provider": "builtin"}} + assert env_path.read_text(encoding="utf-8") == original_env + + +def test_post_setup_creates_minimal_ovcli_and_links(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + ovcli_path = tmp_path / "missing" / "ovcli.conf" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import memory_setup + + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: 0) + monkeypatch.setattr( + memory_setup, + "_prompt", + lambda label, default=None, secret=False: default or "", + ) + config = {"memory": {}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + assert config["memory"]["provider"] == "openviking" + assert config["memory"]["openviking"]["use_ovcli_config"] is True + data = json.loads(ovcli_path.read_text(encoding="utf-8")) + assert data == { + "url": "http://127.0.0.1:1933", + "agent_id": "hermes", + } + env_path = hermes_home / ".env" + if env_path.exists(): + assert env_path.read_text(encoding="utf-8") == "" + + +def test_post_setup_cancel_missing_ovcli_does_not_prompt_or_create(tmp_path, monkeypatch): + _clear_openviking_env(monkeypatch) + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + ovcli_path = tmp_path / "missing" / "ovcli.conf" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENVIKING_CLI_CONFIG_FILE", str(ovcli_path)) + + from hermes_cli import config as hermes_config + from hermes_cli import memory_setup + + save_config = MagicMock() + monkeypatch.setattr(hermes_config, "save_config", save_config) + monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: -1) + monkeypatch.setattr( + memory_setup, + "_prompt", + MagicMock(side_effect=AssertionError("prompts should not run after cancel")), + ) + config = {"memory": {"provider": "builtin"}} + + OpenVikingMemoryProvider().post_setup(str(hermes_home), config) + + save_config.assert_not_called() + assert config == {"memory": {"provider": "builtin"}} + assert not ovcli_path.exists() + assert not (hermes_home / ".env").exists() + + def test_tool_search_sorts_by_raw_score_across_buckets(): provider = OpenVikingMemoryProvider() provider._client = MagicMock() @@ -371,9 +646,7 @@ def test_viking_client_headers_send_tenant_when_default(): assert headers["Authorization"] == "Bearer test-key" -def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(): - # Empty account/user strings fall back to "default" via the constructor. - # Headers are sent even for the default value — ROOT API keys need them. +def test_viking_client_headers_omit_tenant_when_empty(): client = _VikingClient( "https://example.com", api_key="", @@ -382,8 +655,9 @@ def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(): agent="hermes", ) headers = client._headers() - assert headers["X-OpenViking-Account"] == "default" - assert headers["X-OpenViking-User"] == "default" + assert "X-OpenViking-Account" not in headers + assert "X-OpenViking-User" not in headers + assert headers["X-OpenViking-Agent"] == "hermes" assert "Authorization" not in headers assert "X-API-Key" not in headers