Merge pull request #48262 from kshitijk4poor/salvage-32445

feat(memory): improve OpenViking setup UX (salvage #32445)
This commit is contained in:
kshitij 2026-06-18 11:34:11 +05:30 committed by GitHub
commit 832d5967f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 3292 additions and 89 deletions

View file

@ -1156,6 +1156,9 @@ def init_agent(
"hermes_home": str(get_hermes_home()),
"agent_context": "primary",
}
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db:

View file

@ -15,24 +15,50 @@ 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
f"{label} - {desc}" if desc else label
for label, desc in items
]
return curses_radiolist(title, display_items, selected=default, cancel_returns=default)
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
_clear_interactive_transition()
return result
def _print_cancelled_setup() -> None:
print("\n Cancelled. No changes saved.\n")
def _clear_interactive_transition() -> None:
"""Clear stale curses content before entering a follow-up setup screen."""
if not sys.stdout.isatty():
return
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
@ -205,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None:
name, _, provider = match
_clear_interactive_transition()
_install_dependencies(name)
config = load_config()
@ -241,14 +269,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")
@ -257,6 +288,8 @@ def cmd_setup(args) -> None:
name, _, provider = providers[selected]
_clear_interactive_transition()
# Install pip dependencies if declared in plugin.yaml
_install_dependencies(name)
@ -309,7 +342,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
@ -407,43 +443,53 @@ def cmd_status(args) -> None:
print(f" Built-in: always active")
print(f" Provider: {provider_name or '(none — built-in only)'}")
providers = _get_available_providers()
provider = None
for pname, _, candidate in providers:
if pname == provider_name:
provider = candidate
break
if provider_name:
provider_config = mem_config.get(provider_name, {})
if provider_config:
display_config = provider_config
if provider and hasattr(provider, "get_status_config"):
try:
display_config = provider.get_status_config(provider_config)
except Exception as e:
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
if isinstance(display_config, dict):
display_config["status_config_error"] = str(e)
if display_config:
print(f"\n {provider_name} config:")
for key, val in provider_config.items():
for key, val in display_config.items():
print(f" {key}: {val}")
providers = _get_available_providers()
found = any(name == provider_name for name, _, _ in providers)
if found:
if provider:
print(f"\n Plugin: installed ✓")
for pname, _, p in providers:
if pname == provider_name:
if p.is_available():
print(f" Status: available ✓")
else:
print(f" Status: not available ✗")
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
# Check all fields that have env_var (both secret and non-secret)
required_fields = [f for f in schema if f.get("env_var")]
if required_fields:
print(f" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
if url and not is_set:
line += f"{url}"
print(line)
break
if provider.is_available():
print(f" Status: available ✓")
else:
print(f" Status: not available ✗")
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
# Check all fields that have env_var (both secret and non-secret)
required_fields = [f for f in schema if f.get("env_var")]
if required_fields:
print(f" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
if url and not is_set:
line += f"{url}"
print(line)
else:
print(f"\n Plugin: NOT installed ✗")
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
providers = _get_available_providers()
if providers:
print(f"\n Installed plugins:")
for pname, desc, _ in providers:

View file

@ -27,16 +27,16 @@ def _collect_masked_input(
while True:
ch = read_char()
if ch == "":
write("\n")
write("\r\n")
raise EOFError
if ch in _ENTER_CHARS:
write("\n")
write("\r\n")
return "".join(value)
if ch == "\x03":
write("\n")
write("\r\n")
raise KeyboardInterrupt
if ch in _EOF_CHARS:
write("\n")
write("\r\n")
raise EOFError
if ch in _BACKSPACE_CHARS:
if value:

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

File diff suppressed because it is too large Load diff

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,198 @@
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_curses_select_clears_after_picker_returns(monkeypatch):
events = []
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
events.append("picker")
return selected
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"))
result = _curses_select("Pick one", [("first", "")], default=0)
assert result == 0
assert events == ["picker", "clear"]
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_clears_interactive_picker_before_provider_post_setup(monkeypatch):
events = []
class PostSetupProvider:
def post_setup(self, hermes_home, config):
events.append("post_setup")
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: events.append("select") or 0)
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
memory_setup.cmd_setup(SimpleNamespace())
assert events == ["select", "clear", "install", "post_setup"]
def test_cmd_setup_provider_clears_before_provider_post_setup(monkeypatch):
events = []
class PostSetupProvider:
def post_setup(self, hermes_home, config):
events.append("post_setup")
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
memory_setup.cmd_setup_provider("openviking")
assert events == ["clear", "install", "post_setup"]
def test_cmd_status_prefers_provider_status_config(monkeypatch, capsys):
class StatusProvider:
def get_status_config(self, provider_config):
assert provider_config["endpoint"] == "http://stale.local"
return {
"use_ovcli_config": True,
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
"endpoint": "https://vps.example",
"account": "acct",
"user": "alice",
"agent": "hermes",
}
def is_available(self):
return True
config = {
"memory": {
"provider": "openviking",
"openviking": {
"use_ovcli_config": True,
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
"endpoint": "http://stale.local",
},
}
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "API key / local", StatusProvider())])
memory_setup.cmd_status(SimpleNamespace())
output = capsys.readouterr().out
assert "endpoint: https://vps.example" in output
assert "http://stale.local" not in output
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

@ -25,7 +25,7 @@ def test_collect_masked_input_shows_feedback_without_echoing_secret():
value, output = _run_collect("secret\n")
assert value == "secret"
assert output == "API key: ******\n"
assert output == "API key: ******\r\n"
assert "secret" not in output
@ -33,7 +33,7 @@ def test_collect_masked_input_handles_backspace():
value, output = _run_collect("sec\x7fret\r")
assert value == "seret"
assert output == "API key: ***\b \b***\n"
assert output == "API key: ***\b \b***\r\n"
assert "secret" not in output
@ -47,7 +47,7 @@ def test_collect_masked_input_raises_keyboard_interrupt():
"API key: ",
)
assert "".join(output) == "API key: \n"
assert "".join(output) == "API key: \r\n"
def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch):

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"

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,8 @@ def test_aiagent_forwards_user_id_alt_to_memory_provider():
assert provider.init_kwargs["user_id"] == "open-id"
assert provider.init_kwargs["user_id_alt"] == "union-id"
assert provider.init_kwargs["platform"] == "feishu"
assert "warning_callback" not in provider.init_kwargs
assert "status_callback" not in provider.init_kwargs
class CoreShadowProvider:
@ -132,3 +134,34 @@ def test_core_tool_names_rejected_from_memory_routing_table():
assert "clarify" not in schema_names
assert "delegate_task" not in schema_names
assert "honcho_search" in schema_names
def test_aiagent_forwards_warning_callback_to_cli_memory_provider():
provider = RecordingMemoryProvider()
cfg = {"memory": {"provider": "recording"}, "agent": {}}
with (
patch("hermes_cli.config.load_config", return_value=cfg),
patch("plugins.memory.load_memory_provider", return_value=provider),
patch("agent.model_metadata.get_model_context_length", return_value=204_800),
patch("run_agent.get_tool_definitions", return_value=[]),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
):
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=False,
session_id="sess-cli",
platform="cli",
)
assert agent._memory_manager is not None
assert provider.init_session_id == "sess-cli"
assert provider.init_kwargs["platform"] == "cli"
assert provider.init_kwargs["warning_callback"] == agent._emit_warning
assert provider.init_kwargs["status_callback"] == agent._emit_status