diff --git a/plugins/memory/mem0/README.md b/plugins/memory/mem0/README.md index 53046b08e3a..9232f0c0c28 100644 --- a/plugins/memory/mem0/README.md +++ b/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=your-admin-api-key" >> ~/.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 | diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index 3647496375d..7763b385977 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -9,10 +9,15 @@ Configuration ------------- 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 @@ logger = logging.getLogger(__name__) # 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 @@ UPDATE_SCHEMA = { "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 @@ UPDATE_SCHEMA = { 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,6 +201,7 @@ class Mem0MemoryProvider(MemoryProvider): self._backend = None self._mode = "platform" self._api_key = "" + self._host = "" self._user_id = _DEFAULT_USER_ID self._agent_id = "hermes" self._channel = "cli" # gateway channel name (cli/telegram/discord/...) @@ -239,7 +227,9 @@ class Mem0MemoryProvider(MemoryProvider): 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 +252,10 @@ class Mem0MemoryProvider(MemoryProvider): 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 +279,9 @@ class Mem0MemoryProvider(MemoryProvider): 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 +336,7 @@ class Mem0MemoryProvider(MemoryProvider): self._config = _load_config() self._mode = self._config.get("mode", "platform") self._api_key = self._config.get("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 @@ -378,22 +373,28 @@ class Mem0MemoryProvider(MemoryProvider): 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 "" + if self._host: + mode_label = "self-hosted (HTTP API)" + elif self._mode == "platform": + mode_label = "platform (cloud API)" + else: + mode_label = "OSS (self-hosted)" + # 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 +427,7 @@ class Mem0MemoryProvider(MemoryProvider): 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 +498,7 @@ class Mem0MemoryProvider(MemoryProvider): 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 +517,13 @@ class Mem0MemoryProvider(MemoryProvider): 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", False) if isinstance(rerank_raw, str): rerank = rerank_raw.lower() not in ("false", "0", "no") else: @@ -576,7 +554,8 @@ class Mem0MemoryProvider(MemoryProvider): ) 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() diff --git a/plugins/memory/mem0/_backend.py b/plugins/memory/mem0/_backend.py index 429a4f741be..d68a70a9e5a 100644 --- a/plugins/memory/mem0/_backend.py +++ b/plugins/memory/mem0/_backend.py @@ -10,7 +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]: + def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: ... @abstractmethod @@ -57,7 +57,7 @@ class PlatformBackend(Mem0Backend): from mem0 import MemoryClient self._client = MemoryClient(api_key=api_key) - 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) @@ -90,6 +90,83 @@ class PlatformBackend(Mem0Backend): 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. + """ + + _MAX_TOP_K = 10000 + + def __init__(self, api_key: str, host: str): + import httpx + + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key # omitted only for AUTH_DISABLED servers + self._client = httpx.Client( + base_url=host.rstrip("/"), headers=headers, timeout=30.0, + ) + + 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 get_all(self, *, filters: dict, page: int = 1, page_size: int = 100) -> dict: + params: dict[str, Any] = {k: v for k, v in (filters or {}).items() if v} + params["top_k"] = self._MAX_TOP_K + all_results = _unwrap_results(self._json("GET", "/memories", params=params)) + total = len(all_results) + start = (page - 1) * page_size + return {"results": all_results[start : start + page_size], "count": total} + + 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,7 +266,7 @@ class OSSBackend(Mem0Backend): 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) diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index 314cf9eec6f..52e5e42ff7e 100644 --- a/plugins/memory/mem0/_setup.py +++ b/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 = {} diff --git a/plugins/memory/mem0/plugin.yaml b/plugins/memory/mem0/plugin.yaml index ef06b0f37f7..4c67a02d97d 100644 --- a/plugins/memory/mem0/plugin.yaml +++ b/plugins/memory/mem0/plugin.yaml @@ -1,5 +1,5 @@ name: mem0 -version: 1.2.0 +version: 1.3.0 description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication." pip_dependencies: - - mem0ai>=2.0.7,<3 + - mem0ai>=2.0.10,<3 diff --git a/tests/plugins/memory/test_mem0_backend.py b/tests/plugins/memory/test_mem0_backend.py index 221da10823b..da3adeb95e8 100644 --- a/tests/plugins/memory/test_mem0_backend.py +++ b/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 @@ class TestPlatformBackend: 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() @@ -207,3 +212,146 @@ class TestOSSBackend: 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="adminkey", host="http://sh:8888"): + """Build a SelfHostedBackend whose client routes through the stub transport. + + Mirrors __init__'s header setup but injects MockTransport so no real socket + is opened. + """ + backend = SelfHostedBackend.__new__(SelfHostedBackend) + headers = {"Content-Type": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + backend._client = httpx.Client( + base_url=host.rstrip("/"), headers=headers, + transport=httpx.MockTransport(server.handler), + ) + return backend + + +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 + + # --- get_all / pagination ------------------------------------------- + + def test_get_all_passes_user_id_as_query_param(self): + s = _StubServer() + _backend(s).get_all(filters={"user_id": "u1"}, page=1, page_size=3) + req = s.requests[-1] + assert req.method == "GET" and req.url.path == "/memories" + assert req.url.params["user_id"] == "u1" + + def test_get_all_slices_requested_page(self): + s = _StubServer(rows=10) + out = _backend(s).get_all(filters={"user_id": "u1"}, page=2, page_size=3) + # We over-fetch (top_k=_MAX_TOP_K) and slice the page locally... + assert [r["id"] for r in out["results"]] == ["m3", "m4", "m5"] + # ...so count is the TRUE total, not the page size (issue #52921). + assert out["count"] == 10 + + def test_get_all_requests_max_top_k_for_accurate_count(self): + # Always request _MAX_TOP_K (not page*page_size) so `count` reflects the + # real total — self-hosted GET /memories returns no count field (#52921). + s = _StubServer(rows=10) + _backend(s).get_all(filters={"user_id": "u1"}, page=1, page_size=3) + req = s.requests[-1] + assert int(req.url.params["top_k"]) == SelfHostedBackend._MAX_TOP_K + + # --- 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 diff --git a/tests/plugins/memory/test_mem0_v3.py b/tests/plugins/memory/test_mem0_v3.py index 0e381d6a894..f959f955012 100644 --- a/tests/plugins/memory/test_mem0_v3.py +++ b/tests/plugins/memory/test_mem0_v3.py @@ -52,33 +52,6 @@ class TestMem0V3Tools: 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 @@ class TestMem0V3Tools: 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() @@ -280,6 +253,8 @@ class TestMem0V3Internal: 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 +283,7 @@ class TestMem0Prefetch: 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 +343,11 @@ class TestMem0Prefetch: 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 +355,9 @@ class TestMem0V3Config: 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 +430,7 @@ class TestMem0ModeSwitch: 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 +438,6 @@ class TestMem0ModeSwitch: provider._mode = "oss" block = provider.system_prompt_block() assert "mem0_search" in block - assert "mem0_list" in block assert "OSS" in block @@ -547,3 +521,82 @@ class TestMem0WriteMetadata: 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 = api_key + 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="adminkey") + 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) + + +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 diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index a2f5352e621..929bb640fcf 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/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) | +| **Best for** | Hands-off memory management Mem0 handles extraction automatically | +| **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=your-admin-api-key" >> ~/.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 |