mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge origin/main: unify declared-schema and instance-schema provider config
Main's #60569 (dashboard memory provider switching) rewrote the same GET/PUT /api/memory/providers/{name}/config routes this PR owns — the dashboard and desktop share one backend. Resolve by dispatching: providers that declare a config_schema.py get the declared path (host-block storage, locked honcho writes, profile scoping, actions); everything else keeps main's instance get_config_schema()/save_config path unchanged, so the dashboard's PluginsPage behavior is preserved for instance providers. Setup manifests, the /setup endpoint, provider switching, and name validation from #60569 apply to both paths. Instance payloads gain the docs_url/actions keys the desktop panel expects; declared payloads gain the setup block the dashboard expects. Main's hindsight/honcho instance- schema tests are updated for the dispatch, and its status test gets the HOME pin it was missing (it read the developer's real ~/.honcho).
This commit is contained in:
commit
1508ece2d9
948 changed files with 85189 additions and 7133 deletions
|
|
@ -515,20 +515,16 @@ def _ensure_sdk_installed() -> bool:
|
|||
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
import subprocess
|
||||
print(" Installing honcho-ai...", flush=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
result = _pip_install(["honcho-ai>=2.0.1"])
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
return True
|
||||
else:
|
||||
print(f" Install failed:\n{result.stderr.strip()}")
|
||||
print(" Run manually: pip install 'honcho-ai>=2.0.1'\n")
|
||||
print(f" Install failed:\n{(result.stderr or '').strip()}")
|
||||
print(" Run manually: uv pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -1092,7 +1088,7 @@ def cmd_status(args) -> None:
|
|||
if write_path != active_path:
|
||||
print(f" Write to: {write_path} (profile-local)")
|
||||
if active_path == global_path:
|
||||
print(f" Fallback: (none — using global ~/.honcho/config.json)")
|
||||
print(" Fallback: (none — using global ~/.honcho/config.json)")
|
||||
elif global_path.exists():
|
||||
print(f" Fallback: {global_path} (exists, cross-app interop)")
|
||||
|
||||
|
|
@ -1152,7 +1148,7 @@ def _show_peer_cards(hcfg, client) -> None:
|
|||
if ai_text:
|
||||
# Truncate to first 200 chars
|
||||
display = ai_text[:200] + ("..." if len(ai_text) > 200 else "")
|
||||
print(f"\n AI peer representation:")
|
||||
print("\n AI peer representation:")
|
||||
print(f" {display}")
|
||||
|
||||
if not card and not ai_text:
|
||||
|
|
@ -1186,7 +1182,7 @@ def _cmd_status_all() -> None:
|
|||
marker = " *" if name == active else ""
|
||||
print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {recall:<9} {write}")
|
||||
|
||||
print(f"\n * active profile\n")
|
||||
print("\n * active profile\n")
|
||||
|
||||
|
||||
def cmd_peers(args) -> None:
|
||||
|
|
@ -1326,7 +1322,7 @@ def cmd_mode(args) -> None:
|
|||
for m, desc in MODES.items():
|
||||
marker = " <-" if m == current else ""
|
||||
print(f" {m:<10} {desc}{marker}")
|
||||
print(f"\n Set with: hermes honcho mode [hybrid|context|tools]\n")
|
||||
print("\n Set with: hermes honcho mode [hybrid|context|tools]\n")
|
||||
return
|
||||
|
||||
if mode_arg not in MODES:
|
||||
|
|
@ -1361,7 +1357,7 @@ def cmd_strategy(args) -> None:
|
|||
for s, desc in STRATEGIES.items():
|
||||
marker = " <-" if s == current else ""
|
||||
print(f" {s:<15} {desc}{marker}")
|
||||
print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
|
||||
print("\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
|
||||
return
|
||||
|
||||
if strat_arg not in STRATEGIES:
|
||||
|
|
|
|||
|
|
@ -25,14 +25,50 @@ 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 — 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.
|
||||
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 +142,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 |
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@ Configuration
|
|||
-------------
|
||||
Secret (lives in $HERMES_HOME/.env or the environment):
|
||||
MEM0_API_KEY — Mem0 Platform API key (required for platform mode)
|
||||
MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the
|
||||
plugin talks to that server directly over HTTP
|
||||
(X-API-Key auth) instead of the cloud API.
|
||||
|
||||
Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory
|
||||
setup`):
|
||||
mode — Backend mode: "platform" (default) or "oss"
|
||||
host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var).
|
||||
When set, routes to the self-hosted HTTP backend.
|
||||
user_id — Canonical user identifier. When set, it is applied
|
||||
uniformly across every gateway (CLI, Telegram, Slack,
|
||||
Discord, …) so the same human gets one merged memory
|
||||
|
|
@ -44,7 +49,7 @@ logger = logging.getLogger(__name__)
|
|||
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
|
||||
_BREAKER_THRESHOLD = 5
|
||||
_BREAKER_COOLDOWN_SECS = 120
|
||||
_PREFETCH_WAIT_SECS = 1.5
|
||||
_PREFETCH_WAIT_SECS = 3
|
||||
|
||||
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
|
||||
|
||||
|
|
@ -81,6 +86,7 @@ def _load_config() -> dict:
|
|||
config = {
|
||||
"mode": os.environ.get("MEM0_MODE", "platform"),
|
||||
"api_key": os.environ.get("MEM0_API_KEY", ""),
|
||||
"host": os.environ.get("MEM0_HOST", ""),
|
||||
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
|
||||
"oss": {},
|
||||
}
|
||||
|
|
@ -107,41 +113,22 @@ def _load_config() -> dict:
|
|||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIST_SCHEMA = {
|
||||
"name": "mem0_list",
|
||||
"description": (
|
||||
"List ALL stored memories about the user, unranked and paginated. "
|
||||
"Use for a full overview/audit at conversation start, or to browse "
|
||||
"everything when you don't have a specific query. For answering a "
|
||||
"specific question, prefer mem0_search."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {"type": "integer", "description": "Page number (default: 1)."},
|
||||
"page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "mem0_search",
|
||||
"description": (
|
||||
"Search the user's memories by meaning; returns facts ranked by "
|
||||
"relevance. Use this BEFORE answering any question that may depend on "
|
||||
"relevance. Use this before answering any question that may depend on "
|
||||
"what you know about the user (preferences, facts, history, people, "
|
||||
"projects, past decisions). For multi-part or multi-hop questions, "
|
||||
"call it MULTIPLE times — vary the wording and run follow-up searches "
|
||||
"on what earlier results reveal; one search is rarely enough. Set "
|
||||
"rerank=true for higher accuracy on important queries."
|
||||
"call it several times — vary the wording and run follow-up searches "
|
||||
"on what earlier results reveal; one search is rarely enough."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
|
||||
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."},
|
||||
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
|
|
@ -169,7 +156,7 @@ UPDATE_SCHEMA = {
|
|||
"name": "mem0_update",
|
||||
"description": (
|
||||
"Replace the text of an existing memory by its ID (take the ID from a "
|
||||
"mem0_search or mem0_list result). Use when a stored fact has changed "
|
||||
"mem0_search result). Use when a stored fact has changed "
|
||||
"or was wrong — correct it in place instead of adding a duplicate."
|
||||
),
|
||||
"parameters": {
|
||||
|
|
@ -185,7 +172,7 @@ UPDATE_SCHEMA = {
|
|||
DELETE_SCHEMA = {
|
||||
"name": "mem0_delete",
|
||||
"description": (
|
||||
"Delete a memory by its ID (take the ID from a mem0_search or mem0_list "
|
||||
"Delete a memory by its ID (take the ID from a mem0_search "
|
||||
"result). Use when a stored fact is obsolete or the user asks you to "
|
||||
"forget it; prefer mem0_update if the fact merely changed."
|
||||
),
|
||||
|
|
@ -214,8 +201,10 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
self._backend = None
|
||||
self._mode = "platform"
|
||||
self._api_key = ""
|
||||
self._host = ""
|
||||
self._user_id = _DEFAULT_USER_ID
|
||||
self._agent_id = "hermes"
|
||||
self._rerank_default = False
|
||||
self._channel = "cli" # gateway channel name (cli/telegram/discord/...)
|
||||
self._sync_thread = None
|
||||
self._prefetch_thread = None
|
||||
|
|
@ -239,7 +228,9 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
mode = cfg.get("mode", "platform")
|
||||
if mode == "oss":
|
||||
return bool(cfg.get("oss", {}).get("vector_store"))
|
||||
return bool(cfg.get("api_key"))
|
||||
# Platform needs an api_key; self-hosted needs a host (api_key optional
|
||||
# when the server runs with AUTH_DISABLED).
|
||||
return bool(cfg.get("api_key") or cfg.get("host"))
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
"""Write config to $HERMES_HOME/mem0.json."""
|
||||
|
|
@ -262,9 +253,10 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
api_key_required = mode != "oss"
|
||||
return [
|
||||
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
|
||||
{"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"},
|
||||
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
|
||||
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
|
||||
]
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
|
|
@ -288,6 +280,9 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
if self._mode == "oss":
|
||||
from ._backend import OSSBackend
|
||||
return OSSBackend(self._config.get("oss", {}))
|
||||
if self._host:
|
||||
from ._backend import SelfHostedBackend
|
||||
return SelfHostedBackend(self._api_key, self._host)
|
||||
from ._backend import PlatformBackend
|
||||
return PlatformBackend(self._api_key)
|
||||
except Exception as e:
|
||||
|
|
@ -342,6 +337,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
self._config = _load_config()
|
||||
self._mode = self._config.get("mode", "platform")
|
||||
self._api_key = self._config.get("api_key", "")
|
||||
self._host = self._config.get("host", "")
|
||||
# Resolution order for user_id:
|
||||
# 1. Operator-configured MEM0_USER_ID (env or $HERMES_HOME/mem0.json) —
|
||||
# the canonical principal, applied across every gateway so the same
|
||||
|
|
@ -358,6 +354,14 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
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 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
return {"channel": self._channel} if self._channel else {}
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
mode_label = "platform (cloud API)" if self._mode == "platform" else "OSS (self-hosted)"
|
||||
rerank_note = " Rerank is available on search." if self._mode == "platform" else ""
|
||||
# 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 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
body = ""
|
||||
try:
|
||||
results = backend.search(
|
||||
query, filters=self._read_filters(), top_k=10, rerank=True,
|
||||
query, filters=self._read_filters(), top_k=10, rerank=False,
|
||||
)
|
||||
lines = [r.get("memory", "") for r in (results or []) if r.get("memory")]
|
||||
if lines:
|
||||
|
|
@ -497,7 +511,7 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
self._sync_thread.start()
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [LIST_SCHEMA, SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
|
||||
return [SEARCH_SCHEMA, ADD_SCHEMA, UPDATE_SCHEMA, DELETE_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if self._backend is None:
|
||||
|
|
@ -516,36 +530,13 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
msg += f" Check that your {vs.get('provider', 'vector store')} is running."
|
||||
return json.dumps({"error": msg})
|
||||
|
||||
if tool_name == "mem0_list":
|
||||
try:
|
||||
page = max(1, int(args.get("page", 1)))
|
||||
page_size = min(max(1, int(args.get("page_size", 100))), 200)
|
||||
response = self._backend.get_all(
|
||||
filters=self._read_filters(), page=page, page_size=page_size,
|
||||
)
|
||||
self._record_success()
|
||||
results = response.get("results", [])
|
||||
if not results:
|
||||
return json.dumps({"result": "No memories stored yet."})
|
||||
items = [{"id": m.get("id"), "memory": m.get("memory", "")}
|
||||
for m in results]
|
||||
return json.dumps({
|
||||
"results": items,
|
||||
"count": response.get("count", len(items)),
|
||||
"page": page, "page_size": page_size,
|
||||
})
|
||||
except Exception as e:
|
||||
if not _is_client_error(e):
|
||||
self._record_failure()
|
||||
return tool_error(self._format_error("Failed to list memories", e))
|
||||
|
||||
elif tool_name == "mem0_search":
|
||||
if tool_name == "mem0_search":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
try:
|
||||
top_k = max(1, min(int(args.get("top_k", 10)), 50))
|
||||
rerank_raw = args.get("rerank", True)
|
||||
rerank_raw = args.get("rerank", getattr(self, "_rerank_default", False))
|
||||
if isinstance(rerank_raw, str):
|
||||
rerank = rerank_raw.lower() not in ("false", "0", "no")
|
||||
else:
|
||||
|
|
@ -576,7 +567,8 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
)
|
||||
self._record_success()
|
||||
event_id = result.get("event_id") if isinstance(result, dict) else None
|
||||
msg = "Fact stored." if self._mode == "oss" else "Fact queued for storage."
|
||||
# Cloud add is async (server-side extraction); OSS and self-hosted store synchronously.
|
||||
msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage."
|
||||
return json.dumps({"result": msg, "event_id": event_id})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
|
|
|
|||
|
|
@ -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 @@ class PlatformBackend(Mem0Backend):
|
|||
from mem0 import MemoryClient
|
||||
self._client = MemoryClient(api_key=api_key)
|
||||
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
response = self._client.search(query, filters=filters, top_k=top_k, rerank=rerank)
|
||||
return _unwrap_results(response)
|
||||
|
||||
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 @@ class PlatformBackend(Mem0Backend):
|
|||
return {"result": "Memory deleted.", "memory_id": memory_id}
|
||||
|
||||
|
||||
class SelfHostedBackend(Mem0Backend):
|
||||
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
|
||||
|
||||
mem0.MemoryClient can't be reused for self-hosted: it is hardwired to the
|
||||
cloud API — ``Authorization: Token`` auth and a ``GET /v1/ping/`` validation
|
||||
call in ``__init__`` that the self-hosted server does not expose (it would
|
||||
404 before any real request). This client talks to that server directly,
|
||||
using its actual contract: ``X-API-Key`` auth and the ``/memories`` /
|
||||
``/search`` routes.
|
||||
"""
|
||||
|
||||
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 @@ class OSSBackend(Mem0Backend):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = True) -> list[dict]:
|
||||
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
|
||||
response = self._memory.search(query, filters=filters, top_k=top_k)
|
||||
return _unwrap_results(response)
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -230,7 +232,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 +295,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"
|
||||
|
|
@ -305,12 +325,108 @@ def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> No
|
|||
if env_writes:
|
||||
_write_env(Path(hermes_home) / ".env", env_writes)
|
||||
|
||||
print(f"\n Memory provider: mem0")
|
||||
print(f" Activation saved to config.yaml")
|
||||
print(f" Provider config saved")
|
||||
print("\n Memory provider: mem0")
|
||||
print(" Activation saved to config.yaml")
|
||||
print(" Provider config saved")
|
||||
if env_writes:
|
||||
print(f" API keys saved to .env")
|
||||
print(f"\n Start a new session to activate.\n")
|
||||
print(" API keys saved to .env")
|
||||
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:
|
||||
|
|
@ -358,14 +474,14 @@ def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
|
|||
save_config(config)
|
||||
|
||||
_run_connectivity_checks(oss_config)
|
||||
print(f"\n ✓ Mem0 configured (OSS mode)")
|
||||
print("\n ✓ Mem0 configured (OSS mode)")
|
||||
print(f" LLM: {oss_config['llm']['provider']} ({oss_config['llm']['config'].get('model', '')})")
|
||||
print(f" Embedder: {oss_config['embedder']['provider']} ({oss_config['embedder']['config'].get('model', '')})")
|
||||
print(f" Vector: {vector_id}")
|
||||
if env_writes:
|
||||
print(f" API keys saved to .env")
|
||||
print(f" Config saved to mem0.json")
|
||||
print(f" Provider set in config.yaml")
|
||||
print(" API keys saved to .env")
|
||||
print(" Config saved to mem0.json")
|
||||
print(" Provider set in config.yaml")
|
||||
print("\n Start a new session to activate.\n")
|
||||
|
||||
|
||||
|
|
@ -417,7 +533,7 @@ def _ensure_pgvector(host: str = "localhost", port: int = 5432) -> dict | None:
|
|||
_wait_for_port(host, port, timeout=15)
|
||||
ok, _ = _check_pgvector(host, port)
|
||||
if ok:
|
||||
print(f" ✓ PostgreSQL container restarted")
|
||||
print(" ✓ PostgreSQL container restarted")
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -711,14 +827,14 @@ def _setup_oss_interactive(hermes_home: str, config: dict) -> None:
|
|||
save_config(config)
|
||||
|
||||
_run_connectivity_checks(oss_config)
|
||||
print(f"\n ✓ Mem0 configured (OSS mode)")
|
||||
print("\n ✓ Mem0 configured (OSS mode)")
|
||||
print(f" LLM: {oss_config['llm']['provider']} ({oss_config['llm']['config'].get('model', '')})")
|
||||
print(f" Embedder: {oss_config['embedder']['provider']} ({oss_config['embedder']['config'].get('model', '')})")
|
||||
print(f" Vector: {vector_id}")
|
||||
if env_writes:
|
||||
print(f" API keys saved to .env")
|
||||
print(f" Config saved to mem0.json")
|
||||
print(f" Provider set in config.yaml")
|
||||
print(" API keys saved to .env")
|
||||
print(" Config saved to mem0.json")
|
||||
print(" Provider set in config.yaml")
|
||||
print("\n Start a new session to activate.\n")
|
||||
|
||||
|
||||
|
|
@ -828,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:])
|
||||
|
|
@ -841,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
|
||||
|
|
@ -848,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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -576,6 +576,8 @@ _TOOL_STATUS_COMPLETED_ALIASES = {"completed", "complete", "success", "succeeded
|
|||
|
||||
def _zip_directory(dir_path: Path) -> Path:
|
||||
"""Create a temporary zip file containing a directory tree."""
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
|
||||
root = dir_path.resolve()
|
||||
zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
|
|
@ -584,7 +586,12 @@ def _zip_directory(dir_path: Path) -> Path:
|
|||
continue
|
||||
if file_path.is_file():
|
||||
try:
|
||||
file_path.resolve().relative_to(root)
|
||||
resolved = file_path.resolve()
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
raise_if_read_blocked(str(resolved))
|
||||
except ValueError:
|
||||
continue
|
||||
arcname = str(file_path.relative_to(dir_path)).replace("\\", "/")
|
||||
|
|
@ -3645,6 +3652,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def _tool_add_resource(self, args: dict) -> str:
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
|
||||
url = args.get("url", "")
|
||||
if not url:
|
||||
return tool_error("url is required")
|
||||
|
|
@ -3678,6 +3687,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
cleanup_path = _zip_directory(source_path)
|
||||
upload_path = cleanup_path
|
||||
elif source_path.is_file():
|
||||
try:
|
||||
raise_if_read_blocked(str(source_path))
|
||||
except ValueError as exc:
|
||||
return tool_error(str(exc))
|
||||
payload["source_name"] = source_path.name
|
||||
upload_path = source_path
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from typing import Any, Dict, List
|
|||
from urllib.parse import quote
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -702,6 +703,10 @@ class RetainDBMemoryProvider(MemoryProvider):
|
|||
path_obj = Path(local_path)
|
||||
if not path_obj.exists():
|
||||
return {"error": f"File not found: {local_path}"}
|
||||
try:
|
||||
raise_if_read_blocked(str(path_obj))
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
data = path_obj.read_bytes()
|
||||
import mimetypes
|
||||
mime = mimetypes.guess_type(path_obj.name)[0] or "application/octet-stream"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue