From ba23d447358ea2f8cca3a7664f06c7eaaf3be94e Mon Sep 17 00:00:00 2001 From: Luna Xin Date: Wed, 8 Jul 2026 18:09:08 -0400 Subject: [PATCH 1/2] fix(memory): support Mem0 OSS mode in doctor + setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doctor.py: branch Mem0 health check on mode — OSS validates oss.llm.config.api_key, oss.embedder.config.api_key, and oss.vector_store.provider; Platform mode unchanged (checks top-level api_key). Reports full config for both modes. - memory_setup.py: pass provider instance to status display so is_available() reflects OSS mode correctly. - _setup.py: default mode picker cursor to the currently configured mode (from mem0.json) instead of always defaulting to Platform. Adds '← current' label to the active mode option so users can see their existing setup at a glance. Closes #57931 Supersedes #57932 --- hermes_cli/doctor.py | 56 ++++++++++++++++++++++++++++------- hermes_cli/memory_setup.py | 14 +++++++-- plugins/memory/mem0/_setup.py | 21 ++++++++++--- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 12b688b224cd0..9ae8fc796b434 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -2305,17 +2305,53 @@ 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', '?')}") + mem0_mode = mem0_cfg.get("mode", "platform") + if mem0_mode == "oss": + # OSS mode: no top-level api_key; credentials are nested in + # oss.llm / oss.embedder / oss.vector_store configs. + oss_cfg = mem0_cfg.get("oss", {}) + llm_cfg = oss_cfg.get("llm", {}).get("config", {}) + embedder_cfg = oss_cfg.get("embedder", {}).get("config", {}) + vector_cfg = oss_cfg.get("vector_store", {}).get("config", {}) + llm_key = llm_cfg.get("api_key", "") + embedder_key = embedder_cfg.get("api_key", "") + vector_provider = oss_cfg.get("vector_store", {}).get("provider", "") + if llm_key and embedder_key and vector_provider: + 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: + missing_parts = [] + if not llm_key: + missing_parts.append("oss.llm.config.api_key") + if not embedder_key: + missing_parts.append("oss.embedder.config.api_key") + if not vector_provider: + missing_parts.append("oss.vector_store.provider") + _fail_and_issue( + "Mem0 OSS config incomplete", + f"missing: {', '.join(missing_parts)} in ~/.hermes/mem0.json", + "Mem0 is set to OSS mode but config is incomplete", + issues, + ) 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, - ) + # 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", diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 2f6801de3992a..239e95ddc3b46 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -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"] = {} diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index a331ef3a80ea4..5dc4d565ef568 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -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: From a6070f4893b5e4e4874375d117cd8b36b4294adc Mon Sep 17 00:00:00 2001 From: Luna Xin Date: Tue, 21 Jul 2026 12:46:52 -0400 Subject: [PATCH 2/2] fix(memory): resolve OSS creds via provider registry + .env in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mem0 OSS doctor check expected api_key fields inside mem0.json's oss.llm.config / oss.embedder.config, but the setup wizard writes provider keys to ~/.hermes/.env (keyed by each provider's env_var) and only stores provider/model in mem0.json. Wizard-created OpenAI OSS setups therefore still failed doctor. Fix: resolve credentials per the provider registry (_oss_providers.LLM/EMBEDDER_PROVIDERS), which is the same source of truth the wizard uses. A provider with needs_key=False (Ollama) needs no credential; otherwise the key is satisfied if present in mem0.json *or* loaded into os.environ by load_hermes_dotenv() from .env. Also relax the cmd_setup cancellation test: load_config() is now called before the picker to compute the default cursor position, so the old 'load_config.assert_not_called()' contract is invalid. The invariant that matters — no config written on cancel — is preserved. Add regression coverage: - doctor: OSS + OpenAI keys in .env → ok - doctor: OSS + Ollama (needs_key=False), no keys → ok - doctor: OSS + OpenAI, no key → fail, naming OPENAI_API_KEY - picker: active mem0 provider → default cursor on mem0 row - picker: no provider → default on 'Built-in only' - picker: existing mem0.json mode=oss → default idx 2 + '← current' - picker: no mem0.json → default idx 0, no markers Addresses hermes-sweeper review on PR #59865. --- hermes_cli/doctor.py | 72 +++++++++++++---- tests/hermes_cli/test_doctor.py | 101 +++++++++++++++++++++++ tests/hermes_cli/test_memory_setup.py | 110 +++++++++++++++++++++++++- 3 files changed, 266 insertions(+), 17 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 9ae8fc796b434..2da8e1f2a48d5 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -2307,16 +2307,61 @@ def _gh_authenticated() -> bool: mem0_cfg = _load_mem0_config() mem0_mode = mem0_cfg.get("mode", "platform") if mem0_mode == "oss": - # OSS mode: no top-level api_key; credentials are nested in - # oss.llm / oss.embedder / oss.vector_store configs. + # 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", {}) - vector_cfg = oss_cfg.get("vector_store", {}).get("config", {}) - llm_key = llm_cfg.get("api_key", "") - embedder_key = embedder_cfg.get("api_key", "") - vector_provider = oss_cfg.get("vector_store", {}).get("provider", "") - if llm_key and embedder_key and vector_provider: + + 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', '?')} " @@ -2326,16 +2371,13 @@ def _gh_authenticated() -> bool: f"vector_store={vector_provider}" ) else: - missing_parts = [] - if not llm_key: - missing_parts.append("oss.llm.config.api_key") - if not embedder_key: - missing_parts.append("oss.embedder.config.api_key") - if not vector_provider: - missing_parts.append("oss.vector_store.provider") + 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)} in ~/.hermes/mem0.json", + f"missing: {', '.join(missing_parts)} — {hint}", "Mem0 is set to OSS mode but config is incomplete", issues, ) diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index b4c961e2619c3..a6c42dbc5ab41 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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") diff --git a/tests/hermes_cli/test_memory_setup.py b/tests/hermes_cli/test_memory_setup.py index b5b574230c79d..39317737db033 100644 --- a/tests/hermes_cli/test_memory_setup.py +++ b/tests/hermes_cli/test_memory_setup.py @@ -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"]) @@ -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() @@ -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