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

0open issues
0open PRs
10items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50479 fix(mem0): add self-hosted support via MEM0_HOST / host config

    merged to main

    files: plugins/memory/mem0/README.md, plugins/memory/mem0/__init__.py, scripts/release.py

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

    merged to main

    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.md

    Show 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

    merged to main

    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.md

    Show 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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#50479, #56943, #60494
Closed issues
4
Attempts
3 PRs closed without merging
Fix link checked
7 Jul 2026

For your agent

The same facts, typed.