Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 88 additions & 10 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2305,17 +2305,95 @@ def _gh_authenticated() -> bool:
try:
from plugins.memory.mem0 import _load_config as _load_mem0_config
mem0_cfg = _load_mem0_config()
mem0_key = mem0_cfg.get("api_key", "")
if mem0_key:
check_ok("Mem0 API key configured")
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
else:
_fail_and_issue(
"Mem0 API key not set",
"(set MEM0_API_KEY in .env or run hermes memory setup)",
"Mem0 is set as memory provider but API key is missing",
issues,
mem0_mode = mem0_cfg.get("mode", "platform")
if mem0_mode == "oss":
# OSS mode: no top-level api_key. Credentials live in
# ~/.hermes/.env (written by the wizard keyed by each
# provider's env_var) and are already loaded into os.environ
# by load_hermes_dotenv() at module import. Some providers
# (Ollama) declare needs_key=False and need no credential at
# all. Resolve required keys per the provider registry so
# wizard-created setups pass doctor and fully-local Ollama
# configs aren't falsely rejected.
from plugins.memory.mem0._oss_providers import (
EMBEDDER_PROVIDERS,
LLM_PROVIDERS,
)

oss_cfg = mem0_cfg.get("oss", {})
llm_provider = oss_cfg.get("llm", {}).get("provider", "")
embedder_provider = oss_cfg.get("embedder", {}).get("provider", "")
vector_provider = oss_cfg.get("vector_store", {}).get("provider", "")
llm_cfg = oss_cfg.get("llm", {}).get("config", {})
embedder_cfg = oss_cfg.get("embedder", {}).get("config", {})

def _has_credential(kind: str, provider_id: str, cfg: dict) -> bool:
"""True if this OSS component has the credential it needs.

A provider with ``needs_key=False`` (e.g. Ollama) needs
no credential. Otherwise the key is satisfied if it was
written into mem0.json *or* loaded from .env into the
environment (the wizard's actual write path).
"""
registry = LLM_PROVIDERS if kind == "llm" else EMBEDDER_PROVIDERS
entry = registry.get(provider_id, {})
if not entry.get("needs_key", True):
return True
# Inline key in mem0.json (non-wizard path).
if cfg.get("api_key"):
return True
# Wizard path: key written to .env, loaded by
# load_hermes_dotenv() into os.environ.
env_var = entry.get("env_var", "")
return bool(env_var and os.environ.get(env_var, ""))

missing_parts: list[str] = []
if not llm_provider:
missing_parts.append("oss.llm.provider")
elif not _has_credential("llm", llm_provider, llm_cfg):
env_var = LLM_PROVIDERS.get(llm_provider, {}).get("env_var", "?")
missing_parts.append(f"oss.llm key ({env_var})")
if not embedder_provider:
missing_parts.append("oss.embedder.provider")
elif not _has_credential("embedder", embedder_provider, embedder_cfg):
env_var = EMBEDDER_PROVIDERS.get(embedder_provider, {}).get("env_var", "?")
missing_parts.append(f"oss.embedder key ({env_var})")
if not vector_provider:
missing_parts.append("oss.vector_store.provider")

if not missing_parts:
check_ok("Mem0 OSS configured")
check_info(
f"mode=oss user_id={mem0_cfg.get('user_id', '?')} "
f"agent_id={mem0_cfg.get('agent_id', '?')} "
f"llm={llm_cfg.get('model', '?')} "
f"embedder={embedder_cfg.get('model', '?')} "
f"vector_store={vector_provider}"
)
else:
hint = (
"set the listed credential(s) in ~/.hermes/.env or "
"rerun 'hermes memory setup'"
)
_fail_and_issue(
"Mem0 OSS config incomplete",
f"missing: {', '.join(missing_parts)} — {hint}",
"Mem0 is set to OSS mode but config is incomplete",
issues,
)
else:
# Platform mode: requires top-level api_key.
mem0_key = mem0_cfg.get("api_key", "")
if mem0_key:
check_ok("Mem0 API key configured")
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
else:
_fail_and_issue(
"Mem0 API key not set",
"(set MEM0_API_KEY in .env or run hermes memory setup)",
"Mem0 is set as memory provider but API key is missing",
issues,
)
except ImportError:
_fail_and_issue(
"Mem0 plugin not loadable",
Expand Down
14 changes: 11 additions & 3 deletions hermes_cli/memory_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,21 @@ def cmd_setup(args) -> None:
items.append((name, f"— {desc}"))
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, cancel_returns=_CANCELLED)
# Default to the currently active provider if one is configured
config = load_config()
current_provider = (config.get("memory") or {}).get("provider", "")
default_idx = len(items) - 1 # fall back to "Built-in only"
if current_provider:
for i, (pname, _, _) in enumerate(providers):
if pname == current_provider:
default_idx = i
break

selected = _curses_select("Memory provider setup", items, default=default_idx, cancel_returns=_CANCELLED)
if selected == _CANCELLED:
_print_cancelled_setup()
return

config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}

Expand Down
21 changes: 17 additions & 4 deletions plugins/memory/mem0/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,12 +966,25 @@ def post_setup(hermes_home: str, config: dict) -> None:
return

# No --mode flag: show interactive picker
# Default cursor to the currently configured mode so the wizard
# doesn't silently suggest switching away from an active setup.
current_mode = ""
config_path = Path(hermes_home) / "mem0.json"
if config_path.exists():
try:
existing = json.loads(config_path.read_text(encoding="utf-8"))
current_mode = str(existing.get("mode", "")).lower()
except Exception:
pass
mode_items = [
("Platform", "Mem0 Cloud API (lightweight, just needs an API key)"),
("Self-hosted server", "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"),
("Open Source", "Run Mem0 locally (self-hosted LLM + vector store)"),
("Platform" + (" ← current" if current_mode == "platform" else ""), "Mem0 Cloud API (lightweight, just needs an API key)"),
("Self-hosted server" + (" ← current" if current_mode in ("selfhosted", "self-hosted") else ""), "Connect to an existing self-hosted Mem0 server (Docker/FastAPI)"),
("Open Source" + (" ← current" if current_mode == "oss" else ""), "Run Mem0 locally (self-hosted LLM + vector store)"),
]
mode_idx = _curses_select(" Select mode", mode_items, 0)
# Map config mode → picker index
_mode_to_idx = {"platform": 0, "selfhosted": 1, "self-hosted": 1, "oss": 2}
default_idx = _mode_to_idx.get(current_mode, 0)
mode_idx = _curses_select(" Select mode", mode_items, default_idx)
if mode_idx == 1:
_setup_selfhosted(hermes_home, config, flags)
elif mode_idx == 2:
Expand Down
101 changes: 101 additions & 0 deletions tests/hermes_cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,107 @@ def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
assert "Built-in memory active" not in out


class TestDoctorMem0OSSCredentials:
"""Doctor's Mem0 OSS check must resolve credentials via the provider
registry and ~/.hermes/.env — not from mem0.json keys the wizard never
writes — and must not require keys for providers that declare
needs_key=False (Ollama). Regression for the hermes-sweeper review on
PR #59865.
"""

def _make_oss_home(self, tmp_path, oss_cfg):
import yaml

home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
config = {"memory": {"provider": "mem0"}}
(home / "config.yaml").write_text(yaml.dump(config))
mem0_json = {
"mode": "oss",
"user_id": "u1",
"agent_id": "a1",
"oss": oss_cfg,
}
import json

(home / "mem0.json").write_text(json.dumps(mem0_json))
return home

def _run_and_capture(self, monkeypatch, tmp_path, oss_cfg, env_overrides=None):
home = self._make_oss_home(tmp_path, oss_cfg)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
# _load_mem0_config() resolves the home via get_hermes_home() which
# reads the HERMES_HOME env var — set it so mem0.json is found.
monkeypatch.setenv("HERMES_HOME", str(home))
(tmp_path / "project").mkdir(exist_ok=True)

fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod

monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass

# The wizard writes keys to .env; load_hermes_dotenv() (already
# imported at module level in doctor.py) loads them into os.environ.
# Simulate that by setting env vars directly — doctor reads
# os.environ, not the .env file, after load_hermes_dotenv() has run.
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
for k, v in (env_overrides or {}).items():
monkeypatch.setenv(k, v)

import io, contextlib

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()

def test_oss_openai_keys_in_env_passes(self, monkeypatch, tmp_path):
"""Wizard-created OpenAI OSS setup: keys only in .env, not mem0.json."""
oss_cfg = {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {}},
}
out = self._run_and_capture(
monkeypatch, tmp_path, oss_cfg, env_overrides={"OPENAI_API_KEY": "sk-test"}
)
assert "Mem0 OSS configured" in out
assert "Mem0 OSS config incomplete" not in out

def test_oss_ollama_no_keys_anywhere_passes(self, monkeypatch, tmp_path):
"""Fully-local Ollama OSS config: needs_key=False, no keys required."""
oss_cfg = {
"llm": {"provider": "ollama", "config": {"model": "llama3.1:8b"}},
"embedder": {"provider": "ollama", "config": {"model": "nomic-embed-text"}},
"vector_store": {"provider": "qdrant", "config": {}},
}
out = self._run_and_capture(monkeypatch, tmp_path, oss_cfg)
assert "Mem0 OSS configured" in out
assert "Mem0 OSS config incomplete" not in out

def test_oss_openai_missing_key_fails_with_env_var_hint(self, monkeypatch, tmp_path):
"""OSS with OpenAI LLM but no key in .env or mem0.json → fail, naming the env var."""
oss_cfg = {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
"embedder": {"provider": "ollama", "config": {"model": "nomic-embed-text"}},
"vector_store": {"provider": "qdrant", "config": {}},
}
out = self._run_and_capture(monkeypatch, tmp_path, oss_cfg)
assert "Mem0 OSS config incomplete" in out
assert "OPENAI_API_KEY" in out


def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
helper = TestDoctorMemoryProviderSection()
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
Expand Down
110 changes: 108 additions & 2 deletions tests/hermes_cli/test_memory_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ def fake_radiolist(title, items, selected=0, *, cancel_returns=None):

def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch):
save_config = MagicMock()
load_config = MagicMock(side_effect=AssertionError("cancel should not load config"))
# load_config() is now called *before* the picker to compute the default
# cursor position (pre-selecting the currently active provider). That
# call is expected on the cancel path too; the invariant that matters is
# that no config is *written* on cancel.
load_config = MagicMock(return_value={"memory": {}})

monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", object())])
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: kwargs["cancel_returns"])
Expand All @@ -72,7 +76,6 @@ def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch):

memory_setup.cmd_setup(SimpleNamespace())

load_config.assert_not_called()
save_config.assert_not_called()


Expand Down Expand Up @@ -196,3 +199,106 @@ def get_config_schema(self):
save_config.assert_not_called()
provider.save_config.assert_not_called()
assert not (tmp_path / ".env").exists()


# ── picker default-cursor regression tests (PR #59865) ──


def test_cmd_setup_picker_defaults_to_active_provider(monkeypatch):
"""When config.memory.provider matches an available provider, the picker
cursor should default to that provider's row, not to 'Built-in only'."""
providers = [("mem0", "Mem0 cloud", object()), ("openviking", "local", object())]
captured = {}

def fake_select(title, items, default=0, cancel_returns=None):
captured["default"] = default
return cancel_returns

monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers)
monkeypatch.setattr(memory_setup, "_curses_select", fake_select)
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: {"memory": {"provider": "mem0"}}
)
monkeypatch.setattr("hermes_cli.config.save_config", MagicMock())

memory_setup.cmd_setup(SimpleNamespace())

# mem0 is index 0 in providers, so default_idx should be 0 — not
# len(items)-1 (the "Built-in only" row).
assert captured["default"] == 0


def test_cmd_setup_picker_falls_back_to_builtin_when_no_active_provider(monkeypatch):
"""No provider configured → default cursor lands on 'Built-in only'."""
providers = [("mem0", "Mem0 cloud", object())]
captured = {}

def fake_select(title, items, default=0, cancel_returns=None):
captured["default"] = default
return cancel_returns

monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers)
monkeypatch.setattr(memory_setup, "_curses_select", fake_select)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
monkeypatch.setattr("hermes_cli.config.save_config", MagicMock())

memory_setup.cmd_setup(SimpleNamespace())

# 1 provider + "Built-in only" = 2 items; builtin is index 1.
assert captured["default"] == 1


def test_mem0_post_setup_picker_defaults_to_current_oss_mode(monkeypatch, tmp_path):
"""Existing mem0.json with mode=oss → picker default is index 2 (Open Source)
and the label is annotated with '← current'."""
import json

from plugins.memory.mem0 import _setup as mem0_setup

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "mem0.json").write_text(json.dumps({"mode": "oss"}))

captured = {}

def fake_select(title, items, default=0):
captured["default"] = default
captured["items"] = items
return 0 # choose Platform to avoid running full OSS setup

monkeypatch.setattr(mem0_setup, "_curses_select", fake_select)
# Short-circuit the platform setup path so no network/files are touched.
monkeypatch.setattr(mem0_setup, "_setup_platform", lambda *a, **kw: None)

mem0_setup.post_setup(str(hermes_home), {"memory": {"provider": "mem0"}})

assert captured["default"] == 2
# The Open Source label should carry the "← current" marker.
oss_label = captured["items"][2][0]
assert "← current" in oss_label


def test_mem0_post_setup_picker_defaults_to_platform_without_existing_config(
monkeypatch, tmp_path
):
"""No existing mem0.json → default is index 0 (Platform), no '← current' markers."""
from plugins.memory.mem0 import _setup as mem0_setup

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()

captured = {}

def fake_select(title, items, default=0):
captured["default"] = default
captured["items"] = items
return 0

monkeypatch.setattr(mem0_setup, "_curses_select", fake_select)
monkeypatch.setattr(mem0_setup, "_setup_platform", lambda *a, **kw: None)

mem0_setup.post_setup(str(hermes_home), {"memory": {"provider": "mem0"}})

assert captured["default"] == 0
for label, _ in captured["items"]:
assert "← current" not in label