From 5e51b123f32b7f6a51fbd5759e89ba5146ce4003 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:16:48 -0700 Subject: [PATCH] feat(mem0): add self-hosted mode to the setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged SelfHostedBackend made self-hosted servers reachable via mem0.json / MEM0_HOST, but the setup wizard still offered only Platform and OSS — exactly the gap users hit (Discord report: 'At memory setup there's only 2 options'). Adds a third wizard mode: - interactive picker: Platform / Self-hosted server / Open Source - non-interactive: hermes memory setup mem0 --mode selfhosted --host http://... [--api-key ...] [--dry-run] - host -> mem0.json (behavioral), API key -> .env as MEM0_API_KEY (secret), optional key for AUTH_DISABLED servers - best-effort reachability check against the server, non-fatal - README + memory-providers docs updated with the wizard path --- plugins/memory/mem0/README.md | 8 +- plugins/memory/mem0/_setup.py | 113 +++++++++++++++++- tests/plugins/memory/test_mem0_setup.py | 46 +++++++ .../user-guide/features/memory-providers.md | 12 +- 4 files changed, 172 insertions(+), 7 deletions(-) diff --git a/plugins/memory/mem0/README.md b/plugins/memory/mem0/README.md index 9232f0c0c..c5c39f2bc 100644 --- a/plugins/memory/mem0/README.md +++ b/plugins/memory/mem0/README.md @@ -42,7 +42,13 @@ The plugin has three connection modes: Connect the plugin to a standalone Mem0 server you run yourself — the Docker-shipped Mem0 dashboard/server with its own REST API. Unlike OSS mode (which runs `mem0ai` in-process with your own vector store), here the plugin just talks HTTP to your server. 1. Run the Mem0 server (FastAPI + pgvector) from its Docker image and note its URL and `ADMIN_API_KEY`. -2. Point the plugin at it — either via env vars: +2. Point the plugin at it — via the setup wizard: + ```bash + hermes memory setup # select "mem0" → "Self-hosted server" + # Or non-interactive: + hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key + ``` + or via env vars: ```bash echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index c81e5d1f0..a331ef3a8 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -67,6 +67,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]: flags: dict[str, str] = { "mode": "", "api_key": "", + "host": "", "oss_llm": "openai", "oss_llm_key": "", "oss_llm_model": "", @@ -90,6 +91,7 @@ def parse_flags(argv: list[str] | None = None) -> dict[str, str]: flag_map = { "--mode": "mode", "--api-key": "api_key", + "--host": "host", "--oss-llm": "oss_llm", "--oss-llm-key": "oss_llm_key", "--oss-llm-model": "oss_llm_model", @@ -331,6 +333,102 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No print("\n Start a new session to activate.\n") +def _check_selfhosted_server(host: str) -> None: + """Best-effort reachability check for a self-hosted Mem0 server (non-fatal).""" + import urllib.error + import urllib.request as _urlreq + + try: + req = _urlreq.Request(f"{host.rstrip('/')}/docs", method="GET") + _urlreq.urlopen(req, timeout=5) + print(f" ✓ Mem0 server reachable at {host}") + except urllib.error.HTTPError: + # Any HTTP response (401/403/404) still means something is listening. + print(f" ✓ Mem0 server responding at {host}") + except Exception: + print(f" ⚠ Could not reach {host} — check the URL and that the server is running.") + + +def _setup_selfhosted(hermes_home: str, config: dict, flags: dict[str, str]) -> None: + """Self-hosted mode setup — point at an existing Mem0 dashboard server. + + For users already running the Dockerized Mem0 FastAPI server: stores the + server URL (behavioral -> mem0.json) and an optional API key + (secret -> .env as MEM0_API_KEY). + """ + existing_config = {} + config_path = Path(hermes_home) / "mem0.json" + if config_path.exists(): + try: + existing_config = json.loads(config_path.read_text()) + except Exception: + pass + + provider_config = dict(existing_config) + + print("\n Configuring mem0 (self-hosted server):\n") + + host = flags.get("host") or _prompt( + "Mem0 server URL (e.g. http://localhost:8888)", + default=provider_config.get("host") or None, + ) + if not host: + print(" Error: a server URL is required for self-hosted mode.", file=sys.stderr) + return + host = host.rstrip("/") + + env_writes: dict[str, str] = {} + if flags.get("api_key"): + env_writes["MEM0_API_KEY"] = flags["api_key"] + else: + existing_key = os.environ.get("MEM0_API_KEY", "") + if existing_key: + masked = f"...{existing_key[-4:]}" if len(existing_key) > 4 else "set" + val = _prompt(f"Server API key (current: {masked}, blank to keep)", secret=True) + else: + val = _prompt("Server API key (blank if AUTH_DISABLED)", secret=True) + if val: + env_writes["MEM0_API_KEY"] = val + + user_id = flags.get("user_id") or _prompt( + "User identifier", default=provider_config.get("user_id") or "hermes-user" + ) + agent_id = _prompt("Agent identifier", default=provider_config.get("agent_id") or "hermes") + + if flags.get("dry_run"): + print(f"\n [dry-run] Would save config: host={host}, user_id={user_id}, agent_id={agent_id}") + if env_writes: + print(" [dry-run] Would write API key to .env") + _check_selfhosted_server(host) + print(" [dry-run] No files written.\n") + return + + provider_config["mode"] = "platform" # routing: oss > host > platform; host wins + provider_config["host"] = host + provider_config["user_id"] = user_id + provider_config["agent_id"] = agent_id + + from hermes_cli.config import save_config + config["memory"]["provider"] = "mem0" + save_config(config) + + from plugins.memory.mem0 import Mem0MemoryProvider + provider = Mem0MemoryProvider() + provider.save_config(provider_config, hermes_home) + + if env_writes: + _write_env(Path(hermes_home) / ".env", env_writes) + + _check_selfhosted_server(host) + print("\n Memory provider: mem0 (self-hosted)") + print(f" Server: {host}") + print(" Activation saved to config.yaml") + print(" Provider config saved") + if env_writes: + print(" API key saved to .env") + print("\n Start a new session to activate.\n") + + def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None: """OSS mode setup — build config from flags or interactive prompts. @@ -846,10 +944,10 @@ def _check_min_dep_version() -> None: def post_setup(hermes_home: str, config: dict) -> None: """Entry point called by hermes memory setup framework. - Only intercepts when OSS mode is requested (via --mode oss flag or - interactive picker). For platform mode, returns without action so the - framework's schema-based flow handles it (preserving the original - platform onboarding experience). + Routes on --mode (platform / selfhosted / oss); with no flag it shows an + interactive picker with all three modes. Platform keeps the framework's + original schema-based onboarding; selfhosted points at an existing Mem0 + server; oss builds a local SDK config. """ _check_min_dep_version() flags = parse_flags(sys.argv[1:]) @@ -859,6 +957,10 @@ def post_setup(hermes_home: str, config: dict) -> None: _setup_oss(hermes_home, config, flags) return + if flags["mode"] in ("selfhosted", "self-hosted"): + _setup_selfhosted(hermes_home, config, flags) + return + if flags["mode"] == "platform": _setup_platform(hermes_home, config, flags) return @@ -866,10 +968,13 @@ def post_setup(hermes_home: str, config: dict) -> None: # No --mode flag: show interactive picker 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)"), ] mode_idx = _curses_select(" Select mode", mode_items, 0) if mode_idx == 1: + _setup_selfhosted(hermes_home, config, flags) + elif mode_idx == 2: flags["_mode_from_flag"] = False _setup_oss(hermes_home, config, flags) else: diff --git a/tests/plugins/memory/test_mem0_setup.py b/tests/plugins/memory/test_mem0_setup.py index b365970c5..b4b85a586 100644 --- a/tests/plugins/memory/test_mem0_setup.py +++ b/tests/plugins/memory/test_mem0_setup.py @@ -209,6 +209,52 @@ class TestPostSetup: assert mem0_json["mode"] == "oss" assert mem0_json["oss"]["llm"]["provider"] == "openai" + def test_selfhosted_flag_mode(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "selfhosted", + "--host", "http://localhost:8888/", "--api-key", "admin-key", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert config["memory"]["provider"] == "mem0" + env_content = (tmp_path / ".env").read_text() + assert "MEM0_API_KEY=admin-key" in env_content + mem0_json = json.loads((tmp_path / "mem0.json").read_text()) + assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped + assert mem0_json["user_id"] == "hermes-user" + + def test_selfhosted_no_api_key_auth_disabled(self, tmp_path, monkeypatch): + # AUTH_DISABLED servers need no key — setup must not write one. + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "self-hosted", "--host", "http://mem0.lan:8888", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + monkeypatch.delenv("MEM0_API_KEY", raising=False) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert not (tmp_path / ".env").exists() + mem0_json = json.loads((tmp_path / "mem0.json").read_text()) + assert mem0_json["host"] == "http://mem0.lan:8888" + + def test_selfhosted_dry_run_no_files(self, tmp_path, monkeypatch): + monkeypatch.setattr("sys.argv", [ + "hermes", "--mode", "selfhosted", + "--host", "http://localhost:8888", "--api-key", "k", "--dry-run", + ]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + _inject_fake_hermes_cli(monkeypatch) + monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None) + config = {"memory": {}} + post_setup(str(tmp_path), config) + assert not (tmp_path / ".env").exists() + assert not (tmp_path / "mem0.json").exists() + assert "provider" not in config["memory"] + class TestDryRun: diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index ea8aec293..1c6ebf7c4 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -348,7 +348,15 @@ Preview without writing files: hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run ``` -**Setup (Self-Hosted Dashboard):** connect to a Mem0 server you run via Docker (the dashboard's REST API). Set `host` and an API key — either as env vars: +**Setup (Self-Hosted Dashboard):** connect to a Mem0 server you run via Docker (the dashboard's REST API): + +```bash +hermes memory setup # select "mem0" → "Self-hosted server" +# Or via flags: +hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key +``` + +Or configure manually — either as env vars: ```bash echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env @@ -381,7 +389,7 @@ The plugin authenticates with `X-API-Key` and uses the server's `/search` / `/me | Embedder | openai, ollama | | Vector Store | qdrant (local/server), pgvector | -**Switching modes:** Re-run `hermes memory setup mem0 --mode ` or edit `mem0.json` directly. +**Switching modes:** Re-run `hermes memory setup mem0 --mode ` or edit `mem0.json` directly. ---