feat(mem0): self-hosted dashboard backend + recall tuning (salvage #55614)

Salvage of #55614 by @kartik-mem0 (mem0 maintainer). Adds a SelfHostedBackend
that talks to a self-hosted Mem0 Docker server over httpx (X-API-Key auth,
/search + /memories routes), gated behind `host`. Also folds in the mem0
research-team recall tuning that rides with it: rerank defaults to false across
all modes, the mem0_list tool is removed (5->4 tools), search guidance is
de-shouted, and self-hosted get_all reports the true stored total (#52921).

Supersedes the self-hosted portion of #52487 (@liuhao1024, first-submitted).

Closes #52478
Fixes #52921
This commit is contained in:
kartik-mem0 2026-07-02 14:32:29 +05:30 committed by kshitij
parent b2d6a512d5
commit 2a14205ff4
8 changed files with 421 additions and 119 deletions

View file

@ -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 |

View file

@ -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()

View file

@ -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)

View file

@ -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 = {}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 |