Mem0 self-hosted/OSS mode support efforts
NousResearch/hermes-agent · cluster 16401 · agent
Data as of
Nothing open on GitHub. Fix merged in #50479. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #50479. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #56943. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple issues and pull requests addressing the addition or restoration of self-hosted (OSS) Mem0 support, including host configuration, v3 API migration, and related tooling updates. The group reflects ongoing, overlapping work toward enabling a remote or locally-hosted Mem0 endpoint.
Open issues0
Open PRs0
Fixes on record
-
#50479 fix(mem0): add self-hosted support via MEM0_HOST / host config
files:
plugins/memory/mem0/README.md,plugins/memory/mem0/__init__.py,scripts/release.pyShow the diff
-
plugins/memory/mem0/README.md@@ -2,30 +2,45 @@ Server-side LLM fact extraction with semantic search, reranking, and automatic deduplication. +Supports both [Mem0 Cloud](https://app.mem0.ai) and self-hosted instances. + ## Requirements - `pip install mem0ai` -- Mem0 API key from [app.mem0.ai](https://app.mem0.ai) +- Mem0 Cloud API key **or** a self-hosted Mem0 server ## Setup +### Cloud + ```bash hermes memory setup # select "mem0" ``` Or manually: + ```bash hermes config set memory.provider mem0 echo "MEM0_API_KEY=[redacted:secret-assignment]" >> ~/.hermes/.env ``` +### Self-Hosted + +```bash +hermes config set memory.provider mem0 +echo "MEM0_HOST=http://your-mem0-server:24220" >> ~/.hermes/.env +echo "MEM0_API_KEY=[redacted:secret-assignment]" >> ~/.hermes/.env # if auth is enabled +``` + ## Config Config file: `$HERMES_HOME/mem0.json` | Key | Default | Description | |-----|---------|-------------| -| `user_id` | `hermes-user` | User identifier on Mem0 | +| `api_key` | — | API key (required for cloud; optional for self-hosted without auth) | +| `host` | `https://api.mem0.ai` | Self-hosted Mem0 URL. When set, overrides the cloud endpoint. | +| `user_id` | `hermes-user` | User identifier | | `agent_id` | `hermes` | Agent identifier | | `rerank` | `true` | Enable reranking for recall | -
plugins/memory/mem0/__init__.py@@ -1,12 +1,13 @@ """Mem0 memory plugin — MemoryProvider interface. Server-side LLM fact extraction, semantic search with reranking, and -automatic deduplication via the Mem0 Platform API. +automatic deduplication via the Mem0 Platform API or self-hosted instance. Original PR #2933 by kartik-mem0, adapted to MemoryProvider ABC. Config via environment variables: - MEM0_API_KEY — Mem0 Platform API key (required) + MEM0_API_KEY — Mem0 API key (required for cloud, optional for self-hosted) + MEM0_HOST — Self-hosted Mem0 URL (default: https://api.mem0.ai) MEM0_USER_ID — User identifier (default: hermes-user) MEM0_AGENT_ID — Agent identifier (default: hermes) @@ -37,6 +38,7 @@ # Config # --------------------------------------------------------------------------- + def _load_config() -> dict: """Load config from env vars, with $HERMES_HOME/mem0.json overrides. @@ -48,6 +50,7 @@ def _load_config() -> dict: config = { "api_key": os.environ.get("MEM0_API_KEY", ""), + "host": os.environ.get("MEM0_HOST", ""), "user_id": os.environ.get("MEM0_USER_ID", "hermes-user"), "agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"), "rerank": True, @@ -117,13 +120,18 @@ def _load_config() -> dict: # --------------------------------------------------------------------------- class Mem0MemoryProvider(MemoryProvider): - """Mem0 Platform memory with server-side extraction and semantic search.""" + """Mem0 memory with server-side extraction and semantic search. + + Supports both Mem0 Cloud (api.mem0.ai) and self-hosted instances + via the ``host`` config key or ``MEM0_HOST`` env var. + """ def __init__(self): self._config = None self._client = None self._client_lock = threading.Lock() self._api_key = "" + self._host = "" self._user_id = "hermes-user" self._agent_id = "hermes" self._rerank = True @@ -141,7 +149,9 @@ def name(self) -> str: def is_available(self) -> bool: cfg = _load_config() - return bool(cfg.get("api_key")) + host = cfg.get("host", "") + api_key = [redacted:secret-assignment]"api_key", "") + return bool(host) or bool(api_key) def save_config(self, values, hermes_home): """Write config to $HERMES_HOME/mem0.json.""" @@ -160,7 +170,8 @@ def save_config(self, values, hermes_home): def get_config_schema(self): return [ - {"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"}, + {"key": "api_key", "description": "Mem0 API key (cloud or self-hosted)", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"}, + {"key": "host", "description": "Self-hosted Mem0 URL (e.g. http://localhost:24220)", "default": "", "env_var": "MEM0_HOST"}, {"key": "user_id", "description": "User identifier", "default": "hermes-user"}, {"key": "agent_id", "description": "Agent identifier", "default": "hermes"}, {"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]}, @@ -173,7 +184,14 @@ def _get_client(self): return self._client try: from mem0 import MemoryClient - self._client = MemoryClient(api_key=[redacted:secret-assignment] + kwargs = {} + if self._host: + kwargs["host"] = self._host + if self._api_key: + kwargs["api_key"] = self._api_key + elif not self._host: + raise ValueError("Mem0: either api_key or host is required") + self._client = MemoryClient(**kwargs) return self._client except ImportError: raise RuntimeError("mem0 package not installed. Run: pip install mem0ai") @@ -204,6 +222,7 @@ def _record_failure(self): def initialize(self, session_id: str, **kwargs) -> None: self._config = _load_config() self._api_key = [redacted:secret-assignment]"api_key", "") + self._host = self._config.get("host", "") # Prefer gateway-provided user_id for per-user memory scoping; # fall back to config/env default for CLI (single-user) sessions. self._user_id = kwargs.get("user_id") or self._config.get("user_id", "hermes-user") @@ -228,8 +247,9 @@ def _unwrap_results(response: Any) -> list: return [] def system_prompt_block(self) -> str: + target = self._host or "cloud" return ( - "# Mem0 Memory\n" + f"# Mem0 Memory ({target})\n" f"Active. User: {self._user_id}.\n" "Use mem0_search to find memories, mem0_conclude to store facts, " "mem0_profile for a full overview." -
scripts/release.py@@ -123,6 +123,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "buihongduc132", "[redacted:email]": "EtherAura", # PR #45205 salvage (Linux in-app update relaunch / GUI-skew terminal state) "[redacted:email]": "devorun", "[redacted:email]": "x7peeps",
-
-
#56943 feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)
files:
plugins/memory/mem0/README.md,plugins/memory/mem0/__init__.py,plugins/memory/mem0/_backend.py,plugins/memory/mem0/_setup.py,plugins/memory/mem0/plugin.yaml,tests/plugins/memory/test_mem0_backend.py,tests/plugins/memory/test_mem0_setup.py,tests/plugins/memory/test_mem0_v3.py,website/docs/user-guide/features/memory-providers.mdShow the diff
-
plugins/memory/mem0/README.md@@ -25,14 +25,44 @@ Behavioral settings live in `$HERMES_HOME/mem0.json` (set them via `hermes memor | Key | Default | Description | |-----|---------|-------------| -| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) | +| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process) | +| `host` | — | Self-hosted Mem0 server URL (the Docker dashboard). When set, connects over HTTP with `X-API-Key`. Don't combine with `mode: oss` | | `user_id` | `hermes-user` | User identifier on Mem0 | | `agent_id` | `hermes` | Agent identifier | -| `rerank` | `true` | Rerank search results for relevance (platform mode only) | +| `rerank` | `false` | Rerank search results for relevance (platform mode only) | + +The plugin has three connection modes: + +- **Platform** — Mem0's hosted cloud (`api.mem0.ai`). Set `MEM0_API_KEY`. (default) +- **Self-hosted dashboard** — a Mem0 server you run yourself via Docker. Set `host`. See below. +- **OSS** — run Mem0 in-process with your own LLM + vector store. Set `mode: oss`. See below. + +## Self-Hosted Dashboard (Server) Mode + +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: + ```bash + echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env + echo "MEM0_API_KEY=[redacted:secret-assignment]" >> ~/.hermes/.env + ``` + or in `$HERMES_HOME/mem0.json`: + ```json + { + "host": "http://localhost:8888", + "api_key": "your-admin-api-key" + } + ``` +3. Start a fresh Hermes session and call `mem0_search` — it connects to your server. + +The plugin authenticates with `X-API-Key` and uses the server's `/search` and `/memories` routes. `api_key` is optional — omit it only for servers running with `AUTH_DISABLED`. + +> Setting `host` routes to the self-hosted server automatically. Don't set `mode: oss` — OSS takes precedence and ignores `host`. ## OSS (Self-Hosted) Mode -Run Mem0 locally with your own LLM, embedder, and vector store. +Run Mem0 locally with your own LLM, embedder, and vector store. This is the in-process SDK mode. To instead connect to a Mem0 server you run via Docker, see [Self-Hosted Dashboard (Server) Mode](#self-hosted-dashboard-server-mode) above. ### Interactive Setup @@ -106,7 +136,6 @@ hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run | Tool | Description | |------|-------------| -| `mem0_list` | List all stored memories (paginated) | | `mem0_search` | Semantic search by meaning | | `mem0_add` | Store a fact verbatim (no LLM extraction) | | `mem0_update` | Update a memory's text by ID | -
plugins/memory/mem0/__init__.py@@ -9,10 +9,15 @@ ------------- Secret (lives in $HERMES_HOME/.env or the environment): MEM0_API_KEY — Mem0 Platform API key (required for platform mode) + MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the + plugin talks to that server directly over HTTP + (X-API-Key auth) instead of the cloud API. Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory setup`): mode — Backend mode: "platform" (default) or "oss" + host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var). + When set, routes to the self-hosted HTTP backend. user_id — Canonical user identifier. When set, it is applied uniformly across every gateway (CLI, Telegram, Slack, Discord, …) so the same human gets one merged memory @@ -44,7 +49,7 @@ # for _BREAKER_COOLDOWN_SECS to avoid hammering a down server. _BREAKER_THRESHOLD = 5 _BREAKER_COOLDOWN_SECS = 120 -_PREFETCH_WAIT_SECS = 1.5 +_PREFETCH_WAIT_SECS = 3 _CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError") @@ -81,6 +86,7 @@ def _load_config() -> dict: config = { "mode": os.environ.get("MEM0_MODE", "platform"), "api_key": os.environ.get("MEM0_API_KEY", ""), + "host": os.environ.get("MEM0_HOST", ""), "agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"), "oss": {}, } @@ -107,41 +113,22 @@ def _load_config() -> dict: # Tool schemas # --------------------------------------------------------------------------- -LIST_SCHEMA = { - "name": "mem0_list", - "description": ( - "List ALL stored memories about the user, unranked and paginated. " - "Use for a full overview/audit at conversation start, or to browse " - "everything when you don't have a specific query. For answering a " - "specific question, prefer mem0_search." - ), - "parameters": { - "type": "object", - "properties": { - "page": {"type": "integer", "description": "Page number (default: 1)."}, - "page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."}, - }, - "required": [], - }, -} - SEARCH_SCHEMA = { "name": "mem0_search", "description": ( "Search the user's memories by meaning; returns facts ranked by " - "relevance. Use this BEFORE answering any question that may depend on " + "relevance. Use this before answering any question that may depend on " "what you know about the user (preferences, facts, history, people, " "projects, past decisions). For multi-part or multi-hop questions, " - "call it MULTIPLE times — vary the wording and run follow-up searches " - "on what earlier results reveal; one search is rarely enough. Set " - "rerank=true for higher accuracy on important queries." + "call it several times — vary the wording and run follow-up searches " + "on what earlier results reveal; one search is rarely enough." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "What to search for."}, "top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."}, - "rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."}, + "rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."}, }, "required": ["query"], }, @@ -169,7 +156,7 @@ def _load_config() -> dict: "name": "mem0_update", "description": ( "Replace the text of an existing memory by its ID (take the ID from a " - "mem0_search or mem0_list result). Use when a stored fact has changed " + "mem0_search result). Use when a stored fact has changed " "or was wrong — correct it in place instead of adding a duplicate." ), "parameters": { @@ -185,7 +172,7 @@ def _load_config() -> dict: DELETE_SCHEMA = { "name": "mem0_delete", "description": ( - "Delete a memory by its ID (take the ID from a mem0_search or mem0_list " + "Delete a memory by its ID (take the ID from a mem0_search " "result). Use when a stored fact is obsolete or the user asks you to " "forget it; prefer mem0_update if the fact merely changed." ), @@ -214,8 +201,10 @@ def __init__(self): self._backend = None self._mode = "platform" self._api_key = "" + self._host = "" self._user_id = _DEFAULT_USER_ID self._agent_id = "hermes" + self._rerank_default = False self._channel = "cli" # gateway channel name (cli/telegram/discord/...) self._sync_thread = None self._prefetch_thread = None @@ -239,7 +228,9 @@ def is_available(self) -> bool: mode = cfg.get("mode", "platform") if mode == "oss": return bool(cfg.get("oss", {}).get("vector_store")) - return bool(cfg.get("api_key")) + # Platform needs an api_key; self-hosted needs a host (api_key optional + # when the server runs with AUTH_DISABLED). + return bool(cfg.get("api_key") or cfg.get("host")) def save_config(self, values, hermes_home): """Write config to $HERMES_HOME/mem0.json.""" @@ -262,9 +253,10 @@ def get_config_schema(self): api_key_required = mode != "oss" return [ {"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"}, + {"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"}, {"key": "user_id", "description": "User identifier", "default": "hermes-user"}, {"key": "agent_id", "description": "Agent identifier", "default": "hermes"}, - {"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]}, + {"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]}, ] def post_setup(self, hermes_home: str, config: dict) -> None: @@ -288,6 +280,9 @@ def _create_backend(self): if self._mode == "oss": from ._backend import OSSBackend return OSSBackend(self._config.get("oss", {})) + if self._host: + from ._backend import SelfHostedBackend + return SelfHostedBackend(self._api_key, self._host) from ._backend import PlatformBackend return PlatformBackend(self._api_key) except Exception as e: @@ -342,6 +337,7 @@ def initialize(self, session_id: str, **kwargs) -> None: self._config = _load_config() self._mode = self._config.get("mode", "platform") self._api_key = [redacted:secret-assignment]"api_key", "") + self._host = self._config.get("host", "") # Resolution order for user_id: # 1. Operator-configured MEM0_USER_ID (env or $HERMES_HOME/mem0.json) — # the canonical principal, applied across every gateway so the same @@ -358,6 +354,14 @@ def initialize(self, session_id: str, **kwargs) -> None: configured = None self._user_id = configured or kwargs.get("user_id") or _DEFAULT_USER_ID self._agent_id = self._config.get("agent_id", "hermes") + # Persisted rerank preference (setup wizard / mem0.json). Used as the + # DEFAULT for mem0_search when the model doesn't pass ``rerank`` + # explicitly; per-call args still win. Platform-only feature — other + # backends accept-and-ignore the flag. + _rr = self._config.get("rerank", False) + self._rerank_default = ( + _rr.lower() in ("true", "1", "yes") if isinstance(_rr, str) else bool(_rr) + ) self._channel = kwargs.get("platform") or "cli" self._backend = self._create_backend() if self._backend and not self._atexit_registered: @@ -378,22 +382,32 @@ def _write_metadata(self) -> Dict[str, Any]: return {"channel": self._channel} if self._channel else {} def system_prompt_block(self) -> str: - mode_label = "platform (cloud API)" if self._mode == "platform" else "OSS (self-hosted)" - rerank_note = " Rerank is available on search." if self._mode == "platform" else "" + # Mirror the precedence in _create_backend (oss > host > platform) so + # the label always names the backend that actually runs. Checking + # ``host`` first here would mislabel an ``oss``+``host`` config as + # self-hosted HTTP even though OSS wins the routing. + if self._mode == "oss": + mode_label = "OSS (self-hosted)" + elif self._host: + mode_label = "self-hosted (HTTP API)" + else: + mode_label = "platform (cloud API)" + # Rerank is a Mem0 Platform feature only. + rerank_note = " Rerank is available on search." if (self._mode == "platform" and not self._host) else "" return ( "# Mem0 Memory\n" f"Active. Mode: {mode_label}. User: {self._user_id}.\n" "You have persistent memory of this user from past conversations. " - "ALWAYS call mem0_search before answering anything that could depend " + "You should call mem0_search before answering anything that could depend " "on prior context (the user's preferences, facts, history, people, " "projects, or earlier decisions) — do not rely on the chat window " "alone, and do not assume you have no memory.\n" - "For multi-part or multi-hop questions, run SEVERAL searches with " + "For multi-part or multi-hop questions, run several searches with " "different wording/angles and follow-up searches on what the first " "results surface; one search is rarely enough. Keep searching until " "you have every fact the question needs before you answer.\n" "Tools: mem0_search to find memories, mem0_add to store facts, " - f"mem0_list for a full overview, mem0_update and mem0_delete to manage by ID.{rerank_note}" + f"mem0_update and mem0_delete to manage by ID.{rerank_note}" ) def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: @@ -426,7 +440,7 @@ def _run(): body = "" try: results = backend.search( - query, filters=self._read_filters(), top_k=10, rerank=True, + query, filters=self._read_filters(), top_k=10, rerank=False, ) lines = [r.get("memory", "") for r in (results or []) if r.get("memory")] if lines: @@ -497,7 +511,7 @@ def _sync(): self._sync_thread.start() def get_tool_schemas(self) -> List[Dict[str, Any]]: - return [LIST_SCHEMA, SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA] + return [SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA] def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: if self._backend is None: @@ -516,36 +530,13 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: msg += f" Check that your {vs.get('provider', 'vector store')} is running." return json.dumps({"error": msg}) - if tool_name == "mem0_list": - try: - page = max(1, int(args.get("page", 1))) - page_size = min(max(1, int(args.get("page_size", 100))), 200) - response = self._backend.get_all( - filters=self._read_filters(), page=page, page_size=page_size, - ) - self._record_success() - results = response.get("results", []) - if not results: - return json.dumps({"result": "No memories stored yet."}) - items = [{"id": m.get("id"), "memory": m.get("memory", "")} - for m in results] - return json.dumps({ - "results": items, - "count": response.get("count", len(items)), - "page": page, "page_size": page_size, - }) - except Exception as e: - if not _is_client_error(e): - self._record_failure() - return tool_error(self._format_error("Failed to list memories", e)) - - elif tool_name == "mem0_search": + if tool_name == "mem0_search": query = args.get("query", "") if not query: return tool_error("Missing required parameter: query") try: top_k = max(1, min(int(args.get("top_k", 10)), 50)) - rerank_raw = args.get("rerank", True) + rerank_raw = args.get("rerank", getattr(self, "_rerank_default", False)) if isinstance(rerank_raw, str): rerank = rerank_raw.lower() not in ("false", "0", "no") else: @@ -576,7 +567,8 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: ) self._record_success() event_id = result.get("event_id") if isinstance(result, dict) else None - msg = "Fact stored." if self._mode == "oss" else "Fact queued for storage." + # Cloud add is async (server-side extraction); OSS and self-hosted store synchronously. + msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage." return json.dumps({"result": msg, "event_id": event_id}) except Exception as e: self._record_failure() -
plugins/memory/mem0/_backend.py@@ -10,11 +10,7 @@ class Mem0Backend(ABC): """Unified interface over Platform (MemoryClient) and OSS (Memory) backends.""" @abstractmethod - def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]: - ... - - @abstractmethod - def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict: + def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: ... @abstractmethod @@ -57,16 +53,10 @@ def __init__(self, api_key: str): from mem0 import MemoryClient self._client = MemoryClient(api_key=[redacted:secret-assignment] - def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]: + def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank) return _unwrap_results(response) - def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict: - response = self._client.get_all(filters=filters, page=page, page_size=page_size) - results = response.get("results", []) if isinstance(response, dict) else response - count = response.get("count", len(results)) if isinstance(response, dict) else len(results) - return {"results": results, "count": count} - def add( self, messages: list, @@ -90,6 +80,79 @@ def delete(self, memory_id: str) -> dict: return {"result": "Memory deleted.", "memory_id": memory_id} +class SelfHostedBackend(Mem0Backend): + """Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``). + + mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the + cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation + call in ``__init__`` that the self-hosted server does not expose (it would + 404 before any real request). This client talks to that server directly, + using its actual contract: ``X-API-Key`` auth and the ``/memories`` / + ``/search`` routes. + """ + + def __init__(self, api_key: str, host: str, transport=None): + import httpx + + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers + # Connect-level retries smooth over transient blips so a single + # dropped SYN doesn't count toward the provider failure breaker. + # ``transport`` is injectable for tests (httpx.MockTransport). + if transport is None: + transport = httpx.HTTPTransport(retries=2) + self._client = httpx.Client( + base_url=host.rstrip("/"), headers=headers, timeout=30.0, + transport=transport, + ) + + def _json(self, method: str, path: str, **kwargs) -> Any: + resp = self._client.request(method, path, **kwargs) + resp.raise_for_status() + return resp.json() if resp.content else {} + + def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: + # rerank is a platform-only feature; the self-hosted /search ignores it. + body: dict[str, Any] = {"query": query, "top_k": top_k} + if filters: + body["filters"] = filters # user_id belongs in filters (top-level is deprecated) + return _unwrap_results(self._json("POST", "/search", json=body)) + + def add( + self, + messages: list, + *, + user_id: str, + agent_id: str, + infer: bool = False, + metadata: dict | None = None, + ) -> dict: + body: dict[str, Any] = { + "messages": messages, + "user_id": user_id, + "agent_id": agent_id, + "infer": infer, + } + if metadata: + body["metadata"] = metadata + return self._json("POST", "/memories", json=body) + + def update(self, memory_id: str, text: str) -> dict: + self._json("PUT", f"/memories/{memory_id}", json={"text": text}) + return {"result": "Memory updated.", "memory_id": memory_id} + + def delete(self, memory_id: str) -> dict: + self._json("DELETE", f"/memories/{memory_id}") + return {"result": "Memory deleted.", "memory_id": memory_id} + + def close(self) -> None: + try: + self._client.close() + except Exception: + pass + + class OSSBackend(Mem0Backend): """Wraps mem0.Memory for self-hosted (OSS) mode.""" @@ -189,18 +252,10 @@ def _recreate_collection_if_dims_changed(provider: str, vs_config: dict, expecte except Exception: pass - def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]: + def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: response = self._memory.search(query, filters=filters, top_k=top_k) return _unwrap_results(response) - def get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict: - response = self._memory.get_all(filters=filters) - all_results = _unwrap_results(response) - total = len(all_results) - start = (page - 1) * page_size - results = all_results[start : start + page_size] - return {"results": results, "count": total} - def add( self, messages: list, -
plugins/memory/mem0/_setup.py@@ -230,7 +230,7 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No {"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"}, {"key": "user_id", "description": "User identifier", "default": "hermes-user"}, {"key": "agent_id", "description": "Agent identifier", "default": "hermes"}, - {"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]}, + {"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]}, ] existing_config = {} @@ -293,6 +293,24 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No return provider_config["mode"] = "platform" + # Clear any stale self-hosted host: routing checks ``host`` before platform + # (see _create_backend), so leaving it would silently keep routing to the + # self-hosted server even though the user just chose platform mode. Set it + # to "" rather than pop() — save_config merges into the existing mem0.json + # (existing.update), so a popped key would survive; an empty value overwrites + # it and reads as falsy at routing time. + provider_config["host"] = "" + # The json-file clear above can't help when the host comes from the + # environment: _load_config() seeds ``host`` from MEM0_HOST, and the + # docs tell self-hosted users to put MEM0_HOST in ~/.hermes/.env. Warn + # so the user knows platform mode won't take effect until it's removed. + if os.environ.get("MEM0_HOST", "").strip(): + print( + "\n ⚠ MEM0_HOST is set in your environment " + f"({os.environ['MEM0_HOST']}). It overrides platform mode — " + "remove it from ~/.hermes/.env (or unset it) or Hermes will keep " + "routing to the self-hosted server." + ) from hermes_cli.config import save_config config["memory"]["provider"] = "mem0" -
plugins/memory/mem0/plugin.yaml@@ -1,5 +1,5 @@ name: mem0 -version: 1.2.0 -description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication." +version: 1.3.0 +description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)." pip_dependencies: - - mem0ai>=2.0.7,<3 + - mem0ai>=2.0.10,<3 -
tests/plugins/memory/test_mem0_backend.py@@ -1,8 +1,13 @@ -"""Tests for Mem0Backend abstraction — PlatformBackend and OSSBackend.""" +"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend.""" import pytest -from plugins.memory.mem0._backend import Mem0Backend, PlatformBackend, OSSBackend +from plugins.memory.mem0._backend import ( + Mem0Backend, + PlatformBackend, + OSSBackend, + SelfHostedBackend, +) class FakePlatformClient: @@ -52,10 +57,10 @@ def test_search_forwards_rerank(self): backend.search("q", filters={}, rerank=False) assert client.calls[0][2]["rerank"] is False - def test_search_rerank_default_true(self): + def test_search_rerank_default_false(self): backend, client = self._make() backend.search("q", filters={}) - assert client.calls[0][2]["rerank"] is True + assert client.calls[0][2]["rerank"] is False def test_search_returns_list(self): backend, _ = self._make() @@ -63,13 +68,6 @@ def test_search_returns_list(self): assert isinstance(result, list) assert result[0]["id"] == "m1" - def test_get_all_forwards_pagination(self): - backend, client = self._make() - result = backend.get_all(filters={"user_id": "u1"}, page=2, page_size=50) - assert client.calls[0][1]["page"] == 2 - assert client.calls[0][1]["page_size"] == 50 - assert "count" in result - def test_add_forwards_kwargs(self): backend, client = self._make() msgs = [{"role": "user", "content": "hi"}] @@ -163,21 +161,6 @@ def test_search_ignores_rerank(self): backend.search("q", filters={}, rerank=True) assert "rerank" not in memory.calls[0][2] - def test_get_all_ignores_pagination(self): - """OSSBackend accepts page/page_size but does NOT forward to Memory.get_all().""" - backend, memory = self._make() - result = backend.get_all(filters={"user_id": "u1"}, page=2, page_size=50) - call_kwargs = memory.calls[0][1] - assert "page" not in call_kwargs - assert "page_size" not in call_kwargs - assert result["count"] == 1 - - def test_get_all_returns_envelope(self): - backend, _ = self._make() - result = backend.get_all(filters={"user_id": "u1"}) - assert "results" in result - assert "count" in result - def test_add_forwards_kwargs(self): backend, memory = self._make() msgs = [{"role": "user", "content": "hi"}] @@ -207,3 +190,115 @@ def test_delete_normalizes_response(self): backend, _ = self._make() result = backend.delete("m1") assert result == {"result": "Memory deleted.", "memory_id": "m1"} + + +httpx = pytest.importorskip("httpx") + + +class _StubServer: + """Records requests and serves the real self-hosted server's response shapes.""" + + def __init__(self, rows=10): + self.requests = [] + self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)] + + def handler(self, request): + self.requests.append(request) + path, method = request.url.path, request.method + if path == "/search" and method == "POST": + return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]}) + if path == "/memories" and method == "GET": + top_k = int(request.url.params.get("top_k", len(self._rows))) + return httpx.Response(200, json={"results": self._rows[:top_k]}) + if path == "/memories" and method == "POST": + return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]}) + if path.startswith("/memories/") and method in ("PUT", "DELETE"): + if path.endswith("/missing"): # server 404s unknown ids + return httpx.Response(404, json={"detail": "Memory not found"}) + verb = "updated" if method == "PUT" else "Memory deleted successfully" + return httpx.Response(200, json={"message": verb}) + return httpx.Response(404, json={"detail": "not found"}) + + +def _backend(server, api_key="[redacted:secret-assignment]", host="http://sh:8888"): + """Build a SelfHostedBackend routed through the stub transport. + + Uses the real __init__ (via the injectable ``transport`` kwarg) so the + constructor's header/base_url setup is exercised by every test here. + """ + return SelfHostedBackend( + api_key, host, transport=httpx.MockTransport(server.handler) + ) + + +class TestSelfHostedBackend: + # --- constructor / auth setup (the crux of the bug) ------------------- + + def test_init_uses_x_api_key_not_token_auth(self): + b = SelfHostedBackend("adminkey", "http://sh:8888") + assert b._client.headers["x-api-key"] == "adminkey" + assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme + + def test_init_strips_trailing_slash(self): + b = SelfHostedBackend("k", "http://sh:8888/") + assert str(b._client.base_url) == "http://sh:8888" + + def test_init_omits_api_key_header_when_blank(self): + b = SelfHostedBackend("", "http://sh:8888") # AUTH_DISABLED server + assert "x-api-key" not in b._client.headers + + # --- search ---------------------------------------------------------- + + def test_search_posts_to_search_with_filters_in_body(self): + s = _StubServer() + results = _backend(s).search("drink", filters={"user_id": "u1"}, top_k=5) + req = s.requests[-1] + assert (req.method, req.url.path) == ("POST", "/search") + import json + body = json.loads(req.content) + assert body == {"query": "drink", "top_k": 5, "filters": {"user_id": "u1"}} + assert results == [{"id": "m1", "memory": "tea", "score": 0.9}] + + def test_search_sends_x_api_key_header(self): + s = _StubServer() + _backend(s).search("q", filters={"user_id": "u1"}) + req = s.requests[-1] + assert req.headers["x-api-key"] == "adminkey" + assert "authorization" not in req.headers + + # --- add / update / delete ------------------------------------------ + + def test_add_posts_messages_and_identity(self): + s = _StubServer() + msgs = [{"role": "user", "content": "likes tea"}] + result = _backend(s).add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={"channel": "cli"}) + req = s.requests[-1] + assert (req.method, req.url.path) == ("POST", "/memories") + import json + body = json.loads(req.content) + assert body == {"messages": msgs, "user_id": "u1", "agent_id": "hermes", + "infer": False, "metadata": {"channel": "cli"}} + assert result["results"][0]["id"] == "new" + + def test_update_puts_text_to_memory_id(self): + s = _StubServer() + result = _backend(s).update("abc", "new text") + req = s.requests[-1] + assert (req.method, req.url.path) == ("PUT", "/memories/abc") + import json + assert json.loads(req.content) == {"text": "new text"} + assert result == {"result": "Memory updated.", "memory_id": "abc"} + + def test_delete_calls_delete_endpoint(self): + s = _StubServer() + result = _backend(s).delete("abc") + req = s.requests[-1] + assert (req.method, req.url.path) == ("DELETE", "/memories/abc") + assert result == {"result": "Memory deleted.", "memory_id": "abc"} + + # --- error propagation (feeds the plugin's circuit breaker) ---------- + + def test_http_error_raises(self): + s = _StubServer() + with pytest.raises(httpx.HTTPStatusError): + _backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker -
tests/plugins/memory/test_mem0_setup.py@@ -179,6 +179,22 @@ def test_platform_flag_mode(self, tmp_path, monkeypatch): mem0_json = json.loads((tmp_path / "mem0.json").read_text()) assert mem0_json["mode"] == "platform" + def test_platform_setup_clears_stale_host(self, tmp_path, monkeypatch): + # A user who previously ran self-hosted has host in mem0.json. Switching + # to platform must drop host — otherwise routing (host > platform) keeps + # sending them to the self-hosted server despite --mode platform. + (tmp_path / "mem0.json").write_text( + json.dumps({"mode": "platform", "host": "http://old-selfhosted:8888"}) + ) + monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"]) + monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path) + _inject_fake_hermes_cli(monkeypatch) + config = {"memory": {}} + post_setup(str(tmp_path), config) + mem0_json = json.loads((tmp_path / "mem0.json").read_text()) + assert mem0_json["mode"] == "platform" + assert not mem0_json.get("host") # cleared to falsy so routing → platform + def test_oss_flag_mode(self, tmp_path, monkeypatch): monkeypatch.setattr("sys.argv", [ "hermes", "--mode", "oss", "--oss-llm-key", "sk-oai", -
tests/plugins/memory/test_mem0_v3.py@@ -52,33 +52,6 @@ def _make_provider(self, monkeypatch, backend): provider._backend = backend return provider - def test_list_returns_paginated_with_ids(self, monkeypatch): - backend = FakeBackend(all_results={ - "count": 2, - "results": [ - {"id": "mem-1", "memory": "alpha"}, - {"id": "mem-2", "memory": "beta"}, - ] - }) - provider = self._make_provider(monkeypatch, backend) - result = json.loads(provider.handle_tool_call("mem0_list", {})) - assert result["count"] == 2 - assert result["results"][0]["id"] == "mem-1" - assert result["results"][0]["memory"] == "alpha" - - def test_list_pagination_params(self, monkeypatch): - backend = FakeBackend() - provider = self._make_provider(monkeypatch, backend) - provider.handle_tool_call("mem0_list", {"page": 2, "page_size": 50}) - assert backend.captured[0][1]["page"] == 2 - assert backend.captured[0][1]["page_size"] == 50 - - def test_list_empty(self, monkeypatch): - backend = FakeBackend() - provider = self._make_provider(monkeypatch, backend) - result = json.loads(provider.handle_tool_call("mem0_list", {})) - assert result["result"] == "No memories stored yet." - def test_search_returns_ids(self, monkeypatch): backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}]) provider = self._make_provider(monkeypatch, backend) @@ -92,11 +65,11 @@ def test_search_uses_filters(self, monkeypatch): assert backend.captured[0][2]["filters"] == {"user_id": "u123"} assert backend.captured[0][2]["top_k"] == 3 - def test_search_rerank_default_true(self, monkeypatch): + def test_search_rerank_default_false(self, monkeypatch): backend = FakeBackend() provider = self._make_provider(monkeypatch, backend) provider.handle_tool_call("mem0_search", {"query": "test"}) - assert backend.captured[0][2]["rerank"] is True + assert backend.captured[0][2]["rerank"] is False def test_search_rerank_override_false(self, monkeypatch): backend = FakeBackend() @@ -104,6 +77,15 @@ def test_search_rerank_override_false(self, monkeypatch): provider.handle_tool_call("mem0_search", {"query": "test", "rerank": False}) assert backend.captured[0][2]["rerank"] is False + def test_search_rerank_config_default_used_when_arg_absent(self, monkeypatch): + """The persisted mem0.json ``rerank`` preference is the tool default; + a per-call arg still wins (see the explicit-False override above).""" + backend = FakeBackend() + provider = self._make_provider(monkeypatch, backend) + provider._rerank_default = True # as initialize() sets from config + provider.handle_tool_call("mem0_search", {"query": "test"}) + assert backend.captured[0][2]["rerank"] is True + def test_add_uses_content_param(self, monkeypatch): backend = FakeBackend() provider = self._make_provider(monkeypatch, backend) @@ -280,6 +262,8 @@ def test_old_tool_names_return_unknown(self, monkeypatch): assert "error" in result result = json.loads(provider.handle_tool_call("mem0_conclude", {})) assert "error" in result + result = json.loads(provider.handle_tool_call("mem0_list", {})) + assert "error" in result class TestMem0Prefetch: @@ -308,7 +292,7 @@ def test_prefetch_searches_current_query(self): assert query == "what theme do I like?" assert opts["filters"] == {"user_id": "u123"} assert opts["top_k"] == 10 - assert opts["rerank"] is True + assert opts["rerank"] is False assert "## Mem0 Memory" in result assert "user prefers dark mode" in result @@ -368,11 +352,11 @@ def test_queue_prefetch_fires_no_search(self): class TestMem0V3Config: - def test_tool_schemas_five_tools(self): + def test_tool_schemas_four_tools(self): provider = Mem0MemoryProvider() schemas = provider.get_tool_schemas() names = [s["name"] for s in schemas] - assert names == ["mem0_list", "mem0_search", "mem0_add", "mem0_update", "mem0_delete"] + assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"] def test_system_prompt_new_tool_names(self): provider = Mem0MemoryProvider() @@ -380,9 +364,9 @@ def test_system_prompt_new_tool_names(self): block = provider.system_prompt_block() assert "mem0_search" in block assert "mem0_add" in block - assert "mem0_list" in block assert "mem0_update" in block assert "mem0_delete" in block + assert "mem0_list" not in block assert "mem0_profile" not in block assert "mem0_conclude" not in block @@ -455,7 +439,7 @@ def test_tool_schemas_unchanged(self): provider = Mem0MemoryProvider() schemas = provider.get_tool_schemas() names = [s["name"] for s in schemas] - assert names == ["mem0_list", "mem0_search", "mem0_add", "mem0_update", "mem0_delete"] + assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"] def test_system_prompt_includes_mode(self): provider = Mem0MemoryProvider() @@ -463,7 +447,6 @@ def test_system_prompt_includes_mode(self): provider._mode = "oss" block = provider.system_prompt_block() assert "mem0_search" in block - assert "mem0_list" in block assert "OSS" in block @@ -547,3 +530,92 @@ def test_sync_turn_passes_channel_metadata(self): adds = [c for c in provider._backend.captured if c[0] == "add"] assert adds, "expected an add call from sync_turn" assert adds[-1][2]["metadata"] == {"channel": "discord"} + + +class _SentinelBackend: + def __init__(self, *args): + self.args = args + + +class TestCreateBackendRouting: + """_create_backend() must pick the backend matching the configured mode/host.""" + + def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""): + # Neutralize lazy-install so the routing decision is all we exercise. + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False) + provider = Mem0MemoryProvider() + provider._mode = mode + provider._api_key = [redacted:secret-assignment] + provider._host = host + provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}} + return provider + + def test_routes_to_selfhosted_when_host_set(self, monkeypatch): + captured = {} + + class SH(_SentinelBackend): + def __init__(self, api_key, host): + captured["args"] = (api_key, host) + + monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH) + provider = self._provider(monkeypatch, host="http://sh:8888", api_key="[redacted:secret-assignment]") + backend = provider._create_backend() + assert isinstance(backend, SH) + assert captured["args"] == ("adminkey", "http://sh:8888") + + def test_routes_to_platform_when_no_host(self, monkeypatch): + class PB(_SentinelBackend): + def __init__(self, api_key): + pass + + monkeypatch.setattr("plugins.memory.mem0._backend.PlatformBackend", PB) + provider = self._provider(monkeypatch, host="") + assert isinstance(provider._create_backend(), PB) + + def test_routes_to_oss_when_mode_oss(self, monkeypatch): + class OB(_SentinelBackend): + def __init__(self, cfg): + pass + + monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB) + provider = self._provider(monkeypatch, mode="oss") + assert isinstance(provider._create_backend(), OB) + + def test_oss_mode_takes_precedence_over_host(self, monkeypatch): + class OB(_SentinelBackend): + def __init__(self, cfg): + pass + + monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB) + provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888") + assert isinstance(provider._create_backend(), OB) + + def test_prompt_label_matches_routing_when_oss_and_host_both_set(self, monkeypatch): + # system_prompt_block must mirror _create_backend precedence: with both + # mode=oss and host set, OSS wins the routing, so the prompt must label + # OSS — not "self-hosted (HTTP API)". Guards the prompt-vs-routing lie. + provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888") + provider._user_id = "test" + block = provider.system_prompt_block() + assert "OSS" in block + assert "HTTP API" not in block + + +class TestSelfHostedConfig: + """Config plumbing for self-hosted (MEM0_HOST env + is_available).""" + + def test_load_config_reads_mem0_host_env(self, monkeypatch): + monkeypatch.setenv("MEM0_HOST", "http://localhost:8888") + assert mem0_plugin._load_config()["host"] == "http://localhost:8888" + + def test_is_available_true_with_host_only(self, monkeypatch): + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.setenv("MEM0_MODE", "platform") + monkeypatch.setenv("MEM0_HOST", "http://localhost:8888") + assert Mem0MemoryProvider().is_available() is True + + def test_is_available_false_without_key_or_host(self, monkeypatch): + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("MEM0_HOST", raising=False) + monkeypatch.setenv("MEM0_MODE", "platform") + assert Mem0MemoryProvider().is_available() is False -
website/docs/user-guide/features/memory-providers.md@@ -317,16 +317,16 @@ echo "OPENVIKING_API_KEY=..." >> ~/.hermes/.env ### Mem0 -Server-side LLM fact extraction with semantic search, reranking, and automatic deduplication. Supports both Mem0 Platform (cloud) and OSS (self-hosted) modes. +Server-side LLM fact extraction with semantic search, reranking, and automatic deduplication. Three connection modes: **Platform** (Mem0 Cloud), **self-hosted dashboard** (a Mem0 server you run via Docker), and **OSS** (Mem0 in-process with your own LLM + vector store). | | | |---|---| | **Best for** | Hands-off memory management — Mem0 handles extraction automatically | -| **Requires** | `pip install mem0ai` + API key (platform) or LLM/vector store (OSS) | -| **Data storage** | Mem0 Cloud (platform) or self-hosted (OSS) | -| **Cost** | Mem0 pricing (platform) / free (OSS) | +| **Requires** | `pip install mem0ai` + API key (platform), a running Mem0 server (self-hosted dashboard), or an LLM + vector store (OSS) | +| **Data storage** | Mem0 Cloud (platform), your own Mem0 server (self-hosted dashboard), or in-process (OSS) | +| **Cost** | Mem0 pricing (platform) / free (self-hosted or OSS) | -**Tools (5):** `mem0_list` (list all memories, paginated), `mem0_search` (semantic search with reranking in platform mode), `mem0_add` (store verbatim facts), `mem0_update` (update by ID), `mem0_delete` (delete by ID) +**Tools (4):** `mem0_search` (semantic search; optional reranking in platform mode, off by default), `mem0_add` (store verbatim facts), `mem0_update` (update by ID), `mem0_delete` (delete by ID) **Setup (Platform):** ```bash @@ -348,14 +348,30 @@ 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: + +```bash +echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env +echo "MEM0_API_KEY=[redacted:secret-assignment]" >> ~/.hermes/.env +``` + +or in `mem0.json`: + +```json +{ "host": "http://localhost:8888", "api_key": "your-admin-api-key" } +``` + +The plugin authenticates with `X-API-Key` and uses the server's `/search` / `/memories` routes. `api_key` is optional (omit only for `AUTH_DISABLED` servers). Don't set `mode: oss` — it takes precedence over `host`. + **Config:** `$HERMES_HOME/mem0.json` (behavioral settings). Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`. | Key | Default | Description | |-----|---------|-------------| -| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) | +| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process) | +| `host` | — | Self-hosted Mem0 server URL (Docker dashboard). Routes over HTTP with `X-API-Key`; don't combine with `mode: oss` | | `user_id` | `hermes-user` | User identifier | | `agent_id` | `hermes` | Agent identifier | -| `rerank` | `true` | Rerank search results for relevance (platform mode only) | +| `rerank` | `false` | Rerank search results for relevance (platform mode only) | **OSS supported providers:** @@ -595,7 +611,7 @@ hermes memory setup |----------|---------|------|-------|-------------|----------------| | **Honcho** | Cloud | Paid | 5 | `honcho-ai` | Dialectic user modeling + session-scoped context | | **OpenViking** | Self-hosted | Free | 5 | `openviking` + server | Filesystem hierarchy + tiered loading | -| **Mem0** | Cloud/Self-hosted | Free/Paid | 5 | `mem0ai` | Server-side LLM extraction + OSS mode | +| **Mem0** | Cloud/Self-hosted | Free/Paid | 4 | `mem0ai` | Server-side LLM extraction + self-hosted/OSS modes | | **Hindsight** | Cloud/Local | Free/Paid | 3 | `hindsight-client` | Knowledge graph + reflect synthesis | | **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring | | **RetainDB** | Cloud | $20/mo | 5 | `requests` | Delta compression |
-
-
#60494 feat(mem0): add self-hosted mode to the setup wizard
files:
plugins/memory/mem0/README.md,plugins/memory/mem0/_setup.py,tests/plugins/memory/test_mem0_setup.py,website/docs/user-guide/features/memory-providers.mdShow the diff
-
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=[redacted:secret-assignment]" >> ~/.hermes/.env -
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: -
tests/plugins/memory/test_mem0_setup.py@@ -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=[redacted:secret-assignment]" 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: -
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 <platform|oss>` or edit `mem0.json` directly. +**Switching modes:** Re-run `hermes memory setup mem0 --mode <platform|selfhosted|oss>` or edit `mem0.json` directly. ---
-
3 PRs closed without merging
From the close comments: merged via another change 3
-
#31209 Enhance Mem0 memory provider with self-hosting support · merged via another change →
#50479,#13377 -
#52487 fix(mem0): restore self-hosted support dropped by v3 refactor · merged via another change →
#56943,#55614,#60494 -
#55614 feat(mem0): add self-hosted dashboard support and remove list command from the plugin · merged via another change →
2a14205,53edf6f,9a32272,#56943,#60494
Closed issues · 4
- Closed#16401[Feature]: feat(mem0) add OSS mode, v3 API migration, update/delete tools
- Closed#31135[Feature]: Proposal: Self-Hosted Mem0 as Memory Provider in Hermes
- Closed#51894[Feature]: Add support to use a remote Mem0 API Endpoint (self-hosted)
- Closed#52478Bug: mem0 plugin dropped host/self-hosted support after _backend.py refactor (#15624)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab