feat(memory): improve OpenViking setup UX

Support linking, copying, and creating ovcli.conf during OpenViking memory setup.

Make setup cancellation write nothing and cover OpenViking/Hindsight picker cancellation paths.
This commit is contained in:
Hao Zhe 2026-05-13 20:42:18 +08:00
parent c6e99ab375
commit 2dace37f6b
8 changed files with 818 additions and 55 deletions

View file

@ -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

View file

@ -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)"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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