Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
# Mem0 Memory Provider
Server-side LLM fact extraction with semantic search and hybrid multi-signal retrieval via the Mem0 Platform v3 API.
## Requirements
- `pip install mem0ai`
- Mem0 API key from [app.mem0.ai](https://app.mem0.ai)
## Setup
```bash
hermes memory setup # select "mem0"
```
Or manually:
```bash
hermes config set memory.provider mem0
echo "MEM0_API_KEY=your-key" >> ~/.hermes/.env
```
## Config
Behavioral settings live in `$HERMES_HOME/mem0.json` (set them via `hermes memory setup`). Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`.
| Key | Default | Description |
|-----|---------|-------------|
| `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` | `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 — via the setup wizard:
```bash
hermes memory setup # select "mem0" → "Self-hosted server"
# Or non-interactive:
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888 --api-key your-admin-api-key
```
or via env vars:
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```
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. 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
```bash
hermes memory setup
# Select "mem0" → "Open Source (self-hosted)"
# Follow prompts for LLM, embedder, and vector store
```
### Agent-Driven Setup (Flags)
```bash
hermes memory setup mem0 --mode oss \
--oss-llm openai --oss-llm-key sk-... \
--oss-vector qdrant
```
### Supported Providers
| Component | Providers |
|-----------|-----------|
| LLM | openai, ollama |
| Embedder | openai, ollama |
| Vector Store | qdrant (local/server), pgvector |
### Flags Reference
| Flag | Description |
|------|-------------|
| `--mode` | `platform` or `oss` |
| `--oss-llm` | LLM provider (default: openai) |
| `--oss-llm-key` | LLM API key |
| `--oss-embedder` | Embedder provider (default: openai) |
| `--oss-vector` | Vector store (default: qdrant) |
| `--oss-vector-path` | Qdrant local path |
| `--user-id` | User identifier |
## Switching Modes
### Platform to OSS
```bash
hermes memory setup mem0 --mode oss --oss-llm-key sk-...
```
Or edit `$HERMES_HOME/mem0.json` directly:
```json
{
"mode": "oss",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
}
}
```
### OSS to Platform
```bash
hermes memory setup mem0 --mode platform --api-key sk-...
```
### Dry Run (preview without writing)
```bash
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
```
## Tools
| Tool | Description |
|------|-------------|
| `mem0_search` | Semantic search by meaning |
| `mem0_add` | Store a fact verbatim (no LLM extraction) |
| `mem0_update` | Update a memory's text by ID |
| `mem0_delete` | Delete a memory by ID |
## Troubleshooting
### "Mem0 temporarily unavailable"
Circuit breaker tripped after 5 consecutive failures. Resets after 2 minutes.
- **Platform mode**: Check API key and internet connectivity.
- **OSS mode**: Check that your vector store (qdrant/pgvector) is running.
### OSS: Qdrant connection refused
```bash
# If using local Qdrant, check the storage path is writable:
ls -la ~/.hermes/mem0_qdrant
# If using Qdrant server, check it's reachable:
curl http://localhost:6333/healthz
```
### OSS: PGVector connection refused
```bash
# Verify PostgreSQL is running and accepting connections:
pg_isready -h localhost -p 5432
```
### OSS: Ollama not reachable
```bash
# Check Ollama is running:
curl http://localhost:11434/api/tags
```
### Memories not appearing
- `mem0_add` stores verbatim (no extraction). Use `sync_turn` for LLM extraction.
- Search uses semantic matching — try broader queries.
- Check `user_id` matches between sessions (`$HERMES_HOME/mem0.json`).
+628
View File
@@ -0,0 +1,628 @@
"""Mem0 memory plugin — MemoryProvider interface.
Server-side LLM fact extraction, semantic search, and automatic deduplication
via the Mem0 Platform API (cloud) or OSS (self-hosted) via Memory.
Original PR #2933 by kartik-mem0, adapted to MemoryProvider ABC.
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
store. When unset, the gateway-native id (e.g. Telegram
numeric id, Discord snowflake) is used instead.
agent_id — Agent identifier (default: hermes)
The matching MEM0_MODE / MEM0_USER_ID / MEM0_AGENT_ID environment variables are
still read as a backward-compatible fallback, but mem0.json is the canonical
home for these non-secret settings.
"""
from __future__ import annotations
import atexit
import json
import logging
import os
import threading
import time
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from agent.secret_scope import get_secret
from tools.registry import tool_error
logger = logging.getLogger(__name__)
# Circuit breaker: after this many consecutive failures, pause API calls
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
_BREAKER_THRESHOLD = 5
_BREAKER_COOLDOWN_SECS = 120
_PREFETCH_WAIT_SECS = 3
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
# Sentinel returned when neither MEM0_USER_ID nor a gateway-native id is
# available. Treated as "no operator-configured user_id" by initialize() so
# that legacy mem0.json files written by the setup wizard (which historically
# wrote this exact placeholder) still allow gateway-native ids to flow
# through instead of silently overriding them with the placeholder.
_DEFAULT_USER_ID = "hermes-user"
def _is_client_error(exc: Exception) -> bool:
"""True for user-caused errors (bad ID, not found) that should NOT trip circuit breaker."""
etype = type(exc).__name__
if etype in _CLIENT_ERROR_TYPES:
return True
err_str = str(exc).lower()
return "404" in err_str or "not found" in err_str or "valid uuid" in err_str
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def _load_config() -> dict:
"""Load config from env vars, with $HERMES_HOME/mem0.json overrides.
Environment variables provide defaults; mem0.json (if present) overrides
individual keys. This avoids a silent failure when the JSON file exists
but is missing fields like ``api_key`` that the user set in ``.env``.
"""
from hermes_constants import get_hermes_home
config = {
"mode": os.environ.get("MEM0_MODE", "platform"),
"api_key": get_secret("MEM0_API_KEY", ""),
"host": os.environ.get("MEM0_HOST", ""),
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
"oss": {},
}
# Only carry user_id when the operator explicitly configured one (env or
# mem0.json). An absent key tells initialize() to fall back to the
# gateway-native id from kwargs instead of overriding it with a placeholder.
env_user_id = os.environ.get("MEM0_USER_ID")
if env_user_id:
config["user_id"] = env_user_id
config_path = get_hermes_home() / "mem0.json"
if config_path.exists():
try:
file_cfg = json.loads(config_path.read_text(encoding="utf-8"))
config.update({k: v for k, v in file_cfg.items()
if v is not None and v != ""})
except Exception:
pass
return config
# ---------------------------------------------------------------------------
# Tool schemas
# ---------------------------------------------------------------------------
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 "
"what you know about the user (preferences, facts, history, people, "
"projects, past decisions). For multi-part or multi-hop questions, "
"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: false, platform mode only)."},
},
"required": ["query"],
},
}
ADD_SCHEMA = {
"name": "mem0_add",
"description": (
"Store a durable fact about the user, verbatim (no LLM extraction). "
"Call this the moment the user states a lasting preference, correction, "
"decision, or personal detail worth recalling on future turns — don't "
"wait to be asked to remember. Skip transient chit-chat and facts you've "
"already stored."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The fact to store."},
},
"required": ["content"],
},
}
UPDATE_SCHEMA = {
"name": "mem0_update",
"description": (
"Replace the text of an existing memory by its ID (take the ID from a "
"mem0_search result). Use when a stored fact has changed "
"or was wrong — correct it in place instead of adding a duplicate."
),
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "string", "description": "Memory UUID to update."},
"text": {"type": "string", "description": "New text content."},
},
"required": ["memory_id", "text"],
},
}
DELETE_SCHEMA = {
"name": "mem0_delete",
"description": (
"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."
),
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "string", "description": "Memory UUID to delete."},
},
"required": ["memory_id"],
},
}
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class Mem0MemoryProvider(MemoryProvider):
"""Mem0 memory with server-side extraction and semantic search.
Supports Platform API (cloud) and OSS (self-hosted) modes via MEM0_MODE.
"""
def __init__(self):
self._config = None
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
self._prefetch_query = ""
self._prefetch_result = ""
self._prefetch_done = False
# Circuit breaker state
self._consecutive_failures = 0
self._breaker_open_until = 0.0
self._breaker_lock = threading.Lock()
self._sync_lock = threading.Lock()
self._prefetch_lock = threading.Lock()
self._atexit_registered = False
@property
def name(self) -> str:
return "mem0"
def is_available(self) -> bool:
cfg = _load_config()
mode = cfg.get("mode", "platform")
if mode == "oss":
return bool(cfg.get("oss", {}).get("vector_store"))
# 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."""
import json
from pathlib import Path
config_path = Path(hermes_home) / "mem0.json"
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
pass
existing.update(values)
from utils import atomic_json_write
atomic_json_write(config_path, existing, mode=0o600)
def get_config_schema(self):
cfg = _load_config()
mode = cfg.get("mode", "platform")
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": "false", "choices": ["true", "false"]},
]
def post_setup(self, hermes_home: str, config: dict) -> None:
from ._setup import post_setup
post_setup(hermes_home, config)
def _create_backend(self):
# Lazy-install the mem0 SDK on demand before either backend imports
# it. ensure() honors security.allow_lazy_installs (default true) and,
# on a sealed Docker venv, redirects the install to the durable
# target. On failure we fall through so the import inside the backend
# produces the canonical error, captured below.
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("memory.mem0", prompt=False)
except ImportError:
pass
except Exception:
pass
try:
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:
logger.error("Mem0 backend failed to initialize (%s mode): %s", self._mode, e)
self._init_error = str(e)
return None
def _is_breaker_open(self) -> bool:
"""Return True if the circuit breaker is tripped (too many failures)."""
with self._breaker_lock:
if self._consecutive_failures < _BREAKER_THRESHOLD:
return False
if time.monotonic() >= self._breaker_open_until:
self._consecutive_failures = 0
return False
return True
def _format_error(self, prefix: str, exc: Exception) -> str:
msg = f"{prefix}: {exc}"
if self._mode == "oss":
err_str = str(exc).lower()
if "connection" in err_str or "refused" in err_str or "timeout" in err_str:
vs = self._config.get("oss", {}).get("vector_store", {})
msg += f" (check that {vs.get('provider', 'vector store')} is running)"
return msg
def _record_success(self):
with self._breaker_lock:
self._consecutive_failures = 0
def _record_failure(self):
with self._breaker_lock:
self._consecutive_failures += 1
count = self._consecutive_failures
if count >= _BREAKER_THRESHOLD:
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
else:
count = 0
if count >= _BREAKER_THRESHOLD:
hint = ""
if self._mode == "oss":
vs = self._config.get("oss", {}).get("vector_store", {})
provider = vs.get("provider", "unknown")
hint = f" Check that your {provider} vector store is running and reachable."
logger.warning(
"Mem0 circuit breaker tripped after %d consecutive failures. "
"Pausing API calls for %ds.%s",
count, _BREAKER_COOLDOWN_SECS, hint,
)
def initialize(self, session_id: str, **kwargs) -> None:
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
# human gets one merged memory store.
# 2. Gateway-native id from kwargs (Telegram numeric id, Discord
# snowflake, etc.) — preserves per-platform isolation when no
# override is configured.
# 3. Hardcoded fallback _DEFAULT_USER_ID (CLI with no auth).
# The literal _DEFAULT_USER_ID string is treated as unset so users who
# ran the setup wizard with the suggested default still get gateway-
# native ids instead of being silently bucketed together.
configured = self._config.get("user_id")
if configured == _DEFAULT_USER_ID:
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:
atexit.register(self._shutdown_backend)
self._atexit_registered = True
def _read_filters(self) -> Dict[str, Any]:
# Scoped to user_id only — by design — so recall surfaces memories
# written from any gateway/agent under this principal. Writes attach
# agent_id (and metadata.channel) so per-agent / per-channel views are
# still possible at query time when needed; reads default to the wider
# cross-agent recall.
return {"user_id": self._user_id}
def _write_metadata(self) -> Dict[str, Any]:
# Tag every write with the gateway channel so the dashboard can offer
# per-channel filtered views without coupling identity to the channel.
return {"channel": self._channel} if self._channel else {}
def system_prompt_block(self) -> str:
# 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. "
"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 "
"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_update and mem0_delete to manage by ID.{rerank_note}"
)
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
self._start_prefetch(message)
def _consume_prefetch_result(self, query: str) -> str | None:
with self._prefetch_lock:
if self._prefetch_query != query or not self._prefetch_done:
return None
result = self._prefetch_result
self._prefetch_result = ""
self._prefetch_done = False
return result
def _start_prefetch(self, query: str) -> None:
if not query or self._backend is None or self._is_breaker_open():
return
backend = self._backend
with self._prefetch_lock:
if self._prefetch_query == query:
if self._prefetch_done:
return
if self._prefetch_thread and self._prefetch_thread.is_alive():
return
self._prefetch_query = query
self._prefetch_result = ""
self._prefetch_done = False
def _run():
body = ""
try:
results = backend.search(
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:
body = "## Mem0 Memory\n" + "\n".join(f"- {l}" for l in lines)
self._record_success()
except Exception as e:
self._record_failure()
logger.debug("Mem0 prefetch failed: %s", e)
with self._prefetch_lock:
if self._prefetch_query == query:
self._prefetch_result = body
self._prefetch_done = True
t = threading.Thread(target=_run, daemon=True, name="mem0-prefetch")
with self._prefetch_lock:
self._prefetch_thread = t
t.start()
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Recall memories for the CURRENT question with a short hot-path wait."""
cached = self._consume_prefetch_result(query)
if cached is not None:
return cached
self._start_prefetch(query)
with self._prefetch_lock:
thread = self._prefetch_thread if self._prefetch_query == query else None
if thread:
thread.join(timeout=_PREFETCH_WAIT_SECS)
cached = self._consume_prefetch_result(query)
if cached is not None:
return cached
# Slow backend: skip injection; mem0_search tool remains the backstop.
return ""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Send the turn to Mem0 for server-side fact extraction (non-blocking)."""
if self._backend is None or self._is_breaker_open():
return
def _sync():
backend = self._backend
if backend is None:
return
try:
messages = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": assistant_content},
]
backend.add(
messages,
user_id=self._user_id,
agent_id=self._agent_id,
infer=True,
metadata=self._write_metadata(),
)
self._record_success()
except Exception as e:
self._record_failure()
logger.warning("Mem0 sync failed: %s", e)
with self._sync_lock:
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
# If still alive after timeout, skip to avoid duplicate ingestion.
if self._sync_thread and self._sync_thread.is_alive():
return
self._sync_thread = threading.Thread(target=_sync, daemon=True, name="mem0-sync")
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
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:
err = getattr(self, "_init_error", "unknown error")
hint = ""
if self._mode == "oss":
vs = self._config.get("oss", {}).get("vector_store", {})
provider = vs.get("provider", "vector store")
hint = f" Check that {provider} is running and reachable."
return json.dumps({"error": f"Mem0 backend not initialized: {err}.{hint}"})
if self._is_breaker_open():
msg = "Mem0 temporarily unavailable (multiple consecutive failures). Will retry automatically."
if self._mode == "oss":
vs = self._config.get("oss", {}).get("vector_store", {})
msg += f" Check that your {vs.get('provider', 'vector store')} is running."
return json.dumps({"error": msg})
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", getattr(self, "_rerank_default", False))
if isinstance(rerank_raw, str):
rerank = rerank_raw.lower() not in ("false", "0", "no")
else:
rerank = bool(rerank_raw)
results = self._backend.search(query, filters=self._read_filters(), top_k=top_k, rerank=rerank)
self._record_success()
if not results:
return json.dumps({"result": "No relevant memories found."})
items = [{"id": r.get("id"), "memory": r.get("memory", ""),
"score": r.get("score", 0)} for r in results]
return json.dumps({"results": items, "count": len(items)})
except Exception as e:
if not _is_client_error(e):
self._record_failure()
return tool_error(self._format_error("Search failed", e))
elif tool_name == "mem0_add":
content = args.get("content", "")
if not content:
return tool_error("Missing required parameter: content")
try:
result = self._backend.add(
[{"role": "user", "content": content}],
user_id=self._user_id,
agent_id=self._agent_id,
infer=False,
metadata=self._write_metadata(),
)
self._record_success()
event_id = result.get("event_id") if isinstance(result, dict) else None
# 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()
return tool_error(self._format_error("Failed to store", e))
elif tool_name == "mem0_update":
memory_id = args.get("memory_id", "")
text = args.get("text", "")
if not memory_id:
return tool_error("Missing required parameter: memory_id")
if not text:
return tool_error("Missing required parameter: text")
try:
result = self._backend.update(memory_id, text)
self._record_success()
return json.dumps(result)
except Exception as e:
if _is_client_error(e):
return tool_error(f"Memory not found: {memory_id}")
self._record_failure()
return tool_error(self._format_error("Update failed", e))
elif tool_name == "mem0_delete":
memory_id = args.get("memory_id", "")
if not memory_id:
return tool_error("Missing required parameter: memory_id")
try:
result = self._backend.delete(memory_id)
self._record_success()
return json.dumps(result)
except Exception as e:
if _is_client_error(e):
return tool_error(f"Memory not found: {memory_id}")
self._record_failure()
return tool_error(self._format_error("Delete failed", e))
return tool_error(f"Unknown tool: {tool_name}")
def _shutdown_backend(self):
try:
if self._backend:
self._backend.close()
self._backend = None
except Exception:
pass
def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._shutdown_backend()
def register(ctx) -> None:
"""Register Mem0 as a memory provider plugin."""
ctx.register_memory_provider(Mem0MemoryProvider())
+358
View File
@@ -0,0 +1,358 @@
"""Backend abstraction for Mem0 Platform and OSS modes."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
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 = False) -> list[dict]:
...
@abstractmethod
def add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
...
@abstractmethod
def update(self, memory_id: str, text: str) -> dict:
...
@abstractmethod
def delete(self, memory_id: str) -> dict:
...
def close(self) -> None:
pass
def _unwrap_results(response: Any) -> list:
"""Normalize API response — extract results list from dict or pass through."""
if isinstance(response, dict):
return response.get("results", [])
if isinstance(response, list):
return response
return []
class PlatformBackend(Mem0Backend):
"""Wraps mem0.MemoryClient for Mem0 Platform (cloud API)."""
def __init__(self, api_key: str):
from mem0 import MemoryClient
self._client = MemoryClient(api_key=api_key)
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 add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
kwargs: dict[str, Any] = {"user_id": user_id, "agent_id": agent_id, "infer": infer}
if metadata:
kwargs["metadata"] = metadata
return self._client.add(messages, **kwargs)
def update(self, memory_id: str, text: str) -> dict:
self._client.update(memory_id=memory_id, text=text)
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._client.delete(memory_id=memory_id)
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
_DIRECT_OPENAI_PROVIDER = "hermes_openai"
_DIRECT_OPENAI_CLASS_PATH = "plugins.memory.mem0._openai_llm.DirectOpenAILLM"
def _register_direct_openai_provider() -> None:
"""Register Hermes' OpenAI-only Mem0 LLM provider once per factory."""
from mem0.configs.llms.openai import OpenAIConfig
from mem0.utils.factory import LlmFactory
provider_map = getattr(LlmFactory, "provider_to_class", None)
register_provider = getattr(LlmFactory, "register_provider", None)
if not isinstance(provider_map, dict) or not callable(register_provider):
raise RuntimeError(
"mem0 LlmFactory does not support the provider registration required "
"for the Hermes OpenAI OSS backend"
)
registration = (_DIRECT_OPENAI_CLASS_PATH, OpenAIConfig)
if provider_map.get(_DIRECT_OPENAI_PROVIDER) != registration:
register_provider(
_DIRECT_OPENAI_PROVIDER,
_DIRECT_OPENAI_CLASS_PATH,
OpenAIConfig,
)
class OSSBackend(Mem0Backend):
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
def __init__(self, oss_config: dict):
import os
from mem0 import Memory
def _provider_block(name: str) -> dict:
block = dict(oss_config[name])
provider = str(block.get("provider") or "").strip().lower()
provider_config = dict(block.get("config", {}))
legacy_base = provider_config.pop("api_base", None)
if legacy_base:
from ._oss_providers import EMBEDDER_PROVIDERS, LLM_PROVIDERS
provider_def = (
LLM_PROVIDERS if name == "llm" else EMBEDDER_PROVIDERS
).get(provider, {})
canonical_key = provider_def.get("base_url_key")
if canonical_key:
provider_config.setdefault(canonical_key, legacy_base)
block["config"] = provider_config
return block
vector_store = dict(oss_config["vector_store"])
vs_config = dict(vector_store.get("config", {}))
if "path" in vs_config:
vs_config["path"] = os.path.expanduser(vs_config["path"])
embedder_config = oss_config.get("embedder", {}).get("config", {})
dims = embedder_config.get("embedding_dims")
if not dims:
from ._oss_providers import KNOWN_DIMS
model = embedder_config.get("model", "")
dims = KNOWN_DIMS.get(model)
if dims:
vs_config["embedding_model_dims"] = dims
self._recreate_collection_if_dims_changed(
vector_store.get("provider", "qdrant"), vs_config, dims,
)
vector_store["config"] = vs_config
config = {
"vector_store": vector_store,
"llm": _provider_block("llm"),
"embedder": _provider_block("embedder"),
"version": "v1.1",
}
if str(config["llm"].get("provider") or "").strip().lower() == "openai":
# mem0 validates LlmConfig.provider before its factory lookup, so
# first build the supported OpenAI config and only then swap the
# provider on that validated in-memory object.
_register_direct_openai_provider()
from mem0.configs.base import MemoryConfig
memory_config = MemoryConfig(**config)
try:
memory_config.llm.provider = _DIRECT_OPENAI_PROVIDER
except (AttributeError, TypeError) as exc:
raise RuntimeError(
"mem0 MemoryConfig does not expose a mutable llm.provider "
"for the Hermes OpenAI OSS backend"
) from exc
self._memory = Memory(memory_config)
else:
self._memory = Memory.from_config(config)
@staticmethod
def _recreate_collection_if_dims_changed(provider: str, vs_config: dict, expected_dims: int) -> None:
"""Delete stale vector collection when embedding dimensions change."""
collection_name = vs_config.get("collection_name", "mem0")
if provider == "qdrant":
try:
from qdrant_client import QdrantClient
path = vs_config.get("path")
url = vs_config.get("url")
if path:
client = QdrantClient(path=path)
elif url:
client = QdrantClient(url=url, api_key=vs_config.get("api_key"))
else:
return
try:
if not client.collection_exists(collection_name):
return
info = client.get_collection(collection_name)
vectors = info.config.params.vectors
# Named-vector collections expose a dict; unnamed expose an object with .size.
if isinstance(vectors, dict):
first = next(iter(vectors.values()), None)
current_dims = first.size if first else None
else:
current_dims = getattr(vectors, "size", None)
if current_dims is not None and current_dims != expected_dims:
client.delete_collection(collection_name)
finally:
client.close()
except Exception:
pass
elif provider == "pgvector":
try:
import psycopg2
from psycopg2 import sql as pgsql
conn_params = {}
for k in ("host", "port", "user", "password", "dbname"):
if vs_config.get(k):
conn_params[k] = vs_config[k]
if vs_config.get("sslmode"):
conn_params["sslmode"] = vs_config["sslmode"]
conn = psycopg2.connect(**conn_params)
conn.autocommit = True
try:
cur = conn.cursor()
try:
cur.execute(
"SELECT atttypmod FROM pg_attribute "
"WHERE attrelid = %s::regclass AND attname = 'vector'",
(collection_name,),
)
row = cur.fetchone()
if row and row[0] > 0 and row[0] != expected_dims:
cur.execute(pgsql.SQL("DROP TABLE IF EXISTS {}").format(
pgsql.Identifier(collection_name)
))
finally:
cur.close()
finally:
conn.close()
except Exception:
pass
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 add(
self,
messages: list,
*,
user_id: str,
agent_id: str,
infer: bool = False,
metadata: dict | None = None,
) -> dict:
kwargs: dict[str, Any] = {"user_id": user_id, "agent_id": agent_id, "infer": infer}
if metadata:
kwargs["metadata"] = metadata
return self._memory.add(messages, **kwargs)
def update(self, memory_id: str, text: str) -> dict:
self._memory.update(memory_id, data=text)
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._memory.delete(memory_id)
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self):
try:
telemetry = getattr(self._memory, "telemetry", None)
if telemetry and hasattr(telemetry, "posthog"):
try:
telemetry.posthog.shutdown()
except Exception:
pass
if hasattr(self._memory, "close"):
self._memory.close()
vs = getattr(self._memory, "vector_store", None)
if vs and hasattr(vs, "close"):
vs.close()
client = getattr(vs, "client", None)
if client and hasattr(client, "close"):
client.close()
except Exception:
pass
+100
View File
@@ -0,0 +1,100 @@
"""OpenAI-only LLM adapter for Mem0 OSS mode."""
from __future__ import annotations
import logging
import os
from typing import Dict, List, Optional, Union
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.openai import OpenAIConfig
from mem0.llms.base import LLMBase
from mem0.llms.openai import OpenAILLM
class DirectOpenAILLM(OpenAILLM):
"""Use OpenAI credentials and requests regardless of router environment."""
def __init__(
self,
config: Optional[Union[BaseLlmConfig, OpenAIConfig, Dict]] = None,
):
if config is None:
config = OpenAIConfig()
elif isinstance(config, dict):
config = OpenAIConfig(**config)
elif isinstance(config, BaseLlmConfig) and not isinstance(config, OpenAIConfig):
config = OpenAIConfig(
model=config.model,
temperature=config.temperature,
api_key=config.api_key,
max_tokens=config.max_tokens,
top_p=config.top_p,
top_k=config.top_k,
enable_vision=config.enable_vision,
vision_details=config.vision_details,
reasoning_effort=getattr(config, "reasoning_effort", None),
http_client_proxies=config.http_client_proxies,
is_reasoning_model=getattr(config, "is_reasoning_model", None),
)
if not config.model:
config.model = "gpt-5-mini"
# Older, partial, and manually edited configs may predate the setup
# marker. Keep the exact default model safe at runtime without
# overriding an explicit user choice or changing the persisted config.
if config.model == "gpt-5-mini" and config.is_reasoning_model is None:
config.is_reasoning_model = True
# Bypass OpenAILLM.__init__: it intentionally selects OpenRouter when
# OPENROUTER_API_KEY is present. LLMBase still owns validation and
# supported-parameter filtering for parity with Mem0's implementation.
LLMBase.__init__(self, config)
api_key = self.config.api_key or os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError(
"OpenAI API key is required for the Hermes Mem0 OSS provider"
)
base_url = (
self.config.openai_base_url
or os.getenv("OPENAI_BASE_URL")
or "https://api.openai.com/v1"
)
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url)
def generate_response(
self,
messages: List[Dict[str, str]],
response_format=None,
tools: Optional[List[Dict]] = None,
tool_choice: str = "auto",
**kwargs,
):
params = self._get_supported_params(messages=messages, **kwargs)
params.update({"model": self.config.model, "messages": messages})
# OpenRouter-only fields are deliberately not added here. ``store`` is
# opt-in so OpenAI-compatible endpoints do not receive unknown fields.
if self.config.store is not None:
params["store"] = self.config.store
if response_format:
params["response_format"] = response_format
if tools:
params["tools"] = tools
params["tool_choice"] = tool_choice
response = self.client.chat.completions.create(**params)
parsed_response = self._parse_response(response, tools)
if self.config.response_callback:
try:
self.config.response_callback(self, response, params)
except Exception:
logging.error("Error running Mem0 OpenAI response callback")
return parsed_response
+88
View File
@@ -0,0 +1,88 @@
"""OSS provider definitions for LLM, embedder, and vector store."""
from __future__ import annotations
import os
from typing import Any
LLM_PROVIDERS: dict[str, dict[str, Any]] = {
"openai": {
"label": "OpenAI",
"needs_key": True,
"env_var": "OPENAI_API_KEY",
"default_model": "gpt-5-mini",
"base_url_key": "openai_base_url",
},
"ollama": {
"label": "Ollama (local)",
"needs_key": False,
"default_model": "llama3.1:8b",
"default_url": "http://localhost:11434",
"base_url_key": "ollama_base_url",
"pip_dep": "ollama",
},
}
EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
"openai": {
"label": "OpenAI",
"needs_key": True,
"env_var": "OPENAI_API_KEY",
"default_model": "text-embedding-3-small",
"base_url_key": "openai_base_url",
"dims": 1536,
},
"ollama": {
"label": "Ollama (local)",
"needs_key": False,
"default_model": "nomic-embed-text",
"default_url": "http://localhost:11434",
"base_url_key": "ollama_base_url",
"dims": 768,
"pip_dep": "ollama",
},
}
VECTOR_PROVIDERS: dict[str, dict[str, Any]] = {
"qdrant": {
"label": "Qdrant",
"default_config": {"path": os.path.expanduser("~/.hermes/mem0_qdrant")},
"pip_dep": "qdrant-client",
},
"pgvector": {
"label": "PGVector",
"default_config": {"host": "localhost", "port": 5432, "user": os.getenv("USER", "postgres"), "dbname": "postgres"},
"pip_dep": "psycopg2-binary",
},
}
KNOWN_DIMS: dict[str, int] = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
"nomic-embed-text": 768,
}
def validate_oss_config(oss_config: dict) -> list[str]:
"""Validate an OSS config dict. Returns list of error strings (empty = valid)."""
errors: list[str] = []
for section, registry in [("llm", LLM_PROVIDERS), ("embedder", EMBEDDER_PROVIDERS),
("vector_store", VECTOR_PROVIDERS)]:
block = oss_config.get(section)
if not block or not isinstance(block, dict):
errors.append(f"Missing required section: {section}")
continue
provider_id = block.get("provider", "")
if provider_id not in registry:
valid = ", ".join(registry.keys())
errors.append(f"Unknown {section} provider '{provider_id}'. Valid: {valid}")
vs = oss_config.get("vector_store", {})
if vs.get("provider") == "pgvector":
cfg = vs.get("config", {})
if not cfg.get("user"):
errors.append("PGVector requires 'user' in vector_store.config")
return errors
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
name: mem0
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.10,<3