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:
Erosika 2026-07-08 15:03:24 -04:00
commit 1508ece2d9
948 changed files with 85189 additions and 7133 deletions

View file

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

View file

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

View file

@ -9,10 +9,15 @@ Configuration
-------------
Secret (lives in $HERMES_HOME/.env or the environment):
MEM0_API_KEY — Mem0 Platform API key (required for platform mode)
MEM0_HOST — Base URL of a self-hosted Mem0 server. When set, the
plugin talks to that server directly over HTTP
(X-API-Key auth) instead of the cloud API.
Behavioral settings (live in $HERMES_HOME/mem0.json, set via `hermes memory
setup`):
mode — Backend mode: "platform" (default) or "oss"
host — Self-hosted Mem0 server URL (alt: MEM0_HOST env var).
When set, routes to the self-hosted HTTP backend.
user_id — Canonical user identifier. When set, it is applied
uniformly across every gateway (CLI, Telegram, Slack,
Discord, …) so the same human gets one merged memory
@ -44,7 +49,7 @@ logger = logging.getLogger(__name__)
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
_BREAKER_THRESHOLD = 5
_BREAKER_COOLDOWN_SECS = 120
_PREFETCH_WAIT_SECS = 1.5
_PREFETCH_WAIT_SECS = 3
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
@ -81,6 +86,7 @@ def _load_config() -> dict:
config = {
"mode": os.environ.get("MEM0_MODE", "platform"),
"api_key": os.environ.get("MEM0_API_KEY", ""),
"host": os.environ.get("MEM0_HOST", ""),
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
"oss": {},
}
@ -107,41 +113,22 @@ def _load_config() -> dict:
# Tool schemas
# ---------------------------------------------------------------------------
LIST_SCHEMA = {
"name": "mem0_list",
"description": (
"List ALL stored memories about the user, unranked and paginated. "
"Use for a full overview/audit at conversation start, or to browse "
"everything when you don't have a specific query. For answering a "
"specific question, prefer mem0_search."
),
"parameters": {
"type": "object",
"properties": {
"page": {"type": "integer", "description": "Page number (default: 1)."},
"page_size": {"type": "integer", "description": "Results per page (default: 100, max: 200)."},
},
"required": [],
},
}
SEARCH_SCHEMA = {
"name": "mem0_search",
"description": (
"Search the user's memories by meaning; returns facts ranked by "
"relevance. Use this BEFORE answering any question that may depend on "
"relevance. Use this before answering any question that may depend on "
"what you know about the user (preferences, facts, history, people, "
"projects, past decisions). For multi-part or multi-hop questions, "
"call it MULTIPLE times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough. Set "
"rerank=true for higher accuracy on important queries."
"call it several times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: true, platform mode only)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
},
"required": ["query"],
},
@ -169,7 +156,7 @@ UPDATE_SCHEMA = {
"name": "mem0_update",
"description": (
"Replace the text of an existing memory by its ID (take the ID from a "
"mem0_search or mem0_list result). Use when a stored fact has changed "
"mem0_search result). Use when a stored fact has changed "
"or was wrong — correct it in place instead of adding a duplicate."
),
"parameters": {
@ -185,7 +172,7 @@ UPDATE_SCHEMA = {
DELETE_SCHEMA = {
"name": "mem0_delete",
"description": (
"Delete a memory by its ID (take the ID from a mem0_search or mem0_list "
"Delete a memory by its ID (take the ID from a mem0_search "
"result). Use when a stored fact is obsolete or the user asks you to "
"forget it; prefer mem0_update if the fact merely changed."
),
@ -214,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()

View file

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

View file

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

View file

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

View file

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

View file

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