Compare commits

..
Author SHA1 Message Date
kartik-mem0 8e30ef0839 fix(docs): use filters param for search in LiveKit integration 2026-03-11 16:41:22 +05:30
KartikandGitHub 21df43c699 fix(docs): correct Deploy with Docker Compose card link (#4296) 2026-03-11 00:15:54 -07:00
UtkarshandGitHub 0118198143 chore(openclaw): bump version to 0.3.0 (#4283) 2026-03-09 21:50:13 -07:00
UtkarshandGitHub 36537c8326 fix(ts-sdk): replace sqlite3 with better-sqlite3 to fix native binding resolution (#4270) 2026-03-09 11:00:36 -07:00
UtkarshandGitHub 7482c48692 fix(openclaw): migrate platform search to mem0 v2 API (#4276) 2026-03-09 09:48:55 -07:00
UtkarshandGitHub e219961f9d docs(openclaw): clarify userId is user-defined (#4277) 2026-03-09 09:47:15 -07:00
UtkarshandGitHub 3ffe43f99f feat(openclaw): add per-agent memory isolation for multi-agent setups (#4245) 2026-03-09 08:53:49 -07:00
liviaellenandGitHub 72e2c5c24b Fix handle malformed entity dicts and None LLM response in memgraph_memory (#4238) 2026-03-07 12:05:32 -08:00
Saket AryanandGitHub 34c797d285 fix: disable ph telemetry still calls posthog (#4203) 2026-03-04 03:55:34 +05:30
Saket AryanandGitHub a0d8a02b94 chore(ts-sdk): bump axios to 1.13.6 (#4177) 2026-03-02 13:24:35 +05:30
Saket AryanandGitHub 93c720301e docs: update delete_all to reflect filter validation breaking change (#4103) 2026-02-25 21:10:35 +05:30
mgoulartandGitHub db15d5c629 fix(oss): validate LLM fact output via FactRetrievalSchema before embedding (#4083) 2026-02-22 19:57:48 -08:00
mem0-bot[bot]andGitHub aa4a944b51 fix: Bug: Openclaw Extension OSS Mode lacks threshold restrictions (#4106) (#4115) 2026-02-22 18:44:11 -08:00
PrathameshandGitHub ab5e930cf7 Update OpenClaw integration architecture diagram (#4079) 2026-02-19 16:42:43 -08:00
Deshraj YadavandGitHub 0e76de7bd5 Add source openclaw (#4082) 2026-02-19 16:35:41 -08:00
Mragank ShekharandGitHub 5a93643f12 docs: add memory_categorize webhook event type (#4077) 2026-02-19 15:09:11 +05:30
31 changed files with 1319 additions and 1011 deletions
+22
View File
@@ -7,6 +7,11 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
@@ -724,6 +729,23 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-03-09" description="v2.3.0">
**Breaking Changes:**
- **Dependencies:** Minimum Node.js version for OSS sqlite features is now Node 20+ (due to `better-sqlite3` v12)
**Bug Fixes:**
- **OSS Storage:** Replaced `sqlite3` with `better-sqlite3` to fix native binding resolution failures under jiti-based loaders (e.g. OpenClaw plugin system). Fixes issues where the `bindings` module walked V8 stack frames with synthetic filenames, failing to locate the native `.node` addon.
- **OSS Storage:** Fixed async init race condition in `SQLiteManager` — `init()` is now synchronous
- **OSS Vector Store:** Migrated `MemoryVectorStore` from `sqlite3` to `better-sqlite3` with transactional batch inserts
**Improvements:**
- **Performance:** Cached prepared statements in `SQLiteManager` for faster history operations
- **Performance:** Batch `insert()` in `MemoryVectorStore` wrapped in a transaction for atomicity
- **Build:** Updated `tsup.config.ts` externals from `sqlite3` to `better-sqlite3`
</Update>
<Update label="2026-02-17" description="v2.2.3">
**New Features & Updates:**
@@ -107,6 +107,12 @@ client = MemoryClient(api_key="your-api-key")
# Delete all memories for a specific user
client.delete_all(user_id="alice")
# Delete all memories for a specific agent
client.delete_all(agent_id="support-bot")
# Delete all memories for a specific run
client.delete_all(run_id="session-xyz")
```
```javascript JavaScript
@@ -127,7 +133,48 @@ You can also filter by other parameters such as:
- `metadata` (as JSON string)
<Warning>
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
</Warning>
### Wildcard deletes
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Delete all memories across every user in the project
client.delete_all(user_id="*")
# Delete all memories across every agent in the project
client.delete_all(agent_id="*")
# Full project wipe — all four filters must be explicitly set to "*"
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
```
```javascript JavaScript
import MemoryClient from 'mem0ai';
const client = new MemoryClient({ apiKey: "your-api-key" });
// Delete all memories across every user in the project
client.deleteAll({ user_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
// Full project wipe — all four filters must be explicitly set to "*"
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
```
</CodeGroup>
<Warning>
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
</Warning>
## Delete with Mem0 OSS
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+1 -1
View File
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
logger.info("About to await mem0_client.search for RAG context")
search_results = await mem0_client.search(
new_message.text_content,
user_id=RAG_USER_ID,
filters={"user_id": RAG_USER_ID},
)
logger.info(f"mem0_client.search returned: {search_results}")
if search_results and search_results.get('results', []):
+16 -2
View File
@@ -25,6 +25,20 @@ openclaw plugins install @mem0/openclaw-mem0
## Setup and Configuration
### Understanding `userId`
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
Pick any stable, unique identifier for the user. Common choices:
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
- A simple username (e.g. `"alice"`)
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
<Tip>In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.</Tip>
### Platform Mode (Mem0 Cloud)
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
@@ -37,7 +51,7 @@ Add to your `openclaw.json`:
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "your-user-id"
"userId": "alice" // any unique identifier you choose for this user
}
}
```
@@ -51,7 +65,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
"enabled": true,
"config": {
"mode": "open-source",
"userId": "your-user-id"
"userId": "alice" // any unique identifier you choose for this user
}
}
```
+21 -6
View File
@@ -255,13 +255,28 @@ def delete(
### delete_all() Method
#### No Breaking Changes
#### Breaking Change — Empty filter no longer silently deletes everything
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
**After:**
- No filters → raises a validation error (prevents accidental full-project wipe).
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
```python
# Same signature in both versions
def delete_all(
self,
user_id: str
) -> dict
# v0.x — no filter silently wiped all project memories
m.delete_all() # DANGER: deleted everything
m.delete_all(user_id="alice") # deleted alice's memories
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
m.delete_all() # ERROR: at least one filter required
m.delete_all(user_id="alice") # unchanged
m.delete_all(user_id="*") # NEW — delete all users' memories
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
```
## Platform Client (MemoryClient) Changes
+1 -1
View File
@@ -168,6 +168,6 @@ memory = Memory.from_config_file("config.yaml")
title="Deploy with Docker Compose"
description="Follow the end-to-end OSS deployment walkthrough."
icon="server"
href="/cookbooks/companions/local-companion-ollama"
href="/open-source/features/rest-api"
/>
</CardGroup>
+1 -1
View File
@@ -46,7 +46,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
</CardGroup>
<CardGroup cols={2}>
<Card title="Deploy with Docker Compose" icon="server" href="/cookbooks/companions/local-companion-ollama">
<Card title="Deploy with Docker Compose" icon="server" href="/open-source/features/rest-api">
Reference deployment with REST endpoints.
</Card>
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
+21 -19
View File
@@ -1180,7 +1180,7 @@
"tags": [
"memories"
],
"description": "Delete memories.",
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
"operationId": "memories_delete",
"parameters": [
{
@@ -1189,7 +1189,7 @@
"schema": {
"type": "string"
},
"description": "Filter memories by user ID."
"description": "Filter by user ID. Pass `*` to delete memories for all users."
},
{
"name": "agent_id",
@@ -1197,7 +1197,7 @@
"schema": {
"type": "string"
},
"description": "Filter memories by agent ID."
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
},
{
"name": "app_id",
@@ -1205,7 +1205,7 @@
"schema": {
"type": "string"
},
"description": "Filter memories by app ID."
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
},
{
"name": "run_id",
@@ -1213,7 +1213,7 @@
"schema": {
"type": "string"
},
"description": "Filter memories by run ID."
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
},
{
"name": "metadata",
@@ -1263,15 +1263,15 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
},
{
"lang": "cURL",
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
},
{
"lang": "Go",
@@ -4539,7 +4539,7 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\"]\n)\nprint(webhook)"
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Get all webhooks\nwebhooks = client.get_webhooks(project_id=\"your_project_id\")\nprint(webhooks)\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)"
},
{
"lang": "JavaScript",
@@ -4606,7 +4606,8 @@
"enum": [
"memory:add",
"memory:update",
"memory:delete"
"memory:delete",
"memory:categorize"
]
},
"description": "List of event types to subscribe to."
@@ -4701,7 +4702,7 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\"]\n)\nprint(webhook)"
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Create a webhook\nwebhook = client.create_webhook(\n url=\"https://your-webhook-url.com\",\n name=\"My Webhook\",\n project_id=\"your_project_id\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)"
},
{
"lang": "JavaScript",
@@ -4767,7 +4768,8 @@
"enum": [
"memory:add",
"memory:update",
"memory:delete"
"memory:delete",
"memory:categorize"
]
},
"description": "New list of event types to subscribe to"
@@ -4845,11 +4847,11 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory:add\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)"
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\")\n\n# Update a webhook\nwebhook = client.update_webhook(\n webhook_id=\"your_webhook_id\",\n name=\"Updated Webhook\",\n url=\"https://new-webhook-url.com\",\n event_types=[\"memory:add\", \"memory:categorize\"]\n)\nprint(webhook)\n\n# Delete a webhook\nresponse = client.delete_webhook(webhook_id=\"your_webhook_id\")\nprint(response)"
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory:add']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: 'your-api-key' });\n\n// Update a webhook\nclient.updateWebhook('your_webhook_id', {\n name: 'Updated Webhook',\n url: 'https://new-webhook-url.com',\n event_types: ['memory:add', 'memory:categorize']\n})\n .then(webhook => console.log(webhook))\n .catch(err => console.error(err));\n\n// Delete a webhook\nclient.deleteWebhook('your_webhook_id')\n .then(response => console.log(response))\n .catch(err => console.error(err));"
},
{
"lang": "cURL",
@@ -5568,26 +5570,26 @@
},
"DeleteMemoriesInput": {
"type": "object",
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
"properties": {
"user_id": {
"type": "string",
"description": "The unique identifier of the user whose memories should be deleted.",
"description": "User ID to delete memories for. Pass \"*\" for all users.",
"nullable": true
},
"agent_id": {
"type": "string",
"description": "The unique identifier of the agent whose memories should be deleted.",
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
"nullable": true
},
"app_id": {
"type": "string",
"description": "The unique identifier of the application whose memories should be deleted.",
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
"nullable": true
},
"run_id": {
"type": "string",
"description": "The unique identifier of the run whose memories should be deleted.",
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
"nullable": true
}
},
+4
View File
@@ -123,6 +123,10 @@ await client.deleteAll({ user_id: "alice" });
</CodeGroup>
<Note>
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
</Note>
### History
Get the history of a specific memory asynchronously.
+16 -3
View File
@@ -5,7 +5,7 @@ description: 'Configure and manage webhooks to receive real-time notifications a
## Overview
Webhooks enable real-time notifications for memory events in your Mem0 project. Webhooks are configured at the project level, meaning each webhook is tied to a specific project and receives events solely from that project. You can configure webhooks to send HTTP POST requests to your specified URLs whenever memories are created, updated, or deleted.
Webhooks enable real-time notifications for memory events in your Mem0 project. Webhooks are configured at the project level, meaning each webhook is tied to a specific project and receives events solely from that project. You can configure webhooks to send HTTP POST requests to your specified URLs whenever memories are created, updated, deleted, or categorized.
## Managing Webhooks
@@ -27,7 +27,7 @@ webhook = client.create_webhook(
url="https://your-app.com/webhook",
name="Memory Logger",
project_id="proj_123",
event_types=["memory_add"]
event_types=["memory_add", "memory_categorize"]
)
print(webhook)
```
@@ -41,7 +41,7 @@ const webhook = await client.createWebhook({
url: "https://your-app.com/webhook",
name: "Memory Logger",
projectId: "proj_123",
eventTypes: ["memory_add"]
eventTypes: ["memory_add", "memory_categorize"]
});
console.log(webhook);
```
@@ -167,11 +167,13 @@ Mem0 supports the following event types for webhooks:
- `memory_add`: Triggered when a memory is added.
- `memory_update`: Triggered when an existing memory is updated.
- `memory_delete`: Triggered when a memory is deleted.
- `memory_categorize`: Triggered when a memory is categorized.
## Webhook Payload
When a memory event occurs, Mem0 sends an HTTP POST request to your webhook URL with the following payload:
**Memory add/update/delete payload:**
```json
{
"event_details": {
@@ -184,6 +186,17 @@ When a memory event occurs, Mem0 sends an HTTP POST request to your webhook URL
}
```
**Memory categorize payload:**
```json
{
"event_details": {
"event": "CATEGORIZE",
"memory_id": "a1b2c3d4-e5f6-4g7h-8i9j-k0l1m2n3o4p5",
"categories": ["hobbies", "travel"]
}
}
```
## Best Practices
1. **Implement Retry Logic**: Ensure your webhook endpoint can handle temporary failures.
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.2.3",
"version": "2.3.0",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -77,6 +77,7 @@
"author": "Deshraj Yadav",
"license": "Apache-2.0",
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.7.6",
"@types/uuid": "^9.0.8",
"dotenv": "^16.4.5",
@@ -91,7 +92,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.7.7",
"axios": "1.13.6",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
@@ -108,14 +109,13 @@
"@supabase/supabase-js": "^2.49.1",
"@types/jest": "29.5.14",
"@types/pg": "8.11.0",
"@types/sqlite3": "3.1.11",
"better-sqlite3": "^12.6.2",
"cloudflare": "^4.2.0",
"groq-sdk": "0.3.0",
"neo4j-driver": "^5.28.1",
"ollama": "^0.5.14",
"pg": "8.11.3",
"redis": "^4.6.13",
"sqlite3": "5.1.7"
"redis": "^4.6.13"
},
"engines": {
"node": ">=18"
@@ -127,7 +127,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sqlite3"
"better-sqlite3"
]
}
}
+54 -775
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -19,7 +19,6 @@
"@types/node": "^20.11.19",
"@types/pg": "^8.11.0",
"@types/redis": "^4.0.10",
"@types/sqlite3": "^3.1.11",
"@types/uuid": "^9.0.8",
"cloudflare": "^4.2.0",
"dotenv": "^16.4.4",
@@ -27,7 +26,7 @@
"openai": "^4.28.0",
"pg": "^8.11.3",
"redis": "^4.7.0",
"sqlite3": "^5.1.7",
"better-sqlite3": "^12.6.2",
"uuid": "^9.0.1",
"zod": "^3.22.4"
},
+4 -1
View File
@@ -27,9 +27,12 @@ export class OllamaEmbedder implements Embedder {
} catch (err) {
logger.error(`Error ensuring model exists: ${err}`);
}
// Ollama's Go server requires prompt to be a string. Coerce defensively
// since callers may pass values parsed from untrusted LLM JSON output.
const prompt = typeof text === "string" ? text : JSON.stringify(text);
const response = await this.ollama.embeddings({
model: this.model,
prompt: text,
prompt,
});
return response.embedding;
}
+3 -1
View File
@@ -15,6 +15,7 @@ import {
HistoryManagerFactory,
} from "../utils/factory";
import {
FactRetrievalSchema,
getFactRetrievalMessages,
getUpdateMemoryMessages,
parseMessages,
@@ -261,7 +262,8 @@ export class Memory {
const cleanResponse = removeCodeBlocks(response as string);
let facts: string[] = [];
try {
facts = JSON.parse(cleanResponse).facts || [];
const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse));
facts = parsed.facts;
} catch (e) {
console.error(
"Failed to parse facts from LLM response:",
+10 -1
View File
@@ -1,9 +1,18 @@
import { z } from "zod";
// Accepts a string directly, or an object with a "fact" or "text" key
// (common malformed shapes from smaller LLMs like llama3.1:8b).
const factItem = z.union([
z.string(),
z.object({ fact: z.string() }).transform((o) => o.fact),
z.object({ text: z.string() }).transform((o) => o.text),
]);
// Define Zod schema for fact retrieval output
export const FactRetrievalSchema = z.object({
facts: z
.array(z.string())
.array(factItem)
.transform((arr) => arr.filter((s) => s.length > 0))
.describe("An array of distinct facts extracted from the conversation."),
});
+27 -43
View File
@@ -1,16 +1,18 @@
import sqlite3 from "sqlite3";
import Database from "better-sqlite3";
import { HistoryManager } from "./base";
export class SQLiteManager implements HistoryManager {
private db: sqlite3.Database;
private db: Database.Database;
private stmtInsert!: Database.Statement;
private stmtSelect!: Database.Statement;
constructor(dbPath: string) {
this.db = new sqlite3.Database(dbPath);
this.init().catch(console.error);
this.db = new Database(dbPath);
this.init();
}
private async init() {
await this.run(`
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_id TEXT NOT NULL,
@@ -22,24 +24,14 @@ export class SQLiteManager implements HistoryManager {
is_deleted INTEGER DEFAULT 0
)
`);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
this.stmtInsert = this.db.prepare(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
this.stmtSelect = this.db.prepare(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
);
}
async addHistory(
@@ -51,32 +43,24 @@ export class SQLiteManager implements HistoryManager {
updatedAt?: string,
isDeleted: number = 0,
): Promise<void> {
await this.run(
`INSERT INTO memory_history
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
memoryId,
previousValue,
newValue,
action,
createdAt,
updatedAt,
isDeleted,
],
this.stmtInsert.run(
memoryId,
previousValue,
newValue,
action,
createdAt ?? null,
updatedAt ?? null,
isDeleted,
);
}
async getHistory(memoryId: string): Promise<any[]> {
return this.all(
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
[memoryId],
);
return this.stmtSelect.all(memoryId) as any[];
}
async reset(): Promise<void> {
await this.run("DROP TABLE IF EXISTS memory_history");
await this.init();
this.db.exec("DROP TABLE IF EXISTS memory_history");
this.init();
}
close(): void {
@@ -0,0 +1,377 @@
/**
* Tests for the sqlite3 → better-sqlite3 migration.
*
* Covers:
* - SQLiteManager: all HistoryManager interface methods
* - MemoryVectorStore: insert, search, get, update, delete, list, userId mgmt
* - File-based persistence and in-memory mode
* - Backward compatibility: same schema, same data shapes
*/
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryVectorStore } from "../vector_stores/memory";
import fs from "fs";
import path from "path";
import os from "os";
// ---------------------------------------------------------------------------
// SQLiteManager tests
// ---------------------------------------------------------------------------
describe("SQLiteManager (better-sqlite3)", () => {
let mgr: SQLiteManager;
beforeEach(() => {
mgr = new SQLiteManager(":memory:");
});
afterEach(() => {
mgr.close();
});
it("creates an in-memory database without errors", () => {
expect(mgr).toBeDefined();
});
it("addHistory inserts a row that getHistory returns", async () => {
await mgr.addHistory(
"mem-001",
null,
"User likes TypeScript",
"ADD",
"2026-01-01T00:00:00Z",
);
const history = await mgr.getHistory("mem-001");
expect(history).toHaveLength(1);
expect(history[0].memory_id).toBe("mem-001");
expect(history[0].new_value).toBe("User likes TypeScript");
expect(history[0].action).toBe("ADD");
expect(history[0].previous_value).toBeNull();
expect(history[0].is_deleted).toBe(0);
});
it("returns history in DESC order (most recent first)", async () => {
await mgr.addHistory("mem-002", null, "First", "ADD", "2026-01-01");
await mgr.addHistory(
"mem-002",
"First",
"Updated",
"UPDATE",
"2026-01-01",
"2026-01-02",
);
await mgr.addHistory(
"mem-002",
"Updated",
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-002");
expect(history).toHaveLength(3);
expect(history[0].action).toBe("DELETE");
expect(history[1].action).toBe("UPDATE");
expect(history[2].action).toBe("ADD");
expect(history[0].is_deleted).toBe(1);
});
it("isolates history by memory_id", async () => {
await mgr.addHistory("mem-A", null, "Fact A", "ADD", "2026-01-01");
await mgr.addHistory("mem-B", null, "Fact B", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-A")).toHaveLength(1);
expect(await mgr.getHistory("mem-B")).toHaveLength(1);
expect((await mgr.getHistory("mem-A"))[0].new_value).toBe("Fact A");
expect((await mgr.getHistory("mem-B"))[0].new_value).toBe("Fact B");
});
it("handles NULL/undefined optional fields correctly", async () => {
await mgr.addHistory(
"mem-null",
null,
null,
"DELETE",
undefined,
undefined,
1,
);
const history = await mgr.getHistory("mem-null");
expect(history).toHaveLength(1);
expect(history[0].previous_value).toBeNull();
expect(history[0].new_value).toBeNull();
expect(history[0].created_at).toBeNull();
expect(history[0].updated_at).toBeNull();
});
it("reset() clears all history and allows re-insertion", async () => {
await mgr.addHistory("mem-003", null, "Fact", "ADD", "2026-01-01");
expect(await mgr.getHistory("mem-003")).toHaveLength(1);
await mgr.reset();
expect(await mgr.getHistory("mem-003")).toHaveLength(0);
await mgr.addHistory("mem-004", null, "New fact", "ADD", "2026-02-01");
expect(await mgr.getHistory("mem-004")).toHaveLength(1);
});
it("works with file-based database and persists data", async () => {
const dbPath = path.join(os.tmpdir(), `mem0-test-history-${Date.now()}.db`);
try {
const fmgr = new SQLiteManager(dbPath);
await fmgr.addHistory(
"mem-file",
null,
"Persistent",
"ADD",
"2026-01-01",
);
fmgr.close();
expect(fs.existsSync(dbPath)).toBe(true);
expect(fs.statSync(dbPath).size).toBeGreaterThan(0);
// Reopen and verify
const fmgr2 = new SQLiteManager(dbPath);
const history = await fmgr2.getHistory("mem-file");
expect(history).toHaveLength(1);
expect(history[0].new_value).toBe("Persistent");
fmgr2.close();
} finally {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
}
});
it("handles many rapid insertions", async () => {
for (let i = 0; i < 100; i++) {
await mgr.addHistory(
`mem-rapid-${i}`,
null,
`Fact ${i}`,
"ADD",
new Date().toISOString(),
);
}
for (let i = 0; i < 100; i++) {
const h = await mgr.getHistory(`mem-rapid-${i}`);
expect(h).toHaveLength(1);
expect(h[0].new_value).toBe(`Fact ${i}`);
}
});
});
// ---------------------------------------------------------------------------
// MemoryVectorStore tests
// ---------------------------------------------------------------------------
describe("MemoryVectorStore (better-sqlite3)", () => {
const DIM = 4;
let store: MemoryVectorStore;
let dbPath: string;
function normalize(v: number[]): number[] {
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
return v.map((x) => x / norm);
}
beforeEach(() => {
dbPath = path.join(os.tmpdir(), `mem0-test-vectors-${Date.now()}.db`);
store = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
});
afterEach(() => {
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
});
it("insert + get returns the stored payload", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "hello", userId: "u1" }]);
const result = await store.get("id-1");
expect(result).not.toBeNull();
expect(result!.id).toBe("id-1");
expect(result!.payload.data).toBe("hello");
expect(result!.payload.userId).toBe("u1");
});
it("get returns null for non-existent id", async () => {
const result = await store.get("nope");
expect(result).toBeNull();
});
it("search returns results sorted by cosine similarity", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
const v3 = normalize([1, 1, 0, 0]);
await store.insert(
[v1, v2, v3],
["id-1", "id-2", "id-3"],
[{ data: "exact" }, { data: "orthogonal" }, { data: "close" }],
);
const results = await store.search(v1, 3);
expect(results).toHaveLength(3);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
expect(results[1].id).toBe("id-3");
expect(results[2].id).toBe("id-2");
expect(results[2].score).toBeCloseTo(0, 5);
});
it("search respects limit", async () => {
const vectors = [];
const ids = [];
const payloads = [];
for (let i = 0; i < 10; i++) {
const v = [0, 0, 0, 0];
v[i % DIM] = 1;
vectors.push(normalize(v));
ids.push(`id-${i}`);
payloads.push({ data: `item-${i}` });
}
await store.insert(vectors, ids, payloads);
const results = await store.search(normalize([1, 0, 0, 0]), 3);
expect(results).toHaveLength(3);
});
it("search respects filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v],
["id-1", "id-2"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
],
);
const results = await store.search(v, 10, { userId: "alice" });
expect(results).toHaveLength(1);
expect(results[0].id).toBe("id-1");
});
it("search throws on dimension mismatch", async () => {
await expect(store.search([1, 0, 0], 10)).rejects.toThrow(
"dimension mismatch",
);
});
it("insert throws on dimension mismatch", async () => {
await expect(
store.insert([[1, 0, 0]], ["id-1"], [{ data: "x" }]),
).rejects.toThrow("dimension mismatch");
});
it("update modifies the stored vector and payload", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.update("id-1", v2, { data: "updated" });
const result = await store.get("id-1");
expect(result!.payload.data).toBe("updated");
const results = await store.search(v2, 1);
expect(results[0].id).toBe("id-1");
expect(results[0].score).toBeCloseTo(1.0, 5);
});
it("delete removes the vector", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "doomed" }]);
await store.delete("id-1");
expect(await store.get("id-1")).toBeNull();
});
it("deleteCol drops and recreates table", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-1"], [{ data: "will be gone" }]);
await store.deleteCol();
expect(await store.get("id-1")).toBeNull();
await store.insert([v], ["id-2"], [{ data: "fresh" }]);
expect(await store.get("id-2")).not.toBeNull();
});
it("list returns all vectors with optional filters", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[
{ data: "a", userId: "alice" },
{ data: "b", userId: "bob" },
{ data: "c", userId: "alice" },
],
);
const [all, totalAll] = await store.list();
expect(all).toHaveLength(3);
expect(totalAll).toBe(3);
const [filtered, totalFiltered] = await store.list({ userId: "alice" });
expect(filtered).toHaveLength(2);
expect(totalFiltered).toBe(2);
});
it("list respects limit", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert(
[v, v, v],
["id-1", "id-2", "id-3"],
[{ data: "a" }, { data: "b" }, { data: "c" }],
);
const [results] = await store.list(undefined, 2);
expect(results).toHaveLength(2);
});
it("getUserId generates and persists a userId", async () => {
const id1 = await store.getUserId();
expect(typeof id1).toBe("string");
expect(id1.length).toBeGreaterThan(0);
const id2 = await store.getUserId();
expect(id2).toBe(id1);
});
it("setUserId overrides the stored userId", async () => {
await store.setUserId("custom-user-123");
const id = await store.getUserId();
expect(id).toBe("custom-user-123");
});
it("INSERT OR REPLACE upserts on id conflict", async () => {
const v1 = normalize([1, 0, 0, 0]);
const v2 = normalize([0, 1, 0, 0]);
await store.insert([v1], ["id-1"], [{ data: "original" }]);
await store.insert([v2], ["id-1"], [{ data: "replaced" }]);
const result = await store.get("id-1");
expect(result!.payload.data).toBe("replaced");
const [all] = await store.list();
expect(all).toHaveLength(1);
});
it("file-based database persists across reopens", async () => {
const v = normalize([1, 0, 0, 0]);
await store.insert([v], ["id-persist"], [{ data: "persistent" }]);
const store2 = new MemoryVectorStore({ dimension: DIM, dbPath } as any);
const result = await store2.get("id-persist");
expect(result).not.toBeNull();
expect(result!.payload.data).toBe("persistent");
});
});
+58 -72
View File
@@ -1,6 +1,6 @@
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
import sqlite3 from "sqlite3";
import Database from "better-sqlite3";
import path from "path";
interface MemoryVector {
@@ -10,7 +10,7 @@ interface MemoryVector {
}
export class MemoryVectorStore implements VectorStore {
private db: sqlite3.Database;
private db: Database.Database;
private dimension: number;
private dbPath: string;
@@ -20,12 +20,12 @@ export class MemoryVectorStore implements VectorStore {
if (config.dbPath) {
this.dbPath = config.dbPath;
}
this.db = new sqlite3.Database(this.dbPath);
this.init().catch(console.error);
this.db = new Database(this.dbPath);
this.init();
}
private async init() {
await this.run(`
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS vectors (
id TEXT PRIMARY KEY,
vector BLOB NOT NULL,
@@ -33,7 +33,7 @@ export class MemoryVectorStore implements VectorStore {
)
`);
await this.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE
@@ -41,33 +41,6 @@ export class MemoryVectorStore implements VectorStore {
`);
}
private async run(sql: string, params: any[] = []): Promise<void> {
return new Promise((resolve, reject) => {
this.db.run(sql, params, (err) => {
if (err) reject(err);
else resolve();
});
});
}
private async all(sql: string, params: any[] = []): Promise<any[]> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
private async getOne(sql: string, params: any[] = []): Promise<any> {
return new Promise((resolve, reject) => {
this.db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
private cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
@@ -92,18 +65,23 @@ export class MemoryVectorStore implements VectorStore {
ids: string[],
payloads: Record<string, any>[],
): Promise<void> {
for (let i = 0; i < vectors.length; i++) {
if (vectors[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
await this.run(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
[ids[i], vectorBuffer, JSON.stringify(payloads[i])],
);
}
const stmt = this.db.prepare(
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
);
const insertMany = this.db.transaction(
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
for (let i = 0; i < vecs.length; i++) {
if (vecs[i].length !== this.dimension) {
throw new Error(
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
);
}
const vectorBuffer = Buffer.from(new Float32Array(vecs[i]).buffer);
stmt.run(vIds[i], vectorBuffer, JSON.stringify(vPayloads[i]));
}
},
);
insertMany(vectors, ids, payloads);
}
async search(
@@ -117,11 +95,15 @@ export class MemoryVectorStore implements VectorStore {
);
}
const rows = await this.all(`SELECT * FROM vectors`);
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const results: VectorStoreResult[] = [];
for (const row of rows) {
const vector = new Float32Array(row.vector.buffer);
const vector = new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
);
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
@@ -144,9 +126,9 @@ export class MemoryVectorStore implements VectorStore {
}
async get(vectorId: string): Promise<VectorStoreResult | null> {
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
vectorId,
]);
const row = this.db
.prepare(`SELECT * FROM vectors WHERE id = ?`)
.get(vectorId) as any;
if (!row) return null;
const payload = JSON.parse(row.payload);
@@ -167,34 +149,38 @@ export class MemoryVectorStore implements VectorStore {
);
}
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
vectorBuffer,
JSON.stringify(payload),
vectorId,
]);
this.db
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
.run(vectorBuffer, JSON.stringify(payload), vectorId);
}
async delete(vectorId: string): Promise<void> {
await this.run(`DELETE FROM vectors WHERE id = ?`, [vectorId]);
this.db.prepare(`DELETE FROM vectors WHERE id = ?`).run(vectorId);
}
async deleteCol(): Promise<void> {
await this.run(`DROP TABLE IF EXISTS vectors`);
await this.init();
this.db.exec(`DROP TABLE IF EXISTS vectors`);
this.init();
}
async list(
filters?: SearchFilters,
limit: number = 100,
): Promise<[VectorStoreResult[], number]> {
const rows = await this.all(`SELECT * FROM vectors`);
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
const results: VectorStoreResult[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(new Float32Array(row.vector.buffer)),
vector: Array.from(
new Float32Array(
row.vector.buffer,
row.vector.byteOffset,
row.vector.byteLength / 4,
),
),
payload,
};
@@ -210,9 +196,9 @@ export class MemoryVectorStore implements VectorStore {
}
async getUserId(): Promise<string> {
const row = await this.getOne(
`SELECT user_id FROM memory_migrations LIMIT 1`,
);
const row = this.db
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
.get() as any;
if (row) {
return row.user_id;
}
@@ -221,20 +207,20 @@ export class MemoryVectorStore implements VectorStore {
const randomUserId =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
randomUserId,
]);
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(randomUserId);
return randomUserId;
}
async setUserId(userId: string): Promise<void> {
await this.run(`DELETE FROM memory_migrations`);
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
userId,
]);
this.db.prepare(`DELETE FROM memory_migrations`).run();
this.db
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
.run(userId);
}
async initialize(): Promise<void> {
await this.init();
this.init();
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const external = [
"uuid",
"pg",
"zod",
"sqlite3",
"better-sqlite3",
"@qdrant/js-client-rest",
"redis",
];
+4 -3
View File
@@ -219,7 +219,8 @@ class MemoryGraph:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
entity_type_map[item["entity"]] = item["entity_type"]
if "entity" in item and "entity_type" in item:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
@@ -263,8 +264,8 @@ class MemoryGraph:
)
entities = []
if extracted_entities["tool_calls"]:
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
if extracted_entities and extracted_entities.get("tool_calls"):
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
entities = self._remove_spaces_from_entities(entities)
logger.debug(f"Extracted entities: {entities}")
+16 -5
View File
@@ -24,14 +24,18 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if properties is None:
properties = {}
properties = {
@@ -49,13 +53,17 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
self.posthog.shutdown()
if self.posthog is not None:
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -81,6 +89,9 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+38
View File
@@ -0,0 +1,38 @@
# Changelog
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [0.3.0] - 2026-03-10
### Fixed
- Updated `mem0ai` dependency which includes the sqlite3 to better-sqlite3 migration for native binding resolution (#4270)
## [0.2.0] - 2026-03-09
### Added
- "Understanding userId" section in docs clarifying that `userId` is user-defined
- Per-agent memory isolation for multi-agent setups via `agentId`
- Regression tests for per-agent isolation helpers
### Changed
- Updated config examples to use concrete `userId` values instead of placeholders
### Fixed
- Migrated platform search to Mem0 v2 API
## [0.1.2] - 2026-02-19
### Added
- Source field for openclaw memory entries
### Fixed
- Auto-recall injection and auto-capture message drop
## [0.1.0] - 2026-02-02
### Added
- Initial release of the OpenClaw Mem0 plugin
- Platform mode (Mem0 Cloud) and open-source mode support
- Auto-recall: inject relevant memories before each turn
- Auto-capture: store facts after each turn
- Configurable `topK`, `threshold`, and `apiVersion` options
+47 -7
View File
@@ -30,12 +30,46 @@ The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"s
All new parameters are optional and backward-compatible — existing configurations work without changes.
### Per-agent memory isolation
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
**How it works:**
- The agent's session key is inspected on every recall/capture cycle
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
- Different agents never see each other's memories unless explicitly queried
**Explicit cross-agent queries:**
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
```
memory_search({ query: "user's tech stack", agentId: "researcher" })
```
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
## Setup
```bash
openclaw plugins install @mem0/openclaw-mem0
```
### Understanding `userId`
The `userId` field is a **string you choose** to uniquely identify the user whose memories are being stored. It is **not** something you look up in the Mem0 dashboard — you define it yourself.
Pick any stable, unique identifier for the user. Common choices:
- Your application's internal user ID (e.g. `"user_123"`, `"alice@example.com"`)
- A UUID (e.g. `"550e8400-e29b-41d4-a716-446655440000"`)
- A simple username (e.g. `"alice"`)
All memories are scoped to this `userId` — different values create separate memory namespaces. If you don't set it, it defaults to `"default"`, which means all users share the same memory space.
> **Tip:** In a multi-user application, set `userId` dynamically per user (e.g. from your auth system) rather than hardcoding a single value.
### Platform (Mem0 Cloud)
Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`:
@@ -46,7 +80,7 @@ Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `opencl
"enabled": true,
"config": {
"apiKey": "${MEM0_API_KEY}",
"userId": "your-user-id"
"userId": "alice" // any unique identifier you choose for this user
}
}
```
@@ -60,7 +94,7 @@ No Mem0 key needed. Requires `OPENAI_API_KEY` for default embeddings/LLM.
"enabled": true,
"config": {
"mode": "open-source",
"userId": "your-user-id"
"userId": "alice" // any unique identifier you choose for this user
}
}
```
@@ -87,11 +121,11 @@ The agent gets five tools it can call during conversations:
| Tool | Description |
|------|-------------|
| `memory_search` | Search memories by natural language |
| `memory_list` | List all stored memories for a user |
| `memory_store` | Explicitly save a fact |
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
| `memory_get` | Retrieve a memory by ID |
| `memory_forget` | Delete by ID or by query |
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
## CLI
@@ -107,6 +141,12 @@ openclaw mem0 search "what languages does the user know" --scope session
# Stats
openclaw mem0 stats
# Search a specific agent's memories
openclaw mem0 search "user preferences" --agent researcher
# Stats for a specific agent
openclaw mem0 stats --agent researcher
```
## Options
@@ -116,7 +156,7 @@ openclaw mem0 stats
| Key | Type | Default | |
|-----|------|---------|---|
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
| `userId` | `string` | `"default"` | Scope memories per user |
| `userId` | `string` | `"default"` | Any unique identifier you choose for the user (e.g. `"alice"`, `"user_123"`). All memories are scoped to this value. Not found in any dashboard — you define it yourself. |
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
| `topK` | `number` | `5` | Max memories per recall |
+165
View File
@@ -0,0 +1,165 @@
/**
* Regression tests for per-agent memory isolation helpers.
*
* Addresses review feedback: targeted coverage for auth/session state,
* malformed input, and the resolveUserId priority chain.
*/
import { describe, it, expect } from "vitest";
import {
extractAgentId,
effectiveUserId,
agentUserId,
resolveUserId,
} from "./index.ts";
// ---------------------------------------------------------------------------
// extractAgentId
// ---------------------------------------------------------------------------
describe("extractAgentId", () => {
it("returns agentId from a well-formed session key", () => {
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
});
it("returns undefined for the 'main' sentinel", () => {
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
});
it("returns undefined for undefined/null/empty input", () => {
expect(extractAgentId(undefined)).toBeUndefined();
expect(extractAgentId("")).toBeUndefined();
});
it("returns undefined for non-agent session keys", () => {
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
expect(extractAgentId("some-random-uuid")).toBeUndefined();
});
it("handles keys with extra colons after the UUID portion", () => {
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
});
it("returns undefined when agentId segment is empty", () => {
// pattern: agent::<uuid> — empty agentId
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
});
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
expect(extractAgentId("agent:")).toBeUndefined();
});
it("is case-sensitive (Agent != agent)", () => {
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
});
it("handles whitespace-only agentId as truthy string", () => {
// " " is a non-empty match — returned as-is (validation is caller's job)
expect(extractAgentId("agent: :uuid")).toBe(" ");
});
});
// ---------------------------------------------------------------------------
// effectiveUserId
// ---------------------------------------------------------------------------
describe("effectiveUserId", () => {
const base = "alice";
it("returns base userId when sessionKey is undefined", () => {
expect(effectiveUserId(base)).toBe("alice");
expect(effectiveUserId(base, undefined)).toBe("alice");
});
it("returns namespaced userId for agent session keys", () => {
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
"alice:agent:researcher",
);
});
it("falls back to base for 'main' agent sessions", () => {
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
});
it("falls back to base for non-agent session keys", () => {
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// agentUserId
// ---------------------------------------------------------------------------
describe("agentUserId", () => {
it("produces the correct namespaced format", () => {
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
});
it("handles empty agentId (caller is responsible for validation)", () => {
expect(agentUserId("alice", "")).toBe("alice:agent:");
});
});
// ---------------------------------------------------------------------------
// resolveUserId — priority chain
// ---------------------------------------------------------------------------
describe("resolveUserId", () => {
const base = "alice";
it("prefers explicit agentId over everything else", () => {
expect(
resolveUserId(
base,
{ agentId: "researcher", userId: "bob" },
"agent:beta:uuid",
),
).toBe("alice:agent:researcher");
});
it("uses explicit userId when agentId is absent", () => {
expect(
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
).toBe("bob");
});
it("derives from session key when both agentId and userId are absent", () => {
expect(
resolveUserId(base, {}, "agent:gamma:uuid"),
).toBe("alice:agent:gamma");
});
it("falls back to base userId when nothing else is provided", () => {
expect(resolveUserId(base, {})).toBe("alice");
expect(resolveUserId(base, {}, undefined)).toBe("alice");
});
it("ignores empty-string agentId (falsy)", () => {
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
});
it("ignores empty-string userId (falsy)", () => {
expect(resolveUserId(base, { userId: "" })).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// Cross-agent isolation sanity checks
// ---------------------------------------------------------------------------
describe("multi-agent isolation", () => {
const base = "user-42";
it("different agents get different namespaces", () => {
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
expect(alphaId).not.toBe(betaId);
expect(alphaId).toBe("user-42:agent:alpha");
expect(betaId).toBe("user-42:agent:beta");
});
it("same agent across sessions yields the same namespace", () => {
const s1 = effectiveUserId(base, "agent:alpha:session-1");
const s2 = effectiveUserId(base, "agent:alpha:session-2");
expect(s1).toBe(s2);
});
it("main session shares the base namespace (no isolation)", () => {
const mainId = effectiveUserId(base, "agent:main:uuid-m");
expect(mainId).toBe(base);
});
});
+148 -32
View File
@@ -10,6 +10,8 @@
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
* - CLI: openclaw mem0 search, openclaw mem0 stats
* - Dual mode: platform or open-source (self-hosted)
*/
@@ -56,6 +58,7 @@ interface AddOptions {
custom_categories?: Array<Record<string, string>>;
enable_graph?: boolean;
output_format?: string;
source?: string;
}
interface SearchOptions {
@@ -66,12 +69,14 @@ interface SearchOptions {
limit?: number;
keyword_search?: boolean;
reranking?: boolean;
source?: string;
}
interface ListOptions {
user_id: string;
run_id?: string;
page_size?: number;
source?: string;
}
interface MemoryItem {
@@ -152,6 +157,7 @@ class PlatformProvider implements Mem0Provider {
opts.custom_categories = options.custom_categories;
if (options.enable_graph) opts.enable_graph = options.enable_graph;
if (options.output_format) opts.output_format = options.output_format;
if (options.source) opts.source = options.source;
const result = await this.client.add(messages, opts);
return normalizeAddResult(result);
@@ -159,12 +165,17 @@ class PlatformProvider implements Mem0Provider {
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
await this.ensureClient();
const opts: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) opts.run_id = options.run_id;
const filters: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) filters.run_id = options.run_id;
const opts: Record<string, unknown> = {
api_version: "v2",
filters,
};
if (options.top_k != null) opts.top_k = options.top_k;
if (options.threshold != null) opts.threshold = options.threshold;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.reranking != null) opts.rerank = options.reranking;
const results = await this.client.search(query, opts);
return normalizeSearchResults(results);
@@ -181,6 +192,7 @@ class PlatformProvider implements Mem0Provider {
const opts: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) opts.run_id = options.run_id;
if (options.page_size != null) opts.page_size = options.page_size;
if (options.source) opts.source = options.source;
const results = await this.client.getAll(opts);
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
@@ -247,6 +259,7 @@ class OSSProvider implements Mem0Provider {
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
const addOpts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) addOpts.runId = options.run_id;
if (options.source) addOpts.source = options.source;
const result = await this.memory.add(messages, addOpts);
return normalizeAddResult(result);
}
@@ -260,9 +273,18 @@ class OSSProvider implements Mem0Provider {
else if (options.top_k != null) opts.limit = options.top_k;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
if (options.threshold != null) opts.threshold = options.threshold;
const results = await this.memory.search(query, opts);
return normalizeSearchResults(results);
const normalized = normalizeSearchResults(results);
// Filter results by threshold if specified (client-side filtering as fallback)
if (options.threshold != null) {
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
}
return normalized;
}
async get(memoryId: string): Promise<MemoryItem> {
@@ -276,6 +298,7 @@ class OSSProvider implements Mem0Provider {
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) getAllOpts.runId = options.run_id;
if (options.source) getAllOpts.source = options.source;
const results = await this.memory.getAll(getAllOpts);
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
if (results?.results && Array.isArray(results.results))
@@ -588,6 +611,50 @@ function categoriesToArray(
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
}
// ============================================================================
// Per-agent isolation helpers (exported for testability)
// ============================================================================
/**
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
const match = sessionKey.match(/^agent:([^:]+):/);
const agentId = match?.[1];
// "main" is the primary session — fall back to configured userId
if (!agentId || agentId === "main") return undefined;
return agentId;
}
/**
* Derive the effective user_id from a session key, namespacing per-agent.
* Falls back to baseUserId when the session is not agent-scoped.
*/
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
const agentId = extractAgentId(sessionKey);
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
}
/** Build a user_id for an explicit agentId (e.g. from tool params). */
export function agentUserId(baseUserId: string, agentId: string): string {
return `${baseUserId}:agent:${agentId}`;
}
/**
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
*/
export function resolveUserId(
baseUserId: string,
opts: { agentId?: string; userId?: string },
currentSessionId?: string,
): string {
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
if (opts.userId) return opts.userId;
return effectiveUserId(baseUserId, currentSessionId);
}
// ============================================================================
// Plugin Definition
// ============================================================================
@@ -607,14 +674,24 @@ const memoryPlugin = {
// Track current session ID for tool-level session scoping
let currentSessionId: string | undefined;
// ========================================================================
// Per-agent isolation helpers (thin wrappers around exported functions)
// ========================================================================
const _effectiveUserId = (sessionKey?: string) =>
effectiveUserId(cfg.userId, sessionKey);
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
// Helper: build add options
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
const opts: AddOptions = {
user_id: userIdOverride || cfg.userId,
user_id: userIdOverride || _effectiveUserId(sessionKey),
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
if (cfg.mode === "platform") {
@@ -631,14 +708,16 @@ const memoryPlugin = {
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const opts: SearchOptions = {
user_id: userIdOverride || cfg.userId,
user_id: userIdOverride || _effectiveUserId(sessionKey),
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: cfg.searchThreshold,
keyword_search: true,
reranking: true,
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
return opts;
@@ -667,6 +746,12 @@ const memoryPlugin = {
"User ID to scope search (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -679,39 +764,41 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { query, limit, userId, scope = "all" } = params as {
const { query, limit, userId, agentId, scope = "all" } = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
};
try {
let results: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(
query,
buildSearchOptions(userId, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
}
} else if (scope === "long-term") {
results = await provider.search(
query,
buildSearchOptions(userId, limit),
buildSearchOptions(uid, limit),
);
} else {
// "all" — search both scopes and combine
const longTermResults = await provider.search(
query,
buildSearchOptions(userId, limit),
buildSearchOptions(uid, limit),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider.search(
query,
buildSearchOptions(userId, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
}
// Deduplicate by ID, preferring long-term
@@ -784,6 +871,12 @@ const memoryPlugin = {
description: "User ID to scope this memory",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.",
}),
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Optional metadata to attach to this memory",
@@ -797,18 +890,20 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { text, userId, longTerm = true } = params as {
const { text, userId, agentId, longTerm = true } = params as {
text: string;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
};
try {
const uid = _resolveUserId({ agentId, userId });
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
const result = await provider.add(
[{ role: "user", content: text }],
buildAddOptions(userId, runId),
buildAddOptions(uid, runId, currentSessionId),
);
const added =
@@ -900,7 +995,7 @@ const memoryPlugin = {
name: "memory_list",
label: "Memory List",
description:
"List all stored memories for a user. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
parameters: Type.Object({
userId: Type.Optional(
Type.String({
@@ -908,6 +1003,12 @@ const memoryPlugin = {
"User ID to list memories for (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -920,29 +1021,31 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
try {
let memories: MemoryItem[] = [];
const uid = userId || cfg.userId;
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
memories = await provider.getAll({
user_id: uid,
run_id: currentSessionId,
source: "OPENCLAW",
});
}
} else if (scope === "long-term") {
memories = await provider.getAll({ user_id: uid });
memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
} else {
// "all" — combine both scopes
const longTerm = await provider.getAll({ user_id: uid });
const longTerm = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
let session: MemoryItem[] = [];
if (currentSessionId) {
session = await provider.getAll({
user_id: uid,
run_id: currentSessionId,
source: "OPENCLAW",
});
}
const seen = new Set(longTerm.map((r) => r.id));
@@ -1005,7 +1108,7 @@ const memoryPlugin = {
name: "memory_forget",
label: "Memory Forget",
description:
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. GDPR-compliant.",
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(
Type.String({
@@ -1015,11 +1118,18 @@ const memoryPlugin = {
memoryId: Type.Optional(
Type.String({ description: "Specific memory ID to delete" }),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
}),
),
}),
async execute(_toolCallId, params) {
const { query, memoryId } = params as {
const { query, memoryId, agentId } = params as {
query?: string;
memoryId?: string;
agentId?: string;
};
try {
@@ -1034,9 +1144,10 @@ const memoryPlugin = {
}
if (query) {
const uid = _resolveUserId({ agentId });
const results = await provider.search(
query,
buildSearchOptions(undefined, 5),
buildSearchOptions(uid, 5),
);
if (!results || results.length === 0) {
@@ -1127,10 +1238,12 @@ const memoryPlugin = {
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", String(cfg.topK))
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
.action(async (query: string, opts: { limit: string; scope: string }) => {
.option("--agent <agentId>", "Search a specific agent's memory namespace")
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
try {
const limit = parseInt(opts.limit, 10);
const scope = opts.scope as "session" | "long-term" | "all";
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
let allResults: MemoryItem[] = [];
@@ -1138,7 +1251,7 @@ const memoryPlugin = {
if (currentSessionId) {
const sessionResults = await provider.search(
query,
buildSearchOptions(undefined, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
if (sessionResults?.length) {
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
@@ -1152,7 +1265,7 @@ const memoryPlugin = {
if (scope === "long-term" || scope === "all") {
const longTermResults = await provider.search(
query,
buildSearchOptions(undefined, limit),
buildSearchOptions(uid, limit),
);
if (longTermResults?.length) {
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
@@ -1191,13 +1304,16 @@ const memoryPlugin = {
mem0
.command("stats")
.description("Show memory statistics from Mem0")
.action(async () => {
.option("--agent <agentId>", "Show stats for a specific agent")
.action(async (opts: { agent?: string }) => {
try {
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
const memories = await provider.getAll({
user_id: cfg.userId,
user_id: uid,
source: "OPENCLAW",
});
console.log(`Mode: ${cfg.mode}`);
console.log(`User: ${cfg.userId}`);
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
console.log(
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
);
@@ -1227,10 +1343,10 @@ const memoryPlugin = {
if (sessionId) currentSessionId = sessionId;
try {
// Search long-term memories (user-scoped)
// Search long-term memories (user-scoped, isolated per agent)
const longTermResults = await provider.search(
event.prompt,
buildSearchOptions(),
buildSearchOptions(undefined, undefined, undefined, sessionId),
);
// Search session memories (session-scoped) if we have a session ID
@@ -1238,7 +1354,7 @@ const memoryPlugin = {
if (currentSessionId) {
sessionResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, currentSessionId),
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
);
}
@@ -1343,7 +1459,7 @@ const memoryPlugin = {
if (formattedMessages.length === 0) return;
const addOpts = buildAddOptions(undefined, currentSessionId);
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
const result = await provider.add(
formattedMessages,
addOpts,
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -11,6 +11,9 @@
"mem0",
"long-term-memory"
],
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "^2.2.1"
@@ -19,5 +22,8 @@
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"vitest": "^4.0.18"
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.4"
version = "1.0.5"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
+96
View File
@@ -0,0 +1,96 @@
from unittest.mock import MagicMock, Mock, patch
# langchain_memgraph and rank_bm25 are optional deps — mock them so tests run without install
_memgraph_mock = Mock()
patch.dict("sys.modules", {
"langchain_memgraph": _memgraph_mock,
"langchain_memgraph.graphs": _memgraph_mock,
"langchain_memgraph.graphs.memgraph": _memgraph_mock,
"rank_bm25": Mock(),
}).start()
from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402
MemoryGraph = MemgraphMemoryGraph
def _make_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
return instance
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in MemoryGraph."""
def test_normal_entities_extracted(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_malformed_entity_missing_entity_type_is_skipped(self):
"""LLM returns entity dict without entity_type — should skip it, keep valid ones.
Reproduces the exact data from issue #4055."""
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "matrix multiplication", "entity_type": "task"},
{"entity": "task"},
{"entity": "ReLU", "entity_type": "task"},
]}}]
}
result = instance._retrieve_nodes_from_data("some text", {"user_id": "u1"})
assert "matrix_multiplication" in result
assert "relu" in result
assert "task" not in result
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
class TestEstablishNodesRelationsFromData:
"""Tests for _establish_nodes_relations_from_data in MemoryGraph."""
def test_none_response_does_not_crash(self):
"""openai_structured returns None when no relations found — must not crash.
Exact crash from issue #4055: TypeError: 'NoneType' object is not subscriptable."""
instance = _make_instance()
instance.llm.generate_response.return_value = None
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_empty_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": []}
result = instance._establish_nodes_relations_from_data(
"Hello world", {"user_id": "u1"}, {}
)
assert result == []
def test_valid_entities_returned(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "add_entities", "arguments": {"entities": [
{"source": "alice", "relationship": "loves", "destination": "hiking"}
]}}]
}
result = instance._establish_nodes_relations_from_data(
"Alice loves hiking", {"user_id": "u1"}, {"alice": "person"}
)
assert len(result) == 1
assert result[0]["source"] == "alice"
+104 -25
View File
@@ -1,35 +1,114 @@
import os
from unittest.mock import patch
import threading
from unittest.mock import MagicMock, patch
import pytest
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
import mem0.memory.telemetry as telemetry_module
def use_telemetry():
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
return True
return False
class TestTelemetryDisabled:
"""Verify PostHog is never instantiated when telemetry is disabled."""
def test_posthog_not_created_when_disabled(self):
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_not_called()
assert at.posthog is None
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
mock_instance = MagicMock()
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_not_called()
def test_instance_capture_event_noop_when_posthog_is_none(self):
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}) # should not raise
def test_close_noop_when_posthog_is_none(self):
"""close() should not raise when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.close() # should not raise
def test_no_threads_spawned_when_disabled(self):
"""No consumer threads should be created when telemetry is disabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
threads_before = threading.active_count()
telemetry_module.AnonymousTelemetry()
threads_after = threading.active_count()
assert threads_after == threads_before
@pytest.fixture(autouse=True)
def reset_env():
with patch.dict(os.environ, {}, clear=True):
yield
class TestTelemetryEnabled:
"""Verify PostHog works normally when telemetry is enabled."""
def test_posthog_created_when_enabled(self):
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert at.posthog is not None
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_called_once()
def test_telemetry_enabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
assert use_telemetry() is True
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
def test_telemetry_disabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
assert use_telemetry() is False
def test_telemetry_default_enabled():
assert use_telemetry() is True
@pytest.mark.parametrize(
"value,expected",
[
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("false", False),
("False", False),
("0", False),
("no", False),
("anything_else", False),
],
)
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected