Skip to content
Merged
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
8 changes: 7 additions & 1 deletion plugins/memory/mem0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 109 additions & 4 deletions plugins/memory/mem0/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:])
Expand All @@ -859,17 +957,24 @@ 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

# 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:
Expand Down
46 changes: 46 additions & 0 deletions tests/plugins/memory/test_mem0_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,52 @@ def test_oss_flag_mode(self, tmp_path, monkeypatch):
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:

Expand Down
12 changes: 10 additions & 2 deletions website/docs/user-guide/features/memory-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <platform|oss>` or edit `mem0.json` directly.
**Switching modes:** Re-run `hermes memory setup mem0 --mode <platform|selfhosted|oss>` or edit `mem0.json` directly.

---

Expand Down
Loading