Compare commits

..

10 Commits

Author SHA1 Message Date
kartik-mem0 9a5497469d test(plugins): a removed plugin setting gives way to a later mem0 init key 2026-09-25 11:54:58 +05:30
kartik-mem0 12617b6d8d test(plugins): assert the generated DATA_DIR_NAME in the build tests 2026-09-25 11:20:52 +05:30
kartik-mem0 cbfb37c623 fix(plugins): give the MCP server the hooks' data dir, release 0.3.4
mcp_server.py never calls configure_harness, so it resolved ~/.mem0/mem0-plugin while the Cursor, Kimi and Antigravity hooks use ~/.mem0/<host>-plugin. A key set in Cursor's plugin settings and cached by the sessionStart hook was invisible to search_memories. build.py now writes DATA_DIR_NAME into each bundle's _harness_id.py and memory_core seeds its default from it, like the PLATFORM_* values.

An end-to-end test launches the real session-start hook and MCP server for Claude Code, Cursor and Codex the way each host does, with the key in plugin settings and in the mem0 init config, and checks the search reaches the API with that key.

Bumps the Python plugins to 0.3.4, OpenCode to 0.4.2, Pi and DeepSeek to 0.3.3, with changelog entries for each.

Fixes #7346
2026-09-25 11:11:37 +05:30
kartik-mem0 71dc0cae07 fix(plugins): read the mem0 CLI key and ignore unexpanded host placeholders
The Python plugin core and the opencode, pi and deepseek plugins now fall back to the key `mem0 init` saves in ~/.mem0/config.json, so a configured CLI is enough to authenticate. The Python core also stops using or caching a literal ${api_key} left behind when a host (Cursor) does not expand its plugin variables, which is what kept asking for auth after the key was set.

Fixes #7346
2026-09-25 10:24:04 +05:30
mintlify[bot] 8d6c001966 Fix grammar & typos: correct OpenSearch spelling in changelog (#7442)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-09-25 09:58:06 +05:30
Varun S G 989c7da0fc fix(oss): stop ConfigManager from injecting OpenAI's baseURL and model into other providers (#7350) 2026-09-24 22:14:28 +05:30
Mohd Quamar Tyagi d675cf68ad fix(memory): restore Memory and AsyncMemory context-manager protocol (#7354) 2026-09-24 22:04:57 +05:30
Diwakar Ray Yadav 2c6ff619d1 fix(memory): exclude vector-store-rejected records from ADD results (#7066) 2026-09-24 22:01:31 +05:30
Kartik f4acc89a29 docs: remove memory types page and preserve redirects (#7437) 2026-09-24 18:00:54 +05:30
Kartik 43849c6e9d feat(integrations): migrate and validate standalone Hermes Mem0 plugin (#7372) 2026-09-24 17:53:29 +05:30
91 changed files with 3687 additions and 580 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/claude-code-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.3"
"version": "0.3.4"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/cursor-plugin",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.3"
"version": "0.3.4"
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
{
"id": "mem0",
"displayName": "Mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"homepage": "https://mem0.ai",
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
+78 -1
View File
@@ -982,7 +982,7 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
**New Features:**
- **OpenMemory:** Added OpenMemory support
- **Neo4j:** Added weights to Neo4j model
- **AWS:** Added support for Opsearch Serverless
- **AWS:** Added support for OpenSearch Serverless
- **Examples:** Added ElizaOS Example
**Improvements:**
@@ -2100,6 +2100,16 @@ A full-featured command-line interface for Mem0, available in both Python and No
<Tabs>
<Tab title="Mem0 Plugin">
<Update label="2026-09-25" description="Portable agent plugin v0.3.4">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **State:** The `search_memories` MCP server now keeps its state in `~/.mem0/coding-agent-plugin`, the same directory the status, pause, resume and forget skills use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-08" description="Shared agent plugin runtime">
**Changed:**
@@ -2411,6 +2421,15 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Claude Code">
<Update label="2026-09-25" description="Claude Code plugin v0.3.4">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Claude Code plugin v0.3.3">
**Improvements:**
@@ -2452,6 +2471,16 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Cursor">
<Update label="2026-09-25" description="Cursor plugin v0.3.4">
**Fixes:**
- **Auth:** An API key set in the Cursor plugin settings now reaches the `search_memories` MCP server. The server looked for the cached key in `~/.mem0/mem0-plugin` while the hooks cached it in `~/.mem0/cursor-plugin`, so search asked for auth whenever Cursor did not pass the setting to the MCP server directly ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when no key is set in the plugin settings or `MEM0_API_KEY`, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Auth:** A plugin setting that reaches the plugin as an unexpanded `${api_key}` or `${user_id}` placeholder is treated as unset. It is no longer sent as the API key, cached to disk, or used as the user ID ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Cursor plugin v0.3.3">
**Improvements:**
@@ -2492,6 +2521,14 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Codex">
<Update label="2026-09-25" description="Codex plugin v0.3.4">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Codex plugin v0.3.3">
**Improvements:**
@@ -2531,6 +2568,13 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="OpenCode">
<Update label="2026-09-25" description="OpenCode plugin v0.4.2">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `MEM0_API_KEY` nor a shell profile sets one, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="OpenCode plugin v0.4.1">
**Improvements:**
@@ -2646,6 +2690,15 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Antigravity">
<Update label="2026-09-25" description="Antigravity plugin v0.3.4">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Data directory:** The `search_memories` MCP server uses `~/.mem0/antigravity-plugin`, the data directory the hooks use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Antigravity plugin v0.3.3">
**Improvements:**
@@ -2759,6 +2812,15 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Kimi">
<Update label="2026-09-25" description="Kimi Code plugin v0.3.4">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when `MEM0_API_KEY` is not set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Data directory:** The `search_memories` MCP server uses `~/.mem0/kimi-plugin`, the data directory the hooks use, instead of `~/.mem0/mem0-plugin` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Packaging:** `PLUGIN_VERSION` bumped to `0.3.4`, so the `mem0-plugin/<version>` wire header and `plugin_version` telemetry field identify builds with this fix ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Kimi Code plugin v0.3.3">
**Improvements:**
@@ -3117,6 +3179,13 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Pi Agent">
<Update label="2026-09-25" description="Pi Agent plugin v0.3.3">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `MEM0_API_KEY` nor `~/.pi/agent/mem0-config.json` sets one, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="Pi Agent plugin v0.3.2">
**Improvements:**
@@ -3246,6 +3315,14 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="DeepSeek Harness">
<Update label="2026-09-25" description="deepseek-plugin v0.3.3">
**Fixes:**
- **Auth:** Falls back to the API key `mem0 init` saved in `~/.mem0/config.json` when neither `config.apiKey` nor `MEM0_API_KEY` is set, so search and the status check no longer report a missing key after `mem0 init` ([#7449](https://github.com/mem0ai/mem0/pull/7449))
- **Config:** An empty `apiKey` now counts as unset and falls through to `MEM0_API_KEY` and the `mem0 init` key, instead of failing validation ([#7449](https://github.com/mem0ai/mem0/pull/7449))
</Update>
<Update label="2026-09-23" description="deepseek-plugin v0.3.2">
**Improvements:**
+2 -2
View File
@@ -89,8 +89,8 @@ On Mem0 Platform, these stores are managed for you. In OSS, you choose and opera
## Next steps
<CardGroup cols={3}>
<Card title="Memory types" icon="brain" href="/core-concepts/memory-types">
Choose the right scope for user, agent, run, and session memory.
<Card title="Entity scoping" icon="brain" href="/platform/features/entity-scoped-memory">
Organize Platform memories by user, agent, app, and run.
</Card>
<Card title="Memory operations" icon="database" href="/core-concepts/memory-operations/add">
Add, search, update, and delete memories from your app.
-118
View File
@@ -1,118 +0,0 @@
---
title: Memory Types
description: "What memory_type actually does in Mem0: procedural memory is implemented, semantic and episodic are not."
icon: "tag"
iconType: "solid"
---
# Memory Types
Mem0's Python SDK exposes a `memory_type` parameter on `add()`. The underlying `MemoryType` enum defines three values, but only one of them is wired up. This page states plainly which is which so you don't build against a type that doesn't exist yet.
## Status
| Type | Enum value | Status | Notes |
| --- | --- | --- | --- |
| Procedural memory | `procedural_memory` | **Implemented** | Python OSS only (`Memory`/`AsyncMemory`). Pass `memory_type="procedural_memory"` and `agent_id` to `add()`. Not available on the Platform `MemoryClient`, and not available in the TypeScript SDK (OSS or Platform). |
| Semantic memory | `semantic_memory` | **Not implemented** | Defined in the `MemoryType` enum but never read anywhere else in the codebase. Passing it to `add()` raises a validation error. There is no evidence in this repo of a roadmap date for this. |
| Episodic memory | `episodic_memory` | **Not implemented** | Same as above: defined, never wired into the extraction pipeline, rejected by validation, no documented roadmap. |
<Warning>
Only `procedural_memory` is a real, working value. Calling `memory.add(messages, memory_type="semantic_memory")` (or `episodic_memory`) is rejected and tells you to pass `procedural_memory` instead. Sync `Memory.add()` raises `Mem0ValidationError`; `AsyncMemory.add()` raises a plain `ValueError`.
</Warning>
## Procedural memory
Procedural memory stores step-by-step task knowledge (how an agent performs a workflow) rather than facts about a user. It requires `agent_id`:
```python
from mem0 import Memory
memory = Memory()
memory.add(
[
{"role": "user", "content": "Book a flight from SFO to NYC"},
{"role": "assistant", "content": "1. Search flights. 2. Filter by price. 3. Confirm booking."},
],
agent_id="travel-agent",
memory_type="procedural_memory",
)
```
Omit `memory_type` entirely and Mem0 stores the messages as an ordinary memory: there is no semantic/episodic pathway for it to fall into. Any other explicit value is rejected by validation rather than quietly falling back to an ordinary memory.
## How every other memory is scoped
Outside of the `procedural_memory` special case, Mem0 does not sort memories into named types. Every memory is scoped by the identifiers you pass in, and the same identifiers are used to retrieve it later:
- **`user_id`**: ties a memory to a specific person or account.
- **`agent_id`**: ties a memory to a specific agent or assistant persona.
- **`run_id`**: ties a memory to a specific session, task, or conversation thread.
- **`app_id`** (Platform only): ties a memory to a specific application or tenant, in addition to the three above. See <Link href="/platform/features/entity-scoped-memory">Entity-Scoped Memory</Link>.
At least one identifier is required on `add()`. Passing more than one narrows the scope further (for example, `user_id` + `run_id` together).
```python
from mem0 import Memory
memory = Memory()
memory.add(
"I'm Alex and I prefer boutique hotels.",
user_id="alex",
run_id="trip-planning-2025",
)
results = memory.search(
"Any hotel preferences?",
filters={"user_id": "alex", "run_id": "trip-planning-2025"},
)
```
<Tip>
Use `run_id` when you want a set of memories to stay tied to one session or task; use `user_id` alone for anything that should persist across every session for that person.
</Tip>
## How memories are extracted and updated
When `infer=True` (the default) on `add()`, Mem0 runs a single pipeline rather than routing through separate type-specific paths:
1. **Context gathering**: pulls the most recent messages already stored for the same `user_id`/`agent_id`/`run_id` scope.
2. **Existing memory retrieval**: embeds the new messages and runs a vector search against memories already in that same scope, to find candidates that might need to change.
3. **Extraction**: a single LLM call compares the new messages against the retrieved candidates and decides, per fact, whether to `ADD`, `UPDATE`, `DELETE`, or leave a memory alone.
Alongside this, both OSS and Platform extract named entities (people, places, organizations) from memory text and use shared entities between memories to boost related results at search time. On Platform, that entity graph is also queryable directly; see <Link href="/platform/features/graph-memory">Graph Memory</Link>. In OSS, entities only affect ranking, there is no separate graph to query.
<Warning>
Avoid storing secrets or unredacted PII in memories: they are retrievable by design. Encrypt or hash sensitive values before calling `add()`.
</Warning>
## Put it into practice
<CardGroup cols={2}>
<Card
title="Explore Memory Operations"
description="Dive into the add/search/update/delete operations next."
icon="circle-check"
href="/core-concepts/memory-operations/add"
/>
<Card
title="Advanced Memory Operations"
description="Tune metadata, filters, and retrieval on Platform."
icon="sliders"
href="/platform/advanced-memory-operations"
/>
<Card
title="AI Tutor Cookbook"
description="See user_id-scoped memory used in a real tutoring agent."
icon="rocket"
href="/cookbooks/companions/ai-tutor"
/>
<Card
title="Support Inbox Cookbook"
description="See user_id-scoped memory used in a support workflow."
icon="inbox"
href="/cookbooks/operations/support-inbox"
/>
</CardGroup>
+5 -2
View File
@@ -54,7 +54,6 @@
"icon": "brain",
"pages": [
"core-concepts/how-it-works",
"core-concepts/memory-types",
"core-concepts/memory-operations/add",
"core-concepts/memory-operations/search",
"core-concepts/memory-operations/update",
@@ -1033,7 +1032,11 @@
},
{
"source": "/concepts/memory-scoring",
"destination": "/core-concepts/memory-types"
"destination": "/core-concepts/how-it-works"
},
{
"source": "/core-concepts/memory-types",
"destination": "/core-concepts/how-it-works"
},
{
"source": "/cookbooks/research-copilot",
+4
View File
@@ -28,6 +28,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The Mem0 plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
</Tip>
## Installation
**Option A: degit** (recommended):
+3 -3
View File
@@ -130,10 +130,10 @@ print(response.msgs[0].content)
<CardGroup cols={2}>
<Card
title="Memory types in Mem0"
description="Choose between chat history and semantic search for your Camel agents."
title="How Mem0 works"
description="Understand how Mem0 extracts, stores, and retrieves memories for your Camel agents."
icon="sparkles"
href="/core-concepts/memory-types"
href="/core-concepts/how-it-works"
/>
<Card
title="Try LangChain next"
+1 -1
View File
@@ -166,7 +166,7 @@ claude plugin update mem0@mem0-plugins --scope user
| Problem | Fix |
| --- | --- |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set. |
| Missing key | Reinstall with `--config api_key="$MEM0_API_KEY"` while the var is set, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
| `401 Unauthorized` | API key is invalid or expired. Run `/mem0:status` to confirm. |
| No memory after ending a session | Extraction runs in the background. Wait a moment, then search again. |
| Sidekick won't start | Must be in a Git repo. Check that your Claude Code version supports plugin agents and worktrees. |
+4
View File
@@ -35,6 +35,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` takes precedence when set.
</Tip>
## Installation
### Option A: Plugin Marketplace (Recommended)
+5
View File
@@ -35,6 +35,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The full plugin (Option A) also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. That includes Cursor opened from the Dock, which does not load your shell profile. A key from the plugin configuration or the environment takes precedence.
</Tip>
<Warning>
Already have `mem0` configured as an MCP server in Cursor? Remove the existing entry from your Cursor MCP settings before installing to avoid duplicate tools.
</Warning>
@@ -164,6 +168,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
## Troubleshooting
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`
- **Still asked for an API key**: Run `mem0 init` with the [Mem0 CLI](/platform/cli). The full plugin falls back to the key it saves.
- **Duplicate tools**: Do not combine the full plugin with an MCP-only option. Remove the standalone `mem0` MCP entry before installing the plugin.
- **No tools appearing**: Go to Cursor Settings > MCP and verify the `mem0` server shows as connected
+5 -1
View File
@@ -56,6 +56,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `config.apiKey` and `MEM0_API_KEY` take precedence.
</Tip>
## Try it locally
1. Build and pack the plugin:
@@ -103,7 +107,7 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
| Field | Required | Default | Notes |
|---|---|---|---|
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key |
| `apiKey` | no | `$MEM0_API_KEY` | Mem0 platform API key. Falls back to the key `mem0 init` saved. |
| `userId` | yes | | Default entity that owns the memories |
| `allowUserOverride` | no | `false` | Permit model-selected access to a different user only in a trusted multi-user deployment |
| `host` | no | `api.mem0.ai` | Platform base URL (on-prem / dedicated) |
+116 -92
View File
@@ -1,9 +1,9 @@
---
title: Hermes Agent
description: "Add long-term memory to Hermes agents using Mem0 Platform, a self-hosted server, or local OSS mode with background fact extraction."
description: "Add persistent memory to Hermes Agent with Mem0 Cloud, a self-hosted server, or the in-process OSS SDK."
---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat.
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. The [standalone Mem0 plugin](https://github.com/mem0ai/mem0/tree/main/integrations/hermes-plugin-mem0) learns facts from conversations and recalls relevant memories for the current question.
You can run Mem0 in three ways:
@@ -17,11 +17,15 @@ Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alon
### 1. Current-turn recall (bounded wait)
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself — so a slow backend never blocks a turn.
When you send a message, Hermes searches your stored memories for the current question and waits up to 3 seconds for results. If they arrive in time, they are injected into the system prompt so the model can see them. If the backend is slower, Hermes skips the injection and the model can still call `mem0_search` itself after the bounded recall wait.
### 2. Background fact extraction (sync)
Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
Once the model finishes, the plugin sends the user message and assistant response to Mem0 in a background thread for fact extraction. Each write includes the agent identifier and gateway channel.
<Note>
Automatic capture truncates each message to **450 characters by default in every mode**, preferring a sentence boundary. Adjust `sync_max_chars` for your model's context limit. Capture is best effort: if the previous sync is still running after a five-second wait, the next turn is skipped. Use `mem0_add` to store specific text verbatim.
</Note>
## Agent Tools
@@ -29,21 +33,33 @@ When Mem0 is active, the model gets four tools it can call during a conversation
| Tool | Description | Parameters |
|------|-------------|------------|
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `false`, Platform mode only) |
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (uses the configured default, Platform mode only) |
| `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
## Installation
Install Hermes Agent:
Install [Hermes Agent](https://github.com/NousResearch/hermes-agent) with memory-provider plugin support and Python 3.11 or later. Once the plugin directory is available on Mem0's main branch, install it from the repository subdirectory:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
source ~/.bashrc
hermes plugins install mem0ai/mem0/integrations/hermes-plugin-mem0
hermes plugins enable mem0
hermes memory setup
hermes memory status
```
The `mem0ai` package is installed automatically when you enable the Mem0 provider, so there is no manual pip step. OSS providers may need extra packages (for example `qdrant-client`, `psycopg2-binary`, or `ollama`), which the setup flow installs for you when you pick them.
Select **mem0** in setup and choose one of the modes below. Start a fresh Hermes conversation after setup.
Hermes installers with plugin dependency support install `mem0ai>=2.0.10,<3` and `httpx>=0.27,<1` from the plugin's `pyproject.toml`. Older hosts such as Hermes v0.21.3 require those packages to be installed explicitly into the Hermes Python environment. The OSS setup flow installs additional provider packages as needed.
<Note>
Hermes versions that still bundle Mem0 prefer the bundled provider. Use a Hermes release that has completed the standalone-provider migration; installing this plugin alone does not replace the bundled implementation. Existing users should keep their current configuration; see [Migration for existing users](#migration-for-existing-users).
</Note>
<Note>
Run the setup wizard in an interactive terminal. On Hermes hosts whose `hermes memory setup --help` lists only a provider argument, options such as `--mode`, `--host`, and `--oss-llm` are rejected by Hermes before the plugin runs. Use `hermes memory setup mem0` or the manual configuration below. Redirected input cannot select the mode picker and falls back to Platform.
</Note>
## Platform Setup
@@ -52,10 +68,10 @@ Platform mode uses managed Mem0 Cloud and is the fastest way to start.
### Option 1: Interactive wizard (recommended)
```bash
hermes memory setup
hermes memory setup mem0
```
Select **mem0**, choose **Platform**, and paste your API key when prompted. The wizard writes the non-secret settings to `~/.hermes/mem0.json` and keeps the key in `~/.hermes/.env`.
Choose **Platform** and paste your API key when prompted. The wizard writes settings to `$HERMES_HOME/mem0.json` and keeps the key in that profile's `.env`. The default Hermes home is `~/.hermes`; named profiles use their own home directory.
<Note>Get your API key from <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-hermes">app.mem0.ai</a>.</Note>
@@ -63,17 +79,25 @@ Select **mem0**, choose **Platform**, and paste your API key when prompted. The
```bash
hermes config set memory.provider mem0
echo "MEM0_API_KEY=your-api-key" >> ~/.hermes/.env
```
Then in your `config.yaml`:
Add your key to the active Hermes profile's `.env`:
```yaml
memory:
provider: mem0
```dotenv
MEM0_API_KEY=your-api-key
```
That's it. Mem0 runs automatically from here.
Set these values in the active profile's `mem0.json`, choosing a stable user identity:
```json
{
"mode": "platform",
"host": "",
"user_id": "my-hermes-user"
}
```
Remove any stale `MEM0_HOST` from the environment and profile `.env`, and remove an old inline `api_key` from `mem0.json` so the new `.env` key is used. The config command sets `memory.provider: mem0` in that profile's `config.yaml`. Restart Hermes and check `hermes memory status`.
## Self-Hosted Server Setup
@@ -82,48 +106,47 @@ Run the [Mem0 server](https://github.com/mem0ai/mem0/tree/main/server) (FastAPI
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Self-hosted server", and enter the server URL
hermes memory setup mem0
# Choose "Self-hosted server", then enter the server URL and API key
```
### With flags
### Manual configuration
```bash
hermes memory setup mem0 --mode selfhosted \
--host http://localhost:8888 \
--api-key your-admin-api-key
Select `mem0` with `hermes config set memory.provider mem0`. Set these values in the active profile's `mem0.json`:
```json
{
"mode": "platform",
"host": "http://localhost:8888",
"user_id": "my-hermes-user"
}
```
### With environment variables
Add the server key to that profile's `.env`:
```bash
echo "MEM0_HOST=http://localhost:8888" >> ~/.hermes/.env
echo "MEM0_API_KEY=your-admin-api-key" >> ~/.hermes/.env
```dotenv
MEM0_API_KEY=your-admin-api-key
```
Remove an old inline `api_key` from `mem0.json` so the `.env` key is used. `MEM0_HOST` can also supply the server URL, but a non-empty `host` in `mem0.json` overrides it. Keep `mode` set to `platform` for the HTTP server backend.
Then 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. The API key is optional only for servers running with `AUTH_DISABLED`.
<Note>Setting `host` routes to the self-hosted server automatically. Don't combine it with `mode: oss` — OSS takes precedence and ignores `host`.</Note>
## OSS (Self-Hosted) Setup
OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
OSS mode runs the Mem0 SDK in the Hermes process with your chosen LLM, embedder, and vector store. It does not use Mem0 Cloud or require a Mem0 API key. Data goes to the model services you configure; use local Ollama models and local storage for a fully local setup.
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Open Source (self-hosted)"
hermes memory setup mem0
# Choose "Open Source"
# Follow the prompts for LLM, embedder, and vector store
```
### With flags
```bash
hermes memory setup mem0 --mode oss \
--oss-llm openai --oss-llm-key sk-... \
--oss-vector qdrant
```
The wizard uses the listed default OpenAI models and local Qdrant storage. For custom OpenAI-compatible endpoints, deployment names, or a Qdrant server, use manual configuration below.
### Supported providers
@@ -133,52 +156,24 @@ hermes memory setup mem0 --mode oss \
| Embedder | `openai` (default `text-embedding-3-small`), `ollama` (local, default `nomic-embed-text`) |
| Vector store | `qdrant` (local path or server), `pgvector` |
### Flag reference
| Flag | Description |
|------|-------------|
| `--mode` | `platform`, `selfhosted`, or `oss` |
| `--api-key` | Platform API key, or the admin key of a self-hosted server |
| `--host` | Self-hosted server URL (with `--mode selfhosted`) |
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
| `--oss-llm-key` | LLM API key (for `openai`) |
| `--oss-llm-model` | Override the LLM model |
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
| `--oss-embedder` | Embedder provider (default `openai`) |
| `--oss-embedder-key` | Embedder API key |
| `--oss-embedder-model` | Override the embedder model |
| `--oss-embedder-url` | Embedder base URL (for `ollama` or a custom endpoint) |
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
| `--oss-vector-path` | Local Qdrant storage path |
| `--oss-vector-url` | Qdrant server URL |
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
| `--user-id` | Canonical user identifier |
| `--dry-run` | Preview the resolved config without writing it |
## Switching Modes
You can move between the three modes at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
### Manual configuration
```bash
# Platform to OSS
hermes memory setup mem0 --mode oss --oss-llm-key sk-...
# OSS to Platform
hermes memory setup mem0 --mode platform --api-key sk-...
# Platform to a self-hosted server
hermes memory setup mem0 --mode selfhosted --host http://localhost:8888
# Preview without writing anything
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
hermes config set memory.provider mem0
```
A self-hosted `~/.hermes/mem0.json` looks like this:
Add the model key to the active profile's `.env`:
```dotenv
OPENAI_API_KEY=your-model-api-key
```
Set the following in that profile's `mem0.json`. Use your existing storage path when migrating; for a new named profile, choose a path inside that profile's home.
```json
{
"mode": "oss",
"user_id": "my-hermes-user",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
@@ -187,33 +182,65 @@ A self-hosted `~/.hermes/mem0.json` looks like this:
}
```
For an OpenAI-compatible service such as Azure's `/openai/v1` endpoint, add `OPENAI_BASE_URL` to the profile's `.env` and set each `model` to its deployed name. Both the LLM and embedder use this endpoint unless their `config.openai_base_url` overrides it. The main Hermes chat model is configured separately; this JSON configures Mem0's extraction and embedding models.
For a Qdrant server, replace `vector_store.config.path` with `url`, for example `"url": "http://localhost:6333"`. Manual setup does not install optional provider dependencies: install `qdrant-client`, `psycopg2-binary`, or `ollama` in the **Hermes Python environment** as needed for your selected providers. Start a fresh session and verify a memory write and search; `hermes memory status` reports configuration availability, not a full backend health check.
Desktop sessions in the same process and profile share local Qdrant storage when their OSS settings match. Operations are serialized, and storage closes after the last session releases it. Conflicting settings are rejected without changing existing memories; close active sessions before changing models or credentials. For concurrent CLI and Desktop processes, use a Qdrant server or the self-hosted Mem0 HTTP API instead of sharing a local directory.
## Switching Modes
Run `hermes memory setup mem0` in an interactive terminal and choose the new mode, or edit the active profile's `mem0.json` using the examples above. Switching backends does not transfer memories between them. Preserve existing OSS storage paths when editing configuration. When returning to Platform, set `mode` to `platform`, clear `host`, and remove any stale `MEM0_HOST` setting from your environment and profile `.env`.
## Configuration
Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `hermes memory setup`. Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`.
Settings live in `$HERMES_HOME/mem0.json` and are written by `hermes memory setup`. API keys normally live in that profile's `.env`; distinct OpenAI LLM/embedder keys and database credentials are stored in the OSS configuration. Setup writes these files atomically with owner-only permissions.
When editing these files manually, restrict both `.env` and `mem0.json` to their owner (`chmod 600` on Unix). Keep configuration and secrets in the same active Hermes profile.
`MEM0_MODE`, `MEM0_HOST`, `MEM0_USER_ID`, and `MEM0_AGENT_ID` supply environment defaults. Non-empty values in `mem0.json` take precedence. `MEM0_API_KEY` supplies the Cloud or server key unless `api_key` is set in the file.
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-managed, in-process). Self-hosted server routing is set via `host` |
| `host` | none | Self-hosted Mem0 server URL. When set, the plugin talks HTTP to your server instead of the cloud |
| `api_key` | none | Mem0 Platform API key, or the admin key of a self-hosted server. Stored in `.env` as `MEM0_API_KEY` |
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `user_id` | gateway user ID, then `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `false` | Rerank search results for relevance (Platform mode only) |
| `rerank` | `false` | Platform reranking for recall and tool searches that omit `rerank` |
| `sync_max_chars` | `450` | Per-message character cap for automatic fact extraction in every mode |
| `oss` | `{}` | OSS LLM, embedder, and vector-store configuration |
### Cross-channel memories
Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord. The `user_id` setting controls how memories are scoped across them:
- **Set a `user_id`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native id, keeping per-platform memories separate.
- **Set a `user_id` other than `hermes-user`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native ID when available, falling back to `hermes-user`.
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
Every write is tagged with `metadata.channel` (for example `telegram` or `cli`). Plugin searches filter by user identity across sessions; they do not restrict recall to the current channel or session.
## Migration for Existing Users
Keep `memory.provider: mem0`, `mem0.json`, `MEM0_*` settings, user identity, and OSS database paths unchanged. Moving from the bundled provider to this standalone plugin does not require rerunning setup or moving stored memories.
Automatic migration depends on Hermes rollout as well as this repository:
1. Users need a Hermes build containing [PR #114569](https://github.com/NousResearch/hermes-agent/pull/114569).
2. Hermes maintainers must approve a catalog entry named `mem0` with `repo: https://github.com/mem0ai/mem0`, `subdir: integrations/hermes-plugin-mem0`, and a reviewed full commit SHA.
3. The bundled Mem0 provider must be removed so the standalone provider can load.
With these in place, Hermes installs a missing configured provider during `hermes update` across profiles or at agent startup. Startup installation respects `security.allow_lazy_installs`. Offline or disabled installation needs manual action; merging the plugin directory alone does not complete automatic migration.
CLI setup and status are supported. This plugin does not ship a Desktop configuration panel or provider-specific CLI commands.
## Reliability
- **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
- **Non-blocking**: fact extraction runs in a background daemon thread, and current-turn recall waits at most 3 seconds, so a slow or failed call never blocks your conversation.
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and recall threads are guarded so concurrent gateway messages cannot produce duplicate memories.
- **Circuit breaker**: five consecutive backend failures pause calls for two minutes. The agent can continue without memory during that window. Expected update/delete errors such as a missing memory do not trip the breaker.
- **Bounded waits**: recall waits up to three seconds. Capture runs in the background, but an overlapping turn may wait up to five seconds for the previous sync before being skipped.
- **Graceful shutdown**: shutdown and Python process exit wait for active recall and capture workers before closing the backend. Backend network timeouts still apply. Self-hosted HTTP capture uses a 120-second read timeout and a 30-second connection timeout; other self-hosted HTTP operations use 30 seconds.
- **Best-effort capture**: there is no durable queue. Forced termination, including Hermes' 30-second exit watchdog, can interrupt pending writes even while graceful shutdown is waiting.
- **OSS data protection**: an embedding dimension mismatch fails initialization without deleting the existing collection or table.
## Troubleshooting
@@ -248,15 +275,12 @@ curl http://localhost:11434/api/tags
- `mem0_add` stores text verbatim with no extraction. Ordinary conversation turns are extracted automatically by the background sync.
- Search is semantic, so try a broader query.
- Confirm `user_id` is the same across sessions (check `~/.hermes/mem0.json`).
- Confirm `user_id` is the same across sessions (check `$HERMES_HOME/mem0.json`).
- Check `sync_max_chars`: facts beyond the per-message limit are not sent for extraction.
## Key Features
### OSS: embedding dimension mismatch
1. **Three ways to run**: managed Platform, a self-hosted server, or fully local OSS, switchable at any time.
2. **Current-turn recall**: memories for the current question are injected within a 3-second window, with `mem0_search` as the model's own backstop.
3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
Restore the embedding model and dimensions that created the existing collection, or choose a new collection and migrate data explicitly. The plugin leaves the original collection intact when dimensions differ.
<CardGroup cols={2}>
<Card title="OpenClaw Integration" icon="/images/provider-icons/openclaw.svg" href="/integrations/openclaw">
+1 -1
View File
@@ -99,7 +99,7 @@ Captured prompts and responses retain their full redacted text without a per-mes
| Problem | Fix |
| --- | --- |
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported. |
| Missing API key | Start Kimi from a shell where `MEM0_API_KEY` is exported, or run `mem0 init` with the [Mem0 CLI](/platform/cli). The plugin falls back to the key it saves. |
| Plugin changes do not appear | Run `/plugins reload`, then `/reload` or `/new`. |
| MCP server is disabled | Run `/plugins mcp enable mem0 mem0`, then `/reload`. |
| No memory in a later session | Wait a moment for the background flush, then ask Kimi to search memory explicitly. |
+4
View File
@@ -26,6 +26,10 @@ echo 'export MEM0_API_KEY="m0-your-api-key"' >> ~/.bashrc && source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The OpenCode plugin also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and your shell profile take precedence.
</Tip>
## Installation
### Option A: Plugin Install (Recommended)
+6 -2
View File
@@ -40,6 +40,10 @@ source ~/.bashrc
```
</CodeGroup>
<Tip>
Already set up the [Mem0 CLI](/platform/cli) with `mem0 init`? The extension also reads the key it saved in `~/.mem0/config.json`, so you can skip this step. `MEM0_API_KEY` and `apiKey` in `mem0-config.json` take precedence.
</Tip>
## Installation
```bash
@@ -68,7 +72,7 @@ For advanced settings, create `~/.pi/agent/mem0-config.json`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. |
| `apiKey` | `string` | `$MEM0_API_KEY` | Mem0 API key. Environment variable takes precedence. Falls back to the key `mem0 init` saved. |
| `userId` | `string` | `$MEM0_USER_ID` or `"default"` | User identity for memory scoping |
| `autoCapture` | `boolean` | `true` | Store facts from conversations automatically |
| `defaultScope` | `string` | `"project"` | Default memory scope: `project`, `session`, or `global` |
@@ -147,7 +151,7 @@ You: What do you know about my preferences?
## Troubleshooting
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites) or run `mem0 init`
- **Extension not loading**: Check Pi startup output for errors. For a source checkout, run `pnpm build`, then `pi -e ./dist/entry.js` from the plugin directory
- **Memories not capturing**: Verify `autoCapture` is `true` (default). Check `/mem0-status` for connection health
- **Wrong project detected**: The plugin uses the git repository root as `app_id`. If not in a git repo, it falls back to the working directory name. Run `/mem0-status` to see the detected project
+1 -2
View File
@@ -186,7 +186,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
## Core Concepts
- [How Mem0 Works](https://docs.mem0.ai/core-concepts/how-it-works) [Both]: Use when explaining the end-to-end pipeline: extraction (ADD-only distillation), storage across vector/entity/history stores, and multi-signal retrieval.
- [Memory Types](https://docs.mem0.ai/core-concepts/memory-types) [Both]: Use when checking which `memory_type` values actually work: `procedural_memory` is implemented, `semantic_memory` and `episodic_memory` are defined in the enum but rejected by validation.
- [Memory Operations - Add](https://docs.mem0.ai/core-concepts/memory-operations/add) [Both]: Use when explaining how `add()` extracts facts, resolves conflicts, and writes to both stores.
- [Memory Operations - Search](https://docs.mem0.ai/core-concepts/memory-operations/search) [Both]: Use when explaining how queries are processed and ranked.
- [Memory Operations - Update](https://docs.mem0.ai/core-concepts/memory-operations/update) [Both]: Use when memories need to be edited in place or reconciled against new info.
@@ -257,7 +256,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
- [Agno](https://docs.mem0.ai/integrations/agno) [Platform]: Use when the user is on Agno.
- [Camel AI](https://docs.mem0.ai/integrations/camel-ai) [Both]: Use when the user is on Camel AI.
- [ChatDev](https://docs.mem0.ai/integrations/chatdev) [Platform]: Use when the user is on ChatDev.
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when the user is on Hermes.
- [Hermes](https://docs.mem0.ai/integrations/hermes) [Both]: Use when installing or configuring the standalone Hermes memory plugin, or migrating from the bundled Mem0 provider.
- [Pi Agent](https://docs.mem0.ai/integrations/pi-agent) [Platform]: Use when adding automatic capture, prompt recall, scoped memory, and six memory commands to Pi Agent.
- [DeepSeek Harness](https://docs.mem0.ai/integrations/deepseek-plugin) [Platform]: Use when adding automatic recall, completed-turn capture, and native search/add tools to DeepSeek Harness.
- [OpenAI Agents SDK](https://docs.mem0.ai/integrations/openai-agents-sdk) [Platform]: Use when the user is on the OpenAI Agents SDK.
+2 -2
View File
@@ -50,8 +50,8 @@ For the full pipeline, see [How Mem0 works](/core-concepts/how-it-works).
<Card title="Run the quickstart" icon="rocket" href="/platform/quickstart">
Get an API key and save your first memory.
</Card>
<Card title="Understand memory types" icon="brain" href="/core-concepts/memory-types">
How user, agent, app, and run memory differ.
<Card title="Scope your memories" icon="brain" href="/platform/features/entity-scoped-memory">
Organize memories by user, agent, app, and run.
</Card>
<Card title="Add, search, and update" icon="layer-group" href="/core-concepts/memory-operations/add">
The core memory operations, end to end.
+1 -1
View File
@@ -39,7 +39,7 @@ The core memory loop is identical on both: `add`, `search`, `get`, `get_all`, `u
- **Entity scoping** by `user_id`, `agent_id`, and `run_id`
- **Filter grouping**: both accept `AND`/`OR`/`NOT` wrappers, both implicitly AND a flat multi-key filter like `{"user_id": "alice", "agent_id": "a1"}`, and both accept `*` as a wildcard value. Which fields you may filter on, and which operators each field accepts, differ (see below)
- **Entity-aware ranking**: both extract entities from memory text and use shared entities to boost related results at search time
- **Multimodal input**, **memory expiration** (`expiration_date`), **reranking**, **procedural memory** (Python), and **custom extraction instructions** (`custom_instructions`)
- **Multimodal input**, **memory expiration** (`expiration_date`), **reranking**, and **custom extraction instructions** (`custom_instructions`)
- Python and JavaScript SDKs, plus a REST API (self-hosted via `server/`, or hosted)
## What's actually different
+1 -1
View File
@@ -124,7 +124,7 @@ Walk through a real request/response. Include sample payloads and highlight nota
{/* DEBUG: verify CTA targets */}
<CardGroup cols={2}>
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/memory-types">
<Card title="Dive Into Memory Scoring" icon="scale-balanced" href="/core-concepts/how-it-works">
Understand how Mem0 ranks memories under the hood.
</Card>
<Card title="Build a Research Copilot" icon="book-open" href="/cookbooks/operations/deep-research">
+2 -4
View File
@@ -38,7 +38,6 @@ client.memories.add(
user_id: str,
memory: str,
metadata: Optional[dict] = None,
memory_type: Literal["session", "long_term"] = "session",
)
```
@@ -47,13 +46,13 @@ await mem0.memories.add({
userId: string;
memory: string;
metadata?: Record<string, string>;
memoryType?: "session" | "long_term";
});
```
</CodeGroup>
<Info>
Defaults to session memories. Override `memory_type` for long-term storage.
[Describe defaults supported by this operation and SDK. Do not infer a
memory type or retention policy from a scoping identifier.]
</Info>
<Warning>
@@ -67,7 +66,6 @@ await mem0.memories.add({
| `user_id` | string | Yes | Unique identifier for the end user. | Must match follow-up operations. |
| `memory` | string | Yes | Content to persist. | Managed & OSS. Markdown allowed. |
| `metadata` | object | No | Key-value pairs for filters. | OSS stores as JSONB; limit to 2KB. |
| `memory_type` | string | No | Retention bucket | Platform supports `shared`. |
<Tip>
Set `ttl_seconds` when you need memories to expire automatically (OSS only).
+2 -2
View File
@@ -96,8 +96,8 @@ time. Storage: vector embeddings.
**Architecture Overview:**
- Memory is scoped by user_id, agent_id, or run_id
- Core operations: add, search, update, delete
- Memory types: factual (preferences, facts), episodic (past interactions),
semantic (concept relationships), working (session state)
- Store preferences, facts, and past interactions; use run_id to scope a
session. Platform does not expose a memory_type selector.
- Integration pattern: retrieve relevant memories → generate response → store
new memories
+1
View File
@@ -6,6 +6,7 @@ Agent and editor integrations. Most packages are self-contained; coding-agent pl
|-----------|---------|-------|------|------|
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` | tsup (CJS+ESM) | ESLint + Prettier | jest + vitest (edge/node) |
| `openclaw/` | `@mem0/openclaw-mem0` | tsup (ESM) | none | vitest |
| `hermes-plugin-mem0/` | Standalone Hermes memory provider | none | ruff + isort | pytest (host-stubbed offline); live CLI/Desktop validation |
| `agent-plugin-core/` | Shared Python/TypeScript behavior, skill templates, builds, and conformance | Python build script | ruff + tsc | pytest + node:test |
| `mem0-agent-plugin/` | One portable Agent Plugins v1 package | Python | ruff | shared conformance |
| `claude-code-plugin/`, `cursor-plugin/`, `codex-plugin/`, `kimi-plugin/`, `antigravity-plugin/` | Self-contained native plugins generated from the shared Python core | Python | ruff | pytest |
@@ -103,6 +103,7 @@ def _render_harness_id(host: str, *, portable: bool = False) -> str:
"\n"
f'HARNESS_ID = "{host}"\n'
f'SOURCE_TAG = "{tag}"\n'
f'DATA_DIR_NAME = "{host}-plugin"\n'
"\n"
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
"# plugin family is one source; which editor it runs in is the application.\n"
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -92,6 +92,7 @@ def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> Non
assert identity["PLATFORM_APPLICATION"] == ""
# The PostHog-side label is still useful for grouping and stays populated.
assert identity["HARNESS_ID"] == "coding-agent"
assert identity["DATA_DIR_NAME"] == "coding-agent-plugin"
assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN"
@@ -100,6 +101,7 @@ def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Pa
identity = _harness_identity(build(host, "native", tmp_path / host))
assert identity["PLATFORM_APPLICATION"] == host
assert identity["DATA_DIR_NAME"] == f"{host}-plugin"
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
@@ -0,0 +1,175 @@
"""Launch each host's real hooks and MCP server the way the host does, and check the search is authenticated."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from string import Template
import pytest
INTEGRATIONS = Path(__file__).resolve().parents[2]
KEY = "m0-plugin-setting-key"
CODEX_INHERITED_ENV = ("HOME", "PATH", "LANG", "TMPDIR")
class _Mem0Api(BaseHTTPRequestHandler):
authorizations: list[str]
def do_POST(self) -> None:
self.rfile.read(int(self.headers.get("Content-Length") or 0))
self.authorizations.append(self.headers.get("Authorization", ""))
body = b'{"results": []}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
@pytest.fixture
def mem0_api():
handler = type("Handler", (_Mem0Api,), {"authorizations": []})
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}", handler.authorizations
server.shutdown()
def _json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _expand(value: str, variables: dict[str, str]) -> str:
return Template(value).safe_substitute(variables)
def _run_hook(command: str, cwd: Path, env: dict[str, str], payload: dict) -> None:
result = subprocess.run(
["sh", "-c", command], cwd=cwd, env=env, input=json.dumps(payload), text=True, capture_output=True
)
assert result.returncode == 0, result.stderr
def _search(argv: list[str], cwd: Path, env: dict[str, str]) -> dict:
requests = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}},
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "search_memories", "arguments": {"query": "earlier fixes"}},
},
]
result = subprocess.run(
argv,
cwd=cwd,
env=env,
input="\n".join(json.dumps(request) for request in requests) + "\n",
text=True,
capture_output=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.splitlines()[-1])
def _claude_code(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "claude-code-plugin"
plugin_env = {"CLAUDE_PLUGIN_ROOT": str(root), "CLAUDE_PLUGIN_DATA": str(tmp_path / "claude-data")}
hook_env = {**host_env, **plugin_env, **({"CLAUDE_PLUGIN_OPTION_API_KEY": KEY} if setting else {})}
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
env = {**host_env, **{name: _expand(value, plugin_env) for name, value in server["env"].items()}}
return [sys.executable, *(_expand(argument, plugin_env) for argument in server["args"])], repo, env
def _cursor(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "cursor-plugin"
command = _json(root / "hooks" / "hooks.json")["hooks"]["sessionStart"][0]["command"]
if setting:
command = command.replace("${api_key}", KEY)
hook_env = {**host_env, "CURSOR_PLUGIN_ROOT": str(root)}
_run_hook(command, repo, hook_env, {"conversation_id": "c1", "workspace_roots": [str(repo)]})
server = _json(root / "mcp.json")["mcpServers"]["mem0"]
args = [_expand(argument, {"CURSOR_PLUGIN_ROOT": str(root)}) for argument in server["args"]]
return [sys.executable, *args], repo, {**host_env, **server["env"]}
def _codex(host_env: dict[str, str], tmp_path: Path, repo: Path, setting: bool) -> tuple:
root = INTEGRATIONS / "codex-plugin"
codex_env = {**host_env, **({"MEM0_API_KEY": KEY} if setting else {})}
data = str(tmp_path / "codex-data")
hook_env = {**codex_env, "PLUGIN_ROOT": str(root), "PLUGIN_DATA": data, "CLAUDE_PLUGIN_DATA": data}
command = _json(root / "hooks" / "hooks.json")["hooks"]["SessionStart"][0]["hooks"][0]["command"]
_run_hook(command, repo, hook_env, {"session_id": "s1", "cwd": str(repo)})
server = _json(root / ".mcp.json")["mcpServers"]["mem0"]
forwarded = [*CODEX_INHERITED_ENV, *server["env_vars"]]
env = {name: codex_env[name] for name in forwarded if name in codex_env}
return [sys.executable, *server["args"]], root / server["cwd"], env
HOSTS = {"claude-code": _claude_code, "cursor": _cursor, "codex": _codex}
def _mem0_init(home: Path, key: str) -> None:
(home / ".mem0").mkdir(parents=True, exist_ok=True)
(home / ".mem0" / "config.json").write_text(json.dumps({"platform": {"api_key": key}}), encoding="utf-8")
def _host_env(home: Path, api_url: str) -> dict[str, str]:
return {
"HOME": str(home),
"PATH": os.environ["PATH"],
"MEM0_API_URL": api_url,
"MEM0_TELEMETRY": "false",
"MEM0_CODE_USER_ID": "test-user",
}
@pytest.mark.parametrize("key_source", ["plugin setting", "mem0 init"])
@pytest.mark.parametrize("host", sorted(HOSTS))
def test_mcp_server_searches_with_the_key_the_user_configured(host, key_source, tmp_path, mem0_api):
"""The key reaches the MCP server however the host delivers it: plugin setting, env, or `mem0 init`."""
api_url, authorizations = mem0_api
home = tmp_path / "home"
repo = tmp_path / "repo"
repo.mkdir()
if key_source == "mem0 init":
_mem0_init(home, KEY)
argv, cwd, env = HOSTS[host](_host_env(home, api_url), tmp_path, repo, key_source == "plugin setting")
response = _search(argv, cwd, env)
assert not response["result"].get("isError"), response
assert authorizations == [f"Token {KEY}"]
@pytest.mark.parametrize("host", sorted(HOSTS))
def test_a_removed_plugin_setting_gives_way_to_a_later_mem0_init(host, tmp_path, mem0_api):
"""The key saved from a plugin setting is dropped once the setting is gone, so a newer `mem0 init` key wins."""
api_url, authorizations = mem0_api
home = tmp_path / "home"
repo = tmp_path / "repo"
repo.mkdir()
host_env = _host_env(home, api_url)
HOSTS[host](host_env, tmp_path, repo, True)
_mem0_init(home, "m0-mem0-init-key")
argv, cwd, env = HOSTS[host](host_env, tmp_path, repo, False)
response = _search(argv, cwd, env)
assert not response["result"].get("isError"), response
assert authorizations == ["Token m0-mem0-init-key"]
@@ -83,6 +83,26 @@ def test_identity_resolves_without_init(harness, spec):
assert out == f"{harness} {source_tag}"
@pytest.mark.parametrize("harness,spec", sorted(HOSTS.items()))
def test_data_dir_resolves_without_init(harness, spec):
"""mcp_server never configures the harness, so its default must match configure_harness(harness) in hooks and skills."""
directory, _ = spec
core = _core_dir(directory)
if not core.exists():
pytest.skip(f"{directory} is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import os; os.environ.pop('MEM0_CODE_DATA_DIR')\n"
"import mcp_server, memory_core; print(memory_core.data_dir())\n"
f"memory_core.configure_harness({harness!r}); print(memory_core.data_dir())",
)
expected = str(Path(tmp) / ".mem0" / f"{harness}-plugin")
assert out.splitlines() == [expected, expected]
def test_mcp_server_records_the_real_harness():
"""mcp_server imports telemetry and never initialises it (server.py has no init).
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
export function mem0CliApiKey(homeDir: string): string {
try {
const key = JSON.parse(readFileSync(join(homeDir, ".mem0", "config.json"), "utf8"))?.platform?.api_key;
return typeof key === "string" ? key.trim() : "";
} catch {
return "";
}
}
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { mem0CliApiKey } from "../src/credentials.ts";
function homeWithCliConfig(config: string): string {
const home = mkdtempSync(join(tmpdir(), "mem0-cli-home-"));
mkdirSync(join(home, ".mem0"));
writeFileSync(join(home, ".mem0", "config.json"), config);
return home;
}
test("reads the key mem0 init saved", () => {
const home = homeWithCliConfig(JSON.stringify({ platform: { api_key: " m0-cli-key\n" } }));
assert.equal(mem0CliApiKey(home), "m0-cli-key");
});
test("missing, malformed, or non-string config reads as no key", () => {
assert.equal(mem0CliApiKey(mkdtempSync(join(tmpdir(), "mem0-cli-home-"))), "");
assert.equal(mem0CliApiKey(homeWithCliConfig("{not json")), "");
assert.equal(mem0CliApiKey(homeWithCliConfig("null")), "");
assert.equal(mem0CliApiKey(homeWithCliConfig(JSON.stringify({ platform: { api_key: 42 } }))), "");
});
@@ -2,6 +2,7 @@
HARNESS_ID = "antigravity"
SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
DATA_DIR_NAME = "antigravity-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"homepage": "https://docs.mem0.ai/integrations/antigravity",
"native": {
"pluginRoot": "${ANTIGRAVITY_PLUGIN_ROOT}",
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0"
@@ -2,6 +2,7 @@
HARNESS_ID = "claude-code"
SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
DATA_DIR_NAME = "claude-code-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"homepage": "https://docs.mem0.ai/integrations/claude-code",
"native": {
"pluginRoot": "${CLAUDE_PLUGIN_ROOT}",
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -31,9 +31,13 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_USER_ID", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
# The 0.2.x plugin exports these into every hooked shell; without this the
# suite fails for anyone running it inside a session with that plugin active.
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
@@ -3750,6 +3754,71 @@ def test_stale_cached_api_key_is_cleared_when_config_is_removed(
assert memory_core.clear_stale_api_key_cache() is False
def _mem0_cli_init(home: Path, config: object) -> None:
(home / ".mem0").mkdir(parents=True, exist_ok=True)
(home / ".mem0" / "config.json").write_text(json.dumps(config), encoding="utf-8")
def test_api_key_falls_back_to_the_mem0_cli_config(isolated_env):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": " m0-cli-key\n"}})
assert memory_core.api_key() == "m0-cli-key"
@pytest.mark.parametrize(
"config",
[{"platform": {}}, {"platform": "m0-oops"}, {"platform": {"api_key": 42}}, ["m0-list"]],
)
def test_malformed_mem0_cli_config_reads_as_no_key(isolated_env, config):
_mem0_cli_init(isolated_env / "home", config)
assert memory_core.api_key() == ""
def test_unreadable_mem0_cli_config_reads_as_no_key(isolated_env):
(isolated_env / "home" / ".mem0").mkdir(parents=True)
(isolated_env / "home" / ".mem0" / "config.json").write_text("{not json", encoding="utf-8")
assert memory_core.api_key() == ""
def test_plugin_configured_key_wins_over_the_mem0_cli_config(isolated_env, monkeypatch):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "m0-plugin-key")
assert memory_core.cache_plugin_api_key() is True
assert memory_core.api_key() == "m0-plugin-key"
monkeypatch.delenv("PLUGIN_OPTION_API_KEY")
assert memory_core.api_key() == "m0-plugin-key"
def test_unexpanded_host_placeholder_is_never_used_as_the_api_key(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "${api_key}")
assert memory_core.cache_plugin_api_key() is False
assert not (isolated_env / "data" / "api-key").exists()
assert memory_core.api_key() == ""
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_placeholder_cached_by_an_older_plugin_is_ignored(isolated_env):
(isolated_env / "data").mkdir()
(isolated_env / "data" / "api-key").write_text("${api_key}", encoding="utf-8")
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_unexpanded_placeholder_plugin_options_fall_back(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_USER_ID", "${user_id}")
monkeypatch.setenv("PLUGIN_OPTION_TOP_K", "${top_k}")
assert memory_core.user_id() == "test-user"
assert memory_core._plugin_option("top_k") == ""
def _big_batch_messages() -> list[dict[str, str]]:
return [
{"role": "user", "content": "A" * 20000},
@@ -24,6 +24,8 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_API_URL", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
return tmp_path
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"homepage": "https://docs.mem0.ai/integrations/codex",
@@ -2,6 +2,7 @@
HARNESS_ID = "codex"
SOURCE_TAG = "CODEX_PLUGIN"
DATA_DIR_NAME = "codex-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
+46 -41
View File
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"homepage": "https://docs.mem0.ai/integrations/codex",
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"homepage": "https://docs.mem0.ai/integrations/cursor",
@@ -2,6 +2,7 @@
HARNESS_ID = "cursor"
SOURCE_TAG = "CURSOR_PLUGIN"
DATA_DIR_NAME = "cursor-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
+46 -41
View File
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"homepage": "https://docs.mem0.ai/integrations/cursor",
"native": {
"pluginRoot": "${CURSOR_PLUGIN_ROOT}",
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/deepseek-plugin",
"version": "0.3.2",
"version": "0.3.3",
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
"type": "module",
"license": "Apache-2.0",
+3 -1
View File
@@ -10,6 +10,7 @@
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
* plugin unmounts (Cordis revertible effects).
*/
import { homedir } from "node:os";
import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/dsh-agent";
import type { PromptAssembly } from "@deepseek-ai/dsh-system-prompt";
@@ -20,6 +21,7 @@ import { formatMemoryList, formatAddResult } from "./formatting.ts";
import { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.ts";
import { mem0CliApiKey } from "../../agent-plugin-core/typescript/src/credentials.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
import {
USER_RECALL_HEADING,
@@ -94,7 +96,7 @@ const scopeParams = {
} as const;
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
const apiKey = config.apiKey || process.env.MEM0_API_KEY || mem0CliApiKey(homedir());
if (!apiKey) {
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
}
@@ -1,12 +1,19 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Offline mock of the Mem0 SDK so these tests never touch the network.
const mockSearch = vi.fn();
const mockAdd = vi.fn();
const mockClientOptions = vi.fn();
vi.mock("mem0ai", () => ({
MemoryClient: class {
search = mockSearch;
add = mockAdd;
constructor(options: unknown) {
mockClientOptions(options);
}
},
}));
@@ -49,18 +56,24 @@ function applyAndCollectListeners(config: Config): Map<string, HarnessListener>
let savedKey: string | undefined;
let savedTelemetry: string | undefined;
let savedHome: string | undefined;
beforeEach(() => {
savedKey = process.env.MEM0_API_KEY;
savedTelemetry = process.env.MEM0_TELEMETRY;
savedHome = process.env.HOME;
process.env.MEM0_TELEMETRY = "false";
process.env.HOME = mkdtempSync(join(tmpdir(), "deepseek-home-"));
mockSearch.mockReset();
mockAdd.mockReset();
mockClientOptions.mockReset();
});
afterEach(() => {
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
else process.env.MEM0_API_KEY = savedKey;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = savedTelemetry;
});
@@ -71,6 +84,17 @@ describe("apply() config validation", () => {
expect(() => applyAndCollect({ userId: "u" } as Config)).toThrow(/apiKey|MEM0_API_KEY/);
});
it("falls back to the key mem0 init saved", () => {
delete process.env.MEM0_API_KEY;
const home = process.env.HOME as string;
mkdirSync(join(home, ".mem0"));
writeFileSync(join(home, ".mem0", "config.json"), JSON.stringify({ platform: { api_key: "m0-cli-key" } }));
applyAndCollect({ userId: "u" } as Config);
expect(mockClientOptions).toHaveBeenCalledWith({ apiKey: "m0-cli-key" });
});
it("throws when userId is missing", () => {
expect(() => applyAndCollect({ apiKey: "k", userId: "" } as Config)).toThrow(/userId/);
});
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.venv/
+236
View File
@@ -0,0 +1,236 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2023] [Taranjeet Singh]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Third-party software
--------------------
hermes-plugin-mem0
Mem0 contributions are licensed under the Apache License, Version 2.0.
This plugin includes code from Nous Research's hermes-plugin-mem0:
https://github.com/NousResearch/hermes-plugin-mem0
Source commit: 3fc36950b2b7c19cdd81c6de99f10d2cbed850af
The imported code retains the following MIT license and copyright notice:
MIT License
Copyright (c) 2025 Nous Research
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.
+131
View File
@@ -0,0 +1,131 @@
# Mem0 for Hermes Agent
Persistent memory for [Hermes Agent](https://github.com/NousResearch/hermes-agent), powered by [Mem0](https://mem0.ai).
This standalone plugin recalls relevant memories before a response and extracts facts from conversations afterward. It works alongside Hermes' file-based memory and supports Mem0 Cloud, a self-hosted Mem0 server, or the in-process OSS SDK.
## Features
- **Automatic recall and capture** across conversations.
- **Four agent tools** to search, add, update, and delete memories.
- **Three backend modes** with interactive setup through `hermes memory setup`.
- **User-scoped memories** with agent and channel metadata on writes.
## Setup
### 1. Install
Requires [Hermes Agent](https://github.com/NousResearch/hermes-agent) with memory-provider plugin support and Python 3.11 or later. After this directory is merged to Mem0's main branch:
```bash
hermes plugins install mem0ai/mem0/integrations/hermes-plugin-mem0
hermes plugins enable mem0
```
Hermes installers with plugin dependency support install `mem0ai>=2.0.10,<3` and `httpx>=0.27,<1` from this directory's `pyproject.toml`. On older hosts such as Hermes v0.21.3, install those requirements into the **Hermes Python environment** explicitly. The OSS setup wizard installs additional provider packages as needed.
> Hermes versions that still bundle Mem0 prefer the bundled provider. Use a Hermes release that has completed the standalone-provider migration; installing this plugin alone does not replace the bundled implementation. See [Existing users and migration](#existing-users-and-migration).
### 2. Configure
```bash
hermes memory setup mem0
```
Run this in an interactive terminal and choose a backend:
| Mode | What you need |
|------|----------------|
| **Platform** (default) | A Mem0 API key from [app.mem0.ai](https://app.mem0.ai/dashboard/api-keys) |
| **Self-hosted server** | A running [Mem0 server](../../server), its URL, and its API key unless authentication is disabled |
| **OSS** | An LLM, embedder, and vector store; no Mem0 API key needed |
For a self-hosted server, choose **Self-hosted server** and enter its URL and API key. Requests use `X-API-Key` and the server's `/search` and `/memories` routes. Setting `host` selects this backend unless `mode` is `oss`.
For the in-process SDK, choose **Open Source**. The wizard offers OpenAI or Ollama and local Qdrant or PGVector. Use manual configuration for custom OpenAI-compatible endpoints, deployment names, or a Qdrant server. OSS does not use Mem0 Cloud; data still goes to whichever model services you configure. Run setup again to switch modes. When switching to Platform, remove any stale `MEM0_HOST` setting from the environment and profile `.env`.
Desktop sessions in the same process and profile share local Qdrant storage when their OSS settings match. Operations are serialized, and storage closes after the last session releases it. Conflicting settings are rejected without changing existing memories; close the active sessions before changing models or credentials. Use a Qdrant server or the self-hosted Mem0 HTTP API when separate processes (for example, CLI and Desktop together) need the same store.
Hermes hosts whose `hermes memory setup --help` lists only a provider argument reject options such as `--mode`, `--host`, and `--oss-llm` before the plugin runs. Use the interactive command above, or the [manual profile configuration](https://docs.mem0.ai/integrations/hermes) for unattended setup. Redirected input cannot select the mode picker; it falls back to Platform.
### 3. Verify
```bash
hermes memory status
```
Start a fresh Hermes conversation and ask it to remember a fact, then search for that fact in a later session using the same user identity.
## Tools
| Tool | Description | Parameters |
|------|-------------|------------|
| `mem0_search` | Search memories by meaning | `query`, optional `top_k` (default 10, max 50) and `rerank` (Platform only) |
| `mem0_add` | Store text verbatim, without fact extraction | `content` |
| `mem0_update` | Update a memory's text | `memory_id`, `text` |
| `mem0_delete` | Delete a memory | `memory_id` |
## Configuration
Settings live in `$HERMES_HOME/mem0.json`; the default Hermes home is `~/.hermes`. Setup normally stores API keys in that profile's `.env`. Distinct OpenAI LLM/embedder keys and database credentials are stored in the OSS configuration. Setup writes both files atomically with owner-only permissions.
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` for Cloud/server routing, or `oss` for the in-process SDK |
| `host` | unset | Self-hosted server URL; ignored in OSS mode |
| `user_id` | gateway user ID, then `hermes-user` | Set a stable ID to share memories across gateways |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `false` | Platform reranking for automatic recall and tool searches that omit `rerank` |
| `sync_max_chars` | `450` | Maximum characters per user/assistant message sent for automatic extraction |
| `oss` | `{}` | OSS LLM, embedder, and vector-store configuration written by setup |
`MEM0_MODE`, `MEM0_HOST`, `MEM0_USER_ID`, and `MEM0_AGENT_ID` provide environment defaults; non-empty file settings override them. `MEM0_API_KEY` supplies the Cloud or server key when `api_key` is not set in the file.
An explicit `user_id` other than `hermes-user` takes precedence over a gateway's native user ID. Searches use that user identity across sessions; writes attach `agent_id` and `metadata.channel`.
## Automatic recall and capture
Recall waits up to three seconds for memories relevant to the current message. If results are late, the model can still call `mem0_search`.
After a turn, a background worker sends the user message and assistant response for extraction. Each message is truncated to **450 characters by default in every mode**, preferring a sentence boundary. Increase `sync_max_chars` to suit your model's context limit. Explicit `mem0_add` calls store their supplied text verbatim.
Capture is best effort: if the previous sync remains busy after a five-second wait, the new turn is skipped. There is no durable queue. Five consecutive backend failures pause calls for two minutes before retrying.
Graceful shutdown waits for active recall and capture workers before closing the backend, including at Python process exit. Backend network timeouts still apply: self-hosted HTTP capture has a 120-second read timeout and a 30-second connection timeout; other self-hosted HTTP operations use 30 seconds. Forced termination, including Hermes' 30-second exit watchdog, can still interrupt pending writes.
## Existing users and migration
Keep `memory.provider: mem0`, your existing `mem0.json`, `MEM0_*` settings, user identity, and OSS storage paths. Moving the plugin does not require moving memories or rerunning setup.
Automatic migration also requires coordination in Hermes:
1. A Hermes build containing [migration support from PR #114569](https://github.com/NousResearch/hermes-agent/pull/114569).
2. An approved catalog entry named `mem0`, pointing to `https://github.com/mem0ai/mem0`, with `subdir: integrations/hermes-plugin-mem0` and a reviewed full commit SHA.
3. Removal of Hermes' bundled Mem0 provider, which otherwise takes precedence.
With those in place, Hermes can install a missing configured provider during `hermes update` or at agent startup. Startup installation respects `security.allow_lazy_installs`; disabled or offline installation requires manual action. Merging this directory alone does not register the catalog entry or complete rollout.
The plugin supports CLI setup/status. It does not include a Desktop configuration panel or provider-specific CLI commands.
## Troubleshooting
- **Mem0 unavailable:** run `hermes memory status`. Check the API key and backend connectivity; after five consecutive failures the circuit breaker waits two minutes.
- **Memories missing:** confirm the same user identity across sessions and check `sync_max_chars`. Automatic extraction may omit facts; use `mem0_add` to store exact text.
- **OSS connection refused:** check the configured model/vector service, or filesystem permissions for local Qdrant.
- **Embedding dimension mismatch:** initialization fails without deleting existing data. Restore the previous embedding model/dimensions, or use a new collection and migrate data explicitly.
## Development
From the Mem0 repository root, with `ruff` and `isort` installed:
```bash
ruff check integrations/hermes-plugin-mem0
isort --check-only --profile black integrations/hermes-plugin-mem0
```
Validate changes in an isolated Hermes profile using live CLI and Desktop sessions. Check memory
add/search/update/delete, automatic capture and recall, overlapping Desktop sessions, and persistence after restart.
## License
[Apache-2.0](LICENSE) for Mem0 contributions. Includes code from [Nous Research's standalone Mem0 provider](https://github.com/NousResearch/hermes-plugin-mem0/tree/3fc36950b2b7c19cdd81c6de99f10d2cbed850af) under MIT; its original license and copyright notice are preserved in the third-party section of [LICENSE](LICENSE).
+504
View File
@@ -0,0 +1,504 @@
"""Mem0 memory plugin — MemoryProvider interface.
Server-side fact extraction and semantic search via the Mem0 Platform API (cloud), a
self-hosted Mem0 server (MEM0_HOST, HTTP), or OSS Memory. Secrets live in $HERMES_HOME/.env
(MEM0_API_KEY, MEM0_HOST); settings in $HERMES_HOME/mem0.json via `hermes memory setup`:
mode ("platform"|"oss"), host, user_id (canonical id across gateways; unset → gateway-native
id), agent_id. MEM0_* env vars remain a fallback.
"""
from __future__ import annotations
import atexit
import json
import logging
import threading
import time
from contextlib import suppress
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider, spawn_context_thread
from agent.secret_scope import get_secret
from tools.registry import tool_error
from utils import atomic_json_write, read_json_or_empty
logger = logging.getLogger(__name__)
# Circuit breaker: after _BREAKER_THRESHOLD consecutive failures, pause API
# calls for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
_BREAKER_THRESHOLD, _BREAKER_COOLDOWN_SECS, _PREFETCH_WAIT_SECS = 5, 120, 3
_CLIENT_ERROR_TYPES = ("MemoryNotFoundError", "ValidationError")
# Placeholder user_id. initialize() treats it as "no operator-configured user_id"
# so legacy mem0.json files written by the wizard don't override gateway-native ids.
_DEFAULT_USER_ID = "hermes-user"
# sync_turn sends the whole turn to the backend for fact extraction. OSS embedding
# models often have small context windows (bge-small-zh-v1.5: 512 tokens ≈ 500 chars;
# jina-embeddings-v3: 8192), and oversized turns make backend.add() raise — Ollama
# answers HTTP 500, hosted APIs return INPUT_TOKEN_LIMIT_EXCEEDED — which _try only
# logs, silently dropping the turn's memory extraction. Cap each message up front.
# The default fits a 512-token embedder (measured: 450 OK, 600 -> HTTP 500 on
# bge-small-zh-v1.5:f16); ``sync_max_chars`` in mem0.json raises it for larger windows.
_SYNC_MSG_MAX_CHARS = 450
# Sentence ends recognized when trimming a synced message. Deliberately unordered:
# the LAST boundary of ANY kind wins, so one CJK stop early in a mixed-script turn
# cannot outrank a Latin stop near the end of the window. ``".\n"`` is not listed —
# its index can never exceed the bare ``"."`` it starts with.
_SYNC_SENTENCE_ENDS = ("。", "!", "?", ".", "!", "?")
def _truncate_for_sync(text: str, max_len: int = _SYNC_MSG_MAX_CHARS) -> str:
"""Cap a synced message at its last sentence boundary within ``max_len``.
Short messages pass through unchanged; long ones keep the last complete
sentence inside the window so fact extraction still sees coherent statements,
with a hard cut as fallback when no boundary exists (or one only appears in
the first third of the window, which usually means unsegmented input).
"""
if len(text) <= max_len:
return text
window = text[:max_len]
cut = max(window.rfind(sep) for sep in _SYNC_SENTENCE_ENDS)
if cut > max_len // 3:
return text[:cut + 1]
return text[:max_len]
def _is_client_error(exc: Exception) -> bool:
"""True for user-caused errors (bad ID, not found) that should NOT trip circuit breaker."""
err_str = str(exc).lower()
return type(exc).__name__ in _CLIENT_ERROR_TYPES or any(s in err_str for s in ("404", "not found", "valid uuid"))
def _load_config() -> dict:
"""Env vars provide defaults; $HERMES_HOME/mem0.json overrides individual keys.
Layering avoids a silent failure when the JSON file exists but lacks fields
like ``api_key`` that the user set in ``.env``."""
from hermes_constants import get_hermes_home
# Identity (user/agent id), host and mode are .env values like the key: read them through the
# profile scope too, or a secondary profile's memories land in the default profile's account.
# A scope-less multiplex caller raises here on purpose — that is a spawn-site bug, and
# swallowing it would silently route the turn's memories to the default profile.
config = {"mode": get_secret("MEM0_MODE", "") or "platform", "host": get_secret("MEM0_HOST", "") or "",
"agent_id": get_secret("MEM0_AGENT_ID", "") or "hermes", "oss": {}}
if user_id := get_secret("MEM0_USER_ID", ""): # only when explicitly configured, so initialize() can fall back to the gateway-native id
config["user_id"] = user_id
file_cfg = read_json_or_empty(get_hermes_home() / "mem0.json")
config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""})
# MEM0_API_KEY authenticates the Platform and self-hosted HTTP backends; pure OSS mode builds its
# backend from the local ``oss`` config and has no platform credential to resolve, so a profile
# scope WITHOUT the key must still load an OSS config (#99121 as it stands today: the caller is
# scoped, the scope is just empty). Decided after mem0.json overrode the env defaults because
# the file may be what selects ``oss``. Scope-less callers already raised above.
if config.get("mode", "platform") == "oss":
config.setdefault("api_key", "")
elif not config.get("api_key"):
config["api_key"] = get_secret("MEM0_API_KEY", "")
return config
def _schema(name: str, description: str, properties: dict[str, tuple[str, str]], required: list[str]) -> dict:
props = {k: {"type": t, "description": d} for k, (t, d) in properties.items()}
return {"name": name, "description": description, "parameters": {"type": "object", "properties": props, "required": required}}
TOOL_SCHEMAS = [
_schema("mem0_search", "Search the user's memories by meaning; returns facts ranked by relevance. Use this before answering any question that may depend on what you know about the user (preferences, facts, history, people, projects, past decisions). For multi-part or multi-hop questions, call it several times — vary the wording and run follow-up searches on what earlier results reveal; one search is rarely enough.",
{"query": ("string", "What to search for."), "top_k": ("integer", "Max results (default: 10, max: 50)."), "rerank": ("boolean", "Rerank results for relevance (default: false, platform mode only).")}, ["query"]),
_schema("mem0_add", "Store a durable fact about the user, verbatim (no LLM extraction). Call this the moment the user states a lasting preference, correction, decision, or personal detail worth recalling on future turns — don't wait to be asked to remember. Skip transient chit-chat and facts you've already stored.",
{"content": ("string", "The fact to store.")}, ["content"]),
_schema("mem0_update", "Replace the text of an existing memory by its ID (take the ID from a mem0_search result). Use when a stored fact has changed or was wrong — correct it in place instead of adding a duplicate.",
{"memory_id": ("string", "Memory UUID to update."), "text": ("string", "New text content.")}, ["memory_id", "text"]),
_schema("mem0_delete", "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.",
{"memory_id": ("string", "Memory UUID to delete.")}, ["memory_id"]),
]
_PROMPT_BODY = (
"You have persistent memory of this user from past conversations. You should call mem0_search before answering anything that could depend on prior context (the user's preferences, facts, history, people, projects, or earlier decisions) — do not rely on the chat window alone, and do not assume you have no memory.\n"
"For multi-part or multi-hop questions, run several searches with different wording/angles and follow-up searches on what the first results surface; one search is rarely enough. Keep searching until you have every fact the question needs before you answer.\n"
"Tools: mem0_search to find memories, mem0_add to store facts, mem0_update and mem0_delete to manage by ID."
)
class Mem0MemoryProvider(MemoryProvider):
"""Mem0 memory with server-side extraction and semantic search (platform, self-hosted or OSS)."""
def __init__(self):
self._config = self._backend = self._sync_thread = self._prefetch_thread = None
self._mode, self._api_key, self._host, self._user_id, self._agent_id = "platform", "", "", _DEFAULT_USER_ID, "hermes"
self._rerank_default, self._channel = False, "cli" # channel = gateway name (cli/telegram/discord/...)
self._sync_max_chars = _SYNC_MSG_MAX_CHARS
self._prefetch_query = self._prefetch_result = ""
self._prefetch_done = self._atexit_registered = False
self._consecutive_failures, self._breaker_open_until = 0, 0.0 # circuit breaker state
self._breaker_lock, self._sync_lock, self._prefetch_lock = threading.Lock(), threading.Lock(), threading.Lock()
@property
def name(self) -> str:
return "mem0"
def is_available(self) -> bool:
cfg = _load_config()
if cfg.get("mode", "platform") == "oss":
return bool(cfg.get("oss", {}).get("vector_store"))
return bool(cfg.get("api_key") or cfg.get("host")) # platform needs a key; self-hosted a host (key optional with AUTH_DISABLED)
def save_config(self, values, hermes_home):
"""Merge-write config to $HERMES_HOME/mem0.json."""
config_path = Path(hermes_home) / "mem0.json"
atomic_json_write(config_path, {**read_json_or_empty(config_path), **values}, mode=0o600)
def get_config_schema(self):
cfg = _load_config()
api_key_required = cfg.get("mode", "platform") != "oss" and not cfg.get("host")
return [
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": api_key_required, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
{"key": "host", "description": "Self-hosted Mem0 server URL (leave blank for cloud)", "required": False, "env_var": "MEM0_HOST"},
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
{"key": "rerank", "description": "Enable reranking for recall", "default": "false", "choices": ["true", "false"]},
]
def post_setup(self, hermes_home: str, config: dict) -> None:
from ._setup import post_setup
post_setup(hermes_home, config)
def _oss_hint(self, template: str, default: str = "vector store") -> str:
"""OSS-only hint; ``{vs}`` is the configured vector-store provider. "" in other modes."""
return template.format(vs=self._config.get("oss", {}).get("vector_store", {}).get("provider", default)) if self._mode == "oss" else ""
def _create_backend(self):
# Lazy-install the mem0 SDK before the backend imports it (honors security.allow_lazy_installs);
# on failure the backend import raises the canonical error, captured below.
with suppress(Exception):
pass # dependencies come from pyproject.toml (installed by Hermes on install/enable/update)
try:
from . import _backend
if self._mode == "oss":
return _backend.OSSBackend(self._config.get("oss", {}))
return _backend.SelfHostedBackend(self._api_key, self._host) if self._host else _backend.PlatformBackend(self._api_key)
except Exception as e:
logger.error("Mem0 backend failed to initialize (%s mode): %s", self._mode, e)
self._init_error = str(e)
return None
def _is_breaker_open(self) -> bool:
"""True while the breaker is tripped; an expired cooldown resets the failure count."""
with self._breaker_lock:
if self._consecutive_failures >= _BREAKER_THRESHOLD and time.monotonic() < self._breaker_open_until:
return True
if self._consecutive_failures >= _BREAKER_THRESHOLD:
self._consecutive_failures = 0
return False
def _format_error(self, prefix: str, exc: Exception) -> str:
msg = f"{prefix}: {exc}"
if any(s in str(exc).lower() for s in ("connection", "refused", "timeout")):
msg += self._oss_hint(" (check that {vs} is running)")
return msg
def _record_success(self):
with self._breaker_lock:
self._consecutive_failures = 0
def _record_failure(self):
with self._breaker_lock:
self._consecutive_failures = count = self._consecutive_failures + 1
if count >= _BREAKER_THRESHOLD:
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
if count >= _BREAKER_THRESHOLD:
hint = self._oss_hint(" Check that your {vs} vector store is running and reachable.", "unknown")
logger.warning("Mem0 circuit breaker tripped after %d consecutive failures. Pausing API calls for %ds.%s", count, _BREAKER_COOLDOWN_SECS, hint)
def _try(self, call, log, msg: str):
"""Background-path wrapper: run ``call`` under the breaker; on error log ``msg`` and return None."""
try:
result = call()
except Exception as e:
self._record_failure()
log(msg, e)
return None
self._record_success()
return result
def initialize(self, session_id: str, **kwargs) -> None:
self._config = cfg = _load_config()
self._mode, self._api_key, self._host, self._agent_id = cfg.get("mode", "platform"), cfg.get("api_key", ""), cfg.get("host", ""), cfg.get("agent_id", "hermes")
# user_id precedence: operator-configured (env/mem0.json) > gateway-native id (kwargs) > _DEFAULT_USER_ID.
# The literal placeholder counts as unset so wizard users still get gateway-native ids.
configured = cfg.get("user_id")
self._user_id = (None if configured == _DEFAULT_USER_ID else configured) or kwargs.get("user_id") or _DEFAULT_USER_ID
# Persisted rerank preference: default for mem0_search when the model omits ``rerank``. Platform-only.
_rr = cfg.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"
try:
self._sync_max_chars = int(cfg.get("sync_max_chars") or _SYNC_MSG_MAX_CHARS)
except (ValueError, TypeError):
self._sync_max_chars = _SYNC_MSG_MAX_CHARS
self._backend = self._create_backend()
if self._backend and not self._atexit_registered:
atexit.register(self.shutdown)
self._atexit_registered = True
def _search(self, query: str, top_k: int = 10, rerank: bool = False, backend=None) -> list:
# Scoped to user_id only — by design — so recall surfaces memories from any gateway/agent under this
# principal; writes attach agent_id and metadata.channel so narrower views remain possible at query time.
return (backend or self._backend).search(query, filters={"user_id": self._user_id}, top_k=top_k, rerank=rerank)
def _add(self, messages: list, infer: bool):
metadata = {"channel": self._channel} if self._channel else {}
return self._backend.add(messages, user_id=self._user_id, agent_id=self._agent_id, infer=infer, metadata=metadata)
def system_prompt_block(self) -> str:
# Mirror _create_backend precedence (oss > host > platform). Rerank is a Mem0 Platform feature only.
mode_label = "OSS (self-hosted)" if self._mode == "oss" else "self-hosted (HTTP API)" if self._host else "platform (cloud API)"
rerank_note = " Rerank is available on search." if (self._mode == "platform" and not self._host) else ""
return f"# Mem0 Memory\nActive. Mode: {mode_label}. User: {self._user_id}.\n{_PROMPT_BODY}{rerank_note}"
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
self._start_prefetch(message)
def _consume_prefetch_result(self, query: str) -> str | None:
"""Pop the finished prefetch body for ``query`` (None if absent or still running)."""
with self._prefetch_lock:
if self._prefetch_query != query or not self._prefetch_done:
return None
result, self._prefetch_result, self._prefetch_done = self._prefetch_result, "", False
return result
def _start_prefetch(self, query: str) -> None:
backend = self._backend
if not query or backend is None or self._is_breaker_open():
return
def _run():
results = self._try(lambda: self._search(query, rerank=self._rerank_default, backend=backend), logger.debug, "Mem0 prefetch failed: %s")
lines = [r.get("memory", "") for r in (results or []) if r.get("memory")]
body = "## Mem0 Memory\n" + "\n".join(f"- {line}" for line in lines) if lines else ""
with self._prefetch_lock:
if self._prefetch_query == query:
self._prefetch_result, self._prefetch_done = body, True
with self._prefetch_lock:
if self._prefetch_query == query and (self._prefetch_done or (self._prefetch_thread and self._prefetch_thread.is_alive())):
return
self._prefetch_query, self._prefetch_result, self._prefetch_done = query, "", False
self._prefetch_thread = spawn_context_thread(_run, name="mem0-prefetch")
self._prefetch_thread.start()
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Recall memories for the CURRENT question with a short hot-path wait."""
if (cached := self._consume_prefetch_result(query)) is not None:
return cached
self._start_prefetch(query)
with self._prefetch_lock:
thread = self._prefetch_thread if self._prefetch_query == query else None
if thread:
thread.join(timeout=_PREFETCH_WAIT_SECS)
return self._consume_prefetch_result(query) or "" # slow backend: skip injection; mem0_search remains the backstop
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Send the turn to Mem0 for server-side fact extraction (non-blocking)."""
if self._backend is None or self._is_breaker_open():
return
def _sync():
if self._backend is not None:
messages = [
{"role": "user", "content": _truncate_for_sync(user_content, self._sync_max_chars)},
{"role": "assistant", "content": _truncate_for_sync(assistant_content, self._sync_max_chars)},
]
self._try(lambda: self._add(messages, infer=True), logger.warning, "Mem0 sync failed: %s")
with self._sync_lock:
prev = self._sync_thread
if prev and prev.is_alive():
prev.join(timeout=5.0)
if prev.is_alive():
return
with self._sync_lock:
self._sync_thread = spawn_context_thread(_sync, name="mem0-sync")
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return list(TOOL_SCHEMAS)
# -- tool handlers: (required params, error label, body, client-error policy) ---
# Client errors (bad ID / not found) never trip the breaker, except for mem0_add
# where they count as failures; update/delete answer them with "Memory not found".
def _tool_search(self, args: dict) -> str:
top_k = max(1, min(int(args.get("top_k", 10)), 50))
rerank_raw = args.get("rerank", self._rerank_default)
rerank = rerank_raw.lower() not in ("false", "0", "no") if isinstance(rerank_raw, str) else bool(rerank_raw)
results = self._search(args["query"], top_k, rerank)
if not results:
return json.dumps({"result": "No relevant memories found."})
items = [{"id": r.get("id"), "memory": r.get("memory", ""), "score": r.get("score", 0)} for r in results]
return json.dumps({"results": items, "count": len(items)})
def _tool_add(self, args: dict) -> str:
result = self._add([{"role": "user", "content": args["content"]}], infer=False)
event_id = result.get("event_id") if isinstance(result, dict) else None
# Cloud add is async (server-side extraction); OSS and self-hosted store synchronously.
msg = "Fact stored." if (self._mode == "oss" or self._host) else "Fact queued for storage."
return json.dumps({"result": msg, "event_id": event_id})
def _ensure_owns_memory(self, memory_id: str) -> None:
"""Reject a mutation on a memory that doesn't belong to the caller's user_id."""
memory = self._backend.get(memory_id)
if not memory:
raise ValueError(f"Memory not found: {memory_id}")
owner = memory.get("user_id") if isinstance(memory, dict) else None
if owner and owner != self._user_id:
raise PermissionError(f"Memory {memory_id} does not belong to this user.")
def _tool_update(self, args: dict) -> str:
self._ensure_owns_memory(args["memory_id"])
return json.dumps(self._backend.update(args["memory_id"], args["text"]))
def _tool_delete(self, args: dict) -> str:
self._ensure_owns_memory(args["memory_id"])
return json.dumps(self._backend.delete(args["memory_id"]))
_TOOL_HANDLERS = {
"mem0_search": (("query",), "Search failed", _tool_search, "skip"),
"mem0_add": (("content",), "Failed to store", _tool_add, "count"),
"mem0_update": (("memory_id", "text"), "Update failed", _tool_update, "not_found"),
"mem0_delete": (("memory_id",), "Delete failed", _tool_delete, "not_found"),
}
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if self._backend is None:
err = getattr(self, "_init_error", "unknown error")
return json.dumps({"error": f"Mem0 backend not initialized: {err}.{self._oss_hint(' Check that {vs} is running and reachable.')}"})
if self._is_breaker_open():
return json.dumps({"error": f"Mem0 temporarily unavailable (multiple consecutive failures). Will retry automatically.{self._oss_hint(' Check that your {vs} is running.')}"})
if tool_name not in self._TOOL_HANDLERS:
return tool_error(f"Unknown tool: {tool_name}")
required, label, body, on_client_error = self._TOOL_HANDLERS[tool_name]
if not isinstance(args, dict):
return tool_error("Tool arguments must be an object")
if missing := next((k for k in required if not isinstance(args.get(k), str) or not args[k].strip()), None):
return tool_error(f"Missing or invalid required parameter: {missing}")
if tool_name == "mem0_search":
try:
int(args.get("top_k", 10))
except (TypeError, ValueError, OverflowError):
return tool_error("top_k must be an integer")
try:
result = body(self, args)
except PermissionError as e:
return tool_error(str(e))
except Exception as e:
client = _is_client_error(e)
if client and on_client_error == "not_found":
return tool_error(f"Memory not found: {args['memory_id']}")
if not client or on_client_error == "count":
self._record_failure()
return tool_error(self._format_error(label, e))
self._record_success()
return result
def _shutdown_backend(self):
with suppress(Exception):
if self._backend:
self._backend.close()
self._backend = None
def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
if t and t.is_alive():
# Extraction can outlast five seconds. Closing storage underneath it
# loses the turn; let the backend's network timeouts bound the drain.
t.join()
self._shutdown_backend()
def register(ctx) -> None:
"""Register Mem0 as a memory provider plugin."""
ctx.register_memory_provider(Mem0MemoryProvider())
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
ADD_SCHEMA = {
"name": "mem0_add",
"description": (
"Store a durable fact about the user, verbatim (no LLM extraction). "
"Call this the moment the user states a lasting preference, correction, "
"decision, or personal detail worth recalling on future turns — don't "
"wait to be asked to remember. Skip transient chit-chat and facts you've "
"already stored."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The fact to store."},
},
"required": ["content"],
},
}
DELETE_SCHEMA = {
"name": "mem0_delete",
"description": (
"Delete a memory by its ID (take the ID from a mem0_search "
"result). Use when a stored fact is obsolete or the user asks you to "
"forget it; prefer mem0_update if the fact merely changed."
),
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "string", "description": "Memory UUID to delete."},
},
"required": ["memory_id"],
},
}
SEARCH_SCHEMA = {
"name": "mem0_search",
"description": (
"Search the user's memories by meaning; returns facts ranked by "
"relevance. Use this before answering any question that may depend on "
"what you know about the user (preferences, facts, history, people, "
"projects, past decisions). For multi-part or multi-hop questions, "
"call it several times — vary the wording and run follow-up searches "
"on what earlier results reveal; one search is rarely enough."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
"rerank": {"type": "boolean", "description": "Rerank results for relevance (default: false, platform mode only)."},
},
"required": ["query"],
},
}
UPDATE_SCHEMA = {
"name": "mem0_update",
"description": (
"Replace the text of an existing memory by its ID (take the ID from a "
"mem0_search result). Use when a stored fact has changed "
"or was wrong — correct it in place instead of adding a duplicate."
),
"parameters": {
"type": "object",
"properties": {
"memory_id": {"type": "string", "description": "Memory UUID to update."},
"text": {"type": "string", "description": "New text content."},
},
"required": ["memory_id", "text"],
},
}
# ---- END PLUGIN-COMPAT ----
+336
View File
@@ -0,0 +1,336 @@
"""Backend abstraction for Mem0 Platform and OSS modes."""
from __future__ import annotations
import logging
import os
from abc import ABC, abstractmethod
from contextlib import closing, nullcontext, suppress
from copy import deepcopy
from dataclasses import dataclass, field
from threading import RLock
from typing import Any
logger = logging.getLogger(__name__)
def _add_kwargs(user_id: str, agent_id: str, infer: bool, metadata: dict | None) -> dict[str, Any]:
return {"user_id": user_id, "agent_id": agent_id, "infer": infer, **({"metadata": metadata} if metadata else {})}
def _unwrap_results(response: Any) -> list:
"""Normalize API response — extract results list from dict or pass through."""
return response.get("results", []) if isinstance(response, dict) else response if isinstance(response, list) else []
class Mem0Backend(ABC):
"""Unified interface over Platform (MemoryClient), self-hosted (HTTP) and OSS (Memory) backends.
update()/delete() are template methods: subclasses implement raw ``_update``/``_delete``."""
@abstractmethod
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]: ...
@abstractmethod
def add(self, messages: list, *, user_id: str, agent_id: str, infer: bool = False, metadata: dict | None = None) -> dict: ...
@abstractmethod
def get(self, memory_id: str) -> dict | None: ...
@abstractmethod
def _update(self, memory_id: str, text: str) -> None: ...
@abstractmethod
def _delete(self, memory_id: str) -> None: ...
def update(self, memory_id: str, text: str) -> dict:
self._update(memory_id, text)
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id: str) -> dict:
self._delete(memory_id)
return {"result": "Memory deleted.", "memory_id": memory_id}
def close(self) -> None:
pass
class PlatformBackend(Mem0Backend):
"""Wraps mem0.MemoryClient for Mem0 Platform (cloud API)."""
def __init__(self, api_key: str):
from mem0 import MemoryClient
self._client = MemoryClient(api_key=api_key)
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
return _unwrap_results(self._client.search(query, filters=filters, top_k=top_k, rerank=rerank))
def add(self, messages: list, *, user_id: str, agent_id: str, infer: bool = False, metadata: dict | None = None) -> dict:
return self._client.add(messages, **_add_kwargs(user_id, agent_id, infer, metadata))
def get(self, memory_id: str) -> dict | None:
return self._client.get(memory_id)
def _update(self, memory_id: str, text: str) -> None:
self._client.update(memory_id=memory_id, text=text)
def _delete(self, memory_id: str) -> None:
self._client.delete(memory_id=memory_id)
class SelfHostedBackend(Mem0Backend):
"""Direct HTTP backend for a self-hosted Mem0 server (the FastAPI ``server/``).
mem0.MemoryClient is hardwired to the cloud API (``Authorization: Token``, ``GET /v1/ping/`` in ``__init__``),
so this speaks the server's real 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", **({"X-API-Key": api_key} if api_key else {})} # key omitted only for AUTH_DISABLED servers
# Connect-level retries keep one dropped SYN from counting toward the breaker. ``transport`` is injectable for tests.
self._client = httpx.Client(base_url=host.rstrip("/"), headers=headers, timeout=30.0, transport=transport or httpx.HTTPTransport(retries=2))
self._capture_timeout = httpx.Timeout(120.0, connect=30.0)
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 platform-only; the self-hosted /search ignores it. user_id belongs in filters (top-level is deprecated).
return _unwrap_results(self._json("POST", "/search", json={"query": query, "top_k": top_k, **({"filters": filters} if filters else {})}))
def add(self, messages: list, *, user_id: str, agent_id: str, infer: bool = False, metadata: dict | None = None) -> dict:
# Server-side extraction takes longer than a search or verbatim write.
return self._json("POST", "/memories", json={"messages": messages, **_add_kwargs(user_id, agent_id, infer, metadata)},
timeout=self._capture_timeout if infer else self._client.timeout)
def get(self, memory_id: str) -> dict | None:
return self._json("GET", f"/memories/{memory_id}")
def _update(self, memory_id: str, text: str) -> None:
self._json("PUT", f"/memories/{memory_id}", json={"text": text})
def _delete(self, memory_id: str) -> None:
self._json("DELETE", f"/memories/{memory_id}")
def close(self) -> None:
with suppress(Exception):
self._client.close()
_DIRECT_OPENAI_PROVIDER = "hermes_openai"
_DIRECT_OPENAI_CLASS_PATH = f"{__package__}._openai_llm.DirectOpenAILLM"
@dataclass
class _LocalQdrantMemory:
memory: Any
config: dict
profile: str
lock: Any = field(default_factory=RLock)
users: int = 1
_LOCAL_QDRANT_MEMORIES: dict[str, _LocalQdrantMemory] = {}
_LOCAL_QDRANT_LOCK = RLock()
def _register_direct_openai_provider() -> None:
"""Register Hermes' OpenAI-only Mem0 LLM provider once per factory."""
from mem0.configs.llms.openai import OpenAIConfig
from mem0.utils.factory import LlmFactory
provider_map = getattr(LlmFactory, "provider_to_class", None)
register_provider = getattr(LlmFactory, "register_provider", None)
if not isinstance(provider_map, dict) or not callable(register_provider):
raise RuntimeError("mem0 LlmFactory does not support the provider registration required for the Hermes OpenAI OSS backend")
if provider_map.get(_DIRECT_OPENAI_PROVIDER) != (_DIRECT_OPENAI_CLASS_PATH, OpenAIConfig):
register_provider(_DIRECT_OPENAI_PROVIDER, _DIRECT_OPENAI_CLASS_PATH, OpenAIConfig)
class OSSBackend(Mem0Backend):
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
def __init__(self, oss_config: dict):
from ._oss_providers import EMBEDDER_PROVIDERS, KNOWN_DIMS, LLM_PROVIDERS
self._local_path = None
self._owner = None
self._lock = nullcontext()
self._closed = False
def _provider_block(name: str, registry: dict) -> dict:
"""Copy of oss_config[name] with the legacy ``api_base`` key mapped to the provider's canonical base-URL key."""
block = dict(oss_config[name])
provider_config = dict(block.get("config", {}))
legacy_base = provider_config.pop("api_base", None)
canonical_key = registry.get(str(block.get("provider") or "").strip().lower(), {}).get("base_url_key")
if legacy_base and canonical_key:
provider_config.setdefault(canonical_key, legacy_base)
if str(block.get("provider") or "").strip().lower() == "openai":
from agent.secret_scope import get_secret
# Resolve profile secrets before comparing configurations for sharing.
provider_config["api_key"] = provider_config.get("api_key") or get_secret("OPENAI_API_KEY", "")
if not provider_config["api_key"]:
raise ValueError(f"OpenAI API key is required for the Hermes Mem0 OSS {name}")
provider_config["openai_base_url"] = (
provider_config.get("openai_base_url") or get_secret("OPENAI_API_BASE", "")
or get_secret("OPENAI_BASE_URL", "") or "https://api.openai.com/v1"
)
block["config"] = provider_config
return block
vector_store = dict(oss_config["vector_store"])
vs_config = dict(vector_store.get("config", {}))
if vs_config.get("path"):
vs_config["path"] = os.path.expanduser(vs_config["path"])
embedder_config = oss_config.get("embedder", {}).get("config", {})
dims = embedder_config.get("embedding_dims") or KNOWN_DIMS.get(embedder_config.get("model", ""))
if dims:
vs_config["embedding_model_dims"] = dims
remote = (vs_config.get("host") and vs_config.get("port")) or vs_config.get("url") or vs_config.get("api_key")
if (vector_store.get("provider", "qdrant") == "qdrant" and not vs_config.get("client")
and not remote and vs_config.get("https") is None):
from mem0.configs.vector_stores.qdrant import QdrantConfig
path = vs_config.get("path", QdrantConfig.model_fields["path"].default)
if path:
self._local_path = vs_config["path"] = os.path.realpath(os.path.expanduser(path))
vector_store["config"] = vs_config
config = {"vector_store": vector_store, "llm": _provider_block("llm", LLM_PROVIDERS), "embedder": _provider_block("embedder", EMBEDDER_PROVIDERS), "version": "v1.1"}
if self._local_path:
from hermes_constants import get_hermes_home
profile = os.path.realpath(get_hermes_home())
with _LOCAL_QDRANT_LOCK:
owner = _LOCAL_QDRANT_MEMORIES.get(self._local_path)
if owner is None:
owner = _LocalQdrantMemory(self._create_memory(config, dims), deepcopy(config), profile)
_LOCAL_QDRANT_MEMORIES[self._local_path] = owner
else:
if owner.profile != profile or owner.config != config:
raise ValueError("Local Qdrant storage is already open with a different profile or configuration. "
"Existing memories were preserved. Close its active sessions before changing settings, "
"or use a separate storage path.")
owner.users += 1
self._owner, self._lock, self._memory = owner, owner.lock, owner.memory
else:
self._memory = self._create_memory(config, dims)
@staticmethod
def _create_memory(config: dict, dims: int | None):
from mem0 import Memory
vector_store = config["vector_store"]
vs_config = vector_store["config"]
if dims:
OSSBackend._reject_dimension_mismatch(vector_store.get("provider", "qdrant"), vs_config, dims)
else:
logger.warning("Unknown embedding dimensions; skipping dimension-change guard for collection %r.",
vs_config.get("collection_name", "mem0"))
if str(config["llm"].get("provider") or "").strip().lower() == "openai":
# mem0 validates LlmConfig.provider before its factory lookup: build the supported OpenAI config, then swap the provider.
_register_direct_openai_provider()
from mem0.configs.base import MemoryConfig
memory_config = MemoryConfig(**config)
try:
memory_config.llm.provider = _DIRECT_OPENAI_PROVIDER
except (AttributeError, TypeError) as exc:
raise RuntimeError("mem0 MemoryConfig does not expose a mutable llm.provider for the Hermes OpenAI OSS backend") from exc
return Memory(memory_config)
return Memory.from_config(config)
@staticmethod
def _detect_current_dims(provider: str, vs_config: dict, collection_name: str) -> int | None:
"""Current embedding dimension of ``collection_name``, or None if it doesn't exist yet.
Raises on any failure to connect/inspect so the caller can decide whether to skip the guard."""
if provider == "qdrant":
from qdrant_client import QdrantClient
path, url, host = vs_config.get("path"), vs_config.get("url"), vs_config.get("host")
if path:
client = QdrantClient(path=path)
elif url:
client = QdrantClient(url=url, api_key=vs_config.get("api_key"))
elif host:
client = QdrantClient(host=host, port=vs_config.get("port") or 6333, api_key=vs_config.get("api_key"))
else:
return None
with closing(client):
if not client.collection_exists(collection_name):
return None
vectors = client.get_collection(collection_name).config.params.vectors
# Named-vector collections expose a dict; unnamed expose an object with .size.
if isinstance(vectors, dict):
vectors = next(iter(vectors.values()), None)
return getattr(vectors, "size", None)
elif provider == "pgvector":
import psycopg2
conn_params = {k: vs_config[k] for k in ("host", "port", "user", "password", "dbname", "sslmode") if vs_config.get(k)}
with closing(psycopg2.connect(**conn_params)) as conn:
conn.autocommit = True
with closing(conn.cursor()) as cur:
cur.execute("SELECT atttypmod FROM pg_attribute WHERE attrelid = %s::regclass AND attname = 'vector'", (collection_name,))
row = cur.fetchone()
return row[0] if row and row[0] > 0 else None
return None
@staticmethod
def _reject_dimension_mismatch(provider: str, vs_config: dict, expected_dims: int) -> None:
"""Reject embedding dimension changes without deleting existing memories."""
collection_name = vs_config.get("collection_name", "mem0")
try:
current_dims = OSSBackend._detect_current_dims(provider, vs_config, collection_name)
except Exception as dimension_detection_error:
logger.warning(
"Could not determine embedding dimensions for collection %r (%s): %s. Skipping dimension-change guard.",
collection_name, provider, dimension_detection_error,
)
return
if current_dims is not None and current_dims != expected_dims:
raise ValueError(
f"Collection {collection_name!r} has {current_dims} embedding dimensions, but {expected_dims} are configured. "
"Existing memories were preserved. Restore the previous embedder or use a new collection_name."
)
def search(self, query: str, *, filters: dict, top_k: int = 10, rerank: bool = False) -> list[dict]:
return _unwrap_results(self._call("search", query, filters=filters, top_k=top_k))
def add(self, messages: list, *, user_id: str, agent_id: str, infer: bool = False, metadata: dict | None = None) -> dict:
return self._call("add", messages, **_add_kwargs(user_id, agent_id, infer, metadata))
def get(self, memory_id: str) -> dict | None:
return self._call("get", memory_id)
def _update(self, memory_id: str, text: str) -> None:
self._call("update", memory_id, data=text)
def _delete(self, memory_id: str) -> None:
self._call("delete", memory_id)
def _call(self, method, *args, **kwargs):
# ponytail: serialize whole local SDK operations, including extraction's read/modify/write.
# Use a Qdrant server for parallel throughput or access from multiple processes.
with self._lock:
if self._closed:
raise RuntimeError("Mem0 backend is closed")
return getattr(self._memory, method)(*args, **kwargs)
def close(self):
with self._lock:
if self._closed:
return
self._closed = True
if self._owner:
with _LOCAL_QDRANT_LOCK:
self._owner.users -= 1
if self._owner.users == 0:
self._close_memory()
del _LOCAL_QDRANT_MEMORIES[self._local_path]
else:
self._close_memory()
def _close_memory(self):
with suppress(Exception):
telemetry = getattr(self._memory, "telemetry", None)
if telemetry and hasattr(telemetry, "posthog"):
with suppress(Exception):
telemetry.posthog.shutdown()
vs = getattr(self._memory, "vector_store", None)
telemetry_vs = getattr(self._memory, "_telemetry_vector_store", None)
resources = (self._memory, vs, getattr(vs, "client", None), getattr(telemetry_vs, "client", None))
for obj in {id(obj): obj for obj in resources if obj is not None}.values():
if hasattr(obj, "close"):
with suppress(Exception):
obj.close()
@@ -0,0 +1,66 @@
"""OpenAI-only LLM adapter for Mem0 OSS mode."""
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Union
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.openai import OpenAIConfig
from mem0.llms.base import LLMBase
from mem0.llms.openai import OpenAILLM
# BaseLlmConfig fields copied into OpenAIConfig; the last two may be absent on older mem0.
_COPIED_FIELDS = ("model", "temperature", "api_key", "max_tokens", "top_p", "top_k", "enable_vision", "vision_details", "http_client_proxies")
_OPTIONAL_FIELDS = ("reasoning_effort", "is_reasoning_model")
class DirectOpenAILLM(OpenAILLM):
"""Use OpenAI credentials and requests regardless of router environment."""
def __init__(self, config: Optional[Union[BaseLlmConfig, OpenAIConfig, Dict]] = None):
if config is None:
config = OpenAIConfig()
elif isinstance(config, dict):
config = OpenAIConfig(**config)
elif isinstance(config, BaseLlmConfig) and not isinstance(config, OpenAIConfig):
fields = {k: getattr(config, k) for k in _COPIED_FIELDS}
fields.update({k: getattr(config, k, None) for k in _OPTIONAL_FIELDS})
config = OpenAIConfig(**fields)
if not config.model:
config.model = "gpt-5-mini"
# Configs predating the setup marker: keep the default model reasoning-safe
# without overriding an explicit user choice.
if config.model == "gpt-5-mini" and config.is_reasoning_model is None:
config.is_reasoning_model = True
# Bypass OpenAILLM.__init__ (it picks OpenRouter when OPENROUTER_API_KEY is
# set); LLMBase still owns validation and supported-parameter filtering.
LLMBase.__init__(self, config)
# OPENAI_API_KEY / OPENAI_BASE_URL are profile credentials: read them through the secret
# scope, never raw os.environ, or a multiplexed secondary's memory extraction runs on the
# default profile's OpenAI account (and its proxy).
from agent.secret_scope import get_secret
api_key = self.config.api_key or get_secret("OPENAI_API_KEY", "")
if not api_key:
raise ValueError("OpenAI API key is required for the Hermes Mem0 OSS provider")
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=self.config.openai_base_url or get_secret("OPENAI_API_BASE", "") or get_secret("OPENAI_BASE_URL", "") or "https://api.openai.com/v1", timeout=120.0)
def generate_response(self, messages: List[Dict[str, str]], response_format=None, tools: Optional[List[Dict]] = None, tool_choice: str = "auto", **kwargs):
params = self._get_supported_params(messages=messages, **kwargs)
params.update({"model": self.config.model, "messages": messages})
# No OpenRouter-only fields; ``store`` is opt-in so OpenAI-compatible endpoints never receive unknown fields.
if self.config.store is not None:
params["store"] = self.config.store
if response_format:
params["response_format"] = response_format
if tools:
params["tools"], params["tool_choice"] = tools, tool_choice
response = self.client.chat.completions.create(**params)
parsed_response = self._parse_response(response, tools)
if self.config.response_callback:
try:
self.config.response_callback(self, response, params)
except Exception:
logging.error("Error running Mem0 OpenAI response callback")
return parsed_response
@@ -0,0 +1,53 @@
"""OSS provider definitions for LLM, embedder, and vector store."""
from __future__ import annotations
import os
from typing import Any
from hermes_constants import get_hermes_home
LLM_PROVIDERS: dict[str, dict[str, Any]] = {
"openai": {"label": "OpenAI", "needs_key": True, "env_var": "OPENAI_API_KEY", "default_model": "gpt-5-mini", "base_url_key": "openai_base_url"},
"ollama": {"label": "Ollama (local)", "needs_key": False, "default_model": "llama3.1:8b", "default_url": "http://localhost:11434", "base_url_key": "ollama_base_url", "pip_dep": "ollama"},
}
EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
"openai": {"label": "OpenAI", "needs_key": True, "env_var": "OPENAI_API_KEY", "default_model": "text-embedding-3-small", "base_url_key": "openai_base_url", "dims": 1536},
"ollama": {"label": "Ollama (local)", "needs_key": False, "default_model": "nomic-embed-text", "default_url": "http://localhost:11434", "base_url_key": "ollama_base_url", "dims": 768, "pip_dep": "ollama"},
}
VECTOR_PROVIDERS: dict[str, dict[str, Any]] = {
# Resolved lazily (see ``vector_default_config``): the profile home is a ContextVar at call time,
# not an import-time constant, and ``~/.hermes`` is wrong on Windows and under profiles.
"qdrant": {"label": "Qdrant", "default_config": {"path": lambda: str(get_hermes_home() / "mem0_qdrant")}, "pip_dep": "qdrant-client"},
"pgvector": {
"label": "PGVector",
"default_config": {"host": "localhost", "port": 5432, "user": os.getenv("USER", "postgres"), "dbname": "postgres"},
"pip_dep": "psycopg2-binary",
},
}
KNOWN_DIMS: dict[str, int] = {"text-embedding-3-small": 1536, "text-embedding-3-large": 3072, "text-embedding-ada-002": 1536, "nomic-embed-text": 768}
def vector_default_config(provider_id: str) -> dict[str, Any]:
"""A vector store's ``default_config`` with callable defaults resolved for the active profile."""
return {k: (v() if callable(v) else v) for k, v in VECTOR_PROVIDERS[provider_id]["default_config"].items()}
SECTION_REGISTRIES = (("llm", LLM_PROVIDERS), ("embedder", EMBEDDER_PROVIDERS), ("vector_store", VECTOR_PROVIDERS))
def validate_oss_config(oss_config: dict) -> list[str]:
"""Validate an OSS config dict. Returns list of error strings (empty = valid)."""
errors: list[str] = []
for section, registry in SECTION_REGISTRIES:
block = oss_config.get(section)
if not block or not isinstance(block, dict):
errors.append(f"Missing required section: {section}")
elif block.get("provider", "") not in registry:
errors.append(f"Unknown {section} provider '{block.get('provider', '')}'. Valid: {', '.join(registry.keys())}")
vs = oss_config.get("vector_store", {})
if vs.get("provider") == "pgvector" and not vs.get("config", {}).get("user"):
errors.append("PGVector requires 'user' in vector_store.config")
return errors
+567
View File
@@ -0,0 +1,567 @@
"""Setup wizard for Mem0 plugin — interactive and flag-based modes."""
from __future__ import annotations
import getpass
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from contextlib import suppress
from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home # noqa: F401 — patched by tests
from ._oss_providers import (
EMBEDDER_PROVIDERS,
KNOWN_DIMS,
LLM_PROVIDERS,
SECTION_REGISTRIES,
VECTOR_PROVIDERS,
validate_oss_config,
vector_default_config,
)
_OLLAMA_URL = "http://localhost:11434"
_PGVECTOR_CONTAINER, _PGVECTOR_IMAGE = "hermes-pgvector", "pgvector/pgvector:pg17"
def _version_tuple(version: str) -> tuple[int, ...]:
"""Best-effort (major, minor, patch) from a version string, tolerating pre-release suffixes like '2.1.0rc1'."""
parts = version.split(".")[:3]
return tuple(int(m.group()) if (m := re.match(r"\d+", p)) else 0 for p in parts)
def _scrub(text: str, *secrets_to_hide: str) -> str:
"""Replace any occurrence of the given secrets in ``text`` (e.g. before printing a subprocess error)."""
for secret in secrets_to_hide:
if secret:
text = text.replace(secret, "***")
return text
def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int:
from hermes_cli.curses_ui import curses_radiolist
return curses_radiolist(title, [f"{label} {desc}" if desc else label for label, desc in items], selected=default, cancel_returns=default)
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
"""Prompt for a value with optional default and secret masking."""
sys.stdout.write(f" {label}{f' [{default}]' if default else ''}: ")
sys.stdout.flush()
val = getpass.getpass(prompt="") if secret and sys.stdin.isatty() else sys.stdin.readline().strip()
return val or (default or "")
def _input(label: str, default: str) -> str:
return input(f" {label} [{default}]: ").strip() or default
def _masked(secret: str) -> str:
return f"...{secret[-4:]}" if len(secret) > 4 else "set"
def _http_get(url: str, path: str, timeout: int):
return urllib.request.urlopen(urllib.request.Request(f"{url.rstrip('/')}{path}", method="GET"), timeout=timeout)
def _prompt_api_key(label: str, env_var: str, hermes_home: str) -> str:
"""Prompt for API key, showing masked existing value if found."""
existing = os.environ.get(env_var, "")
if not existing:
from agent.secret_scope import load_env_file
existing = load_env_file(Path(hermes_home) / ".env").get(env_var, "")
hint = f" (current: {_masked(existing)}, blank to keep)" if existing else ""
return getpass.getpass(f" {label} API key{hint}: ").strip()
def _api_key_writes(flags: dict, label: str, *, url: str | None = None, fresh_label: str | None = None) -> dict[str, str]:
"""MEM0_API_KEY for .env: from --api-key, else prompt (masking any key already in the environment)."""
if flags.get("api_key"):
return {"MEM0_API_KEY": flags["api_key"]}
existing = os.environ.get("MEM0_API_KEY", "")
if url and not existing:
print(f" Get yours at {url}")
val = _prompt(f"{label} (current: {_masked(existing)}, blank to keep)" if existing else fresh_label or label, secret=True)
return {"MEM0_API_KEY": val} if val else {}
def _print_dry_run(summary: str, env_writes: dict, check=None) -> None:
print(f"\n [dry-run] Would save config: {summary}")
if env_writes:
print(" [dry-run] Would write API key to .env")
if check:
check()
print(" [dry-run] No files written.\n")
# --oss-vector-<key> flags accepted per vector store (also the pgvector key order).
_VECTOR_FLAG_KEYS = {"qdrant": ("path", "url"), "pgvector": ("host", "port", "user", "password", "dbname")}
_FLAG_KEYS = ("mode", "api_key", "host", *(f"oss_{s}{k}" for s in ("llm", "embedder") for k in ("", "_key", "_model", "_url")),
"oss_vector", *(f"oss_vector_{k}" for ks in _VECTOR_FLAG_KEYS.values() for k in ks), "user_id")
_FLAG_DEFAULTS = {"oss_llm": "openai", "oss_embedder": "openai", "oss_vector": "qdrant"}
def parse_flags(argv: list[str] | None = None) -> dict[str, str]:
args = argv if argv is not None else sys.argv[1:]
flags: dict[str, Any] = {**{k: _FLAG_DEFAULTS.get(k, "") for k in _FLAG_KEYS}, "dry_run": False}
flag_map = {"--" + k.replace("_", "-"): k for k in _FLAG_KEYS}
i = 0
while i < len(args):
if args[i] == "--dry-run":
flags["dry_run"] = True
elif args[i] in flag_map and i + 1 < len(args):
flags[flag_map[args[i]]] = args[i + 1]
i += 1
i += 1
return flags
def _model_block(flags: dict, registry: dict, prefix: str) -> tuple[str, dict, dict[str, Any]]:
"""Resolve (provider_id, provider_def, config) for an LLM/embedder section from flags."""
pid = flags.get(prefix, "openai")
pdef = registry[pid]
cfg: dict[str, Any] = {"model": flags.get(f"{prefix}_model") or pdef["default_model"]}
url = flags.get(f"{prefix}_url") or pdef.get("default_url")
if url and pdef.get("base_url_key"):
cfg[pdef["base_url_key"]] = url
return pid, pdef, cfg
def build_oss_config(flags: dict[str, str]) -> tuple[dict, dict[str, str]]:
"""Build (oss_config for mem0.json, env_writes of secrets for .env) from parsed flags."""
llm_id, llm_def, llm_config = _model_block(flags, LLM_PROVIDERS, "oss_llm")
if llm_id == "openai" and llm_config["model"] == "gpt-5-mini":
llm_config["is_reasoning_model"] = True
embedder_id, embedder_def, embedder_config = _model_block(flags, EMBEDDER_PROVIDERS, "oss_embedder")
dims = KNOWN_DIMS.get(embedder_config["model"])
if dims:
embedder_config["embedding_dims"] = dims
vector_id = flags.get("oss_vector", "qdrant")
vector_config = vector_default_config(vector_id)
for key in _VECTOR_FLAG_KEYS.get(vector_id, ()):
if val := flags.get(f"oss_vector_{key}"):
if key == "port" and not val.isdigit():
raise ValueError(f"--oss-vector-port must be a number, got {val!r}")
vector_config[key] = int(val) if key == "port" else val
if "url" in vector_config:
vector_config.pop("path", None) # a remote Qdrant URL replaces local storage
oss_config = {"llm": {"provider": llm_id, "config": llm_config}, "embedder": {"provider": embedder_id, "config": embedder_config}, "vector_store": {"provider": vector_id, "config": vector_config}}
# An embedder sharing the LLM's provider reuses the LLM key when no embedder key was given.
llm_key = flags.get("oss_llm_key") if llm_def.get("needs_key") else ""
emb_key = (flags.get("oss_embedder_key") or (flags.get("oss_llm_key") if embedder_id == llm_id else "")) if embedder_def.get("needs_key") else ""
env_writes = {d["env_var"]: k for d, k in ((llm_def, llm_key), (embedder_def, emb_key)) if k}
if llm_key and emb_key and llm_key != emb_key and llm_def["env_var"] == embedder_def["env_var"]:
# One environment variable cannot hold two accounts; save explicit keys in the private config.
llm_config["api_key"], embedder_config["api_key"] = llm_key, emb_key
env_writes.pop(llm_def["env_var"])
return oss_config, env_writes
def _write_env(env_path: Path, env_writes: dict[str, str]) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
# utf-8-sig like the canonical .env readers: a BOM'd first line would miss the key match and get duplicated.
existing_lines = env_path.read_text(encoding="utf-8-sig").splitlines() if env_path.exists() else []
keys = [line.split("=", 1)[0].strip() if "=" in line and not line.startswith("#") else None for line in existing_lines]
new_lines = [f"{k}={env_writes[k]}" if k in env_writes else line for k, line in zip(keys, existing_lines)]
new_lines += [f"{k}={v}" for k, v in env_writes.items() if k not in keys]
from utils import atomic_write_text
atomic_write_text(env_path, "\n".join(new_lines) + "\n", mode=0o600)
def _activate_provider(config: dict) -> None:
"""Point config.yaml's memory.provider at mem0."""
from hermes_cli.config import save_config
config["memory"]["provider"] = "mem0"
save_config(config)
def _persist_provider_config(hermes_home: str, config: dict, provider_config: dict, env_writes: dict[str, str], label: str, key_line: str, server: str | None = None) -> None:
"""Shared platform/self-hosted tail: activate, write mem0.json (0600), then .env, then a saved summary."""
_activate_provider(config)
from . import Mem0MemoryProvider
if "MEM0_API_KEY" in env_writes:
provider_config["api_key"] = "" # Let the newly saved .env key replace legacy inline credentials.
Mem0MemoryProvider().save_config(provider_config, hermes_home)
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
if server:
_check_selfhosted_server(server)
print("\n".join(["", f" Memory provider: {label}", *([f" Server: {server}"] if server else []), " Activation saved to config.yaml", " Provider config saved",
*([f" {key_line}"] if env_writes else []), "", " Start a new session to activate.", ""]))
def _setup_platform(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""Platform mode setup — prompts for API key (secret -> .env), user/agent ids and rerank (-> mem0.json)."""
from utils import read_json_or_empty
provider_config = read_json_or_empty(Path(hermes_home) / "mem0.json")
print("\n Configuring mem0:\n")
env_writes = _api_key_writes(flags, "Mem0 Platform API key", url="https://app.mem0.ai")
for key, desc, default in (("user_id", "User identifier", "hermes-user"), ("agent_id", "Agent identifier", "hermes")):
if val := flags.get(key) or _prompt(desc, default=str(provider_config.get(key) or default)):
provider_config[key] = val
choices = ["true", "false"]
current = str(provider_config.get("rerank", "false") or "").lower()
provider_config["rerank"] = choices[_curses_select(" Enable reranking for recall", [(c, "") for c in choices], default=choices.index(current) if current in choices else 0)]
if flags.get("dry_run"):
_print_dry_run(str({k: provider_config.get(k) for k in ("user_id", "agent_id", "rerank")}), env_writes)
return
# Routing checks ``host`` before platform, so clear a stale self-hosted host. "" rather than
# pop(): save_config merges into the existing mem0.json, so a popped key would survive.
provider_config.update(mode="platform", host="")
# _load_config() also seeds ``host`` from MEM0_HOST (.env); the file clear can't help there, so warn.
if os.environ.get("MEM0_HOST", "").strip():
print(f"\n ⚠ MEM0_HOST is set in your environment ({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.")
_persist_provider_config(hermes_home, config, provider_config, env_writes, "mem0", "API keys saved to .env")
def _check_selfhosted_server(host: str) -> None:
"""Best-effort reachability check for a self-hosted Mem0 server (non-fatal)."""
try:
_http_get(host, "/docs", 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 — point at an existing Mem0 server: URL -> mem0.json, key -> .env (MEM0_API_KEY)."""
from utils import read_json_or_empty
provider_config = read_json_or_empty(Path(hermes_home) / "mem0.json")
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 = _api_key_writes(flags, "Server API key", fresh_label="Server API key (blank if AUTH_DISABLED)")
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_dry_run(f"host={host}, user_id={user_id}, agent_id={agent_id}", env_writes, lambda: _check_selfhosted_server(host))
return
provider_config.update(mode="platform", host=host, user_id=user_id, agent_id=agent_id) # routing: oss > host > platform
_persist_provider_config(hermes_home, config, provider_config, env_writes, "mem0 (self-hosted)", "API key saved to .env", server=host)
def _print_oss_summary(oss_config: dict, env_writes: dict, dry_run: bool = False) -> None:
llm, emb = oss_config["llm"], oss_config["embedder"]
w = 0 if dry_run else 9 # final summary column-aligns the labels
lines = ["", " [dry-run] OSS config would be:" if dry_run else " ✓ Mem0 configured (OSS mode)",
f" {'LLM:':<{w}} {llm['provider']} ({llm['config'].get('model', '')})", f" {'Embedder:':<{w}} {emb['provider']} ({emb['config'].get('model', '')})",
f" {'Vector:':<{w}} {oss_config['vector_store']['provider']}"]
if dry_run:
lines += [f" Env vars: {', '.join(env_writes.keys())}"] if env_writes else []
else:
lines += [*([" API keys saved to .env"] if env_writes else []), " Config saved to mem0.json", " Provider set in config.yaml", "", " Start a new session to activate.", ""]
print("\n".join(lines))
def _finish_oss(hermes_home: str, config: dict, oss_config: dict, env_writes: dict[str, str], user_id: str, agent_id: str, pgvector_config: dict | None = None) -> None:
"""Shared OSS tail: write secrets + mem0.json, install deps, activate, check, summarize."""
from . import Mem0MemoryProvider
if env_writes:
_write_env(Path(hermes_home) / ".env", env_writes)
Mem0MemoryProvider().save_config(
{"mode": "oss", "user_id": user_id, "agent_id": agent_id, "oss": oss_config}, hermes_home
)
_install_provider_deps(oss_config["llm"]["provider"], oss_config["embedder"]["provider"], oss_config["vector_store"]["provider"])
if pgvector_config:
_ensure_pgvector_extension(pgvector_config)
_activate_provider(config)
_run_connectivity_checks(oss_config)
_print_oss_summary(oss_config, env_writes)
def _setup_oss(hermes_home: str, config: dict, flags: dict[str, str]) -> None:
"""OSS mode — non-interactive when --mode was given, otherwise curses pickers."""
if not flags.get("_mode_from_flag"):
_setup_oss_interactive(hermes_home, config)
return
try:
oss_config, env_writes = build_oss_config(flags)
except ValueError as e:
print(f" Error: {e}", file=sys.stderr)
sys.exit(1)
if errors := validate_oss_config(oss_config):
print("".join(f" Error: {e}\n" for e in errors), end="", file=sys.stderr)
sys.exit(1)
if flags.get("dry_run"):
_print_oss_summary(oss_config, env_writes, dry_run=True)
_run_connectivity_checks(oss_config)
print(" [dry-run] No files written.\n")
return
_finish_oss(hermes_home, config, oss_config, env_writes, flags.get("user_id") or os.getenv("USER", "hermes-user"), "hermes")
def _docker(*args: str, timeout: int, **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL, **kwargs)
def _pg_ready(host: str, port: int, wait: int) -> bool:
"""Wait up to ``wait`` seconds for the port, then report whether PostgreSQL answers."""
_wait_for_port(host, port, timeout=wait)
return _check_pgvector(host, port)[0]
def _ensure_pgvector(host: str = "localhost", port: int = 5432) -> dict | None:
"""Ensure pgvector is reachable, offering Docker if not; returns the started container's vector_config, else None."""
if _check_pgvector(host, port)[0]:
print(f" ✓ PostgreSQL reachable at {host}:{port}")
return None
print(f" PostgreSQL not reachable at {host}:{port}")
if not shutil.which("docker"):
print(" Docker not found. Install Docker to auto-start pgvector,\n or run PostgreSQL with pgvector manually.")
return None
with suppress(Exception): # restart our own container if it exists but is stopped
result = _docker("inspect", _PGVECTOR_CONTAINER, "--format", "{{.State.Status}}", timeout=10, text=True, encoding='utf-8', errors='replace')
if result.returncode == 0 and "exited" in result.stdout:
print(f" Found stopped container '{_PGVECTOR_CONTAINER}', restarting...")
_docker("start", _PGVECTOR_CONTAINER, timeout=15)
if _pg_ready(host, port, 15):
print(" ✓ PostgreSQL container restarted")
return None
if input(" Start pgvector via Docker? [Y/n]: ").strip().lower() not in ("", "y", "yes"):
print(" Skipping Docker setup. Make sure PostgreSQL with pgvector is running.")
return None
password = secrets.token_urlsafe(24)
try:
print(f" Pulling {_PGVECTOR_IMAGE}...")
_docker("pull", _PGVECTOR_IMAGE, timeout=120)
print(f" Starting container '{_PGVECTOR_CONTAINER}' on port {port}...")
_docker("run", "-d", "--name", _PGVECTOR_CONTAINER, "-e", f"POSTGRES_PASSWORD={password}", "-p", f"127.0.0.1:{port}:5432", _PGVECTOR_IMAGE, timeout=30, check=True)
if _pg_ready(host, port, 20):
print(f" ✓ pgvector running on {host}:{port}")
else:
print(" Warning: Container started but PostgreSQL not yet accepting connections.\n It may need a few more seconds. Config will be saved; retry later.")
return {"host": host, "port": port, "user": "postgres", "password": password, "dbname": "postgres"}
except subprocess.CalledProcessError as e:
print(f" Failed to start Docker container: {_scrub(str(e), password)}")
except Exception as e:
print(f" Docker error: {_scrub(str(e), password)}")
return None
def _ensure_ollama(models: list[str]) -> bool:
"""Ensure Ollama is running and ``models`` are pulled; False when the user must handle it manually."""
ollama_bin = shutil.which("ollama")
if not (ok := _check_ollama(_OLLAMA_URL)[0]):
if not ollama_bin:
print(" Ollama not found. Install it:\n curl -fsSL https://ollama.com/install.sh | sh\n Or on macOS: brew install ollama")
return False
print(" Ollama installed but not running. Starting...")
try:
subprocess.Popen([ollama_bin, "serve"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
_wait_for_port("localhost", 11434, timeout=10)
if ok := _check_ollama(_OLLAMA_URL)[0]:
print(" ✓ Ollama started")
except Exception as e:
print(f" Could not start Ollama: {e}")
if not ok:
print(" Warning: Ollama not reachable. Models cannot be pulled.")
return False
for model in models:
try:
names = [m.get("name", "") for m in json.loads(_http_get(_OLLAMA_URL, "/api/tags", 5).read()).get("models", [])]
except Exception:
names = []
if any(model in n or model.split(":")[0] in n for n in names):
print(f" ✓ Model '{model}' available")
continue
print(f" Pulling '{model}'... (this may take a few minutes)")
try:
subprocess.run([ollama_bin or "ollama", "pull", model], timeout=600, stdin=subprocess.DEVNULL)
print(f" ✓ Model '{model}' pulled")
except Exception as e:
print(f" Warning: Could not pull '{model}': {e}\n Run manually: ollama pull {model}")
return True
def _ensure_pgvector_extension(pg_config: dict) -> None:
try:
import psycopg2
except ImportError:
return
defaults = {"host": "localhost", "port": 5432, "user": "postgres", "dbname": "postgres"}
try:
conn = psycopg2.connect(**(defaults | {k: v for k, v in pg_config.items() if k in defaults or (k == "password" and v)}))
conn.autocommit = True
conn.cursor().execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.close()
print(" ✓ pgvector extension enabled")
except Exception as e:
print(f" Warning: Could not enable pgvector extension: {e}")
def _wait_for_port(host: str, port: int, timeout: int = 15) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
socket.create_connection((host, port), timeout=1).close()
return
except OSError:
time.sleep(0.5)
# Picker descriptions: LLM/embedder show model (+ URL); vector stores by provider id (default: the id itself).
_VECTOR_DESCRIPTIONS = {"qdrant": lambda cfg: cfg.get("path", "local storage"), "pgvector": lambda cfg: f"{cfg.get('host', 'localhost')}:{cfg.get('port', 5432)}"}
def _configure_model_provider(kind: str, registry: dict, hermes_home: str, env_writes: dict[str, str], llm: tuple[str, dict] | None = None) -> tuple[str, dict, str, str | None]:
"""Pick an LLM/embedder provider, collect its key, and (for Ollama) model + URL -> (id, definition, model, url).
For the embedder (``llm`` given), a provider shared with the LLM reuses the LLM key instead of prompting again."""
items = [(v["label"], f"{v.get('default_model', '')} ({v['default_url']})" if v.get("default_url") else v.get("default_model", "")) for v in registry.values()]
pid = list(registry)[_curses_select(f"{kind} Provider", items, 0)]
pdef = registry[pid]
model, url = pdef["default_model"], pdef.get("default_url")
if pdef["needs_key"]:
if llm is None or pid != llm[0]:
if key := _prompt_api_key(pdef["label"] if llm is None else f"{pdef['label']} embedder", pdef["env_var"], hermes_home):
env_writes[pdef["env_var"]] = key
elif llm[1].get("env_var") in env_writes:
env_writes[pdef["env_var"]] = env_writes[llm[1]["env_var"]]
if pid == "ollama":
model = _input(f"{kind} model", pdef["default_model"])
url = _input("Ollama URL", pdef["default_url"])
return pid, pdef, model, url
def _setup_oss_interactive(hermes_home: str, config: dict) -> None:
env_writes: dict[str, str] = {}
llm_id, llm_def, llm_model, llm_url = _configure_model_provider("LLM", LLM_PROVIDERS, hermes_home, env_writes)
embedder_id, _, embedder_model, embedder_url = _configure_model_provider("Embedder", EMBEDDER_PROVIDERS, hermes_home, env_writes, llm=(llm_id, llm_def))
vector_items = [(v["label"], _VECTOR_DESCRIPTIONS.get(pid, lambda cfg: pid)(vector_default_config(pid))) for pid, v in VECTOR_PROVIDERS.items()]
vector_id = list(VECTOR_PROVIDERS)[_curses_select("Vector Store", vector_items, 0)]
# Auto-setup: ensure Ollama is running and models are pulled; ensure pgvector is reachable (offer Docker if not).
ollama_models = [m for pid, m in ((llm_id, llm_model), (embedder_id, embedder_model)) if pid == "ollama"]
if ollama_models:
_ensure_ollama(ollama_models)
pgvector_config = _ensure_pgvector() if vector_id == "pgvector" else None
if vector_id == "pgvector" and not pgvector_config: # native PostgreSQL: prompt for connection details (user first, historical order)
pg = {k: _input(f"PostgreSQL {label}", d) for k, label, d in (("user", "user", os.getenv("USER", "postgres")), ("host", "host", "localhost"), ("port", "port", "5432"), ("dbname", "database", "postgres"))}
pg_password = getpass.getpass(" PostgreSQL password (blank if none): ").strip()
pgvector_config = {**pg, "port": int(pg["port"]), **({"password": pg_password} if pg_password else {})}
user_id = _input("User ID", os.getenv("USER", "hermes-user"))
agent_id = _input("Agent ID", "hermes")
flags = {
"oss_llm": llm_id, "oss_llm_model": llm_model, "oss_llm_url": llm_url or "",
"oss_llm_key": env_writes.get(llm_def["env_var"], "") if llm_def.get("env_var") else "",
"oss_embedder": embedder_id, "oss_embedder_model": embedder_model, "oss_embedder_url": embedder_url or "",
"oss_vector": vector_id, "user_id": user_id,
}
flags.update({f"oss_vector_{key}": str(val) for key, val in (pgvector_config or {}).items() if val})
oss_config, _ = build_oss_config(flags)
_finish_oss(hermes_home, config, oss_config, env_writes, user_id, agent_id, pgvector_config)
def _install_provider_deps(llm_id: str, embedder_id: str, vector_id: str) -> None:
deps = {registry[pid]["pip_dep"] for (_, registry), pid in zip(SECTION_REGISTRIES, (llm_id, embedder_id, vector_id)) if registry.get(pid, {}).get("pip_dep")}
for dep in sorted(deps):
print(f" Installing {dep}...")
try:
# Environment-aware install: sealed hosted venvs redirect to the durable data-volume target instead of /opt/hermes.
from tools.lazy_deps import install_specs
outcome = install_specs([dep], timeout=60)
except Exception:
outcome = None
print(f" ✓ Installed {dep}" if outcome is not None and outcome.ok else f" Warning: cannot install {dep}: {outcome.reason}" if outcome is not None and outcome.blocked
else f" Warning: Could not install {dep}. Install manually: uv pip install {dep}")
if deps:
import importlib
importlib.invalidate_caches()
def _probe(fn, ok: str, fail: str, exc=Exception) -> tuple[bool, str]:
"""Run ``fn``; (True, ok) on success, (False, "fail: <error>") on ``exc``."""
try:
fn()
return True, ok
except exc as e:
return False, f"{fail}: {e}"
def _check_qdrant_path(path: str) -> tuple[bool, str]:
"""Check that qdrant local storage parent dir is writable."""
parent = Path(path).expanduser().parent
return _probe(lambda: parent.mkdir(parents=True, exist_ok=True), f"Directory writable: {parent}", f"Cannot write to {parent}", OSError)
def _check_ollama(url: str) -> tuple[bool, str]:
return _probe(lambda: _http_get(url, "/api/tags", 3), "Ollama reachable", f"Ollama not reachable at {url}")
def _check_pgvector(host: str, port: int) -> tuple[bool, str]:
return _probe(lambda: socket.create_connection((host, port), timeout=3).close(), f"PGVector reachable at {host}:{port}", f"PGVector not reachable at {host}:{port}")
def _warn_unless(check: tuple[bool, str]) -> None:
ok, msg = check
if not ok:
print(f" Warning: {msg}")
def _run_connectivity_checks(oss_config: dict) -> None:
vs = oss_config.get("vector_store", {})
cfg = vs.get("config", {})
if vs.get("provider") == "qdrant":
path, url = cfg.get("path"), cfg.get("url")
if path:
_warn_unless(_check_qdrant_path(path))
elif url:
_warn_unless(_probe(lambda: _http_get(url, "/healthz", 3), "Qdrant reachable", f"Qdrant not reachable at {url}"))
elif vs.get("provider") == "pgvector":
_warn_unless(_check_pgvector(cfg.get("host", "localhost"), cfg.get("port", 5432)))
llm = oss_config.get("llm", {})
if llm.get("provider") == "ollama":
_warn_unless(_check_ollama(llm.get("config", {}).get("ollama_base_url", _OLLAMA_URL)))
_MODE_HANDLERS = {"oss": _setup_oss, "selfhosted": _setup_selfhosted, "self-hosted": _setup_selfhosted, "platform": _setup_platform}
# Interactive picker order: Platform, Self-hosted server, Open Source.
_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_PICKER = (_setup_platform, _setup_selfhosted, _setup_oss)
def post_setup(hermes_home: str, config: dict) -> None:
"""Entry point for `hermes memory setup`: routes on --mode (platform / selfhosted / oss), else shows a picker.
OSS is non-interactive only when the mode came from the flag."""
with suppress(ImportError): # mem0ai must meet the minimum version from plugin.yaml
import mem0
installed_ver = getattr(mem0, "__version__", None)
if installed_ver and _version_tuple(installed_ver) < (2, 0, 10):
print(f"\n ⚠ mem0ai {installed_ver} installed but >=2.0.10 required.\n Run: uv pip install --python {sys.executable} 'mem0ai>=2.0.10'")
flags = parse_flags(sys.argv[1:])
handler = _MODE_HANDLERS.get(flags["mode"])
flags["_mode_from_flag"] = handler is not None
if handler is None:
handler = _MODE_PICKER[_curses_select(" Select mode", _MODE_ITEMS, 0)]
handler(hermes_home, config, flags)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def has_oss_flags() -> bool:
"""Check if OSS-related flags are present in sys.argv."""
flags = parse_flags(sys.argv[1:])
if flags["mode"] == "oss":
return True
if any(flags.get(k) for k in ("oss_llm_key", "oss_vector_path", "oss_vector_url")):
return True
return False
# ---- END PLUGIN-COMPAT ----
@@ -0,0 +1,5 @@
name: mem0
version: 1.3.0
description: "Mem0 — server-side LLM fact extraction with semantic search, automatic deduplication, and opt-in reranking (platform mode)."
pip_dependencies:
- mem0ai>=2.0.10,<3
@@ -0,0 +1,17 @@
[project]
name = "hermes-plugin-mem0"
version = "1.3.0"
description = "Hermes Agent memory provider plugin: mem0"
requires-python = ">=3.11"
license = { text = "Apache-2.0 AND MIT" }
# Installed into the Hermes venv by `hermes plugins install` / `enable` and re-applied after
# every `hermes update` (hermes-agent#113851). Keep upper bounds: Hermes pins its own deps exactly
# and refuses a plugin whose requirements cannot resolve against them.
dependencies = [
"mem0ai>=2.0.10,<3",
"httpx>=0.27,<1",
]
[project.optional-dependencies]
postgres = ['psycopg2-binary>=2.9,<3']
qdrant = ['qdrant-client>=1.9,<2']
@@ -0,0 +1,178 @@
"""Real Hermes + Mem0 SDK + local Qdrant; only the model API is simulated.
HERMES_SOURCE=/path/to/hermes-agent python tests/smoke_hermes.py
Requires the Hermes dependencies, mem0ai, and qdrant-client in the active environment.
All configuration, credentials, and database files are temporary.
"""
import importlib
import io
import json
import os
import shutil
import sys
import tempfile
import threading
from contextlib import redirect_stdout
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
class ModelAPI(BaseHTTPRequestHandler):
calls = []
def log_message(self, *args):
pass
def do_POST(self):
if self.headers.get("Authorization") != "Bearer local-test":
self.send_error(401)
return
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
self.calls.append(self.path)
if self.path == "/v1/embeddings":
result = {
"object": "list",
"data": [{"object": "embedding", "index": i, "embedding": [1.0, 0.0, 0.0]}
for i, _ in enumerate(body["input"])],
"model": "test-embedding",
"usage": {"prompt_tokens": 1, "total_tokens": 1},
}
elif self.path == "/v1/chat/completions":
result = {
"id": "test-completion", "object": "chat.completion", "created": 0, "model": "gpt-4.1-mini",
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content":
json.dumps({"facts": ["Prefers green tea."], "memory": [
{"id": "0", "text": "Prefers green tea.", "event": "ADD"}]})}}],
}
else:
self.send_error(404)
return
encoded = json.dumps(result).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def main():
hermes_source = Path(os.environ["HERMES_SOURCE"]).resolve()
sys.path.insert(0, str(hermes_source))
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
os.environ.update(HERMES_HOME=temporary, MEM0_DIR=str(home / "mem0-data"), MEM0_TELEMETRY="false")
(home / "mem0-data").mkdir()
server = ThreadingHTTPServer(("127.0.0.1", 0), ModelAPI)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
url = f"http://127.0.0.1:{server.server_port}/v1"
config = {
"mode": "oss", "user_id": "existing-user", "agent_id": "hermes",
"oss": {
"llm": {"provider": "openai", "config": {
"model": "gpt-4.1-mini", "api_key": "local-test", "openai_base_url": url}},
"embedder": {"provider": "openai", "config": {
"model": "test-embedding", "embedding_dims": 3,
"api_key": "local-test", "openai_base_url": url}},
"vector_store": {"provider": "qdrant", "config": {"path": str(home / "qdrant")}},
},
}
(home / "mem0.json").write_text(json.dumps(config))
destination = home / "plugins" / "mem0"
shutil.copytree(Path(__file__).resolve().parents[1], destination)
import plugins.memory as memory_plugins
from agent.memory_manager import MemoryManager
# Simulate bundled-provider removal only inside this test process.
memory_plugins._MEMORY_PLUGINS_DIR = home / "empty-bundled"
memory_plugins._MEMORY_PLUGINS_DIR.mkdir()
assert memory_plugins.find_provider_dir("mem0") == destination
provider = memory_plugins.load_memory_provider("mem0", register_skills=False)
assert provider is not None and provider.__class__.__module__.startswith("_hermes_user_memory.")
setup = importlib.import_module(f"{provider.__class__.__module__}._setup")
from hermes_cli.memory_setup import cmd_setup_provider, cmd_status
output = io.StringIO()
arguments = ["hermes", "memory", "setup", "mem0", "--mode", "platform", "--api-key", "local-test"]
with patch.object(sys, "argv", arguments), patch.object(sys, "stdin", io.StringIO("\n\n")):
with patch.object(setup, "_curses_select", return_value=0), redirect_stdout(output):
cmd_setup_provider("mem0")
assert json.loads((home / "mem0.json").read_text())["user_id"] == "existing-user"
provider.save_config(config, home)
assert (home / "mem0.json").stat().st_mode & 0o777 == 0o600
with redirect_stdout(output):
cmd_status(None)
assert "installed ✓" in output.getvalue() and "available ✓" in output.getvalue()
assert (home / ".env").stat().st_mode & 0o777 == 0o600
manager = MemoryManager()
manager.add_provider(provider)
manager.initialize_all("smoke-session", platform="cli", user_id="gateway-user")
assert provider._backend is not None, getattr(provider, "_init_error", "initialization failed")
def tool(name, **arguments):
result = json.loads(manager.handle_tool_call(name, arguments))
assert "error" not in result, result
return result
try:
assert tool("mem0_add", content="Prefers Python.")["result"] == "Fact stored."
memories = tool("mem0_search", query="language")["results"]
assert len(memories) == 1 and memories[0]["memory"] == "Prefers Python."
memory_id = memories[0]["id"]
tool("mem0_update", memory_id=memory_id, text="Prefers Rust.")
assert "Prefers Rust." in provider.prefetch("language preference")
assert tool("mem0_search", query="language")["results"][0]["memory"] == "Prefers Rust."
tool("mem0_delete", memory_id=memory_id)
assert tool("mem0_search", query="language")["result"] == "No relevant memories found."
manager.sync_all("I prefer green tea.", "Noted.", session_id="smoke-session")
assert provider._user_id == "existing-user"
finally:
manager.shutdown_all()
assert provider._backend is None
# A changed embedder must fail without destroying the existing collection.
config["oss"]["embedder"]["config"]["embedding_dims"] = 4
provider.save_config(config, home)
mismatched = type(provider)()
mismatched.initialize("mismatched-session")
assert mismatched._backend is None and "Existing memories were preserved" in mismatched._init_error
config["oss"]["embedder"]["config"]["embedding_dims"] = 3
# Resolve embedder credentials from the active profile, not another profile's process env.
del config["oss"]["embedder"]["config"]["api_key"]
del config["oss"]["embedder"]["config"]["openai_base_url"]
provider.save_config(config, home)
from agent.secret_scope import (
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
resumed = type(provider)()
with patch.dict(os.environ, {"OPENAI_API_KEY": "wrong-profile", "OPENAI_API_BASE": "http://127.0.0.1:1/v1"}):
set_multiplex_active(True)
token = set_secret_scope({"OPENAI_API_KEY": "local-test", "OPENAI_BASE_URL": url})
try:
resumed.initialize("resumed-session", user_id="different-gateway-user")
finally:
reset_secret_scope(token)
set_multiplex_active(False)
try:
assert resumed._backend is not None
found = json.loads(resumed.handle_tool_call("mem0_search", {"query": "drink"}))
assert "results" in found, found
assert found["results"][0]["memory"] == "Prefers green tea."
finally:
resumed.shutdown()
assert "/v1/chat/completions" in ModelAPI.calls
print("PASS: external Hermes loader, CLI setup/status, real Mem0/Qdrant CRUD, recall, background extraction,")
print(" existing identity, profile credentials, private files, shutdown, dimension safety and persistence across restart. Model responses simulated locally.")
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
if __name__ == "__main__":
main()
@@ -0,0 +1,286 @@
"""Offline regressions for the standalone Hermes plugin."""
import contextvars
import importlib
import importlib.util
import json
import sys
import threading
import types
from pathlib import Path
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def plugin(monkeypatch, tmp_path):
def spawn(target, *, name):
return threading.Thread(target=contextvars.copy_context().run, args=(target,), name=name)
def atomic_write_text(path, value, *, mode):
path.write_text(value)
path.chmod(mode)
modules = {
"agent.memory_provider": {"MemoryProvider": object, "spawn_context_thread": spawn},
"agent.secret_scope": {"get_secret": lambda key, default="": default},
"tools.registry": {"tool_error": lambda message: json.dumps({"error": message})},
"utils": {
"read_json_or_empty": lambda path: json.loads(path.read_text()) if path.exists() else {},
"atomic_json_write": lambda path, value, **kwargs: atomic_write_text(path, json.dumps(value), **kwargs),
"atomic_write_text": atomic_write_text,
},
"hermes_constants": {"get_hermes_home": lambda: tmp_path},
}
for name, values in modules.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
spec = importlib.util.spec_from_file_location("standalone_mem0", ROOT / "__init__.py")
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
monkeypatch.setattr(module.atexit, "register", lambda *args: None)
yield module
for name in list(sys.modules):
if name.startswith("standalone_mem0."):
monkeypatch.delitem(sys.modules, name)
def test_dimension_mismatch_preserves_qdrant_collection(plugin, monkeypatch):
backend = importlib.import_module(f"{plugin.__name__}._backend")
client = Mock()
client.collection_exists.return_value = True
client.get_collection.return_value.config.params.vectors = types.SimpleNamespace(size=1536)
monkeypatch.setitem(sys.modules, "qdrant_client", types.SimpleNamespace(QdrantClient=Mock(return_value=client)))
with pytest.raises(ValueError, match="1536.*768"):
backend.OSSBackend._reject_dimension_mismatch("qdrant", {"path": "/unused"}, 768)
client.delete_collection.assert_not_called()
client.close.assert_called_once()
def test_dimension_mismatch_preserves_pgvector_table(plugin, monkeypatch):
backend = importlib.import_module(f"{plugin.__name__}._backend")
cursor, connection = Mock(), Mock()
cursor.fetchone.return_value = (1536,)
connection.cursor.return_value = cursor
driver = types.SimpleNamespace(connect=Mock(return_value=connection), sql=Mock())
monkeypatch.setitem(sys.modules, "psycopg2", driver)
with pytest.raises(ValueError, match="1536.*768"):
backend.OSSBackend._reject_dimension_mismatch("pgvector", {"user": "test"}, 768)
assert cursor.execute.call_count == 1
assert cursor.execute.call_args.args[0].startswith("SELECT")
connection.close.assert_called_once()
def test_setup_keeps_credentials_private_and_preserves_existing_values(plugin, tmp_path):
setup = importlib.import_module(f"{plugin.__name__}._setup")
path = tmp_path / ".env"
path.write_text("EXISTING=value\nMEM0_API_KEY=old\n")
path.chmod(0o644)
setup._write_env(path, {"MEM0_API_KEY": "new"})
assert path.read_text() == "EXISTING=value\nMEM0_API_KEY=new\n"
assert path.stat().st_mode & 0o777 == 0o600
def test_oss_setup_keeps_database_password_private(plugin, tmp_path, monkeypatch):
setup = importlib.import_module(f"{plugin.__name__}._setup")
for name in ("_install_provider_deps", "_activate_provider", "_run_connectivity_checks"):
monkeypatch.setattr(setup, name, Mock())
config = {"llm": {"provider": "openai", "config": {}}, "embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "pgvector", "config": {"password": "test-password"}}}
setup._finish_oss(str(tmp_path), {}, config, {}, "existing-user", "hermes")
path = tmp_path / "mem0.json"
assert json.loads(path.read_text())["user_id"] == "existing-user"
assert path.stat().st_mode & 0o777 == 0o600
def test_optional_server_key_and_prefetch_rerank(plugin, monkeypatch):
monkeypatch.setattr(plugin, "_load_config", lambda: {"host": "http://localhost:8888", "rerank": True})
provider = plugin.Mem0MemoryProvider()
assert not next(field for field in provider.get_config_schema() if field["key"] == "api_key")["required"]
backend = Mock()
backend.search.return_value = [{"memory": "Prefers Python"}]
monkeypatch.setattr(provider, "_create_backend", lambda: backend)
provider.initialize("test-session")
try:
assert "Prefers Python" in provider.prefetch("language")
assert backend.search.call_args.kwargs["rerank"] is True
finally:
provider.shutdown()
def test_invalid_tool_arguments_never_reach_backend(plugin, monkeypatch):
provider = plugin.Mem0MemoryProvider()
provider._backend = Mock()
for args in (None, {"query": []}, {"query": " "}, {"query": "fact", "top_k": "invalid"}):
assert "error" in json.loads(provider.handle_tool_call("mem0_search", args))
provider._backend.search.assert_not_called()
assert provider._consecutive_failures == 0
def test_selfhosted_http_auth_and_tool_routes(plugin):
import httpx
backend = importlib.import_module(f"{plugin.__name__}._backend")
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"results": [{"id": "existing", "memory": "Prefers Python"}]})
client = backend.SelfHostedBackend("test-key", "http://localhost:8888", transport=httpx.MockTransport(respond))
try:
client.add([], user_id="existing-user", agent_id="hermes", infer=True)
assert requests[-1].headers["X-API-Key"] == "test-key"
assert json.loads(requests[-1].content)["user_id"] == "existing-user"
assert client.search("language", filters={"user_id": "existing-user"})[0]["id"] == "existing"
assert json.loads(requests[-1].content)["filters"] == {"user_id": "existing-user"}
client.update("existing", "Prefers Rust")
assert json.loads(requests[-1].content) == {"text": "Prefers Rust"}
client.delete("existing")
assert [(r.method, r.url.path) for r in requests] == [
("POST", "/memories"), ("POST", "/search"), ("PUT", "/memories/existing"), ("DELETE", "/memories/existing")
]
finally:
client.close()
@pytest.mark.parametrize("mode", ["platform", "selfhosted"])
def test_setup_rotates_legacy_file_key(plugin, monkeypatch, tmp_path, mode):
setup = importlib.import_module(f"{plugin.__name__}._setup")
(tmp_path / "mem0.json").write_text(json.dumps({"api_key": "old-key", "user_id": "existing-user"}))
monkeypatch.setattr(setup, "_activate_provider", Mock())
monkeypatch.setattr(setup, "_check_selfhosted_server", Mock())
monkeypatch.setattr(setup, "_prompt", lambda label, default=None, **kwargs: default or "")
monkeypatch.setattr(setup, "_curses_select", lambda *args, **kwargs: 0)
setup._MODE_HANDLERS[mode](str(tmp_path), {}, {"api_key": "new-key", "host": "http://localhost:8888"})
monkeypatch.setattr(plugin, "get_secret", lambda key, default="": "new-key" if key == "MEM0_API_KEY" else default)
assert plugin._load_config()["api_key"] == "new-key"
assert "old-key" not in (tmp_path / "mem0.json").read_text()
assert "MEM0_API_KEY=new-key" in (tmp_path / ".env").read_text()
def test_platform_setup_honors_user_id_flag(plugin, monkeypatch, tmp_path):
setup = importlib.import_module(f"{plugin.__name__}._setup")
monkeypatch.setattr(setup, "_activate_provider", Mock())
monkeypatch.setattr(setup, "_prompt", lambda label, default=None, **kwargs: default or "")
monkeypatch.setattr(setup, "_curses_select", lambda *args, **kwargs: 0)
setup._setup_platform(str(tmp_path), {}, {"api_key": "new-key", "user_id": "chosen-user"})
assert plugin._load_config()["user_id"] == "chosen-user"
@pytest.mark.parametrize("scoped_key", ["profile-key", ""])
def test_oss_embedder_never_uses_another_profiles_credentials(plugin, monkeypatch, scoped_key):
backend = importlib.import_module(f"{plugin.__name__}._backend")
memory = Mock()
monkeypatch.setitem(sys.modules, "mem0", types.SimpleNamespace(Memory=memory))
qdrant_config = types.SimpleNamespace(model_fields={"path": types.SimpleNamespace(default=None)})
monkeypatch.setitem(
sys.modules, "mem0.configs.vector_stores.qdrant", types.SimpleNamespace(QdrantConfig=qdrant_config)
)
monkeypatch.setenv("OPENAI_API_KEY", "other-profile-key")
monkeypatch.setenv("OPENAI_API_BASE", "https://other-profile.invalid/v1")
secrets = {"OPENAI_API_KEY": scoped_key, "OPENAI_BASE_URL": "https://profile.invalid/v1"}
monkeypatch.setattr(sys.modules["agent.secret_scope"], "get_secret", lambda key, default="": secrets.get(key, default))
config = {
"llm": {"provider": "ollama", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
if not scoped_key:
with pytest.raises(ValueError, match="OpenAI API key"):
backend.OSSBackend(config)
memory.from_config.assert_not_called()
else:
backend.OSSBackend(config)
resolved = memory.from_config.call_args.args[0]["embedder"]["config"]
assert resolved["api_key"] == scoped_key
assert resolved["openai_base_url"] == "https://profile.invalid/v1"
assert config["embedder"]["config"] == {}
def test_pgvector_setup_never_removes_existing_container(plugin, monkeypatch):
setup = importlib.import_module(f"{plugin.__name__}._setup")
monkeypatch.setattr(setup, "_check_pgvector", lambda *args: (False, "unreachable"))
monkeypatch.setattr(setup.shutil, "which", lambda name: "/test/docker")
monkeypatch.setattr("builtins.input", lambda prompt: "y")
calls = []
def docker(*args, **kwargs):
calls.append(args)
if args[0] == "run":
raise setup.subprocess.CalledProcessError(1, "docker run: container name already exists")
return types.SimpleNamespace(returncode=0, stdout="paused")
monkeypatch.setattr(setup, "_docker", docker)
assert setup._ensure_pgvector() is None
assert not any(args[0] == "rm" for args in calls)
def test_platform_dry_run_does_not_print_stored_secrets(plugin, monkeypatch, tmp_path, capsys):
setup = importlib.import_module(f"{plugin.__name__}._setup")
config = {"api_key": "old-secret", "oss": {"vector_store": {"config": {"password": "db-secret"}}}}
path = tmp_path / "mem0.json"
path.write_text(json.dumps(config))
monkeypatch.setattr(setup, "_prompt", lambda label, default=None, **kwargs: default or "")
monkeypatch.setattr(setup, "_curses_select", lambda *args, **kwargs: 0)
setup._setup_platform(str(tmp_path), {}, {"api_key": "new-secret", "dry_run": True})
output = capsys.readouterr().out
assert all(secret not in output for secret in ("old-secret", "db-secret", "new-secret"))
assert json.loads(path.read_text()) == config
assert not (tmp_path / ".env").exists()
def test_oss_setup_preserves_distinct_llm_and_embedder_keys(plugin):
setup = importlib.import_module(f"{plugin.__name__}._setup")
config, env = setup.build_oss_config({"oss_llm_key": "llm-key", "oss_embedder_key": "embedder-key"})
assert config["llm"]["config"].get("api_key", env.get("OPENAI_API_KEY")) == "llm-key"
assert config["embedder"]["config"].get("api_key", env.get("OPENAI_API_KEY")) == "embedder-key"
def test_direct_openai_llm_uses_scoped_credentials(plugin, monkeypatch):
llm_mod = importlib.import_module(f"{plugin.__name__}._openai_llm")
openai_mock = types.SimpleNamespace(OpenAI=Mock(return_value=Mock()))
monkeypatch.setitem(sys.modules, "openai", openai_mock)
monkeypatch.setenv("OPENROUTER_API_KEY", "should-be-ignored")
monkeypatch.setenv("OPENAI_API_KEY", "env-key-should-be-ignored")
secrets = {"OPENAI_API_KEY": "scoped-key", "OPENAI_API_BASE": "", "OPENAI_BASE_URL": ""}
monkeypatch.setattr(sys.modules["agent.secret_scope"], "get_secret", lambda key, default="": secrets.get(key, default))
llm_mod.DirectOpenAILLM({"api_key": "", "model": "gpt-5-mini"})
call_kwargs = openai_mock.OpenAI.call_args.kwargs
assert call_kwargs["api_key"] == "scoped-key"
assert "openrouter" not in call_kwargs.get("base_url", "").lower()
def test_direct_openai_llm_rejects_missing_key(plugin, monkeypatch):
llm_mod = importlib.import_module(f"{plugin.__name__}._openai_llm")
monkeypatch.setattr(sys.modules["agent.secret_scope"], "get_secret", lambda key, default="": "")
with pytest.raises(ValueError, match="API key"):
llm_mod.DirectOpenAILLM({"api_key": "", "model": "gpt-5-mini"})
def test_selfhosted_keyless_omits_auth_header(plugin):
import httpx
backend = importlib.import_module(f"{plugin.__name__}._backend")
requests = []
def respond(request):
requests.append(request)
return httpx.Response(200, json={"results": []})
client = backend.SelfHostedBackend("", "http://localhost:8888", transport=httpx.MockTransport(respond))
client.search("test", filters={"user_id": "u"})
assert "X-API-Key" not in requests[0].headers
client.close()
def test_initialize_tolerates_non_numeric_sync_max_chars(plugin, monkeypatch):
monkeypatch.setattr(plugin, "_load_config", lambda: {"sync_max_chars": "not-a-number"})
provider = plugin.Mem0MemoryProvider()
provider.initialize("test-session")
assert provider._sync_max_chars == plugin._SYNC_MSG_MAX_CHARS
@@ -2,6 +2,7 @@
HARNESS_ID = "kimi"
SOURCE_TAG = "KIMI_PLUGIN"
DATA_DIR_NAME = "kimi-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
+46 -41
View File
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
"author": { "name": "Mem0", "email": "support@mem0.ai" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"homepage": "https://docs.mem0.ai/integrations/kimi",
"native": {
"pluginRoot": "${KIMI_PLUGIN_ROOT}",
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -2,6 +2,7 @@
HARNESS_ID = "coding-agent"
SOURCE_TAG = "CODING_AGENT_PLUGIN"
DATA_DIR_NAME = "coding-agent-plugin"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
@@ -28,12 +28,23 @@ from typing import Any, Iterable
import telemetry
# Read from the generated per-host module so a new entrypoint is correct without
# remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import DATA_DIR_NAME as _DATA_DIR_NAME
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_DATA_DIR_NAME = "mem0-plugin"
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.3"
PLUGIN_VERSION = "0.3.4"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_data_dir_name: str = _DATA_DIR_NAME
_harness_source_tag: str = "mem0_plugin"
@@ -378,30 +389,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +456,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +479,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -1798,16 +1813,6 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "mem0",
"version": "0.3.3",
"version": "0.3.4",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0",
@@ -18,5 +18,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -102,6 +102,16 @@ describe("resolveApiKey", () => {
expect(resolveApiKey({}, dir)).toBe("m0-later");
});
test("falls back to the key mem0 init saved", () => {
const dir = home();
mkdirSync(join(dir, ".mem0"));
writeFileSync(join(dir, ".mem0", "config.json"), JSON.stringify({platform: {api_key: "m0-cli-key"}}));
expect(resolveApiKey({}, dir)).toBe("m0-cli-key");
writeFileSync(join(dir, ".zshrc"), "export MEM0_API_KEY=m0-from-profile\n");
expect(resolveApiKey({}, dir)).toBe("m0-from-profile");
});
test("ignores unsupported files and invalid assignments", () => {
const dir = home();
writeFileSync(join(dir, ".env"), "MEM0_API_KEY=unsupported\n");
+2 -1
View File
@@ -1,6 +1,7 @@
import {readFileSync} from "fs";
import {homedir} from "os";
import {join} from "path";
import {mem0CliApiKey} from "../agent-plugin-core/typescript/src/credentials.ts";
const PROFILE_FILES = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".profile"];
@@ -26,5 +27,5 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = ho
}
}
return "";
return mem0CliApiKey(homeDir);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/opencode-plugin",
"version": "0.4.1",
"version": "0.4.2",
"type": "module",
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
"main": "dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/pi-agent-plugin",
"version": "0.3.2",
"version": "0.3.3",
"type": "module",
"description": "Mem0 memory extension for Pi Agent persistent, scoped, semantic memory across sessions and projects",
"license": "Apache-2.0",
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { mem0CliApiKey } from "../../../agent-plugin-core/typescript/src/credentials.ts";
import type { Mem0Config } from "../types.ts";
const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
@@ -36,6 +37,9 @@ export function loadConfig(): Mem0Config {
if (process.env.MEM0_API_KEY) {
config.apiKey = process.env.MEM0_API_KEY;
}
if (!config.apiKey) {
config.apiKey = mem0CliApiKey(os.homedir());
}
if (process.env.MEM0_USER_ID) {
config.userId = process.env.MEM0_USER_ID;
}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { loadConfig } from "../src/config/index.ts";
vi.mock("node:fs");
@@ -33,6 +34,17 @@ describe("loadConfig", () => {
expect(config.apiKey).toBe("");
});
it("falls back to the key mem0 init saved", () => {
delete process.env.MEM0_API_KEY;
vi.mocked(fs.readFileSync).mockImplementation((file) => {
if (String(file).endsWith(path.join(".mem0", "config.json"))) {
return JSON.stringify({ platform: { api_key: "m0-cli-key" } });
}
throw new Error("ENOENT");
});
expect(loadConfig().apiKey).toBe("m0-cli-key");
});
it("reads config file and merges with defaults", () => {
delete process.env.MEM0_API_KEY;
delete process.env.MEM0_USER_ID;
+1 -1
View File
@@ -13,7 +13,7 @@
},
"category": "Productivity",
"description": "Cross-session memory and token savings for coding agents.",
"version": "0.3.3"
"version": "0.3.4"
}
]
}
+2
View File
@@ -125,6 +125,8 @@ export interface Memory {
categories?: Array<string>;
createdAt?: Date;
updatedAt?: Date;
// TODO: Review this response field for removal in a future breaking release.
// It is not a selectable memory type; TypeScript has no procedural-memory path.
memoryType?: string;
score?: number;
metadata?: any | null;
+13 -4
View File
@@ -113,7 +113,18 @@ export class ConfigManager {
const userConf = userConfig.llm?.config;
const provider =
userConfig.llm?.provider || DEFAULT_MEMORY_CONFIG.llm.provider;
let finalModel: string | any = defaultConf.model;
// DEFAULT_MEMORY_CONFIG.llm.config holds OpenAI's own defaults (baseURL and
// model). Handing those to any other provider shadows that provider's default
// *and* its env fallback (DEEPSEEK_API_BASE, XAI_API_BASE, ...), so a config
// copied from the docs for another provider ends up pointed at OpenAI with an
// OpenAI model name. vLLM already needed a carve-out here for exactly this
// reason; every non-OpenAI provider needs it.
const usesOpenAIDefaults =
provider.toLowerCase() === "openai" ||
provider.toLowerCase() === "openai_structured";
let finalModel: string | any = usesOpenAIDefaults
? defaultConf.model
: undefined;
if (userConf?.model && typeof userConf.model === "object") {
finalModel = userConf.model;
@@ -131,9 +142,7 @@ export class ConfigManager {
| string
| undefined) ??
userConf?.url ??
(provider.toLowerCase() === "vllm"
? undefined
: defaultConf.baseURL);
(usesOpenAIDefaults ? defaultConf.baseURL : undefined);
const temperature =
userConf?.temperature ??
(llmRaw?.temperature as number | undefined);
+56 -4
View File
@@ -1,5 +1,6 @@
/// <reference types="jest" />
import { ConfigManager } from "../src/config/manager";
import { LLMFactory } from "../src/utils/factory";
describe("ConfigManager", () => {
describe("mergeConfig - dimension handling", () => {
@@ -141,7 +142,7 @@ describe("ConfigManager", () => {
expect(config.llm.config.url).toBe("http://my-ollama-host:11434");
});
it("should use default baseURL when no url or baseURL provided", () => {
it("should not fall back to the OpenAI default baseURL for a non-OpenAI provider", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
@@ -152,7 +153,9 @@ describe("ConfigManager", () => {
});
expect(config.llm.config.url).toBeUndefined();
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
// OllamaLLM defaults to http://localhost:11434. The OpenAI default used to be
// injected here, which pointed OllamaLLM at OpenAI instead.
expect(config.llm.config.baseURL).toBeUndefined();
});
it("normalizes vllm_base_url to baseURL for vLLM", () => {
@@ -367,14 +370,63 @@ describe("ConfigManager", () => {
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
});
it("falls back to default baseURL when neither is provided for LLM", () => {
it("does not inject the OpenAI baseURL default for a non-OpenAI provider", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider: "lmstudio", config: { model: "test-model" } },
});
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
// The provider supplies its own baseURL (http://localhost:1234/v1) when none is
// given. Injecting OpenAI's here shadowed it and sent lmstudio traffic to OpenAI.
expect(cfg.llm.config.baseURL).toBeUndefined();
});
it("does not inject the OpenAI model default for a non-OpenAI provider", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider: "deepseek", config: { apiKey: "k" } },
});
// DeepSeekLLM falls back to "deepseek-chat" when model is unset. Injecting
// "gpt-5-mini" here made that fallback unreachable.
expect(cfg.llm.config.model).toBeUndefined();
});
it("still applies the OpenAI defaults for the OpenAI providers", () => {
for (const provider of ["openai", "openai_structured"]) {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider, config: { apiKey: "k" } },
});
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
expect(cfg.llm.config.model).toBe("gpt-5-mini");
}
});
it("lets each non-OpenAI provider resolve its own endpoint", () => {
const cases: Array<[string, string]> = [
["deepseek", "https://api.deepseek.com"],
["xai", "https://api.x.ai/v1"],
["lmstudio", "http://localhost:1234/v1"],
];
for (const [provider, expected] of cases) {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider, config: { apiKey: "k" } },
});
const built = LLMFactory.create(provider, cfg.llm.config);
// The client the provider actually built must not point at OpenAI.
const baseURL =
(built as any).openai?.baseURL ?? (built as any).baseURL;
expect(String(baseURL)).toBe(expected);
}
});
});
+59 -15
View File
@@ -22,7 +22,7 @@ from mem0.configs.prompts import (
PROCEDURAL_MEMORY_SYSTEM_PROMPT,
generate_additive_extraction_prompt,
)
from mem0.exceptions import LLMError
from mem0.exceptions import LLMError, VectorStoreError
from mem0.exceptions import ValidationError as Mem0ValidationError
from mem0.memory.base import MemoryBase
from mem0.memory.notices import (
@@ -850,6 +850,8 @@ class Memory(MemoryBase):
suggestion="Convert your input to a string, dictionary, or list of dictionaries."
)
# TODO: Remove procedural-memory support in a future breaking release.
# Remove memory_type and its helpers from Memory and AsyncMemory together.
if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value:
results = self._create_procedural_memory(messages, metadata=processed_metadata, prompt=prompt)
scale_threshold_notice = detect_scale_threshold_from_add_result(self, results)
@@ -1047,19 +1049,31 @@ class Memory(MemoryBase):
all_ids = [r[0] for r in records]
all_payloads = [r[3] for r in records]
# Only records confirmed to be stored make it into history, entity
# links, and the returned results — a record the store rejected must
# never be reported back as a successful ADD.
persisted_records = []
try:
self.vector_store.insert(
vectors=all_vectors,
ids=all_ids,
payloads=all_payloads,
)
persisted_records = records
except Exception:
# Fallback: insert one by one
for mid, vec, pay in zip(all_ids, all_vectors, all_payloads):
for rec in records:
try:
self.vector_store.insert(vectors=[vec], ids=[mid], payloads=[pay])
self.vector_store.insert(vectors=[rec[2]], ids=[rec[0]], payloads=[rec[3]])
persisted_records.append(rec)
except Exception as e:
logger.error(f"Failed to insert memory {mid}: {e}")
logger.error(f"Failed to insert memory {rec[0]}: {e}")
if not persisted_records:
self.db.save_messages(messages, session_scope)
raise VectorStoreError(
f"Failed to insert any of the {len(records)} extracted memories into the vector store"
)
# Batch history
history_records = [
@@ -1071,7 +1085,7 @@ class Memory(MemoryBase):
"created_at": r[3].get("created_at"),
"is_deleted": 0,
}
for r in records
for r in persisted_records
]
try:
self.db.batch_add_history(history_records)
@@ -1085,12 +1099,12 @@ class Memory(MemoryBase):
# Phase 7: Batch entity linking
try:
all_texts = [r[1] for r in records]
all_texts = [r[1] for r in persisted_records]
all_entities = extract_entities_batch(all_texts)
# 7a: Global dedup — collect unique entities across all memories
global_entities = {} # normalized_key -> (entity_type, entity_text, set of memory_ids)
for idx, (memory_id, text, embedding, payload) in enumerate(records):
for idx, (memory_id, text, embedding, payload) in enumerate(persisted_records):
entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities:
key = self._normalize_entity_text(entity_text)
@@ -1194,7 +1208,7 @@ class Memory(MemoryBase):
returned_memories = [
{"id": r[0], "memory": r[1], "event": "ADD"}
for r in records
for r in persisted_records
]
keys, encoded_ids = process_telemetry_filters(filters)
@@ -2165,6 +2179,13 @@ class Memory(MemoryBase):
self.db.close()
self.db = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def chat(self, query):
raise NotImplementedError("Chat function not implemented yet.")
@@ -2502,6 +2523,8 @@ class AsyncMemory(MemoryBase):
suggestion="Convert your input to a string, dictionary, or list of dictionaries."
)
# TODO: Remove procedural-memory support in a future breaking release.
# Remove memory_type and its helpers from Memory and AsyncMemory together.
if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value:
results = await self._create_procedural_memory(
messages, metadata=processed_metadata, prompt=prompt, llm=llm
@@ -2705,6 +2728,10 @@ class AsyncMemory(MemoryBase):
all_ids = [r[0] for r in records]
all_payloads = [r[3] for r in records]
# Only records confirmed to be stored make it into history, entity
# links, and the returned results — a record the store rejected must
# never be reported back as a successful ADD.
persisted_records = []
try:
await asyncio.to_thread(
self.vector_store.insert,
@@ -2712,12 +2739,22 @@ class AsyncMemory(MemoryBase):
ids=all_ids,
payloads=all_payloads,
)
persisted_records = records
except Exception:
for mid, vec, pay in zip(all_ids, all_vectors, all_payloads):
for rec in records:
try:
await asyncio.to_thread(self.vector_store.insert, vectors=[vec], ids=[mid], payloads=[pay])
await asyncio.to_thread(
self.vector_store.insert, vectors=[rec[2]], ids=[rec[0]], payloads=[rec[3]]
)
persisted_records.append(rec)
except Exception as e:
logger.error(f"Failed to insert memory {mid} (async): {e}")
logger.error(f"Failed to insert memory {rec[0]} (async): {e}")
if not persisted_records:
await asyncio.to_thread(self.db.save_messages, messages, session_scope)
raise VectorStoreError(
f"Failed to insert any of the {len(records)} extracted memories into the vector store"
)
# Batch history
history_records = [
@@ -2729,7 +2766,7 @@ class AsyncMemory(MemoryBase):
"created_at": r[3].get("created_at"),
"is_deleted": 0,
}
for r in records
for r in persisted_records
]
try:
await asyncio.to_thread(self.db.batch_add_history, history_records)
@@ -2745,12 +2782,12 @@ class AsyncMemory(MemoryBase):
# Phase 7: Batch entity linking
try:
all_texts = [r[1] for r in records]
all_texts = [r[1] for r in persisted_records]
all_entities = await asyncio.to_thread(extract_entities_batch, all_texts)
# 7a: Global dedup
global_entities = {}
for idx, (memory_id, text, embedding, payload) in enumerate(records):
for idx, (memory_id, text, embedding, payload) in enumerate(persisted_records):
entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities:
key = self._normalize_entity_text(entity_text)
@@ -2852,7 +2889,7 @@ class AsyncMemory(MemoryBase):
returned_memories = [
{"id": r[0], "memory": r[1], "event": "ADD"}
for r in records
for r in persisted_records
]
keys, encoded_ids = process_telemetry_filters(effective_filters)
@@ -3864,5 +3901,12 @@ class AsyncMemory(MemoryBase):
self.db.close()
self.db = None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
async def chat(self, query):
raise NotImplementedError("Chat function not implemented yet.")
+1 -1
View File
@@ -3,7 +3,7 @@ uvicorn==0.34.0
pydantic[email]==2.10.4
mem0ai>=0.1.48
python-dotenv==1.2.2
psycopg>=3.2.8
psycopg[binary]>=3.2.8,<4.0.0
psycopg-pool>=3.2.6,<4.0.0
# Bundled LLM/embedder providers. Extend by adding the provider's package
+156 -1
View File
@@ -1,3 +1,4 @@
import json
import logging
import time
from datetime import datetime, timezone
@@ -6,7 +7,7 @@ from unittest.mock import MagicMock, Mock
import pytest
from mem0.exceptions import LLMError
from mem0.exceptions import LLMError, VectorStoreError
from mem0.memory.main import AsyncMemory, Memory
@@ -1178,3 +1179,157 @@ class TestAddPipelineEntityEmbeddingCountGuard:
assert any("padding/truncating" in r.message for r in caplog.records), (
"expected count-mismatch warning was not emitted"
)
class TestPartialInsertFailure:
"""Records the vector store rejects must never be reported as successful ADDs (#6911)."""
LLM_RESPONSE = json.dumps(
{
"memory": [
{"text": "User's name is Aryan"},
{"text": "User is allergic to penicillin"},
{"text": "User works as an engineer"},
]
}
)
@pytest.fixture
def mock_memory(self, mocker):
mock_llm, _ = _setup_mocks(mocker)
memory = Memory()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.custom_instructions = None
memory.api_version = "v1.1"
memory.db.get_last_messages = MagicMock(return_value=[])
memory.db.save_messages = MagicMock()
memory.db.batch_add_history = MagicMock()
memory.embedding_model.embed_batch = Mock(side_effect=lambda texts, action: [[0.1, 0.2, 0.3] for _ in texts])
mocker.patch("mem0.memory.main.extract_entities_batch", return_value=[])
mocker.patch("mem0.memory.main.capture_event")
return memory
def test_rejected_records_are_not_reported_as_add(self, mock_memory):
"""A record rejected by the vector store must be absent from results and history."""
poison = "User is allergic to penicillin"
real_insert = mock_memory.vector_store.insert
def flaky_insert(vectors, ids, payloads):
if len(ids) > 1:
raise RuntimeError("batch insert rejected")
if payloads[0]["data"] == poison:
raise RuntimeError("record rejected by vector store")
return real_insert(vectors=vectors, ids=ids, payloads=payloads)
mock_memory.vector_store.insert = Mock(side_effect=flaky_insert)
mock_memory.llm.generate_response.return_value = self.LLM_RESPONSE
result = mock_memory._add_to_vector_store(
messages=[{"role": "user", "content": "My name is Aryan. I work as an engineer."}],
metadata={},
filters={},
infer=True,
)
assert [r["memory"] for r in result] == [
"User's name is Aryan",
"User works as an engineer",
]
assert all(r["event"] == "ADD" for r in result)
history = mock_memory.db.batch_add_history.call_args.args[0]
assert {h["new_memory"] for h in history} == {
"User's name is Aryan",
"User works as an engineer",
}
def test_all_inserts_failed_raises_vector_store_error(self, mock_memory):
"""If nothing persisted, add() must raise VectorStoreError instead of reporting success."""
mock_memory.vector_store.insert = Mock(side_effect=RuntimeError("vector store down"))
mock_memory.llm.generate_response.return_value = self.LLM_RESPONSE
with pytest.raises(VectorStoreError, match="Failed to insert any"):
mock_memory._add_to_vector_store(
messages=[{"role": "user", "content": "test"}],
metadata={},
filters={},
infer=True,
)
# Raw messages are still saved so a later retry can re-extract them.
mock_memory.db.save_messages.assert_called_once()
@pytest.mark.asyncio
class TestAsyncPartialInsertFailure:
"""Async mirror of TestPartialInsertFailure (#6911)."""
LLM_RESPONSE = TestPartialInsertFailure.LLM_RESPONSE
@pytest.fixture
def mock_async_memory(self, mocker):
mock_llm, _ = _setup_mocks(mocker)
memory = AsyncMemory()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.custom_instructions = None
memory.api_version = "v1.1"
memory.db.get_last_messages = MagicMock(return_value=[])
memory.db.save_messages = MagicMock()
memory.db.batch_add_history = MagicMock()
memory.embedding_model.embed_batch = Mock(side_effect=lambda texts, action: [[0.1, 0.2, 0.3] for _ in texts])
mocker.patch("mem0.memory.main.extract_entities_batch", return_value=[])
mocker.patch("mem0.memory.main.capture_event")
return memory
async def test_rejected_records_are_not_reported_as_add(self, mock_async_memory):
poison = "User is allergic to penicillin"
real_insert = mock_async_memory.vector_store.insert
def flaky_insert(vectors, ids, payloads):
if len(ids) > 1:
raise RuntimeError("batch insert rejected")
if payloads[0]["data"] == poison:
raise RuntimeError("record rejected by vector store")
return real_insert(vectors=vectors, ids=ids, payloads=payloads)
mock_async_memory.vector_store.insert = Mock(side_effect=flaky_insert)
mock_async_memory.llm.generate_response.return_value = self.LLM_RESPONSE
result = await mock_async_memory._add_to_vector_store(
messages=[{"role": "user", "content": "My name is Aryan. I work as an engineer."}],
metadata={},
effective_filters={},
infer=True,
)
assert [r["memory"] for r in result] == [
"User's name is Aryan",
"User works as an engineer",
]
history = mock_async_memory.db.batch_add_history.call_args.args[0]
assert {h["new_memory"] for h in history} == {
"User's name is Aryan",
"User works as an engineer",
}
async def test_all_inserts_failed_raises_vector_store_error(self, mock_async_memory):
mock_async_memory.vector_store.insert = Mock(side_effect=RuntimeError("vector store down"))
mock_async_memory.llm.generate_response.return_value = self.LLM_RESPONSE
with pytest.raises(VectorStoreError, match="Failed to insert any"):
await mock_async_memory._add_to_vector_store(
messages=[{"role": "user", "content": "test"}],
metadata={},
effective_filters={},
infer=True,
)
mock_async_memory.db.save_messages.assert_called_once()
+40
View File
@@ -333,6 +333,25 @@ class TestMemoryLifecycle:
# db attribute not set at all
m.close() # should not raise due to hasattr guard
def test_context_manager_returns_self_and_closes(self):
"""with-block should yield the instance and close() it on exit."""
m = self._make_mock_memory()
db = m.db
with m as ctx:
assert ctx is m
db.close.assert_called_once_with()
assert m.db is None
def test_context_manager_closes_on_exception(self):
"""Exceptions from the with-body should propagate after close()."""
m = self._make_mock_memory()
db = m.db
with pytest.raises(RuntimeError, match="boom"):
with m:
raise RuntimeError("boom")
db.close.assert_called_once_with()
assert m.db is None
class TestAsyncMemoryLifecycle:
"""Verify AsyncMemory.close() and async context manager support."""
@@ -357,6 +376,27 @@ class TestAsyncMemoryLifecycle:
m = AsyncMemory.__new__(AsyncMemory)
m.close() # should not raise
@pytest.mark.asyncio
async def test_async_context_manager_returns_self_and_closes(self):
"""async with-block should yield the instance and close() it on exit."""
m = self._make_mock_async_memory()
db = m.db
async with m as ctx:
assert ctx is m
db.close.assert_called_once_with()
assert m.db is None
@pytest.mark.asyncio
async def test_async_context_manager_closes_on_exception(self):
"""Exceptions from the async with-body should propagate after close()."""
m = self._make_mock_async_memory()
db = m.db
with pytest.raises(RuntimeError, match="boom"):
async with m:
raise RuntimeError("boom")
db.close.assert_called_once_with()
assert m.db is None
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""