Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630d44c634 | ||
|
|
5ae627b51a | ||
|
|
df79a43925 | ||
|
|
a6242710df | ||
|
|
4e1e4c0c5a | ||
|
|
fa5c85f9f6 | ||
|
|
7c29eb2645 | ||
|
|
6f079c313f | ||
|
|
e95090e116 | ||
|
|
861cbb7289 | ||
|
|
54aa760720 | ||
|
|
59c3b050bd | ||
|
|
5b3acf416b | ||
|
|
63f587c922 | ||
|
|
21df43c699 | ||
|
|
0118198143 | ||
|
|
36537c8326 | ||
|
|
7482c48692 | ||
|
|
e219961f9d | ||
|
|
3ffe43f99f | ||
|
|
72e2c5c24b | ||
|
|
34c797d285 | ||
|
|
a0d8a02b94 | ||
|
|
93c720301e | ||
|
|
db15d5c629 | ||
|
|
aa4a944b51 | ||
|
|
ab5e930cf7 | ||
|
|
0e76de7bd5 | ||
|
|
5a93643f12 |
@@ -7,6 +7,11 @@ mode: "wide"
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Python">
|
<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">
|
<Update label="2026-02-17" description="v1.0.4">
|
||||||
|
|
||||||
**New Features & Updates:**
|
**New Features & Updates:**
|
||||||
@@ -724,6 +729,36 @@ mode: "wide"
|
|||||||
|
|
||||||
<Tab title="TypeScript">
|
<Tab title="TypeScript">
|
||||||
|
|
||||||
|
<Update label="2026-03-14" description="v2.4.0">
|
||||||
|
|
||||||
|
**Bug Fixes:**
|
||||||
|
- **OSS Storage:** Fixed `SQLITE_CANTOPEN` errors when running as a LaunchAgent, systemd service, or in containers where `process.cwd()` is read-only (e.g. `/`). Default `vector_store.db` location changed from `process.cwd()/vector_store.db` to `~/.mem0/vector_store.db`.
|
||||||
|
- **OSS Storage:** Fixed `historyDbPath` config being silently ignored — config merging always overwrote it with defaults. Top-level `historyDbPath` is now correctly propagated into `historyStore.config` with proper precedence.
|
||||||
|
- **OSS Storage:** Added `ensureSQLiteDirectory()` — parent directories for SQLite database files are now auto-created before opening, preventing `SQLITE_CANTOPEN` when using nested paths.
|
||||||
|
|
||||||
|
**Improvements:**
|
||||||
|
- **Migration:** Added deprecation warning when an existing `vector_store.db` is found at the old `process.cwd()` location, guiding users to move it or set `vectorStore.config.dbPath` explicitly.
|
||||||
|
- **Config:** Limited default SQLite config spreading to only SQLite history providers, preventing config leaking into Supabase or other providers.
|
||||||
|
|
||||||
|
</Update>
|
||||||
|
|
||||||
|
<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">
|
<Update label="2026-02-17" description="v2.2.3">
|
||||||
|
|
||||||
**New Features & Updates:**
|
**New Features & Updates:**
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ client = MemoryClient(api_key="your-api-key")
|
|||||||
|
|
||||||
# Delete all memories for a specific user
|
# Delete all memories for a specific user
|
||||||
client.delete_all(user_id="alice")
|
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
|
```javascript JavaScript
|
||||||
@@ -127,7 +133,48 @@ You can also filter by other parameters such as:
|
|||||||
- `metadata` (as JSON string)
|
- `metadata` (as JSON string)
|
||||||
|
|
||||||
<Warning>
|
<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>
|
</Warning>
|
||||||
|
|
||||||
## Delete with Mem0 OSS
|
## Delete with Mem0 OSS
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 178 KiB |
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
|
|||||||
logger.info("About to await mem0_client.search for RAG context")
|
logger.info("About to await mem0_client.search for RAG context")
|
||||||
search_results = await mem0_client.search(
|
search_results = await mem0_client.search(
|
||||||
new_message.text_content,
|
new_message.text_content,
|
||||||
user_id=RAG_USER_ID,
|
filters={"user_id": RAG_USER_ID},
|
||||||
)
|
)
|
||||||
logger.info(f"mem0_client.search returned: {search_results}")
|
logger.info(f"mem0_client.search returned: {search_results}")
|
||||||
if search_results and search_results.get('results', []):
|
if search_results and search_results.get('results', []):
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ openclaw plugins install @mem0/openclaw-mem0
|
|||||||
|
|
||||||
## Setup and Configuration
|
## 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)
|
### Platform Mode (Mem0 Cloud)
|
||||||
|
|
||||||
<Note>Get your API key from [app.mem0.ai](https://app.mem0.ai).</Note>
|
<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,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"apiKey": "${MEM0_API_KEY}",
|
"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,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"mode": "open-source",
|
"mode": "open-source",
|
||||||
"userId": "your-user-id"
|
"userId": "alice" // any unique identifier you choose for this user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -255,13 +255,28 @@ def delete(
|
|||||||
|
|
||||||
### delete_all() Method
|
### 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
|
```python
|
||||||
# Same signature in both versions
|
# v0.x — no filter silently wiped all project memories
|
||||||
def delete_all(
|
m.delete_all() # DANGER: deleted everything
|
||||||
self,
|
m.delete_all(user_id="alice") # deleted alice's memories
|
||||||
user_id: str
|
|
||||||
) -> dict
|
# 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
|
## Platform Client (MemoryClient) Changes
|
||||||
|
|||||||
@@ -168,6 +168,6 @@ memory = Memory.from_config_file("config.yaml")
|
|||||||
title="Deploy with Docker Compose"
|
title="Deploy with Docker Compose"
|
||||||
description="Follow the end-to-end OSS deployment walkthrough."
|
description="Follow the end-to-end OSS deployment walkthrough."
|
||||||
icon="server"
|
icon="server"
|
||||||
href="/cookbooks/companions/local-companion-ollama"
|
href="/open-source/features/rest-api"
|
||||||
/>
|
/>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
|
|||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
<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.
|
Reference deployment with REST endpoints.
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
|
<Card title="Use the REST API" icon="code" href="/open-source/features/rest-api">
|
||||||
|
|||||||
+21
-19
@@ -1180,7 +1180,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"memories"
|
"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",
|
"operationId": "memories_delete",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -1189,7 +1189,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Filter memories by user ID."
|
"description": "Filter by user ID. Pass `*` to delete memories for all users."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "agent_id",
|
"name": "agent_id",
|
||||||
@@ -1197,7 +1197,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Filter memories by agent ID."
|
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "app_id",
|
"name": "app_id",
|
||||||
@@ -1205,7 +1205,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Filter memories by app ID."
|
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "run_id",
|
"name": "run_id",
|
||||||
@@ -1213,7 +1213,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Filter memories by run ID."
|
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "metadata",
|
"name": "metadata",
|
||||||
@@ -1263,15 +1263,15 @@
|
|||||||
"x-code-samples": [
|
"x-code-samples": [
|
||||||
{
|
{
|
||||||
"lang": "Python",
|
"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",
|
"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",
|
"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",
|
"lang": "Go",
|
||||||
@@ -4539,7 +4539,7 @@
|
|||||||
"x-code-samples": [
|
"x-code-samples": [
|
||||||
{
|
{
|
||||||
"lang": "Python",
|
"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",
|
"lang": "JavaScript",
|
||||||
@@ -4606,7 +4606,8 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"memory:add",
|
"memory:add",
|
||||||
"memory:update",
|
"memory:update",
|
||||||
"memory:delete"
|
"memory:delete",
|
||||||
|
"memory:categorize"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "List of event types to subscribe to."
|
"description": "List of event types to subscribe to."
|
||||||
@@ -4701,7 +4702,7 @@
|
|||||||
"x-code-samples": [
|
"x-code-samples": [
|
||||||
{
|
{
|
||||||
"lang": "Python",
|
"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",
|
"lang": "JavaScript",
|
||||||
@@ -4767,7 +4768,8 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"memory:add",
|
"memory:add",
|
||||||
"memory:update",
|
"memory:update",
|
||||||
"memory:delete"
|
"memory:delete",
|
||||||
|
"memory:categorize"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "New list of event types to subscribe to"
|
"description": "New list of event types to subscribe to"
|
||||||
@@ -4845,11 +4847,11 @@
|
|||||||
"x-code-samples": [
|
"x-code-samples": [
|
||||||
{
|
{
|
||||||
"lang": "Python",
|
"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",
|
"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",
|
"lang": "cURL",
|
||||||
@@ -5568,26 +5570,26 @@
|
|||||||
},
|
},
|
||||||
"DeleteMemoriesInput": {
|
"DeleteMemoriesInput": {
|
||||||
"type": "object",
|
"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": {
|
"properties": {
|
||||||
"user_id": {
|
"user_id": {
|
||||||
"type": "string",
|
"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
|
"nullable": true
|
||||||
},
|
},
|
||||||
"agent_id": {
|
"agent_id": {
|
||||||
"type": "string",
|
"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
|
"nullable": true
|
||||||
},
|
},
|
||||||
"app_id": {
|
"app_id": {
|
||||||
"type": "string",
|
"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
|
"nullable": true
|
||||||
},
|
},
|
||||||
"run_id": {
|
"run_id": {
|
||||||
"type": "string",
|
"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
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ await client.deleteAll({ user_id: "alice" });
|
|||||||
|
|
||||||
</CodeGroup>
|
</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
|
### History
|
||||||
|
|
||||||
Get the history of a specific memory asynchronously.
|
Get the history of a specific memory asynchronously.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: 'Configure and manage webhooks to receive real-time notifications a
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## Managing Webhooks
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ webhook = client.create_webhook(
|
|||||||
url="https://your-app.com/webhook",
|
url="https://your-app.com/webhook",
|
||||||
name="Memory Logger",
|
name="Memory Logger",
|
||||||
project_id="proj_123",
|
project_id="proj_123",
|
||||||
event_types=["memory_add"]
|
event_types=["memory_add", "memory_categorize"]
|
||||||
)
|
)
|
||||||
print(webhook)
|
print(webhook)
|
||||||
```
|
```
|
||||||
@@ -41,7 +41,7 @@ const webhook = await client.createWebhook({
|
|||||||
url: "https://your-app.com/webhook",
|
url: "https://your-app.com/webhook",
|
||||||
name: "Memory Logger",
|
name: "Memory Logger",
|
||||||
projectId: "proj_123",
|
projectId: "proj_123",
|
||||||
eventTypes: ["memory_add"]
|
eventTypes: ["memory_add", "memory_categorize"]
|
||||||
});
|
});
|
||||||
console.log(webhook);
|
console.log(webhook);
|
||||||
```
|
```
|
||||||
@@ -167,11 +167,13 @@ Mem0 supports the following event types for webhooks:
|
|||||||
- `memory_add`: Triggered when a memory is added.
|
- `memory_add`: Triggered when a memory is added.
|
||||||
- `memory_update`: Triggered when an existing memory is updated.
|
- `memory_update`: Triggered when an existing memory is updated.
|
||||||
- `memory_delete`: Triggered when a memory is deleted.
|
- `memory_delete`: Triggered when a memory is deleted.
|
||||||
|
- `memory_categorize`: Triggered when a memory is categorized.
|
||||||
|
|
||||||
## Webhook Payload
|
## Webhook Payload
|
||||||
|
|
||||||
When a memory event occurs, Mem0 sends an HTTP POST request to your webhook URL with the following 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
|
```json
|
||||||
{
|
{
|
||||||
"event_details": {
|
"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
|
## Best Practices
|
||||||
|
|
||||||
1. **Implement Retry Logic**: Ensure your webhook endpoint can handle temporary failures.
|
1. **Implement Retry Logic**: Ensure your webhook endpoint can handle temporary failures.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0ai",
|
"name": "mem0ai",
|
||||||
"version": "2.2.3",
|
"version": "2.4.0",
|
||||||
"description": "The Memory Layer For Your AI Apps",
|
"description": "The Memory Layer For Your AI Apps",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
"author": "Deshraj Yadav",
|
"author": "Deshraj Yadav",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.7.6",
|
"@types/node": "^22.7.6",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
"typescript": "5.5.4"
|
"typescript": "5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.7.7",
|
"axios": "1.13.6",
|
||||||
"openai": "^4.93.0",
|
"openai": "^4.93.0",
|
||||||
"uuid": "9.0.1",
|
"uuid": "9.0.1",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
@@ -108,14 +109,13 @@
|
|||||||
"@supabase/supabase-js": "^2.49.1",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/pg": "8.11.0",
|
"@types/pg": "8.11.0",
|
||||||
"@types/sqlite3": "3.1.11",
|
"better-sqlite3": "^12.6.2",
|
||||||
"cloudflare": "^4.2.0",
|
"cloudflare": "^4.2.0",
|
||||||
"groq-sdk": "0.3.0",
|
"groq-sdk": "0.3.0",
|
||||||
"neo4j-driver": "^5.28.1",
|
"neo4j-driver": "^5.28.1",
|
||||||
"ollama": "^0.5.14",
|
"ollama": "^0.5.14",
|
||||||
"pg": "8.11.3",
|
"pg": "8.11.3",
|
||||||
"redis": "^4.6.13",
|
"redis": "^4.6.13"
|
||||||
"sqlite3": "5.1.7"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"sqlite3"
|
"better-sqlite3"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+54
-775
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@
|
|||||||
"@types/node": "^20.11.19",
|
"@types/node": "^20.11.19",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/redis": "^4.0.10",
|
"@types/redis": "^4.0.10",
|
||||||
"@types/sqlite3": "^3.1.11",
|
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"cloudflare": "^4.2.0",
|
"cloudflare": "^4.2.0",
|
||||||
"dotenv": "^16.4.4",
|
"dotenv": "^16.4.4",
|
||||||
@@ -27,7 +26,7 @@
|
|||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
"sqlite3": "^5.1.7",
|
"better-sqlite3": "^12.6.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class ConfigManager {
|
|||||||
? userConf.apiKey
|
? userConf.apiKey
|
||||||
: defaultConf.apiKey,
|
: defaultConf.apiKey,
|
||||||
model: finalModel,
|
model: finalModel,
|
||||||
|
baseURL: userConf?.baseURL,
|
||||||
url: userConf?.url,
|
url: userConf?.url,
|
||||||
embeddingDims: userConf?.embeddingDims,
|
embeddingDims: userConf?.embeddingDims,
|
||||||
modelProperties:
|
modelProperties:
|
||||||
@@ -43,13 +44,23 @@ export class ConfigManager {
|
|||||||
const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config;
|
const defaultConf = DEFAULT_MEMORY_CONFIG.vectorStore.config;
|
||||||
const userConf = userConfig.vectorStore?.config;
|
const userConf = userConfig.vectorStore?.config;
|
||||||
|
|
||||||
|
// Resolve the vector store dimension. If the user explicitly
|
||||||
|
// provided one, use it. Otherwise leave it undefined so that
|
||||||
|
// Memory._autoInitialize() can auto-detect it by running a
|
||||||
|
// probe embedding at startup — this makes *any* embedder work
|
||||||
|
// out of the box without the user needing to know or set the
|
||||||
|
// dimension manually.
|
||||||
|
const explicitDimension =
|
||||||
|
userConf?.dimension ||
|
||||||
|
userConfig.embedder?.config?.embeddingDims ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
// Prioritize user-provided client instance
|
// Prioritize user-provided client instance
|
||||||
if (userConf?.client && typeof userConf.client === "object") {
|
if (userConf?.client && typeof userConf.client === "object") {
|
||||||
return {
|
return {
|
||||||
client: userConf.client,
|
client: userConf.client,
|
||||||
// Include other fields from userConf if necessary, or omit defaults
|
collectionName: userConf.collectionName,
|
||||||
collectionName: userConf.collectionName, // Can be undefined
|
dimension: explicitDimension,
|
||||||
dimension: userConf.dimension || defaultConf.dimension, // Merge dimension
|
|
||||||
...userConf, // Include any other passthrough fields from user
|
...userConf, // Include any other passthrough fields from user
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +68,7 @@ export class ConfigManager {
|
|||||||
return {
|
return {
|
||||||
collectionName:
|
collectionName:
|
||||||
userConf?.collectionName || defaultConf.collectionName,
|
userConf?.collectionName || defaultConf.collectionName,
|
||||||
dimension: userConf?.dimension || defaultConf.dimension,
|
dimension: explicitDimension,
|
||||||
// Ensure client is not carried over from defaults if not provided by user
|
// Ensure client is not carried over from defaults if not provided by user
|
||||||
client: undefined,
|
client: undefined,
|
||||||
// Include other passthrough fields from userConf even if no client
|
// Include other passthrough fields from userConf even if no client
|
||||||
@@ -95,16 +106,34 @@ export class ConfigManager {
|
|||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
historyDbPath:
|
historyDbPath:
|
||||||
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.historyDbPath,
|
userConfig.historyDbPath ||
|
||||||
|
userConfig.historyStore?.config?.historyDbPath ||
|
||||||
|
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
|
||||||
customPrompt: userConfig.customPrompt,
|
customPrompt: userConfig.customPrompt,
|
||||||
graphStore: {
|
graphStore: {
|
||||||
...DEFAULT_MEMORY_CONFIG.graphStore,
|
...DEFAULT_MEMORY_CONFIG.graphStore,
|
||||||
...userConfig.graphStore,
|
...userConfig.graphStore,
|
||||||
},
|
},
|
||||||
historyStore: {
|
historyStore: (() => {
|
||||||
...DEFAULT_MEMORY_CONFIG.historyStore,
|
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
|
||||||
...userConfig.historyStore,
|
const historyProvider =
|
||||||
},
|
userConfig.historyStore?.provider || defaultHistoryStore.provider;
|
||||||
|
const isSqlite = historyProvider.toLowerCase() === "sqlite";
|
||||||
|
|
||||||
|
// Precedence: explicit historyStore.config > top-level historyDbPath > default
|
||||||
|
return {
|
||||||
|
...defaultHistoryStore,
|
||||||
|
...userConfig.historyStore,
|
||||||
|
provider: historyProvider,
|
||||||
|
config: {
|
||||||
|
...(isSqlite ? defaultHistoryStore.config : {}),
|
||||||
|
...(isSqlite && userConfig.historyDbPath
|
||||||
|
? { historyDbPath: userConfig.historyDbPath }
|
||||||
|
: {}),
|
||||||
|
...userConfig.historyStore?.config,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
disableHistory:
|
disableHistory:
|
||||||
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
|
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
|
||||||
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
|
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class GoogleEmbedder implements Embedder {
|
|||||||
const response = await this.google.models.embedContent({
|
const response = await this.google.models.embedContent({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
contents: texts,
|
contents: texts,
|
||||||
config: { outputDimensionality: 768 },
|
config: { outputDimensionality: this.embeddingDims },
|
||||||
});
|
});
|
||||||
return response.embeddings!.map((item) => item.values!);
|
return response.embeddings!.map((item) => item.values!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ export class OllamaEmbedder implements Embedder {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Error ensuring model exists: ${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({
|
const response = await this.ollama.embeddings({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
prompt: text,
|
prompt,
|
||||||
});
|
});
|
||||||
return response.embedding;
|
return response.embedding;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export class OpenAIEmbedder implements Embedder {
|
|||||||
private embeddingDims?: number;
|
private embeddingDims?: number;
|
||||||
|
|
||||||
constructor(config: EmbeddingConfig) {
|
constructor(config: EmbeddingConfig) {
|
||||||
this.openai = new OpenAI({ apiKey: config.apiKey });
|
this.openai = new OpenAI({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
baseURL: config.baseURL || config.url,
|
||||||
|
});
|
||||||
this.model = config.model || "text-embedding-3-small";
|
this.model = config.model || "text-embedding-3-small";
|
||||||
this.embeddingDims = config.embeddingDims || 1536;
|
this.embeddingDims = config.embeddingDims || 1536;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ Memory Format:
|
|||||||
source -- relationship -- destination
|
source -- relationship -- destination
|
||||||
|
|
||||||
Provide a list of deletion instructions, each specifying the relationship to be deleted.
|
Provide a list of deletion instructions, each specifying the relationship to be deleted.
|
||||||
|
|
||||||
|
Respond in JSON format.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function getDeleteMessages(
|
export function getDeleteMessages(
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export class MemoryGraph {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.`,
|
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: data },
|
{ role: "user", content: data },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
HistoryManagerFactory,
|
HistoryManagerFactory,
|
||||||
} from "../utils/factory";
|
} from "../utils/factory";
|
||||||
import {
|
import {
|
||||||
|
FactRetrievalSchema,
|
||||||
getFactRetrievalMessages,
|
getFactRetrievalMessages,
|
||||||
getUpdateMemoryMessages,
|
getUpdateMemoryMessages,
|
||||||
parseMessages,
|
parseMessages,
|
||||||
@@ -40,7 +41,7 @@ export class Memory {
|
|||||||
private config: MemoryConfig;
|
private config: MemoryConfig;
|
||||||
private customPrompt: string | undefined;
|
private customPrompt: string | undefined;
|
||||||
private embedder: Embedder;
|
private embedder: Embedder;
|
||||||
private vectorStore: VectorStore;
|
private vectorStore!: VectorStore;
|
||||||
private llm: LLM;
|
private llm: LLM;
|
||||||
private db: HistoryManager;
|
private db: HistoryManager;
|
||||||
private collectionName: string | undefined;
|
private collectionName: string | undefined;
|
||||||
@@ -48,6 +49,8 @@ export class Memory {
|
|||||||
private graphMemory?: MemoryGraph;
|
private graphMemory?: MemoryGraph;
|
||||||
private enableGraph: boolean;
|
private enableGraph: boolean;
|
||||||
telemetryId: string;
|
telemetryId: string;
|
||||||
|
private _initPromise: Promise<void>;
|
||||||
|
private _initError?: Error;
|
||||||
|
|
||||||
constructor(config: Partial<MemoryConfig> = {}) {
|
constructor(config: Partial<MemoryConfig> = {}) {
|
||||||
// Merge and validate config
|
// Merge and validate config
|
||||||
@@ -58,10 +61,9 @@ export class Memory {
|
|||||||
this.config.embedder.provider,
|
this.config.embedder.provider,
|
||||||
this.config.embedder.config,
|
this.config.embedder.config,
|
||||||
);
|
);
|
||||||
this.vectorStore = VectorStoreFactory.create(
|
// Vector store creation is deferred to _autoInitialize() so that
|
||||||
this.config.vectorStore.provider,
|
// the embedding dimension can be auto-detected first when not
|
||||||
this.config.vectorStore.config,
|
// explicitly configured.
|
||||||
);
|
|
||||||
this.llm = LLMFactory.create(
|
this.llm = LLMFactory.create(
|
||||||
this.config.llm.provider,
|
this.config.llm.provider,
|
||||||
this.config.llm.config,
|
this.config.llm.config,
|
||||||
@@ -69,20 +71,10 @@ export class Memory {
|
|||||||
if (this.config.disableHistory) {
|
if (this.config.disableHistory) {
|
||||||
this.db = new DummyHistoryManager();
|
this.db = new DummyHistoryManager();
|
||||||
} else {
|
} else {
|
||||||
const defaultConfig = {
|
this.db = HistoryManagerFactory.create(
|
||||||
provider: "sqlite",
|
this.config.historyStore!.provider,
|
||||||
config: {
|
this.config.historyStore!,
|
||||||
historyDbPath: this.config.historyDbPath || ":memory:",
|
);
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.db =
|
|
||||||
this.config.historyStore && !this.config.disableHistory
|
|
||||||
? HistoryManagerFactory.create(
|
|
||||||
this.config.historyStore.provider,
|
|
||||||
this.config.historyStore,
|
|
||||||
)
|
|
||||||
: HistoryManagerFactory.create("sqlite", defaultConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.collectionName = this.config.vectorStore.config.collectionName;
|
this.collectionName = this.config.vectorStore.config.collectionName;
|
||||||
@@ -95,8 +87,67 @@ export class Memory {
|
|||||||
this.graphMemory = new MemoryGraph(this.config);
|
this.graphMemory = new MemoryGraph(this.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize telemetry if vector store is initialized
|
// Auto-detect embedding dimension (if needed), create vector store,
|
||||||
this._initializeTelemetry();
|
// and initialize it. All public methods await this before proceeding.
|
||||||
|
this._initPromise = this._autoInitialize().catch((error) => {
|
||||||
|
this._initError =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error(this._initError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If no explicit dimension was provided, runs a probe embedding to
|
||||||
|
* detect it. Then creates and initializes the vector store.
|
||||||
|
*/
|
||||||
|
private async _autoInitialize(): Promise<void> {
|
||||||
|
if (!this.config.vectorStore.config.dimension) {
|
||||||
|
try {
|
||||||
|
const probe = await this.embedder.embed("dimension probe");
|
||||||
|
this.config.vectorStore.config.dimension = probe.length;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to auto-detect embedding dimension from provider '${this.config.embedder.provider}': ${error.message}. ` +
|
||||||
|
`Please set 'dimension' in vectorStore.config or 'embeddingDims' in embedder.config explicitly.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.vectorStore = VectorStoreFactory.create(
|
||||||
|
this.config.vectorStore.provider,
|
||||||
|
this.config.vectorStore.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The vector store constructor may fire initialize() asynchronously
|
||||||
|
// (e.g. Qdrant). Explicitly await it here to guarantee the backing
|
||||||
|
// store (collections, tables, etc.) is ready before any public method
|
||||||
|
// attempts to read or write.
|
||||||
|
await this.vectorStore.initialize();
|
||||||
|
|
||||||
|
await this._initializeTelemetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that auto-initialization (dimension detection + vector store
|
||||||
|
* creation) has completed before any public method proceeds.
|
||||||
|
* If a previous init attempt failed, retries automatically.
|
||||||
|
*/
|
||||||
|
private async _ensureInitialized(): Promise<void> {
|
||||||
|
await this._initPromise;
|
||||||
|
if (this._initError) {
|
||||||
|
// Clear failed state and retry — the embedder or vector store
|
||||||
|
// may have been transiently unavailable at startup.
|
||||||
|
this._initError = undefined;
|
||||||
|
this._initPromise = this._autoInitialize().catch((error) => {
|
||||||
|
this._initError =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error(this._initError);
|
||||||
|
});
|
||||||
|
await this._initPromise;
|
||||||
|
if (this._initError) {
|
||||||
|
throw this._initError;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _initializeTelemetry() {
|
private async _initializeTelemetry() {
|
||||||
@@ -156,6 +207,7 @@ export class Memory {
|
|||||||
messages: string | Message[],
|
messages: string | Message[],
|
||||||
config: AddMemoryOptions,
|
config: AddMemoryOptions,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("add", {
|
await this._captureEvent("add", {
|
||||||
message_count: Array.isArray(messages) ? messages.length : 1,
|
message_count: Array.isArray(messages) ? messages.length : 1,
|
||||||
has_metadata: !!config.metadata,
|
has_metadata: !!config.metadata,
|
||||||
@@ -261,7 +313,8 @@ export class Memory {
|
|||||||
const cleanResponse = removeCodeBlocks(response as string);
|
const cleanResponse = removeCodeBlocks(response as string);
|
||||||
let facts: string[] = [];
|
let facts: string[] = [];
|
||||||
try {
|
try {
|
||||||
facts = JSON.parse(cleanResponse).facts || [];
|
const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse));
|
||||||
|
facts = parsed.facts;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to parse facts from LLM response:",
|
"Failed to parse facts from LLM response:",
|
||||||
@@ -380,6 +433,7 @@ export class Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(memoryId: string): Promise<MemoryItem | null> {
|
async get(memoryId: string): Promise<MemoryItem | null> {
|
||||||
|
await this._ensureInitialized();
|
||||||
const memory = await this.vectorStore.get(memoryId);
|
const memory = await this.vectorStore.get(memoryId);
|
||||||
if (!memory) return null;
|
if (!memory) return null;
|
||||||
|
|
||||||
@@ -421,6 +475,7 @@ export class Memory {
|
|||||||
query: string,
|
query: string,
|
||||||
config: SearchMemoryOptions,
|
config: SearchMemoryOptions,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("search", {
|
await this._captureEvent("search", {
|
||||||
query_length: query.length,
|
query_length: query.length,
|
||||||
limit: config.limit,
|
limit: config.limit,
|
||||||
@@ -487,6 +542,7 @@ export class Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(memoryId: string, data: string): Promise<{ message: string }> {
|
async update(memoryId: string, data: string): Promise<{ message: string }> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("update", { memory_id: memoryId });
|
await this._captureEvent("update", { memory_id: memoryId });
|
||||||
const embedding = await this.embedder.embed(data);
|
const embedding = await this.embedder.embed(data);
|
||||||
await this.updateMemory(memoryId, data, { [data]: embedding });
|
await this.updateMemory(memoryId, data, { [data]: embedding });
|
||||||
@@ -494,6 +550,7 @@ export class Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(memoryId: string): Promise<{ message: string }> {
|
async delete(memoryId: string): Promise<{ message: string }> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("delete", { memory_id: memoryId });
|
await this._captureEvent("delete", { memory_id: memoryId });
|
||||||
await this.deleteMemory(memoryId);
|
await this.deleteMemory(memoryId);
|
||||||
return { message: "Memory deleted successfully!" };
|
return { message: "Memory deleted successfully!" };
|
||||||
@@ -502,6 +559,7 @@ export class Memory {
|
|||||||
async deleteAll(
|
async deleteAll(
|
||||||
config: DeleteAllMemoryOptions,
|
config: DeleteAllMemoryOptions,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("delete_all", {
|
await this._captureEvent("delete_all", {
|
||||||
has_user_id: !!config.userId,
|
has_user_id: !!config.userId,
|
||||||
has_agent_id: !!config.agentId,
|
has_agent_id: !!config.agentId,
|
||||||
@@ -529,10 +587,12 @@ export class Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async history(memoryId: string): Promise<any[]> {
|
async history(memoryId: string): Promise<any[]> {
|
||||||
|
await this._ensureInitialized();
|
||||||
return this.db.getHistory(memoryId);
|
return this.db.getHistory(memoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<void> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("reset");
|
await this._captureEvent("reset");
|
||||||
await this.db.reset();
|
await this.db.reset();
|
||||||
|
|
||||||
@@ -557,28 +617,30 @@ export class Memory {
|
|||||||
await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check?
|
await this.graphMemory.deleteAll({ userId: "default" }); // Assuming this is okay, or needs similar check?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize factories/clients based on the original config
|
// Re-initialize factories/clients based on the original config.
|
||||||
|
// Dimension is already set in this.config from the initial probe,
|
||||||
|
// so _autoInitialize will skip the probe and just re-create the store.
|
||||||
this.embedder = EmbedderFactory.create(
|
this.embedder = EmbedderFactory.create(
|
||||||
this.config.embedder.provider,
|
this.config.embedder.provider,
|
||||||
this.config.embedder.config,
|
this.config.embedder.config,
|
||||||
);
|
);
|
||||||
// Re-create vector store instance - crucial for Langchain to reset wrapper state if needed
|
|
||||||
this.vectorStore = VectorStoreFactory.create(
|
|
||||||
this.config.vectorStore.provider,
|
|
||||||
this.config.vectorStore.config, // This will pass the original client instance back
|
|
||||||
);
|
|
||||||
this.llm = LLMFactory.create(
|
this.llm = LLMFactory.create(
|
||||||
this.config.llm.provider,
|
this.config.llm.provider,
|
||||||
this.config.llm.config,
|
this.config.llm.config,
|
||||||
);
|
);
|
||||||
// Re-init DB if needed (though db.reset() likely handles its state)
|
|
||||||
// Re-init Graph if needed
|
|
||||||
|
|
||||||
// Re-initialize telemetry
|
// Re-create vector store via _autoInitialize (which handles dimension + creation)
|
||||||
this._initializeTelemetry();
|
this._initError = undefined;
|
||||||
|
this._initPromise = this._autoInitialize().catch((error) => {
|
||||||
|
this._initError =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error(this._initError);
|
||||||
|
});
|
||||||
|
await this._initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
|
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
|
||||||
|
await this._ensureInitialized();
|
||||||
await this._captureEvent("get_all", {
|
await this._captureEvent("get_all", {
|
||||||
limit: config.limit,
|
limit: config.limit,
|
||||||
has_user_id: !!config.userId,
|
has_user_id: !!config.userId,
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { z } from "zod";
|
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
|
// Define Zod schema for fact retrieval output
|
||||||
export const FactRetrievalSchema = z.object({
|
export const FactRetrievalSchema = z.object({
|
||||||
facts: z
|
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."),
|
.describe("An array of distinct facts extracted from the conversation."),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,5 +278,9 @@ export function parseMessages(messages: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeCodeBlocks(text: string): string {
|
export function removeCodeBlocks(text: string): string {
|
||||||
return text.replace(/```[^`]*```/g, "");
|
// Extract content inside code fences instead of deleting it.
|
||||||
|
// The old regex /```[^`]*```/g replaced the entire block (including
|
||||||
|
// its content) with an empty string, so when an LLM returned JSON
|
||||||
|
// wrapped in ```json ... ``` the actual payload was discarded.
|
||||||
|
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import sqlite3 from "sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { HistoryManager } from "./base";
|
import { HistoryManager } from "./base";
|
||||||
|
import { ensureSQLiteDirectory } from "../utils/sqlite";
|
||||||
|
|
||||||
export class SQLiteManager implements HistoryManager {
|
export class SQLiteManager implements HistoryManager {
|
||||||
private db: sqlite3.Database;
|
private db: Database.Database;
|
||||||
|
private stmtInsert!: Database.Statement;
|
||||||
|
private stmtSelect!: Database.Statement;
|
||||||
|
|
||||||
constructor(dbPath: string) {
|
constructor(dbPath: string) {
|
||||||
this.db = new sqlite3.Database(dbPath);
|
ensureSQLiteDirectory(dbPath);
|
||||||
this.init().catch(console.error);
|
this.db = new Database(dbPath);
|
||||||
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async init() {
|
private init(): void {
|
||||||
await this.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS memory_history (
|
CREATE TABLE IF NOT EXISTS memory_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
memory_id TEXT NOT NULL,
|
memory_id TEXT NOT NULL,
|
||||||
@@ -22,24 +26,14 @@ export class SQLiteManager implements HistoryManager {
|
|||||||
is_deleted INTEGER DEFAULT 0
|
is_deleted INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
}
|
this.stmtInsert = this.db.prepare(
|
||||||
|
`INSERT INTO memory_history
|
||||||
private async run(sql: string, params: any[] = []): Promise<void> {
|
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
||||||
return new Promise((resolve, reject) => {
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
this.db.run(sql, params, (err) => {
|
);
|
||||||
if (err) reject(err);
|
this.stmtSelect = this.db.prepare(
|
||||||
else resolve();
|
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
||||||
});
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addHistory(
|
async addHistory(
|
||||||
@@ -51,32 +45,24 @@ export class SQLiteManager implements HistoryManager {
|
|||||||
updatedAt?: string,
|
updatedAt?: string,
|
||||||
isDeleted: number = 0,
|
isDeleted: number = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.run(
|
this.stmtInsert.run(
|
||||||
`INSERT INTO memory_history
|
memoryId,
|
||||||
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
previousValue,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
newValue,
|
||||||
[
|
action,
|
||||||
memoryId,
|
createdAt ?? null,
|
||||||
previousValue,
|
updatedAt ?? null,
|
||||||
newValue,
|
isDeleted,
|
||||||
action,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
isDeleted,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(memoryId: string): Promise<any[]> {
|
async getHistory(memoryId: string): Promise<any[]> {
|
||||||
return this.all(
|
return this.stmtSelect.all(memoryId) as any[];
|
||||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
|
||||||
[memoryId],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<void> {
|
||||||
await this.run("DROP TABLE IF EXISTS memory_history");
|
this.db.exec("DROP TABLE IF EXISTS memory_history");
|
||||||
await this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* Backward-compatibility tests for SQLite path handling changes.
|
||||||
|
*
|
||||||
|
* These tests verify that every documented and common usage pattern
|
||||||
|
* from before the fix continues to work identically after the change.
|
||||||
|
*/
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { ConfigManager } from "../config/manager";
|
||||||
|
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||||
|
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||||
|
import {
|
||||||
|
ensureSQLiteDirectory,
|
||||||
|
getDefaultVectorStoreDbPath,
|
||||||
|
} from "../utils/sqlite";
|
||||||
|
|
||||||
|
function normalize(vector: number[]): number[] {
|
||||||
|
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||||
|
return vector.map((value) => value / norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Config merging – existing patterns must keep working
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("backward compat: ConfigManager.mergeConfig", () => {
|
||||||
|
it("empty config returns all expected defaults", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({});
|
||||||
|
|
||||||
|
expect(cfg.version).toBe("v1.1");
|
||||||
|
expect(cfg.embedder.provider).toBe("openai");
|
||||||
|
expect(cfg.vectorStore.provider).toBe("memory");
|
||||||
|
expect(cfg.vectorStore.config.collectionName).toBe("memories");
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(1536);
|
||||||
|
expect(cfg.llm.provider).toBe("openai");
|
||||||
|
expect(cfg.historyStore).toBeDefined();
|
||||||
|
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||||
|
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
|
||||||
|
expect(cfg.disableHistory).toBe(false);
|
||||||
|
expect(cfg.enableGraph).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workaround: explicit historyStore still works (existing user pattern)", () => {
|
||||||
|
// This is the documented workaround from all three issues
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyStore: {
|
||||||
|
provider: "sqlite",
|
||||||
|
config: { historyDbPath: "/tmp/workaround.db" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||||
|
expect(cfg.historyStore!.config.historyDbPath).toBe("/tmp/workaround.db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disableHistory: true still works", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({ disableHistory: true });
|
||||||
|
expect(cfg.disableHistory).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supabase historyStore config is preserved", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyStore: {
|
||||||
|
provider: "supabase",
|
||||||
|
config: {
|
||||||
|
supabaseUrl: "https://abc.supabase.co",
|
||||||
|
supabaseKey: "secret-key",
|
||||||
|
tableName: "custom_history",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.historyStore!.provider).toBe("supabase");
|
||||||
|
expect(cfg.historyStore!.config.supabaseUrl).toBe(
|
||||||
|
"https://abc.supabase.co",
|
||||||
|
);
|
||||||
|
expect(cfg.historyStore!.config.supabaseKey).toBe("secret-key");
|
||||||
|
expect(cfg.historyStore!.config.tableName).toBe("custom_history");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom embedder, llm, vectorStore configs pass through unchanged", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", url: "http://localhost:11434" },
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "llama3.1:8b" },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: {
|
||||||
|
collectionName: "test",
|
||||||
|
dimension: 768,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.embedder.provider).toBe("ollama");
|
||||||
|
expect(cfg.embedder.config.model).toBe("nomic-embed-text");
|
||||||
|
expect(cfg.llm.provider).toBe("ollama");
|
||||||
|
expect(cfg.llm.config.model).toBe("llama3.1:8b");
|
||||||
|
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||||
|
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("graphStore config passes through unchanged", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
enableGraph: true,
|
||||||
|
graphStore: {
|
||||||
|
provider: "neo4j",
|
||||||
|
config: {
|
||||||
|
url: "neo4j://custom:7687",
|
||||||
|
username: "admin",
|
||||||
|
password: "pass",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.enableGraph).toBe(true);
|
||||||
|
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customPrompt passes through unchanged", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
customPrompt: "You are a helpful assistant",
|
||||||
|
});
|
||||||
|
expect(cfg.customPrompt).toBe("You are a helpful assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version override passes through unchanged", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({ version: "v1.0" });
|
||||||
|
expect(cfg.version).toBe("v1.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. SQLiteManager – existing behavior preserved
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("backward compat: SQLiteManager", () => {
|
||||||
|
it("relative path still works (resolves from CWD)", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.chdir(tempDir);
|
||||||
|
const manager = new SQLiteManager("memory.db");
|
||||||
|
await manager.addHistory("m1", null, "value", "ADD");
|
||||||
|
const history = await manager.getHistory("m1");
|
||||||
|
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(fs.existsSync(path.join(tempDir, "memory.db"))).toBe(true);
|
||||||
|
manager.close();
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absolute path still works", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||||
|
const dbPath = path.join(tempDir, "history.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new SQLiteManager(dbPath);
|
||||||
|
await manager.addHistory("m1", null, "value", "ADD");
|
||||||
|
expect(fs.existsSync(dbPath)).toBe(true);
|
||||||
|
manager.close();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(":memory: still works", async () => {
|
||||||
|
const manager = new SQLiteManager(":memory:");
|
||||||
|
await manager.addHistory("m1", null, "value", "ADD");
|
||||||
|
const history = await manager.getHistory("m1");
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset clears history and allows re-use", async () => {
|
||||||
|
const manager = new SQLiteManager(":memory:");
|
||||||
|
await manager.addHistory("m1", null, "val", "ADD");
|
||||||
|
await manager.reset();
|
||||||
|
const history = await manager.getHistory("m1");
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
await manager.addHistory("m2", null, "new-val", "ADD");
|
||||||
|
const history2 = await manager.getHistory("m2");
|
||||||
|
expect(history2).toHaveLength(1);
|
||||||
|
manager.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. MemoryVectorStore – existing API preserved
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("backward compat: MemoryVectorStore", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit dbPath still works (the existing config.dbPath feature)", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "my_vectors.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||||
|
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]);
|
||||||
|
|
||||||
|
expect(fs.existsSync(dbPath)).toBe(true);
|
||||||
|
|
||||||
|
const result = await store.get("id1");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.payload.text).toBe("hello");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insert, search, get, update, delete, list all work", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "test.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||||
|
const v1 = normalize([1, 0, 0]);
|
||||||
|
const v2 = normalize([0, 1, 0]);
|
||||||
|
|
||||||
|
// insert
|
||||||
|
await store.insert([v1, v2], ["a", "b"], [{ t: "a" }, { t: "b" }]);
|
||||||
|
|
||||||
|
// get
|
||||||
|
const a = await store.get("a");
|
||||||
|
expect(a!.payload.t).toBe("a");
|
||||||
|
|
||||||
|
// search
|
||||||
|
const results = await store.search(v1, 2);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0].id).toBe("a"); // closest to v1
|
||||||
|
|
||||||
|
// update
|
||||||
|
await store.update("a", v2, { t: "updated" });
|
||||||
|
const updated = await store.get("a");
|
||||||
|
expect(updated!.payload.t).toBe("updated");
|
||||||
|
|
||||||
|
// list
|
||||||
|
const [listed, count] = await store.list();
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(listed).toHaveLength(2);
|
||||||
|
|
||||||
|
// delete
|
||||||
|
await store.delete("a");
|
||||||
|
const deleted = await store.get("a");
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
|
||||||
|
// deleteCol
|
||||||
|
await store.deleteCol();
|
||||||
|
const [afterDrop] = await store.list();
|
||||||
|
expect(afterDrop).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dimension mismatch on insert still throws", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "test.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||||
|
await expect(
|
||||||
|
store.insert([[1, 0]], ["id1"], [{ t: "x" }]),
|
||||||
|
).rejects.toThrow("Vector dimension mismatch");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dimension mismatch on search still throws", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "test.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||||
|
await expect(store.search([1, 0], 1)).rejects.toThrow(
|
||||||
|
"Query dimension mismatch",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default dimension is 1536 when not specified", () => {
|
||||||
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||||
|
try {
|
||||||
|
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||||
|
const store = new MemoryVectorStore({});
|
||||||
|
// Verify by trying to insert a 1536-dim vector
|
||||||
|
const vec = new Array(1536).fill(0);
|
||||||
|
vec[0] = 1;
|
||||||
|
expect(store.insert([vec], ["id1"], [{ t: "x" }])).resolves.not.toThrow();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search with filters still works", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "test.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||||
|
await store.insert(
|
||||||
|
[normalize([1, 0, 0]), normalize([0, 1, 0])],
|
||||||
|
["a", "b"],
|
||||||
|
[
|
||||||
|
{ text: "hello", userId: "user1" },
|
||||||
|
{ text: "world", userId: "user2" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await store.search(normalize([1, 0, 0]), 10, {
|
||||||
|
userId: "user2",
|
||||||
|
});
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe("b");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. VectorStoreConfig type – dbPath is optional, existing configs work
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("backward compat: VectorStoreConfig type", () => {
|
||||||
|
it("config without dbPath still works (no required field breakage)", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
vectorStore: {
|
||||||
|
provider: "memory",
|
||||||
|
config: { collectionName: "test", dimension: 512 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dbPath).toBeUndefined();
|
||||||
|
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(512);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("config with client instance passes through unchanged", () => {
|
||||||
|
const fakeClient = { connect: () => {} };
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { client: fakeClient, dimension: 768 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.client).toBe(fakeClient);
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. ensureSQLiteDirectory – does not break existing paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("backward compat: ensureSQLiteDirectory", () => {
|
||||||
|
it("no-ops for already existing directory", () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-existing-"));
|
||||||
|
try {
|
||||||
|
// Should not throw even though directory already exists
|
||||||
|
expect(() =>
|
||||||
|
ensureSQLiteDirectory(path.join(tempDir, "test.db")),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles path with trailing slash gracefully", () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-trailing-"));
|
||||||
|
try {
|
||||||
|
// path.dirname of "dir/sub/" is "dir/sub", mkdirSync should handle it
|
||||||
|
expect(() =>
|
||||||
|
ensureSQLiteDirectory(path.join(tempDir, "sub", "test.db")),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { ConfigManager } from "../config/manager";
|
||||||
|
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||||
|
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||||
|
import {
|
||||||
|
ensureSQLiteDirectory,
|
||||||
|
getDefaultVectorStoreDbPath,
|
||||||
|
} from "../utils/sqlite";
|
||||||
|
|
||||||
|
function normalize(vector: number[]): number[] {
|
||||||
|
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||||
|
return vector.map((value) => value / norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config merging – historyDbPath
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ConfigManager.mergeConfig – historyDbPath handling", () => {
|
||||||
|
it("propagates top-level historyDbPath into historyStore.config", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyDbPath: "/tmp/custom/history.db",
|
||||||
|
});
|
||||||
|
expect(cfg.historyDbPath).toBe("/tmp/custom/history.db");
|
||||||
|
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBe(
|
||||||
|
"/tmp/custom/history.db",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit historyStore.config.historyDbPath takes precedence over top-level", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyDbPath: "/tmp/shorthand.db",
|
||||||
|
historyStore: {
|
||||||
|
provider: "sqlite",
|
||||||
|
config: { historyDbPath: "/tmp/explicit.db" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/explicit.db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves default memory.db when nothing is provided", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({});
|
||||||
|
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBe("memory.db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects only historyStore.config when top-level is absent", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyStore: {
|
||||||
|
provider: "sqlite",
|
||||||
|
config: { historyDbPath: "/tmp/nested-only.db" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/nested-only.db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak historyDbPath into non-sqlite providers", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
historyDbPath: "/tmp/should-not-apply.db",
|
||||||
|
historyStore: {
|
||||||
|
provider: "supabase",
|
||||||
|
config: {
|
||||||
|
supabaseUrl: "https://x.supabase.co",
|
||||||
|
supabaseKey: "key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cfg.historyStore?.provider).toBe("supabase");
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disableHistory does not prevent historyStore config from merging", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
disableHistory: true,
|
||||||
|
historyDbPath: "/tmp/disabled.db",
|
||||||
|
});
|
||||||
|
expect(cfg.disableHistory).toBe(true);
|
||||||
|
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/disabled.db");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SQLiteManager – directory creation & DB operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SQLiteManager – directory auto-creation", () => {
|
||||||
|
it("creates nested parent directories and writes to the DB", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-sqlite-"));
|
||||||
|
const dbPath = path.join(tempDir, "a", "b", "c", "history.db");
|
||||||
|
let manager: SQLiteManager | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager = new SQLiteManager(dbPath);
|
||||||
|
await manager.addHistory("mem-1", null, "test value", "ADD");
|
||||||
|
const history = await manager.getHistory("mem-1");
|
||||||
|
|
||||||
|
expect(fs.existsSync(dbPath)).toBe(true);
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0].new_value).toBe("test value");
|
||||||
|
} finally {
|
||||||
|
manager?.close();
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end-to-end: mergeConfig + SQLiteManager at configured path", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "mem0-history-path-"),
|
||||||
|
);
|
||||||
|
const historyDbPath = path.join(tempDir, "nested", "history.db");
|
||||||
|
let manager: SQLiteManager | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mergedConfig = ConfigManager.mergeConfig({ historyDbPath });
|
||||||
|
|
||||||
|
manager = new SQLiteManager(
|
||||||
|
mergedConfig.historyStore!.config.historyDbPath!,
|
||||||
|
);
|
||||||
|
await manager.addHistory("memory-1", null, "remember me", "ADD");
|
||||||
|
|
||||||
|
expect(fs.existsSync(historyDbPath)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
manager?.close();
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with :memory: without attempting directory creation", () => {
|
||||||
|
const manager = new SQLiteManager(":memory:");
|
||||||
|
expect(manager).toBeDefined();
|
||||||
|
manager.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MemoryVectorStore – path handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("MemoryVectorStore – path handling", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ~/.mem0/vector_store.db by default", () => {
|
||||||
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||||
|
try {
|
||||||
|
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||||
|
new MemoryVectorStore({ dimension: 4 });
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||||
|
).toBe(true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit dbPath config", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||||
|
const dbPath = path.join(tempDir, "custom", "vectors.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = new MemoryVectorStore({ dimension: 4, dbPath });
|
||||||
|
await store.insert(
|
||||||
|
[normalize([1, 0, 0, 0])],
|
||||||
|
["v1"],
|
||||||
|
[{ text: "hello" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.existsSync(dbPath)).toBe(true);
|
||||||
|
const results = await store.search(normalize([1, 0, 0, 0]), 1);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].payload.text).toBe("hello");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when CWD is read-only", async () => {
|
||||||
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||||
|
const readOnlyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ro-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.chmodSync(readOnlyCwd, 0o555);
|
||||||
|
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||||
|
process.chdir(readOnlyCwd);
|
||||||
|
|
||||||
|
const store = new MemoryVectorStore({ dimension: 4 });
|
||||||
|
await store.insert(
|
||||||
|
[normalize([0, 1, 0, 0])],
|
||||||
|
["v2"],
|
||||||
|
[{ text: "works" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||||
|
).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(readOnlyCwd, 0o755);
|
||||||
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(readOnlyCwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits migration warning when old CWD-based vector_store.db exists", () => {
|
||||||
|
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||||
|
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||||
|
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||||
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
process.chdir(tempCwd);
|
||||||
|
|
||||||
|
new MemoryVectorStore({ dimension: 4 });
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Default vector_store.db location changed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT emit migration warning when dbPath is explicitly set", () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||||
|
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||||
|
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
process.chdir(tempCwd);
|
||||||
|
|
||||||
|
new MemoryVectorStore({
|
||||||
|
dimension: 4,
|
||||||
|
dbPath: path.join(tempDir, "explicit.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utils
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ensureSQLiteDirectory", () => {
|
||||||
|
it("creates nested directories", () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ensure-"));
|
||||||
|
const target = path.join(tempDir, "x", "y", "z", "test.db");
|
||||||
|
try {
|
||||||
|
ensureSQLiteDirectory(target);
|
||||||
|
expect(fs.existsSync(path.join(tempDir, "x", "y", "z"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips :memory:", () => {
|
||||||
|
expect(() => ensureSQLiteDirectory(":memory:")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips file: URIs", () => {
|
||||||
|
expect(() => ensureSQLiteDirectory("file::memory:")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty string", () => {
|
||||||
|
expect(() => ensureSQLiteDirectory("")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultVectorStoreDbPath", () => {
|
||||||
|
it("returns path under homedir/.mem0", () => {
|
||||||
|
const result = getDefaultVectorStoreDbPath();
|
||||||
|
expect(result).toBe(path.join(os.homedir(), ".mem0", "vector_store.db"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ export interface Message {
|
|||||||
export interface EmbeddingConfig {
|
export interface EmbeddingConfig {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string | any;
|
model?: string | any;
|
||||||
|
baseURL?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
embeddingDims?: number;
|
embeddingDims?: number;
|
||||||
modelProperties?: Record<string, any>;
|
modelProperties?: Record<string, any>;
|
||||||
@@ -23,6 +24,7 @@ export interface EmbeddingConfig {
|
|||||||
export interface VectorStoreConfig {
|
export interface VectorStoreConfig {
|
||||||
collectionName?: string;
|
collectionName?: string;
|
||||||
dimension?: number;
|
dimension?: number;
|
||||||
|
dbPath?: string;
|
||||||
client?: any;
|
client?: any;
|
||||||
instance?: any;
|
instance?: any;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -129,6 +131,7 @@ export const MemoryConfigSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
collectionName: z.string().optional(),
|
collectionName: z.string().optional(),
|
||||||
dimension: z.number().optional(),
|
dimension: z.number().optional(),
|
||||||
|
dbPath: z.string().optional(),
|
||||||
client: z.any().optional(),
|
client: z.any().optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export function getDefaultVectorStoreDbPath(): string {
|
||||||
|
return path.join(os.homedir(), ".mem0", "vector_store.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSQLiteDirectory(dbPath: string): void {
|
||||||
|
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ export class AzureAISearch implements VectorStore {
|
|||||||
private readonly hybridSearch: boolean;
|
private readonly hybridSearch: boolean;
|
||||||
private readonly vectorFilterMode: string;
|
private readonly vectorFilterMode: string;
|
||||||
private readonly apiKey: string | undefined;
|
private readonly apiKey: string | undefined;
|
||||||
|
private _initPromise?: Promise<void>;
|
||||||
|
|
||||||
constructor(config: AzureAISearchConfig) {
|
constructor(config: AzureAISearchConfig) {
|
||||||
this.serviceName = config.serviceName;
|
this.serviceName = config.serviceName;
|
||||||
@@ -117,6 +118,13 @@ export class AzureAISearch implements VectorStore {
|
|||||||
* Initialize the Azure AI Search index if it doesn't exist
|
* Initialize the Azure AI Search index if it doesn't exist
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
if (!this._initPromise) {
|
||||||
|
this._initPromise = this._doInitialize();
|
||||||
|
}
|
||||||
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const collections = await this.listCols();
|
const collections = await this.listCols();
|
||||||
if (!collections.includes(this.indexName)) {
|
if (!collections.includes(this.indexName)) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { VectorStore } from "./base";
|
import { VectorStore } from "./base";
|
||||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||||
import sqlite3 from "sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import {
|
||||||
|
ensureSQLiteDirectory,
|
||||||
|
getDefaultVectorStoreDbPath,
|
||||||
|
} from "../utils/sqlite";
|
||||||
|
|
||||||
interface MemoryVector {
|
interface MemoryVector {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,22 +15,31 @@ interface MemoryVector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MemoryVectorStore implements VectorStore {
|
export class MemoryVectorStore implements VectorStore {
|
||||||
private db: sqlite3.Database;
|
private db: Database.Database;
|
||||||
private dimension: number;
|
private dimension: number;
|
||||||
private dbPath: string;
|
private dbPath: string;
|
||||||
|
|
||||||
constructor(config: VectorStoreConfig) {
|
constructor(config: VectorStoreConfig) {
|
||||||
this.dimension = config.dimension || 1536; // Default OpenAI dimension
|
this.dimension = config.dimension || 1536; // Default OpenAI dimension
|
||||||
this.dbPath = path.join(process.cwd(), "vector_store.db");
|
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
|
||||||
if (config.dbPath) {
|
|
||||||
this.dbPath = config.dbPath;
|
if (!config.dbPath) {
|
||||||
|
const oldDefault = path.join(process.cwd(), "vector_store.db");
|
||||||
|
if (fs.existsSync(oldDefault) && oldDefault !== this.dbPath) {
|
||||||
|
console.warn(
|
||||||
|
`[mem0] Default vector_store.db location changed from ${oldDefault} to ${this.dbPath}. ` +
|
||||||
|
`Move your existing file or set vectorStore.config.dbPath explicitly.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.db = new sqlite3.Database(this.dbPath);
|
|
||||||
this.init().catch(console.error);
|
ensureSQLiteDirectory(this.dbPath);
|
||||||
|
this.db = new Database(this.dbPath);
|
||||||
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async init() {
|
private init(): void {
|
||||||
await this.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS vectors (
|
CREATE TABLE IF NOT EXISTS vectors (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
vector BLOB NOT NULL,
|
vector BLOB NOT NULL,
|
||||||
@@ -33,7 +47,7 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await this.run(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS memory_migrations (
|
CREATE TABLE IF NOT EXISTS memory_migrations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL UNIQUE
|
user_id TEXT NOT NULL UNIQUE
|
||||||
@@ -41,33 +55,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 {
|
private cosineSimilarity(a: number[], b: number[]): number {
|
||||||
let dotProduct = 0;
|
let dotProduct = 0;
|
||||||
let normA = 0;
|
let normA = 0;
|
||||||
@@ -92,18 +79,23 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
ids: string[],
|
ids: string[],
|
||||||
payloads: Record<string, any>[],
|
payloads: Record<string, any>[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (let i = 0; i < vectors.length; i++) {
|
const stmt = this.db.prepare(
|
||||||
if (vectors[i].length !== this.dimension) {
|
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
||||||
throw new Error(
|
);
|
||||||
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
|
const insertMany = this.db.transaction(
|
||||||
);
|
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
|
||||||
}
|
for (let i = 0; i < vecs.length; i++) {
|
||||||
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
|
if (vecs[i].length !== this.dimension) {
|
||||||
await this.run(
|
throw new Error(
|
||||||
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
|
||||||
[ids[i], vectorBuffer, JSON.stringify(payloads[i])],
|
);
|
||||||
);
|
}
|
||||||
}
|
const vectorBuffer = Buffer.from(new Float32Array(vecs[i]).buffer);
|
||||||
|
stmt.run(vIds[i], vectorBuffer, JSON.stringify(vPayloads[i]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
insertMany(vectors, ids, payloads);
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(
|
async search(
|
||||||
@@ -117,11 +109,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[] = [];
|
const results: VectorStoreResult[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
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 payload = JSON.parse(row.payload);
|
||||||
const memoryVector: MemoryVector = {
|
const memoryVector: MemoryVector = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -144,9 +140,9 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||||
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
|
const row = this.db
|
||||||
vectorId,
|
.prepare(`SELECT * FROM vectors WHERE id = ?`)
|
||||||
]);
|
.get(vectorId) as any;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const payload = JSON.parse(row.payload);
|
const payload = JSON.parse(row.payload);
|
||||||
@@ -167,34 +163,38 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
|
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
|
||||||
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
|
this.db
|
||||||
vectorBuffer,
|
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
|
||||||
JSON.stringify(payload),
|
.run(vectorBuffer, JSON.stringify(payload), vectorId);
|
||||||
vectorId,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(vectorId: string): Promise<void> {
|
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> {
|
async deleteCol(): Promise<void> {
|
||||||
await this.run(`DROP TABLE IF EXISTS vectors`);
|
this.db.exec(`DROP TABLE IF EXISTS vectors`);
|
||||||
await this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
filters?: SearchFilters,
|
filters?: SearchFilters,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
): Promise<[VectorStoreResult[], number]> {
|
): 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[] = [];
|
const results: VectorStoreResult[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const payload = JSON.parse(row.payload);
|
const payload = JSON.parse(row.payload);
|
||||||
const memoryVector: MemoryVector = {
|
const memoryVector: MemoryVector = {
|
||||||
id: row.id,
|
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,
|
payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUserId(): Promise<string> {
|
async getUserId(): Promise<string> {
|
||||||
const row = await this.getOne(
|
const row = this.db
|
||||||
`SELECT user_id FROM memory_migrations LIMIT 1`,
|
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
|
||||||
);
|
.get() as any;
|
||||||
if (row) {
|
if (row) {
|
||||||
return row.user_id;
|
return row.user_id;
|
||||||
}
|
}
|
||||||
@@ -221,20 +221,20 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
const randomUserId =
|
const randomUserId =
|
||||||
Math.random().toString(36).substring(2, 15) +
|
Math.random().toString(36).substring(2, 15) +
|
||||||
Math.random().toString(36).substring(2, 15);
|
Math.random().toString(36).substring(2, 15);
|
||||||
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
this.db
|
||||||
randomUserId,
|
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
||||||
]);
|
.run(randomUserId);
|
||||||
return randomUserId;
|
return randomUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setUserId(userId: string): Promise<void> {
|
async setUserId(userId: string): Promise<void> {
|
||||||
await this.run(`DELETE FROM memory_migrations`);
|
this.db.prepare(`DELETE FROM memory_migrations`).run();
|
||||||
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
this.db
|
||||||
userId,
|
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
||||||
]);
|
.run(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
await this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class Qdrant implements VectorStore {
|
|||||||
private client: QdrantClient;
|
private client: QdrantClient;
|
||||||
private readonly collectionName: string;
|
private readonly collectionName: string;
|
||||||
private dimension: number;
|
private dimension: number;
|
||||||
|
private _initPromise?: Promise<void>;
|
||||||
|
|
||||||
constructor(config: QdrantConfig) {
|
constructor(config: QdrantConfig) {
|
||||||
if (config.client) {
|
if (config.client) {
|
||||||
@@ -211,22 +212,8 @@ export class Qdrant implements VectorStore {
|
|||||||
|
|
||||||
async getUserId(): Promise<string> {
|
async getUserId(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// First check if the collection exists
|
// Ensure collection exists (idempotent — handles race conditions)
|
||||||
const collections = await this.client.getCollections();
|
await this.ensureCollection("memory_migrations", 1);
|
||||||
const userCollectionExists = collections.collections.some(
|
|
||||||
(col: { name: string }) => col.name === "memory_migrations",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!userCollectionExists) {
|
|
||||||
// Create the collection if it doesn't exist
|
|
||||||
await this.client.createCollection("memory_migrations", {
|
|
||||||
vectors: {
|
|
||||||
size: 1,
|
|
||||||
distance: "Cosine",
|
|
||||||
on_disk: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now try to get the user ID
|
// Now try to get the user ID
|
||||||
const result = await this.client.scroll("memory_migrations", {
|
const result = await this.client.scroll("memory_migrations", {
|
||||||
@@ -286,66 +273,58 @@ export class Qdrant implements VectorStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
private async ensureCollection(name: string, size: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Create collection if it doesn't exist
|
await this.client.createCollection(name, {
|
||||||
const collections = await this.client.getCollections();
|
vectors: {
|
||||||
const exists = collections.collections.some(
|
size,
|
||||||
(c) => c.name === this.collectionName,
|
distance: "Cosine",
|
||||||
);
|
},
|
||||||
|
});
|
||||||
if (!exists) {
|
} catch (error: any) {
|
||||||
try {
|
if (error?.status === 409) {
|
||||||
await this.client.createCollection(this.collectionName, {
|
// Collection already exists — verify configuration for the main collection
|
||||||
vectors: {
|
if (name === this.collectionName) {
|
||||||
size: this.dimension,
|
try {
|
||||||
distance: "Cosine",
|
const collectionInfo = await this.client.getCollection(name);
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle case where collection was created between our check and create
|
|
||||||
if (error?.status === 409) {
|
|
||||||
// Collection already exists - verify it has the correct configuration
|
|
||||||
const collectionInfo = await this.client.getCollection(
|
|
||||||
this.collectionName,
|
|
||||||
);
|
|
||||||
const vectorConfig = collectionInfo.config?.params?.vectors;
|
const vectorConfig = collectionInfo.config?.params?.vectors;
|
||||||
|
|
||||||
if (!vectorConfig || vectorConfig.size !== this.dimension) {
|
if (vectorConfig && vectorConfig.size !== size) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Collection ${this.collectionName} exists but has wrong configuration. ` +
|
`Collection ${name} exists but has wrong vector size. ` +
|
||||||
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
|
`Expected: ${size}, got: ${vectorConfig.size}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Collection exists with correct configuration - we can proceed
|
} catch (verifyError: any) {
|
||||||
} else {
|
// Re-throw dimension mismatch errors
|
||||||
throw error;
|
if (verifyError?.message?.includes("wrong vector size")) {
|
||||||
|
throw verifyError;
|
||||||
|
}
|
||||||
|
// Transient errors (e.g. 500 while collection is being committed)
|
||||||
|
// are non-fatal — the collection exists per the 409.
|
||||||
|
console.warn(
|
||||||
|
`Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Otherwise collection exists and is fine — proceed
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create memory_migrations collection if it doesn't exist
|
async initialize(): Promise<void> {
|
||||||
const userExists = collections.collections.some(
|
if (!this._initPromise) {
|
||||||
(c) => c.name === "memory_migrations",
|
this._initPromise = this._doInitialize();
|
||||||
);
|
}
|
||||||
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
if (!userExists) {
|
private async _doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.client.createCollection("memory_migrations", {
|
await this.ensureCollection(this.collectionName, this.dimension);
|
||||||
vectors: {
|
await this.ensureCollection("memory_migrations", 1);
|
||||||
size: 1, // Minimal size since we only store user_id
|
|
||||||
distance: "Cosine",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// Handle case where collection was created between our check and create
|
|
||||||
if (error?.status === 409) {
|
|
||||||
// Collection already exists - we can proceed
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing Qdrant:", error);
|
console.error("Error initializing Qdrant:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export class RedisDB implements VectorStore {
|
|||||||
private readonly indexName: string;
|
private readonly indexName: string;
|
||||||
private readonly indexPrefix: string;
|
private readonly indexPrefix: string;
|
||||||
private readonly schema: RedisSchema;
|
private readonly schema: RedisSchema;
|
||||||
|
private _initPromise?: Promise<void>;
|
||||||
|
|
||||||
constructor(config: RedisConfig) {
|
constructor(config: RedisConfig) {
|
||||||
this.indexName = config.collectionName;
|
this.indexName = config.collectionName;
|
||||||
@@ -240,6 +241,13 @@ export class RedisDB implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
if (!this._initPromise) {
|
||||||
|
this._initPromise = this._doInitialize();
|
||||||
|
}
|
||||||
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
console.log("Connected to Redis");
|
console.log("Connected to Redis");
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class SupabaseDB implements VectorStore {
|
|||||||
private readonly tableName: string;
|
private readonly tableName: string;
|
||||||
private readonly embeddingColumnName: string;
|
private readonly embeddingColumnName: string;
|
||||||
private readonly metadataColumnName: string;
|
private readonly metadataColumnName: string;
|
||||||
|
private _initPromise?: Promise<void>;
|
||||||
|
|
||||||
constructor(config: SupabaseConfig) {
|
constructor(config: SupabaseConfig) {
|
||||||
this.client = createClient(config.supabaseUrl, config.supabaseKey);
|
this.client = createClient(config.supabaseUrl, config.supabaseKey);
|
||||||
@@ -100,6 +101,13 @@ export class SupabaseDB implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
if (!this._initPromise) {
|
||||||
|
this._initPromise = this._doInitialize();
|
||||||
|
}
|
||||||
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Verify table exists and vector operations work by attempting a test insert
|
// Verify table exists and vector operations work by attempting a test insert
|
||||||
const testVector = Array(1536).fill(0);
|
const testVector = Array(1536).fill(0);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class VectorizeDB implements VectorStore {
|
|||||||
private dimensions: number;
|
private dimensions: number;
|
||||||
private indexName: string;
|
private indexName: string;
|
||||||
private accountId: string;
|
private accountId: string;
|
||||||
|
private _initPromise?: Promise<void>;
|
||||||
|
|
||||||
constructor(config: VectorizeConfig) {
|
constructor(config: VectorizeConfig) {
|
||||||
this.client = new Cloudflare({ apiToken: config.apiKey });
|
this.client = new Cloudflare({ apiToken: config.apiKey });
|
||||||
@@ -343,6 +344,13 @@ export class VectorizeDB implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
if (!this._initPromise) {
|
||||||
|
this._initPromise = this._doInitialize();
|
||||||
|
}
|
||||||
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check if the index already exists
|
// Check if the index already exists
|
||||||
let indexFound = false;
|
let indexFound = false;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/// <reference types="jest" />
|
||||||
|
import { ConfigManager } from "../src/config/manager";
|
||||||
|
|
||||||
|
describe("ConfigManager", () => {
|
||||||
|
describe("mergeConfig - dimension handling", () => {
|
||||||
|
const baseLlm = {
|
||||||
|
provider: "openai",
|
||||||
|
config: { apiKey: "test-key" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
|
||||||
|
const config = ConfigManager.mergeConfig({
|
||||||
|
embedder: { provider: "openai", config: { apiKey: "test-key" } },
|
||||||
|
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dimension should be undefined so Memory._autoInitialize() will
|
||||||
|
// auto-detect it via a probe embedding at runtime.
|
||||||
|
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use embeddingDims from embedder config when provided", () => {
|
||||||
|
const config = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prefer explicit vector store dimension over embedder dims", () => {
|
||||||
|
const config = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "test", dimension: 1024 },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.vectorStore.config.dimension).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should leave dimension undefined when using a custom client without explicit dims", () => {
|
||||||
|
const mockClient = { someMethod: () => {} };
|
||||||
|
const config = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text" },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "test", client: mockClient },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No embeddingDims and no explicit dimension → should be undefined
|
||||||
|
// for auto-detection at runtime.
|
||||||
|
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use embeddingDims when using a custom client", () => {
|
||||||
|
const mockClient = { someMethod: () => {} };
|
||||||
|
const config = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "test", client: mockClient },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
/// <reference types="jest" />
|
||||||
|
/**
|
||||||
|
* Tests for embedding dimension auto-detection.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - ConfigManager: dimension resolution logic
|
||||||
|
* - Memory class: probe-based auto-detection, lazy init gate, backward compat
|
||||||
|
* - MemoryVectorStore: backward compat with explicit dimensions
|
||||||
|
* - Explicit error messages on probe failure
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConfigManager } from "../src/config/manager";
|
||||||
|
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. ConfigManager – dimension resolution
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describe("ConfigManager – dimension resolution", () => {
|
||||||
|
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||||
|
|
||||||
|
it("leaves dimension undefined when nothing explicit is set", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
vectorStore: { provider: "memory", config: { collectionName: "t" } },
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses embeddingDims from embedder config", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers explicit vectorStore.dimension over embeddingDims", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "t", dimension: 1024 },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves dimension undefined for custom client without explicit dims", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "t", client: {} },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses embeddingDims with a custom client", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: { collectionName: "t", client: {} },
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all other vectorStore config fields", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: {
|
||||||
|
collectionName: "my-coll",
|
||||||
|
host: "my-host",
|
||||||
|
port: 6333,
|
||||||
|
apiKey: "qdrant-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.collectionName).toBe("my-coll");
|
||||||
|
expect(cfg.vectorStore.config.host).toBe("my-host");
|
||||||
|
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||||
|
expect(cfg.vectorStore.config.apiKey).toBe("qdrant-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves dimension undefined with empty config", () => {
|
||||||
|
const cfg = ConfigManager.mergeConfig({
|
||||||
|
embedder: { provider: "openai", config: {} },
|
||||||
|
vectorStore: { provider: "memory", config: {} },
|
||||||
|
llm: baseLlm,
|
||||||
|
});
|
||||||
|
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. MemoryVectorStore – backward compat with explicit dimensions
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describe("MemoryVectorStore – backward compat", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to dimension 1536 when not specified", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const vector = new Array(1536).fill(0.1);
|
||||||
|
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||||
|
const result = await store.get("id-1");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit dimension=1536 still works", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dimension: 1536,
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const vector = new Array(1536).fill(0.1);
|
||||||
|
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||||
|
const result = await store.get("id-1");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit dimension rejects mismatched vectors", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dimension: 1536,
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrongVector = new Array(768).fill(0.1);
|
||||||
|
await expect(
|
||||||
|
store.insert([wrongVector], ["id-1"], [{ data: "hello" }]),
|
||||||
|
).rejects.toThrow("Vector dimension mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search validates dimension", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dimension: 4,
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.search([1, 2, 3], 1)).rejects.toThrow(
|
||||||
|
"Query dimension mismatch",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom dimension=768 works end-to-end", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dimension: 768,
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[
|
||||||
|
[1, ...new Array(767).fill(0)],
|
||||||
|
[0, 1, ...new Array(766).fill(0)],
|
||||||
|
],
|
||||||
|
["a", "b"],
|
||||||
|
[{ data: "alpha" }, { data: "beta" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await store.search([1, ...new Array(767).fill(0)], 2);
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
expect(results[0].id).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getUserId and setUserId still work", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = await store.getUserId();
|
||||||
|
expect(typeof userId).toBe("string");
|
||||||
|
expect(userId.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await store.setUserId("custom-user");
|
||||||
|
const newUserId = await store.getUserId();
|
||||||
|
expect(newUserId).toBe("custom-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initialize() is idempotent", async () => {
|
||||||
|
const store = new MemoryVectorStore({
|
||||||
|
collectionName: "test",
|
||||||
|
dbPath: path.join(tmpDir, "vs.db"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.initialize();
|
||||||
|
await store.initialize();
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. Memory class – auto-init with probe, lazy gate, backward compat
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describe("Memory – auto-initialization", () => {
|
||||||
|
let mockEmbedderFactory: any;
|
||||||
|
let mockVectorStoreFactory: any;
|
||||||
|
let mockLlmFactory: any;
|
||||||
|
let mockHistoryFactory: any;
|
||||||
|
let MemoryClass: any;
|
||||||
|
|
||||||
|
function createMockEmbedder(dims: number) {
|
||||||
|
return {
|
||||||
|
embed: jest.fn().mockResolvedValue(new Array(dims).fill(0)),
|
||||||
|
embedBatch: jest.fn().mockResolvedValue([new Array(dims).fill(0)]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockVectorStore() {
|
||||||
|
return {
|
||||||
|
insert: jest.fn().mockResolvedValue(undefined),
|
||||||
|
search: jest.fn().mockResolvedValue([]),
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||||
|
list: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||||
|
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||||
|
initialize: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
const mockEmbedder = createMockEmbedder(768);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
|
||||||
|
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||||
|
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||||
|
mockLlmFactory = {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockHistoryFactory = {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
reset: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.doMock("../src/utils/factory", () => ({
|
||||||
|
EmbedderFactory: mockEmbedderFactory,
|
||||||
|
VectorStoreFactory: mockVectorStoreFactory,
|
||||||
|
LLMFactory: mockLlmFactory,
|
||||||
|
HistoryManagerFactory: mockHistoryFactory,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock("../src/utils/telemetry", () => ({
|
||||||
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
MemoryClass = require("../src/memory").Memory;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes embedder to detect dimension when none set", async () => {
|
||||||
|
const mockEmbedder = createMockEmbedder(768);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "u1" });
|
||||||
|
|
||||||
|
// Should have called embed("dimension probe") to detect dimension
|
||||||
|
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||||
|
|
||||||
|
// VectorStoreFactory should have been called with detected dimension
|
||||||
|
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||||
|
expect(vsCreateCall[1].dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips probe when explicit dimension provided", async () => {
|
||||||
|
const mockEmbedder = createMockEmbedder(1536);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
vectorStore: {
|
||||||
|
provider: "memory",
|
||||||
|
config: { collectionName: "test", dimension: 1536 },
|
||||||
|
},
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "u1" });
|
||||||
|
|
||||||
|
// embed should NOT have been called for probing
|
||||||
|
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||||
|
|
||||||
|
// VectorStoreFactory gets the explicit dimension
|
||||||
|
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||||
|
expect(vsCreateCall[1].dimension).toBe(1536);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips probe when embeddingDims provided", async () => {
|
||||||
|
const mockEmbedder = createMockEmbedder(768);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "u1" });
|
||||||
|
|
||||||
|
// ConfigManager resolves dimension from embeddingDims → no probe needed
|
||||||
|
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all public methods wait for initialization", async () => {
|
||||||
|
let resolveProbe: () => void;
|
||||||
|
let probeCallCount = 0;
|
||||||
|
const mockEmbedder = {
|
||||||
|
embed: jest.fn().mockImplementation(() => {
|
||||||
|
probeCallCount++;
|
||||||
|
if (probeCallCount === 1) {
|
||||||
|
// First call is the dimension probe — hang until manually resolved
|
||||||
|
return new Promise<number[]>((resolve) => {
|
||||||
|
resolveProbe = () => resolve(new Array(768).fill(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Subsequent calls (from search, etc.) resolve immediately
|
||||||
|
return Promise.resolve(new Array(768).fill(0));
|
||||||
|
}),
|
||||||
|
embedBatch: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: { provider: "ollama", config: { model: "test" } },
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let getAllDone = false;
|
||||||
|
let searchDone = false;
|
||||||
|
let getDone = false;
|
||||||
|
|
||||||
|
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
|
||||||
|
const searchP = mem
|
||||||
|
.search("q", { userId: "u" })
|
||||||
|
.then(() => (searchDone = true));
|
||||||
|
const getP = mem.get("id").then(() => (getDone = true));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(getAllDone).toBe(false);
|
||||||
|
expect(searchDone).toBe(false);
|
||||||
|
expect(getDone).toBe(false);
|
||||||
|
|
||||||
|
// Resolve the probe — init completes — methods unblock
|
||||||
|
resolveProbe!();
|
||||||
|
await Promise.all([getAllP, searchP, getP]);
|
||||||
|
expect(getAllDone).toBe(true);
|
||||||
|
expect(searchDone).toBe(true);
|
||||||
|
expect(getDone).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset re-creates vector store with correct dimension", async () => {
|
||||||
|
const mockEmbedder = createMockEmbedder(768);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "u1" });
|
||||||
|
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Reset should re-create vector store
|
||||||
|
const mockVStore2 = createMockVectorStore();
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore2);
|
||||||
|
await mem.reset();
|
||||||
|
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Second creation should still have dimension=768 (cached from first probe)
|
||||||
|
const secondCall = mockVectorStoreFactory.create.mock.calls[1];
|
||||||
|
expect(secondCall[1].dimension).toBe(768);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backward compat: full explicit config works without probe", async () => {
|
||||||
|
const mockEmbedder = createMockEmbedder(1536);
|
||||||
|
const mockVStore = createMockVectorStore();
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
version: "v1.1",
|
||||||
|
embedder: {
|
||||||
|
provider: "openai",
|
||||||
|
config: { apiKey: "sk-fake", model: "text-embedding-3-small" },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "memory",
|
||||||
|
config: { collectionName: "test-memories", dimension: 1536 },
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
provider: "openai",
|
||||||
|
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
|
||||||
|
},
|
||||||
|
historyDbPath: ":memory:",
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "u1" });
|
||||||
|
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws explicit error when probe fails", async () => {
|
||||||
|
const mockEmbedder = {
|
||||||
|
embed: jest.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
embedBatch: jest.fn(),
|
||||||
|
};
|
||||||
|
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||||
|
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleSpy = jest
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const mem = new MemoryClass({
|
||||||
|
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||||
|
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||||
|
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// getAll should reject with the init error
|
||||||
|
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
|
||||||
|
"auto-detect embedding dimension",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the error was logged and contains helpful information
|
||||||
|
const errorCall = consoleSpy.mock.calls.find(
|
||||||
|
(call) =>
|
||||||
|
call[0] instanceof Error &&
|
||||||
|
call[0].message.includes("auto-detect embedding dimension"),
|
||||||
|
);
|
||||||
|
expect(errorCall).toBeDefined();
|
||||||
|
const errorMsg = (errorCall![0] as Error).message;
|
||||||
|
expect(errorMsg).toContain("ollama");
|
||||||
|
expect(errorMsg).toContain("Connection refused");
|
||||||
|
expect(errorMsg).toContain("dimension");
|
||||||
|
expect(errorMsg).toContain("embeddingDims");
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for graph_memory.ts response parsing (issue #4248).
|
||||||
|
*
|
||||||
|
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
|
||||||
|
* 1. _retrieveNodesFromData → entity extraction
|
||||||
|
* 2. _establishNodesRelationsFromData → relation extraction
|
||||||
|
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
|
||||||
|
*
|
||||||
|
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
|
||||||
|
* string-only responses, empty tool calls, and prompt construction.
|
||||||
|
*
|
||||||
|
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MemoryGraph } from "../src/memory/graph_memory";
|
||||||
|
import {
|
||||||
|
EXTRACT_RELATIONS_PROMPT,
|
||||||
|
getDeleteMessages,
|
||||||
|
} from "../src/graphs/utils";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Mock neo4j-driver: provides a fake Driver with a no-op session
|
||||||
|
jest.mock("neo4j-driver", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
driver: jest.fn(() => ({
|
||||||
|
session: () => ({
|
||||||
|
run: jest.fn().mockResolvedValue({ records: [] }),
|
||||||
|
close: jest.fn(),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
auth: { basic: jest.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
|
||||||
|
const mockGenerateResponse = jest.fn();
|
||||||
|
const mockGenerateChat = jest.fn();
|
||||||
|
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
|
||||||
|
|
||||||
|
jest.mock("../src/utils/factory", () => ({
|
||||||
|
LLMFactory: {
|
||||||
|
create: jest.fn(() => ({
|
||||||
|
generateResponse: mockGenerateResponse,
|
||||||
|
generateChat: mockGenerateChat,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
EmbedderFactory: {
|
||||||
|
create: jest.fn(() => ({
|
||||||
|
embed: mockEmbed,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Minimal config that satisfies the MemoryGraph constructor
|
||||||
|
function makeConfig(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
graphStore: {
|
||||||
|
config: {
|
||||||
|
url: "bolt://localhost:7687",
|
||||||
|
username: "neo4j",
|
||||||
|
password: "test",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
embedder: { provider: "openai", config: {} },
|
||||||
|
llm: { provider: "openai", config: {} },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to access private methods via `any` cast
|
||||||
|
function graph(overrides: Record<string, any> = {}): any {
|
||||||
|
return new MemoryGraph(makeConfig(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS = { userId: "test-user" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 1. _retrieveNodesFromData – entity extraction
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("_retrieveNodesFromData", () => {
|
||||||
|
it("parses a well-formed extract_entities tool call", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "extract_entities",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
entities: [
|
||||||
|
{ entity: "Alice", entity_type: "person" },
|
||||||
|
{ entity: "Pizza", entity_type: "food" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData(
|
||||||
|
"Alice likes pizza",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ alice: "person", pizza: "food" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty map when LLM returns a plain string", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty map when toolCalls is undefined", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty map when toolCalls is an empty array", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed JSON in tool call arguments gracefully", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
// Should not throw — the catch block in the source logs the error
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing entities array in arguments", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "extract_entities",
|
||||||
|
arguments: JSON.stringify({ wrong_key: [] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
// args.entities is undefined → for..of on undefined throws → caught
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tool calls with unrelated names", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "some_other_tool",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
entities: [{ entity: "X", entity_type: "Y" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises entity names to lowercase with underscores", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "extract_entities",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
entities: [{ entity: "New York City", entity_type: "City Name" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||||
|
expect(result).toEqual({ new_york_city: "city_name" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes json_object response format and the correct system prompt", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
await mg._retrieveNodesFromData("test data", FILTERS);
|
||||||
|
|
||||||
|
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||||
|
expect(responseFormat).toEqual({ type: "json_object" });
|
||||||
|
|
||||||
|
const systemMsg = messages[0].content as string;
|
||||||
|
expect(systemMsg.toLowerCase()).toContain("json");
|
||||||
|
expect(systemMsg).toContain("test-user");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 2. _establishNodesRelationsFromData – relation extraction
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("_establishNodesRelationsFromData", () => {
|
||||||
|
it("parses a well-formed establish_relationships tool call", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "establish_relationships",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
entities: [
|
||||||
|
{ source: "Alice", relationship: "likes", destination: "Pizza" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._establishNodesRelationsFromData(
|
||||||
|
"Alice likes pizza",
|
||||||
|
FILTERS,
|
||||||
|
{ alice: "person", pizza: "food" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when LLM returns a string", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce("just a string");
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when toolCalls is empty", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when entities key is missing from arguments", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "establish_relationships",
|
||||||
|
arguments: JSON.stringify({ not_entities: [] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||||
|
// args.entities is undefined → falls back to []
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
// _establishNodesRelationsFromData does JSON.parse without try/catch
|
||||||
|
await expect(
|
||||||
|
mg._establishNodesRelationsFromData("x", FILTERS, {}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
|
||||||
|
|
||||||
|
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||||
|
expect(responseFormat).toEqual({ type: "json_object" });
|
||||||
|
|
||||||
|
const systemContent = messages[0].content as string;
|
||||||
|
expect(systemContent.toLowerCase()).toContain("json");
|
||||||
|
expect(systemContent).toContain("test-user");
|
||||||
|
expect(systemContent).not.toContain("USER_ID");
|
||||||
|
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
|
||||||
|
// (only replaced when config.graphStore.customPrompt is set)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends JSON format suffix and custom prompt when configured", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph({ customPrompt: "Focus on food relationships only." });
|
||||||
|
await mg._establishNodesRelationsFromData("data", FILTERS, {});
|
||||||
|
|
||||||
|
const [messages] = mockGenerateResponse.mock.calls[0];
|
||||||
|
const systemContent = messages[0].content as string;
|
||||||
|
expect(systemContent.toLowerCase()).toContain("json");
|
||||||
|
expect(systemContent).toContain("Focus on food relationships only.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("_getDeleteEntitiesFromSearchOutput", () => {
|
||||||
|
const SEARCH_OUTPUT = [
|
||||||
|
{
|
||||||
|
source: "alice",
|
||||||
|
source_id: "1",
|
||||||
|
relationship: "likes",
|
||||||
|
relation_id: "r1",
|
||||||
|
destination: "pizza",
|
||||||
|
destination_id: "2",
|
||||||
|
similarity: 0.95,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("parses a well-formed delete_graph_memory tool call", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "delete_graph_memory",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
source: "Alice",
|
||||||
|
relationship: "likes",
|
||||||
|
destination: "Pizza",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
SEARCH_OUTPUT,
|
||||||
|
"Alice hates pizza",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when LLM returns a string", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce("string response");
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
SEARCH_OUTPUT,
|
||||||
|
"x",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no tool calls are present", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
SEARCH_OUTPUT,
|
||||||
|
"x",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips non-delete_graph_memory tool calls", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "noop",
|
||||||
|
arguments: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
SEARCH_OUTPUT,
|
||||||
|
"x",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects multiple delete tool calls", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "delete_graph_memory",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
source: "A",
|
||||||
|
relationship: "r1",
|
||||||
|
destination: "B",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_graph_memory",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
source: "C",
|
||||||
|
relationship: "r2",
|
||||||
|
destination: "D",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
SEARCH_OUTPUT,
|
||||||
|
"x",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].source).toBe("a");
|
||||||
|
expect(result[1].source).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes json_object format and includes 'json' in system prompt", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
|
||||||
|
|
||||||
|
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||||
|
expect(responseFormat).toEqual({ type: "json_object" });
|
||||||
|
|
||||||
|
const systemContent = messages[0].content as string;
|
||||||
|
expect(systemContent.toLowerCase()).toContain("json");
|
||||||
|
expect(systemContent).toContain("test-user");
|
||||||
|
expect(systemContent).not.toContain("USER_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty searchOutput array", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||||
|
[],
|
||||||
|
"data",
|
||||||
|
FILTERS,
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 4. Prompt construction — JSON keyword present in every json_object site
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("Prompt construction — all json_object sites include 'json'", () => {
|
||||||
|
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
|
||||||
|
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
const mg = graph();
|
||||||
|
await mg._retrieveNodesFromData("test", { userId });
|
||||||
|
|
||||||
|
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||||
|
expect(systemMsg.toLowerCase()).toContain("json");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
|
||||||
|
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
const mg = graph();
|
||||||
|
await mg._establishNodesRelationsFromData("test", { userId }, {});
|
||||||
|
|
||||||
|
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||||
|
expect(systemMsg.toLowerCase()).toContain("json");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
|
||||||
|
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||||
|
const mg = graph();
|
||||||
|
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
|
||||||
|
|
||||||
|
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||||
|
expect(systemMsg.toLowerCase()).toContain("json");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
|
||||||
|
it("normalises spaces and case in entity source/relationship/destination", async () => {
|
||||||
|
mockGenerateResponse.mockResolvedValueOnce({
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "establish_relationships",
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
source: "New York",
|
||||||
|
relationship: "Capital Of",
|
||||||
|
destination: "United States",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mg = graph();
|
||||||
|
const result = await mg._establishNodesRelationsFromData(
|
||||||
|
"test",
|
||||||
|
FILTERS,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
source: "new_york",
|
||||||
|
relationship: "capital_of",
|
||||||
|
destination: "united_states",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
DELETE_RELATIONS_SYSTEM_PROMPT,
|
||||||
|
EXTRACT_RELATIONS_PROMPT,
|
||||||
|
UPDATE_GRAPH_PROMPT,
|
||||||
|
getDeleteMessages,
|
||||||
|
formatEntities,
|
||||||
|
} from "../src/graphs/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for graph prompts (issue #4248).
|
||||||
|
*
|
||||||
|
* When response_format: { type: "json_object" } is used, OpenAI requires
|
||||||
|
* the word "json" (case-insensitive) to appear in at least one message.
|
||||||
|
* Missing it produces a 400 error.
|
||||||
|
*
|
||||||
|
* Three call sites use json_object today:
|
||||||
|
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
|
||||||
|
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
|
||||||
|
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
|
||||||
|
*
|
||||||
|
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── JSON keyword presence ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Graph prompts — JSON keyword requirement", () => {
|
||||||
|
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
|
||||||
|
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
|
||||||
|
// graph_memory.ts appends "\nPlease provide your response in JSON format."
|
||||||
|
const withSuffix =
|
||||||
|
EXTRACT_RELATIONS_PROMPT +
|
||||||
|
"\nPlease provide your response in JSON format.";
|
||||||
|
expect(withSuffix.toLowerCase()).toContain("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
|
||||||
|
const [systemContent] = getDeleteMessages(
|
||||||
|
"alice -- loves -- pizza",
|
||||||
|
"Alice now hates pizza",
|
||||||
|
"user-42",
|
||||||
|
);
|
||||||
|
expect(systemContent.toLowerCase()).toContain("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
|
||||||
|
// Mirrors the template in _retrieveNodesFromData()
|
||||||
|
const userId = "user-1";
|
||||||
|
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
|
||||||
|
expect(prompt.toLowerCase()).toContain("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getDeleteMessages ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getDeleteMessages", () => {
|
||||||
|
it("replaces USER_ID with the provided userId in the system prompt", () => {
|
||||||
|
const [system] = getDeleteMessages("mem", "data", "alice-123");
|
||||||
|
expect(system).toContain("alice-123");
|
||||||
|
expect(system).not.toContain("USER_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes existing memories and new data in the user prompt", () => {
|
||||||
|
const existing = "bob -- knows -- carol";
|
||||||
|
const newData = "Bob no longer knows Carol";
|
||||||
|
const [, user] = getDeleteMessages(existing, newData, "u1");
|
||||||
|
expect(user).toContain(existing);
|
||||||
|
expect(user).toContain(newData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a 2-tuple [system, user]", () => {
|
||||||
|
const result = getDeleteMessages("a", "b", "c");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(typeof result[0]).toBe("string");
|
||||||
|
expect(typeof result[1]).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
// — Malformed / edge-case inputs —
|
||||||
|
|
||||||
|
it("handles empty strings without throwing", () => {
|
||||||
|
expect(() => getDeleteMessages("", "", "")).not.toThrow();
|
||||||
|
const [system, user] = getDeleteMessages("", "", "");
|
||||||
|
expect(system.toLowerCase()).toContain("json");
|
||||||
|
expect(typeof user).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
|
||||||
|
const [system] = getDeleteMessages(
|
||||||
|
"mem",
|
||||||
|
"data",
|
||||||
|
'<script>alert("xss")</script>',
|
||||||
|
);
|
||||||
|
expect(system).toContain('<script>alert("xss")</script>');
|
||||||
|
expect(system).not.toContain("USER_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unicode input", () => {
|
||||||
|
const [system, user] = getDeleteMessages(
|
||||||
|
"日本語メモリ",
|
||||||
|
"新しい情報",
|
||||||
|
"ユーザー1",
|
||||||
|
);
|
||||||
|
expect(system).toContain("ユーザー1");
|
||||||
|
expect(user).toContain("日本語メモリ");
|
||||||
|
expect(user).toContain("新しい情報");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very long input strings", () => {
|
||||||
|
const longStr = "x".repeat(100_000);
|
||||||
|
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
|
||||||
|
const [system] = getDeleteMessages(longStr, longStr, "u");
|
||||||
|
expect(system.toLowerCase()).toContain("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── formatEntities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("formatEntities", () => {
|
||||||
|
it("formats a single entity triplet", () => {
|
||||||
|
const result = formatEntities([
|
||||||
|
{ source: "Alice", relationship: "knows", destination: "Bob" },
|
||||||
|
]);
|
||||||
|
expect(result).toBe("Alice -- knows -- Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple entities with newlines", () => {
|
||||||
|
const result = formatEntities([
|
||||||
|
{ source: "A", relationship: "r1", destination: "B" },
|
||||||
|
{ source: "C", relationship: "r2", destination: "D" },
|
||||||
|
]);
|
||||||
|
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty array", () => {
|
||||||
|
expect(formatEntities([])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves special characters in entity fields", () => {
|
||||||
|
const result = formatEntities([
|
||||||
|
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
|
||||||
|
]);
|
||||||
|
expect(result).toContain("O'Brien");
|
||||||
|
expect(result).toContain('said "hello"');
|
||||||
|
expect(result).toContain("café");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Prompt structural invariants ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Prompt structural invariants", () => {
|
||||||
|
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
|
||||||
|
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
|
||||||
|
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
|
||||||
|
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
|
||||||
|
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
|
||||||
|
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
|
||||||
|
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
|
||||||
|
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
/// <reference types="jest" />
|
||||||
|
/**
|
||||||
|
* End-to-end tests for Qdrant dimension mismatch fix.
|
||||||
|
*
|
||||||
|
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
|
||||||
|
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
|
||||||
|
*
|
||||||
|
* Skipped automatically when Qdrant is not available.
|
||||||
|
*
|
||||||
|
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||||
|
import { Qdrant } from "../src/vector_stores/qdrant";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
const QDRANT_HOST = "localhost";
|
||||||
|
const QDRANT_PORT = 6333;
|
||||||
|
|
||||||
|
// Check if Qdrant is reachable synchronously at load time using
|
||||||
|
// a sync check via child_process so describe.skip works correctly.
|
||||||
|
function isQdrantAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
execSync(
|
||||||
|
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||||
|
{ timeout: 3000, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qdrantAvailable = isQdrantAvailable();
|
||||||
|
if (!qdrantAvailable) {
|
||||||
|
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
let qdrantClient: QdrantClient;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!qdrantAvailable) return;
|
||||||
|
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
|
||||||
|
const collections = await qdrantClient.getCollections();
|
||||||
|
expect(collections).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: delete a collection if it exists
|
||||||
|
async function deleteCollectionIfExists(name: string) {
|
||||||
|
try {
|
||||||
|
await qdrantClient.deleteCollection(name);
|
||||||
|
} catch {
|
||||||
|
// Collection doesn't exist — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: create a fake embedder that produces vectors of a given dimension
|
||||||
|
function createFakeEmbedder(dims: number) {
|
||||||
|
return {
|
||||||
|
embed: jest.fn().mockImplementation(async (_text: string) => {
|
||||||
|
const vec = new Array(dims).fill(0);
|
||||||
|
for (let i = 0; i < _text.length && i < dims; i++) {
|
||||||
|
vec[i] = _text.charCodeAt(i) / 255;
|
||||||
|
}
|
||||||
|
return vec;
|
||||||
|
}),
|
||||||
|
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
|
||||||
|
return Promise.all(
|
||||||
|
texts.map(async (t) => {
|
||||||
|
const vec = new Array(dims).fill(0);
|
||||||
|
for (let i = 0; i < t.length && i < dims; i++) {
|
||||||
|
vec[i] = t.charCodeAt(i) / 255;
|
||||||
|
}
|
||||||
|
return vec;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditionally skip tests when Qdrant is unavailable
|
||||||
|
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteCollectionIfExists("e2e_test_768");
|
||||||
|
await deleteCollectionIfExists("e2e_test_1536");
|
||||||
|
await deleteCollectionIfExists("e2e_test_race");
|
||||||
|
await deleteCollectionIfExists("e2e_test_race2");
|
||||||
|
await deleteCollectionIfExists("e2e_test_noexplicit");
|
||||||
|
await deleteCollectionIfExists("e2e_test_explicit");
|
||||||
|
await deleteCollectionIfExists("e2e_test_embdims");
|
||||||
|
await deleteCollectionIfExists("e2e_test_autodetect");
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
|
||||||
|
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
|
||||||
|
const collectionName = "e2e_test_1536";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await qdrantClient.createCollection(collectionName, {
|
||||||
|
vectors: { size: 1536, distance: "Cosine" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert a 768-dim vector — this is what nomic-embed-text produces
|
||||||
|
const vector768 = new Array(768).fill(0.1);
|
||||||
|
try {
|
||||||
|
await qdrantClient.upsert(collectionName, {
|
||||||
|
points: [
|
||||||
|
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
|
||||||
|
} catch (error: any) {
|
||||||
|
// This is the exact "Bad Request" error users were hitting
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
|
||||||
|
const collectionName = "e2e_test_768";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
// Create Qdrant store with correct dimension (what our auto-detect provides)
|
||||||
|
const store = new Qdrant({
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
embeddingModelDims: 768,
|
||||||
|
dimension: 768,
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
|
||||||
|
// Verify collection was created with 768 dims
|
||||||
|
const info = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
// Insert 768-dim vectors (what nomic-embed-text produces)
|
||||||
|
const vec1 = new Array(768).fill(0);
|
||||||
|
vec1[0] = 1.0;
|
||||||
|
const vec2 = new Array(768).fill(0);
|
||||||
|
vec2[1] = 1.0;
|
||||||
|
|
||||||
|
const id1 = uuidv4();
|
||||||
|
const id2 = uuidv4();
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec1, vec2],
|
||||||
|
[id1, id2],
|
||||||
|
[
|
||||||
|
{ data: "hello", userId: "u1" },
|
||||||
|
{ data: "world", userId: "u1" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search with 768-dim query — this USED TO fail with Bad Request
|
||||||
|
const results = await store.search(vec1, 2, { userId: "u1" });
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
expect(results[0].id).toBe(id1); // Most similar to itself
|
||||||
|
expect(results[0].score).toBeGreaterThan(0.9);
|
||||||
|
|
||||||
|
// Get by ID
|
||||||
|
const item = await store.get(id1);
|
||||||
|
expect(item).not.toBeNull();
|
||||||
|
expect(item!.payload.data).toBe("hello");
|
||||||
|
|
||||||
|
// Update with 768-dim vector
|
||||||
|
const vec3 = new Array(768).fill(0);
|
||||||
|
vec3[2] = 1.0;
|
||||||
|
await store.update(id1, vec3, { data: "updated", userId: "u1" });
|
||||||
|
const updated = await store.get(id1);
|
||||||
|
expect(updated!.payload.data).toBe("updated");
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await store.delete(id2);
|
||||||
|
const deleted = await store.get(id2);
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
|
||||||
|
// List
|
||||||
|
const [listed, count] = await store.list({ userId: "u1" });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(listed[0].payload.data).toBe("updated");
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
|
||||||
|
const collectionName = "e2e_test_autodetect";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
const fakeEmbedder = createFakeEmbedder(768);
|
||||||
|
|
||||||
|
// Mock only the non-Qdrant factories to avoid Google SDK import crash
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock("../src/utils/factory", () => {
|
||||||
|
// Import Qdrant directly (avoids loading Google embedder via factory)
|
||||||
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||||
|
return {
|
||||||
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||||
|
VectorStoreFactory: {
|
||||||
|
create: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_provider: string, config: any) => {
|
||||||
|
return new QdrantStore(config);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
LLMFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
HistoryManagerFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
reset: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock("../src/utils/telemetry", () => ({
|
||||||
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Memory } = require("../src/memory");
|
||||||
|
|
||||||
|
// This is the EXACT config from issue #4212 — NO dimension specified
|
||||||
|
const mem = new Memory({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text" },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: {
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This triggers init — probe should detect 768 dims
|
||||||
|
await mem.getAll({ userId: "test-user" });
|
||||||
|
|
||||||
|
// Verify the probe was called
|
||||||
|
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||||
|
|
||||||
|
// Verify Qdrant collection was created with auto-detected 768 dims
|
||||||
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
// Search should work (this used to throw Bad Request)
|
||||||
|
const searchResult = await mem.search("hello world", {
|
||||||
|
userId: "test-user",
|
||||||
|
});
|
||||||
|
expect(searchResult).toBeDefined();
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
|
||||||
|
const collectionName = "e2e_test_explicit";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
const fakeEmbedder = createFakeEmbedder(768);
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock("../src/utils/factory", () => {
|
||||||
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||||
|
return {
|
||||||
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||||
|
VectorStoreFactory: {
|
||||||
|
create: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_provider: string, config: any) => {
|
||||||
|
return new QdrantStore(config);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
LLMFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
HistoryManagerFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
reset: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock("../src/utils/telemetry", () => ({
|
||||||
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Memory } = require("../src/memory");
|
||||||
|
|
||||||
|
// Workaround config from #4212 — explicit dimension
|
||||||
|
const mem = new Memory({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text" },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: {
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
dimension: 768,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "test-user" });
|
||||||
|
|
||||||
|
// Probe should NOT have been called
|
||||||
|
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||||
|
|
||||||
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
|
||||||
|
const collectionName = "e2e_test_embdims";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
const fakeEmbedder = createFakeEmbedder(768);
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
jest.doMock("../src/utils/factory", () => {
|
||||||
|
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||||
|
return {
|
||||||
|
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||||
|
VectorStoreFactory: {
|
||||||
|
create: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_provider: string, config: any) => {
|
||||||
|
return new QdrantStore(config);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
LLMFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
HistoryManagerFactory: {
|
||||||
|
create: jest.fn().mockReturnValue({
|
||||||
|
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
reset: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock("../src/utils/telemetry", () => ({
|
||||||
|
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { Memory } = require("../src/memory");
|
||||||
|
|
||||||
|
const mem = new Memory({
|
||||||
|
embedder: {
|
||||||
|
provider: "ollama",
|
||||||
|
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||||
|
},
|
||||||
|
vectorStore: {
|
||||||
|
provider: "qdrant",
|
||||||
|
config: {
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||||
|
disableHistory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mem.getAll({ userId: "test-user" });
|
||||||
|
|
||||||
|
// Probe should NOT have been called — dimension inferred from embeddingDims
|
||||||
|
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||||
|
|
||||||
|
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Reproduce #4056 issue 1: Collection creation race condition
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
|
||||||
|
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
|
||||||
|
const collectionName = "e2e_test_race";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
// Create 5 Qdrant instances concurrently — this simulates the race
|
||||||
|
// that caused "Collection memory_migrations already exists!" in #4056
|
||||||
|
const instances = Array.from(
|
||||||
|
{ length: 5 },
|
||||||
|
() =>
|
||||||
|
new Qdrant({
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
embeddingModelDims: 768,
|
||||||
|
dimension: 768,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// All should initialize without throwing 409 Conflict
|
||||||
|
await Promise.all(instances.map((inst) => inst.initialize()));
|
||||||
|
|
||||||
|
// Verify collection exists with correct dimension
|
||||||
|
const info = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
// memory_migrations should also exist (created by initialize)
|
||||||
|
const migrInfo = await qdrantClient.getCollection("memory_migrations");
|
||||||
|
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getUserId works after concurrent initialization", async () => {
|
||||||
|
const collectionName = "e2e_test_race2";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
const instance = new Qdrant({
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
embeddingModelDims: 768,
|
||||||
|
dimension: 768,
|
||||||
|
});
|
||||||
|
|
||||||
|
await instance.initialize();
|
||||||
|
|
||||||
|
// getUserId should work without 409 crash
|
||||||
|
const userId = await instance.getUserId();
|
||||||
|
expect(typeof userId).toBe("string");
|
||||||
|
expect(userId.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// setUserId + getUserId roundtrip
|
||||||
|
await instance.setUserId("custom-e2e-user");
|
||||||
|
const updated = await instance.getUserId();
|
||||||
|
expect(updated).toBe("custom-e2e-user");
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
|
||||||
|
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
|
||||||
|
const collectionName = "e2e_test_noexplicit";
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
|
||||||
|
const instance = new Qdrant({
|
||||||
|
host: QDRANT_HOST,
|
||||||
|
port: QDRANT_PORT,
|
||||||
|
collectionName,
|
||||||
|
embeddingModelDims: 768,
|
||||||
|
dimension: 768,
|
||||||
|
});
|
||||||
|
|
||||||
|
await instance.initialize();
|
||||||
|
|
||||||
|
// Allow Qdrant a moment to fully commit collections
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// Main collection should be 768
|
||||||
|
const mainInfo = await qdrantClient.getCollection(collectionName);
|
||||||
|
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
|
||||||
|
|
||||||
|
// memory_migrations should be 1 (NOT 768!)
|
||||||
|
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
|
||||||
|
const migrationsInfo =
|
||||||
|
await qdrantClient.getCollection("memory_migrations");
|
||||||
|
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
|
||||||
|
|
||||||
|
// getUserId should work — vector dim=1 in memory_migrations
|
||||||
|
const userId = await instance.getUserId();
|
||||||
|
expect(typeof userId).toBe("string");
|
||||||
|
|
||||||
|
// setUserId should also work
|
||||||
|
await instance.setUserId("custom-test-user");
|
||||||
|
const newUserId = await instance.getUserId();
|
||||||
|
expect(newUserId).toBe("custom-test-user");
|
||||||
|
|
||||||
|
await deleteCollectionIfExists(collectionName);
|
||||||
|
await deleteCollectionIfExists("memory_migrations");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/// <reference types="jest" />
|
||||||
|
/**
|
||||||
|
* End-to-end tests for Redis vector store with init guard fix.
|
||||||
|
*
|
||||||
|
* Requires a running Redis Stack instance at localhost:6379.
|
||||||
|
* Skipped automatically when Redis is not available.
|
||||||
|
*
|
||||||
|
* Run: npx jest --config jest.config.js src/oss/tests/redis-e2e.test.ts --forceExit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from "redis";
|
||||||
|
import { RedisDB } from "../src/vector_stores/redis";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
|
||||||
|
const REDIS_HOST = "localhost";
|
||||||
|
const REDIS_PORT = 6379;
|
||||||
|
const REDIS_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
|
||||||
|
const COLLECTION_NAME = "e2e_redis_test";
|
||||||
|
|
||||||
|
// Check if Redis is reachable synchronously at load time
|
||||||
|
function isRedisAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
execSync(
|
||||||
|
`node -e "const s=require('net').createConnection({host:'${REDIS_HOST}',port:${REDIS_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||||
|
{ timeout: 3000, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisAvailable = isRedisAvailable();
|
||||||
|
if (!redisAvailable) {
|
||||||
|
console.warn("Redis not available at localhost:6379 — skipping e2e tests");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone client for cleanup
|
||||||
|
let cleanupClient: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
async function cleanupRedis() {
|
||||||
|
if (!redisAvailable) return;
|
||||||
|
try {
|
||||||
|
// Drop the index if it exists
|
||||||
|
await cleanupClient.ft.dropIndex(COLLECTION_NAME);
|
||||||
|
} catch {
|
||||||
|
// Index doesn't exist — fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all keys with our prefix
|
||||||
|
const keys = await cleanupClient.keys(`mem0:${COLLECTION_NAME}:*`);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await cleanupClient.del(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up memory_migrations key
|
||||||
|
await cleanupClient.del("memory_migrations:1");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!redisAvailable) return;
|
||||||
|
|
||||||
|
cleanupClient = createClient({ url: REDIS_URL });
|
||||||
|
await cleanupClient.connect();
|
||||||
|
|
||||||
|
// Verify Redis Stack is running with search module
|
||||||
|
const modules = (await cleanupClient.moduleList()) as unknown as any[];
|
||||||
|
const hasSearch = modules.some((mod: any[]) => {
|
||||||
|
const moduleMap = new Map();
|
||||||
|
for (let i = 0; i < mod.length; i += 2) {
|
||||||
|
moduleMap.set(mod[i], mod[i + 1]);
|
||||||
|
}
|
||||||
|
return moduleMap.get("name")?.toLowerCase() === "search";
|
||||||
|
});
|
||||||
|
expect(hasSearch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!redisAvailable) return;
|
||||||
|
await cleanupRedis();
|
||||||
|
await cleanupClient.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conditionally skip tests when Redis is unavailable
|
||||||
|
const describeIfRedis = redisAvailable ? describe : describe.skip;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Basic initialization and idempotent init guard
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfRedis("Redis: initialization", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupRedis();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes successfully and creates index", async () => {
|
||||||
|
const store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: 128,
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
|
||||||
|
// Verify the index was created by querying index info
|
||||||
|
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(info.indexName).toBe(COLLECTION_NAME);
|
||||||
|
|
||||||
|
await store.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent initialize() — multiple calls don't crash", async () => {
|
||||||
|
const store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call initialize multiple times concurrently
|
||||||
|
await Promise.all([
|
||||||
|
store.initialize(),
|
||||||
|
store.initialize(),
|
||||||
|
store.initialize(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should still work fine
|
||||||
|
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
|
||||||
|
await store.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Full CRUD operations
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfRedis("Redis: CRUD operations", () => {
|
||||||
|
let store: RedisDB;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanupRedis();
|
||||||
|
store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: 4, // Small dims for testing
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await store.close();
|
||||||
|
await cleanupRedis();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insert and search vectors", async () => {
|
||||||
|
const id1 = uuidv4();
|
||||||
|
const id2 = uuidv4();
|
||||||
|
const vec1 = [1.0, 0.0, 0.0, 0.0];
|
||||||
|
const vec2 = [0.0, 1.0, 0.0, 0.0];
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec1, vec2],
|
||||||
|
[id1, id2],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "hello world",
|
||||||
|
hash: "h1",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: "goodbye world",
|
||||||
|
hash: "h2",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search — vec1 should be most similar to itself
|
||||||
|
const results = await store.search(vec1, 2, { userId: "user1" });
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
// The first result should be closest to the query
|
||||||
|
expect(results[0].id).toBe(id1);
|
||||||
|
expect(results[0].score).toBeDefined();
|
||||||
|
expect(results[0].payload).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get vector by ID", async () => {
|
||||||
|
const id = uuidv4();
|
||||||
|
const vec = [0.5, 0.5, 0.0, 0.0];
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec],
|
||||||
|
[id],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "test memory",
|
||||||
|
hash: "h-test",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await store.get(id);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.id).toBe(id);
|
||||||
|
expect(result!.payload.data).toBe("test memory");
|
||||||
|
expect(result!.payload.hash).toBe("h-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get non-existent vector returns null", async () => {
|
||||||
|
const result = await store.get("non-existent-id");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update vector and payload", async () => {
|
||||||
|
const id = uuidv4();
|
||||||
|
const vec = [1.0, 0.0, 0.0, 0.0];
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec],
|
||||||
|
[id],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "original",
|
||||||
|
hash: "h-orig",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update with new vector and payload
|
||||||
|
const newVec = [0.0, 0.0, 1.0, 0.0];
|
||||||
|
await store.update(id, newVec, {
|
||||||
|
data: "updated memory",
|
||||||
|
hash: "h-updated",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await store.get(id);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.payload.data).toBe("updated memory");
|
||||||
|
expect(result!.payload.hash).toBe("h-updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete vector", async () => {
|
||||||
|
const id = uuidv4();
|
||||||
|
const vec = [0.0, 0.0, 0.0, 1.0];
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec],
|
||||||
|
[id],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "to be deleted",
|
||||||
|
hash: "h-del",
|
||||||
|
userId: "user1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
const before = await store.get(id);
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await store.delete(id);
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
const after = await store.get(id);
|
||||||
|
expect(after).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list vectors with filters", async () => {
|
||||||
|
const id1 = uuidv4();
|
||||||
|
const id2 = uuidv4();
|
||||||
|
const id3 = uuidv4();
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
],
|
||||||
|
[id1, id2, id3],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "mem1",
|
||||||
|
hash: "h1",
|
||||||
|
userId: "usera",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: "mem2",
|
||||||
|
hash: "h2",
|
||||||
|
userId: "usera",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: "mem3",
|
||||||
|
hash: "h3",
|
||||||
|
userId: "userb",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// List all
|
||||||
|
const [all, allCount] = await store.list();
|
||||||
|
expect(allCount).toBe(3);
|
||||||
|
expect(all.length).toBe(3);
|
||||||
|
|
||||||
|
// List with filter
|
||||||
|
const [filtered, filteredCount] = await store.list({
|
||||||
|
userId: "usera",
|
||||||
|
});
|
||||||
|
expect(filteredCount).toBe(2);
|
||||||
|
expect(filtered.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. getUserId / setUserId
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfRedis("Redis: getUserId / setUserId", () => {
|
||||||
|
let store: RedisDB;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanupRedis();
|
||||||
|
store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: 4,
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await store.close();
|
||||||
|
await cleanupRedis();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getUserId generates random ID if none exists", async () => {
|
||||||
|
const userId = await store.getUserId();
|
||||||
|
expect(typeof userId).toBe("string");
|
||||||
|
expect(userId.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setUserId + getUserId roundtrip", async () => {
|
||||||
|
await store.setUserId("custom-redis-user");
|
||||||
|
const retrieved = await store.getUserId();
|
||||||
|
expect(retrieved).toBe("custom-redis-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getUserId returns same value on subsequent calls", async () => {
|
||||||
|
const first = await store.getUserId();
|
||||||
|
const second = await store.getUserId();
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Dimension handling (our fix ensures correct dims from Memory)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
describeIfRedis("Redis: dimension handling", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupRedis();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates index with correct dimensions from config", async () => {
|
||||||
|
const store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: 768,
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
|
||||||
|
// Verify the index has the right dimension in its schema
|
||||||
|
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||||
|
// Check that the vector field has DIM=768
|
||||||
|
const attributes = info.attributes as any[];
|
||||||
|
const vectorAttr = attributes.find(
|
||||||
|
(a: any) => a.identifier === "embedding" || a.attribute === "embedding",
|
||||||
|
);
|
||||||
|
expect(vectorAttr).toBeDefined();
|
||||||
|
|
||||||
|
await store.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insert with matching dimension succeeds", async () => {
|
||||||
|
const dims = 128;
|
||||||
|
const store = new RedisDB({
|
||||||
|
redisUrl: REDIS_URL,
|
||||||
|
collectionName: COLLECTION_NAME,
|
||||||
|
embeddingModelDims: dims,
|
||||||
|
});
|
||||||
|
await store.initialize();
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const vec = new Array(dims).fill(0.1);
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
[vec],
|
||||||
|
[id],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: "test",
|
||||||
|
hash: "h1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await store.get(id);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.id).toBe(id);
|
||||||
|
|
||||||
|
await store.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { removeCodeBlocks } from "../src/prompts";
|
||||||
|
|
||||||
|
describe("removeCodeBlocks", () => {
|
||||||
|
it("extracts JSON from ```json code fence", () => {
|
||||||
|
const input = '```json\n{"facts": ["hello"]}\n```';
|
||||||
|
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts content from bare ``` code fence", () => {
|
||||||
|
const input = '```\n{"key": "value"}\n```';
|
||||||
|
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns plain text unchanged", () => {
|
||||||
|
const input = '{"facts": ["hello"]}';
|
||||||
|
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple code blocks", () => {
|
||||||
|
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
|
||||||
|
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Claude-style response with surrounding text", () => {
|
||||||
|
const input =
|
||||||
|
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
|
||||||
|
expect(removeCodeBlocks(input)).toContain('"facts"');
|
||||||
|
expect(removeCodeBlocks(input)).not.toContain("```");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ const external = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"pg",
|
"pg",
|
||||||
"zod",
|
"zod",
|
||||||
"sqlite3",
|
"better-sqlite3",
|
||||||
"@qdrant/js-client-rest",
|
"@qdrant/js-client-rest",
|
||||||
"redis",
|
"redis",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ class MemoryGraph:
|
|||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.graph = Neo4jGraph(
|
self.graph = Neo4jGraph(
|
||||||
self.config.graph_store.config.url,
|
url=self.config.graph_store.config.url,
|
||||||
self.config.graph_store.config.username,
|
username=self.config.graph_store.config.username,
|
||||||
self.config.graph_store.config.password,
|
password=self.config.graph_store.config.password,
|
||||||
self.config.graph_store.config.database,
|
database=self.config.graph_store.config.database,
|
||||||
refresh_schema=False,
|
refresh_schema=False,
|
||||||
driver_config={"notifications_min_severity": "OFF"},
|
driver_config={"notifications_min_severity": "OFF"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ class MemoryGraph:
|
|||||||
if tool_call["name"] != "extract_entities":
|
if tool_call["name"] != "extract_entities":
|
||||||
continue
|
continue
|
||||||
for item in tool_call["arguments"]["entities"]:
|
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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
|
f"Error in search tool: {e}, llm_provider={self.llm_provider}, search_results={search_results}"
|
||||||
@@ -263,8 +264,8 @@ class MemoryGraph:
|
|||||||
)
|
)
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
if extracted_entities["tool_calls"]:
|
if extracted_entities and extracted_entities.get("tool_calls"):
|
||||||
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
|
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
||||||
|
|
||||||
entities = self._remove_spaces_from_entities(entities)
|
entities = self._remove_spaces_from_entities(entities)
|
||||||
logger.debug(f"Extracted entities: {entities}")
|
logger.debug(f"Extracted entities: {entities}")
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
|
|||||||
|
|
||||||
class AnonymousTelemetry:
|
class AnonymousTelemetry:
|
||||||
def __init__(self, vector_store=None):
|
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)
|
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):
|
def capture_event(self, event_name, properties=None, user_email=None):
|
||||||
|
if self.posthog is None:
|
||||||
|
return
|
||||||
|
|
||||||
if properties is None:
|
if properties is None:
|
||||||
properties = {}
|
properties = {}
|
||||||
properties = {
|
properties = {
|
||||||
@@ -49,13 +53,17 @@ class AnonymousTelemetry:
|
|||||||
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
|
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.posthog.shutdown()
|
if self.posthog is not None:
|
||||||
|
self.posthog.shutdown()
|
||||||
|
|
||||||
|
|
||||||
client_telemetry = AnonymousTelemetry()
|
client_telemetry = AnonymousTelemetry()
|
||||||
|
|
||||||
|
|
||||||
def capture_event(event_name, memory_instance, additional_data=None):
|
def capture_event(event_name, memory_instance, additional_data=None):
|
||||||
|
if not MEM0_TELEMETRY:
|
||||||
|
return
|
||||||
|
|
||||||
oss_telemetry = AnonymousTelemetry(
|
oss_telemetry = AnonymousTelemetry(
|
||||||
vector_store=memory_instance._telemetry_vector_store
|
vector_store=memory_instance._telemetry_vector_store
|
||||||
if hasattr(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):
|
def capture_client_event(event_name, instance, additional_data=None):
|
||||||
|
if not MEM0_TELEMETRY:
|
||||||
|
return
|
||||||
|
|
||||||
event_data = {
|
event_data = {
|
||||||
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
|
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-03-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **README image on npmjs.com**: Changed architecture diagram from relative path to absolute GitHub URL so it renders correctly on the npm registry
|
||||||
|
|
||||||
|
## [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
|
||||||
+48
-8
@@ -7,7 +7,7 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
||||||
@@ -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.
|
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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw plugins install @mem0/openclaw-mem0
|
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)
|
### Platform (Mem0 Cloud)
|
||||||
|
|
||||||
Get an API key from [app.mem0.ai](https://app.mem0.ai), then add to your `openclaw.json`:
|
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,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"apiKey": "${MEM0_API_KEY}",
|
"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,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"mode": "open-source",
|
"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 |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `memory_search` | Search memories by natural language |
|
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
|
||||||
| `memory_list` | List all stored memories for a user |
|
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
|
||||||
| `memory_store` | Explicitly save a fact |
|
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
|
||||||
| `memory_get` | Retrieve a memory by ID |
|
| `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
|
## CLI
|
||||||
|
|
||||||
@@ -107,6 +141,12 @@ openclaw mem0 search "what languages does the user know" --scope session
|
|||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
openclaw mem0 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
|
## Options
|
||||||
@@ -116,7 +156,7 @@ openclaw mem0 stats
|
|||||||
| Key | Type | Default | |
|
| Key | Type | Default | |
|
||||||
|-----|------|---------|---|
|
|-----|------|---------|---|
|
||||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
|
| `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 |
|
| `autoRecall` | `boolean` | `true` | Inject memories before each turn |
|
||||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
|
| `autoCapture` | `boolean` | `true` | Store facts after each turn |
|
||||||
| `topK` | `number` | `5` | Max memories per recall |
|
| `topK` | `number` | `5` | Max memories per recall |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+181
-37
@@ -10,6 +10,8 @@
|
|||||||
* - Short-term (session-scoped) and long-term (user-scoped) memory
|
* - Short-term (session-scoped) and long-term (user-scoped) memory
|
||||||
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
|
* - 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
|
* - 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
|
* - CLI: openclaw mem0 search, openclaw mem0 stats
|
||||||
* - Dual mode: platform or open-source (self-hosted)
|
* - Dual mode: platform or open-source (self-hosted)
|
||||||
*/
|
*/
|
||||||
@@ -39,6 +41,7 @@ type Mem0Config = {
|
|||||||
vectorStore?: { provider: string; config: Record<string, unknown> };
|
vectorStore?: { provider: string; config: Record<string, unknown> };
|
||||||
llm?: { provider: string; config: Record<string, unknown> };
|
llm?: { provider: string; config: Record<string, unknown> };
|
||||||
historyDbPath?: string;
|
historyDbPath?: string;
|
||||||
|
disableHistory?: boolean;
|
||||||
};
|
};
|
||||||
// Shared
|
// Shared
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -56,6 +59,7 @@ interface AddOptions {
|
|||||||
custom_categories?: Array<Record<string, string>>;
|
custom_categories?: Array<Record<string, string>>;
|
||||||
enable_graph?: boolean;
|
enable_graph?: boolean;
|
||||||
output_format?: string;
|
output_format?: string;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -66,12 +70,14 @@ interface SearchOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
keyword_search?: boolean;
|
keyword_search?: boolean;
|
||||||
reranking?: boolean;
|
reranking?: boolean;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListOptions {
|
interface ListOptions {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
run_id?: string;
|
run_id?: string;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoryItem {
|
interface MemoryItem {
|
||||||
@@ -127,7 +133,10 @@ class PlatformProvider implements Mem0Provider {
|
|||||||
private async ensureClient(): Promise<void> {
|
private async ensureClient(): Promise<void> {
|
||||||
if (this.client) return;
|
if (this.client) return;
|
||||||
if (this.initPromise) return this.initPromise;
|
if (this.initPromise) return this.initPromise;
|
||||||
this.initPromise = this._init();
|
this.initPromise = this._init().catch((err) => {
|
||||||
|
this.initPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +161,7 @@ class PlatformProvider implements Mem0Provider {
|
|||||||
opts.custom_categories = options.custom_categories;
|
opts.custom_categories = options.custom_categories;
|
||||||
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
if (options.enable_graph) opts.enable_graph = options.enable_graph;
|
||||||
if (options.output_format) opts.output_format = options.output_format;
|
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);
|
const result = await this.client.add(messages, opts);
|
||||||
return normalizeAddResult(result);
|
return normalizeAddResult(result);
|
||||||
@@ -159,12 +169,17 @@ class PlatformProvider implements Mem0Provider {
|
|||||||
|
|
||||||
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
|
||||||
await this.ensureClient();
|
await this.ensureClient();
|
||||||
const opts: Record<string, unknown> = { user_id: options.user_id };
|
const filters: Record<string, unknown> = { user_id: options.user_id };
|
||||||
if (options.run_id) opts.run_id = options.run_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.top_k != null) opts.top_k = options.top_k;
|
||||||
if (options.threshold != null) opts.threshold = options.threshold;
|
if (options.threshold != null) opts.threshold = options.threshold;
|
||||||
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
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);
|
const results = await this.client.search(query, opts);
|
||||||
return normalizeSearchResults(results);
|
return normalizeSearchResults(results);
|
||||||
@@ -181,6 +196,7 @@ class PlatformProvider implements Mem0Provider {
|
|||||||
const opts: Record<string, unknown> = { user_id: options.user_id };
|
const opts: Record<string, unknown> = { user_id: options.user_id };
|
||||||
if (options.run_id) opts.run_id = options.run_id;
|
if (options.run_id) opts.run_id = options.run_id;
|
||||||
if (options.page_size != null) opts.page_size = options.page_size;
|
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);
|
const results = await this.client.getAll(opts);
|
||||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||||
@@ -213,7 +229,10 @@ class OSSProvider implements Mem0Provider {
|
|||||||
private async ensureMemory(): Promise<void> {
|
private async ensureMemory(): Promise<void> {
|
||||||
if (this.memory) return;
|
if (this.memory) return;
|
||||||
if (this.initPromise) return this.initPromise;
|
if (this.initPromise) return this.initPromise;
|
||||||
this.initPromise = this._init();
|
this.initPromise = this._init().catch((err) => {
|
||||||
|
this.initPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +253,30 @@ class OSSProvider implements Mem0Provider {
|
|||||||
config.historyDbPath = dbPath;
|
config.historyDbPath = dbPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.ossConfig?.disableHistory) {
|
||||||
|
config.disableHistory = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.customPrompt) config.customPrompt = this.customPrompt;
|
if (this.customPrompt) config.customPrompt = this.customPrompt;
|
||||||
|
|
||||||
this.memory = new Memory(config);
|
try {
|
||||||
|
this.memory = new Memory(config);
|
||||||
|
} catch (err) {
|
||||||
|
// If initialization fails (e.g. native SQLite binding resolution under
|
||||||
|
// jiti), retry with history disabled — the history DB is the most common
|
||||||
|
// source of native-binding failures and is not required for core
|
||||||
|
// memory operations.
|
||||||
|
if (!config.disableHistory) {
|
||||||
|
console.warn(
|
||||||
|
"[mem0] Memory initialization failed, retrying with history disabled:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
config.disableHistory = true;
|
||||||
|
this.memory = new Memory(config);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(
|
async add(
|
||||||
@@ -247,6 +287,7 @@ class OSSProvider implements Mem0Provider {
|
|||||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||||
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
const addOpts: Record<string, unknown> = { userId: options.user_id };
|
||||||
if (options.run_id) addOpts.runId = options.run_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);
|
const result = await this.memory.add(messages, addOpts);
|
||||||
return normalizeAddResult(result);
|
return normalizeAddResult(result);
|
||||||
}
|
}
|
||||||
@@ -260,9 +301,18 @@ class OSSProvider implements Mem0Provider {
|
|||||||
else if (options.top_k != null) opts.limit = options.top_k;
|
else if (options.top_k != null) opts.limit = options.top_k;
|
||||||
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
|
||||||
if (options.reranking != null) opts.reranking = options.reranking;
|
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);
|
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> {
|
async get(memoryId: string): Promise<MemoryItem> {
|
||||||
@@ -276,6 +326,7 @@ class OSSProvider implements Mem0Provider {
|
|||||||
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
// OSS SDK uses camelCase: userId/runId, not user_id/run_id
|
||||||
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
|
const getAllOpts: Record<string, unknown> = { userId: options.user_id };
|
||||||
if (options.run_id) getAllOpts.runId = options.run_id;
|
if (options.run_id) getAllOpts.runId = options.run_id;
|
||||||
|
if (options.source) getAllOpts.source = options.source;
|
||||||
const results = await this.memory.getAll(getAllOpts);
|
const results = await this.memory.getAll(getAllOpts);
|
||||||
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
if (Array.isArray(results)) return results.map(normalizeMemoryItem);
|
||||||
if (results?.results && Array.isArray(results.results))
|
if (results?.results && Array.isArray(results.results))
|
||||||
@@ -498,7 +549,7 @@ function assertAllowedKeys(
|
|||||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mem0ConfigSchema = {
|
export const mem0ConfigSchema = {
|
||||||
parse(value: unknown): Mem0Config {
|
parse(value: unknown): Mem0Config {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
throw new Error("openclaw-mem0 config required");
|
throw new Error("openclaw-mem0 config required");
|
||||||
@@ -564,7 +615,7 @@ const mem0ConfigSchema = {
|
|||||||
// Provider Factory
|
// Provider Factory
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function createProvider(
|
export function createProvider(
|
||||||
cfg: Mem0Config,
|
cfg: Mem0Config,
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
): Mem0Provider {
|
): Mem0Provider {
|
||||||
@@ -588,6 +639,50 @@ function categoriesToArray(
|
|||||||
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
|
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
|
// Plugin Definition
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -607,14 +702,24 @@ const memoryPlugin = {
|
|||||||
// Track current session ID for tool-level session scoping
|
// Track current session ID for tool-level session scoping
|
||||||
let currentSessionId: string | undefined;
|
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(
|
api.logger.info(
|
||||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper: build add options
|
// Helper: build add options
|
||||||
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
|
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
|
||||||
const opts: AddOptions = {
|
const opts: AddOptions = {
|
||||||
user_id: userIdOverride || cfg.userId,
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||||
|
source: "OPENCLAW",
|
||||||
};
|
};
|
||||||
if (runId) opts.run_id = runId;
|
if (runId) opts.run_id = runId;
|
||||||
if (cfg.mode === "platform") {
|
if (cfg.mode === "platform") {
|
||||||
@@ -631,14 +736,16 @@ const memoryPlugin = {
|
|||||||
userIdOverride?: string,
|
userIdOverride?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
runId?: string,
|
runId?: string,
|
||||||
|
sessionKey?: string,
|
||||||
): SearchOptions {
|
): SearchOptions {
|
||||||
const opts: SearchOptions = {
|
const opts: SearchOptions = {
|
||||||
user_id: userIdOverride || cfg.userId,
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||||
top_k: limit ?? cfg.topK,
|
top_k: limit ?? cfg.topK,
|
||||||
limit: limit ?? cfg.topK,
|
limit: limit ?? cfg.topK,
|
||||||
threshold: cfg.searchThreshold,
|
threshold: cfg.searchThreshold,
|
||||||
keyword_search: true,
|
keyword_search: true,
|
||||||
reranking: true,
|
reranking: true,
|
||||||
|
source: "OPENCLAW",
|
||||||
};
|
};
|
||||||
if (runId) opts.run_id = runId;
|
if (runId) opts.run_id = runId;
|
||||||
return opts;
|
return opts;
|
||||||
@@ -667,6 +774,12 @@ const memoryPlugin = {
|
|||||||
"User ID to scope search (default: configured userId)",
|
"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(
|
scope: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("session"),
|
Type.Literal("session"),
|
||||||
@@ -679,39 +792,41 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { query, limit, userId, scope = "all" } = params as {
|
const { query, limit, userId, agentId, scope = "all" } = params as {
|
||||||
query: string;
|
query: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
agentId?: string;
|
||||||
scope?: "session" | "long-term" | "all";
|
scope?: "session" | "long-term" | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let results: MemoryItem[] = [];
|
let results: MemoryItem[] = [];
|
||||||
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
|
|
||||||
if (scope === "session") {
|
if (scope === "session") {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
results = await provider.search(
|
results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (scope === "long-term") {
|
} else if (scope === "long-term") {
|
||||||
results = await provider.search(
|
results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// "all" — search both scopes and combine
|
// "all" — search both scopes and combine
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
let sessionResults: MemoryItem[] = [];
|
let sessionResults: MemoryItem[] = [];
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Deduplicate by ID, preferring long-term
|
// Deduplicate by ID, preferring long-term
|
||||||
@@ -784,6 +899,12 @@ const memoryPlugin = {
|
|||||||
description: "User ID to scope this memory",
|
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(
|
metadata: Type.Optional(
|
||||||
Type.Record(Type.String(), Type.Unknown(), {
|
Type.Record(Type.String(), Type.Unknown(), {
|
||||||
description: "Optional metadata to attach to this memory",
|
description: "Optional metadata to attach to this memory",
|
||||||
@@ -797,18 +918,20 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { text, userId, longTerm = true } = params as {
|
const { text, userId, agentId, longTerm = true } = params as {
|
||||||
text: string;
|
text: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
agentId?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
longTerm?: boolean;
|
longTerm?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
||||||
const result = await provider.add(
|
const result = await provider.add(
|
||||||
[{ role: "user", content: text }],
|
[{ role: "user", content: text }],
|
||||||
buildAddOptions(userId, runId),
|
buildAddOptions(uid, runId, currentSessionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const added =
|
const added =
|
||||||
@@ -900,7 +1023,7 @@ const memoryPlugin = {
|
|||||||
name: "memory_list",
|
name: "memory_list",
|
||||||
label: "Memory List",
|
label: "Memory List",
|
||||||
description:
|
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({
|
parameters: Type.Object({
|
||||||
userId: Type.Optional(
|
userId: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -908,6 +1031,12 @@ const memoryPlugin = {
|
|||||||
"User ID to list memories for (default: configured userId)",
|
"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(
|
scope: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("session"),
|
Type.Literal("session"),
|
||||||
@@ -920,29 +1049,31 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
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 {
|
try {
|
||||||
let memories: MemoryItem[] = [];
|
let memories: MemoryItem[] = [];
|
||||||
const uid = userId || cfg.userId;
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
|
|
||||||
if (scope === "session") {
|
if (scope === "session") {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
memories = await provider.getAll({
|
memories = await provider.getAll({
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
run_id: currentSessionId,
|
run_id: currentSessionId,
|
||||||
|
source: "OPENCLAW",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (scope === "long-term") {
|
} else if (scope === "long-term") {
|
||||||
memories = await provider.getAll({ user_id: uid });
|
memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
|
||||||
} else {
|
} else {
|
||||||
// "all" — combine both scopes
|
// "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[] = [];
|
let session: MemoryItem[] = [];
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
session = await provider.getAll({
|
session = await provider.getAll({
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
run_id: currentSessionId,
|
run_id: currentSessionId,
|
||||||
|
source: "OPENCLAW",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const seen = new Set(longTerm.map((r) => r.id));
|
const seen = new Set(longTerm.map((r) => r.id));
|
||||||
@@ -1005,7 +1136,7 @@ const memoryPlugin = {
|
|||||||
name: "memory_forget",
|
name: "memory_forget",
|
||||||
label: "Memory Forget",
|
label: "Memory Forget",
|
||||||
description:
|
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({
|
parameters: Type.Object({
|
||||||
query: Type.Optional(
|
query: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -1015,11 +1146,18 @@ const memoryPlugin = {
|
|||||||
memoryId: Type.Optional(
|
memoryId: Type.Optional(
|
||||||
Type.String({ description: "Specific memory ID to delete" }),
|
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) {
|
async execute(_toolCallId, params) {
|
||||||
const { query, memoryId } = params as {
|
const { query, memoryId, agentId } = params as {
|
||||||
query?: string;
|
query?: string;
|
||||||
memoryId?: string;
|
memoryId?: string;
|
||||||
|
agentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1034,9 +1172,10 @@ const memoryPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
|
const uid = _resolveUserId({ agentId });
|
||||||
const results = await provider.search(
|
const results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, 5),
|
buildSearchOptions(uid, 5),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
if (!results || results.length === 0) {
|
||||||
@@ -1127,10 +1266,12 @@ const memoryPlugin = {
|
|||||||
.argument("<query>", "Search query")
|
.argument("<query>", "Search query")
|
||||||
.option("--limit <n>", "Max results", String(cfg.topK))
|
.option("--limit <n>", "Max results", String(cfg.topK))
|
||||||
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
|
.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 {
|
try {
|
||||||
const limit = parseInt(opts.limit, 10);
|
const limit = parseInt(opts.limit, 10);
|
||||||
const scope = opts.scope as "session" | "long-term" | "all";
|
const scope = opts.scope as "session" | "long-term" | "all";
|
||||||
|
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
|
||||||
|
|
||||||
let allResults: MemoryItem[] = [];
|
let allResults: MemoryItem[] = [];
|
||||||
|
|
||||||
@@ -1138,7 +1279,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const sessionResults = await provider.search(
|
const sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
if (sessionResults?.length) {
|
if (sessionResults?.length) {
|
||||||
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
|
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
|
||||||
@@ -1152,7 +1293,7 @@ const memoryPlugin = {
|
|||||||
if (scope === "long-term" || scope === "all") {
|
if (scope === "long-term" || scope === "all") {
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
if (longTermResults?.length) {
|
if (longTermResults?.length) {
|
||||||
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
|
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
|
||||||
@@ -1191,13 +1332,16 @@ const memoryPlugin = {
|
|||||||
mem0
|
mem0
|
||||||
.command("stats")
|
.command("stats")
|
||||||
.description("Show memory statistics from Mem0")
|
.description("Show memory statistics from Mem0")
|
||||||
.action(async () => {
|
.option("--agent <agentId>", "Show stats for a specific agent")
|
||||||
|
.action(async (opts: { agent?: string }) => {
|
||||||
try {
|
try {
|
||||||
|
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
|
||||||
const memories = await provider.getAll({
|
const memories = await provider.getAll({
|
||||||
user_id: cfg.userId,
|
user_id: uid,
|
||||||
|
source: "OPENCLAW",
|
||||||
});
|
});
|
||||||
console.log(`Mode: ${cfg.mode}`);
|
console.log(`Mode: ${cfg.mode}`);
|
||||||
console.log(`User: ${cfg.userId}`);
|
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
|
||||||
console.log(
|
console.log(
|
||||||
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
||||||
);
|
);
|
||||||
@@ -1227,10 +1371,10 @@ const memoryPlugin = {
|
|||||||
if (sessionId) currentSessionId = sessionId;
|
if (sessionId) currentSessionId = sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search long-term memories (user-scoped)
|
// Search long-term memories (user-scoped, isolated per agent)
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(),
|
buildSearchOptions(undefined, undefined, undefined, sessionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search session memories (session-scoped) if we have a session ID
|
// Search session memories (session-scoped) if we have a session ID
|
||||||
@@ -1238,7 +1382,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(undefined, undefined, currentSessionId),
|
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,7 +1487,7 @@ const memoryPlugin = {
|
|||||||
|
|
||||||
if (formattedMessages.length === 0) return;
|
if (formattedMessages.length === 0) return;
|
||||||
|
|
||||||
const addOpts = buildAddOptions(undefined, currentSessionId);
|
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
|
||||||
const result = await provider.add(
|
const result = await provider.add(
|
||||||
formattedMessages,
|
formattedMessages,
|
||||||
addOpts,
|
addOpts,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/openclaw-mem0",
|
"name": "@mem0/openclaw-mem0",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
"mem0",
|
"mem0",
|
||||||
"long-term-memory"
|
"long-term-memory"
|
||||||
],
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"mem0ai": "^2.2.1"
|
"mem0ai": "^2.2.1"
|
||||||
@@ -19,5 +22,8 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Tests for SQLite resilience fixes:
|
||||||
|
* 1. disableHistory config passthrough
|
||||||
|
* 2. initPromise poisoning fix (retry after failure)
|
||||||
|
* 3. Graceful SQLite fallback in OSSProvider
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { mem0ConfigSchema, createProvider } from "./index.ts";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Config: disableHistory passthrough
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("mem0ConfigSchema — disableHistory", () => {
|
||||||
|
const baseConfig = {
|
||||||
|
mode: "open-source",
|
||||||
|
oss: {
|
||||||
|
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("preserves oss.disableHistory: true through config parsing", () => {
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
...baseConfig,
|
||||||
|
oss: { ...baseConfig.oss, disableHistory: true },
|
||||||
|
});
|
||||||
|
expect(cfg.oss?.disableHistory).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves oss.disableHistory: false through config parsing", () => {
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
...baseConfig,
|
||||||
|
oss: { ...baseConfig.oss, disableHistory: false },
|
||||||
|
});
|
||||||
|
expect(cfg.oss?.disableHistory).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits disableHistory when not provided", () => {
|
||||||
|
const cfg = mem0ConfigSchema.parse(baseConfig);
|
||||||
|
expect(cfg.oss?.disableHistory).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reject unknown keys inside oss object", () => {
|
||||||
|
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
|
||||||
|
expect(() =>
|
||||||
|
mem0ConfigSchema.parse({
|
||||||
|
...baseConfig,
|
||||||
|
oss: { ...baseConfig.oss, disableHistory: true },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. OSSProvider: disableHistory flows to Memory constructor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("OSSProvider — disableHistory passthrough to Memory", () => {
|
||||||
|
let capturedConfig: Record<string, unknown> | undefined;
|
||||||
|
let memoryCallCount: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedConfig = undefined;
|
||||||
|
memoryCallCount = 0;
|
||||||
|
|
||||||
|
vi.doMock("mem0ai/oss", () => ({
|
||||||
|
Memory: class MockMemory {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
memoryCallCount++;
|
||||||
|
capturedConfig = { ...config };
|
||||||
|
}
|
||||||
|
async add() { return { results: [] }; }
|
||||||
|
async search() { return { results: [] }; }
|
||||||
|
async get() { return {}; }
|
||||||
|
async getAll() { return []; }
|
||||||
|
async delete() { }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes disableHistory: true to Memory when configured", async () => {
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "open-source",
|
||||||
|
oss: { disableHistory: true },
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
// Trigger lazy init by calling search
|
||||||
|
try {
|
||||||
|
await provider.search("test", { user_id: "u1" });
|
||||||
|
} catch { /* provider may fail on mock, that's ok */ }
|
||||||
|
|
||||||
|
expect(capturedConfig).toBeDefined();
|
||||||
|
expect(capturedConfig!.disableHistory).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set disableHistory when not configured", async () => {
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "open-source",
|
||||||
|
oss: {},
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provider.search("test", { user_id: "u1" });
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
expect(capturedConfig).toBeDefined();
|
||||||
|
expect(capturedConfig!.disableHistory).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("OSSProvider — initPromise retry after failure", () => {
|
||||||
|
let callCount: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
callCount = 0;
|
||||||
|
|
||||||
|
vi.doMock("mem0ai/oss", () => ({
|
||||||
|
Memory: class MockMemory {
|
||||||
|
constructor() {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
|
||||||
|
}
|
||||||
|
// Second+ call succeeds
|
||||||
|
}
|
||||||
|
async search() { return { results: [] }; }
|
||||||
|
async get() { return {}; }
|
||||||
|
async getAll() { return []; }
|
||||||
|
async add() { return { results: [] }; }
|
||||||
|
async delete() { }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries initialization after a transient failure", async () => {
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "open-source",
|
||||||
|
oss: { disableHistory: true },
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
// First call: _init throws, but initPromise is cleared so retry is possible
|
||||||
|
await expect(
|
||||||
|
provider.search("test", { user_id: "u1" }),
|
||||||
|
).rejects.toThrow("SQLITE_CANTOPEN");
|
||||||
|
|
||||||
|
// Second call: should retry _init (not return cached rejection)
|
||||||
|
// callCount === 1 threw, so callCount === 2 should succeed
|
||||||
|
const results = await provider.search("test", { user_id: "u1" });
|
||||||
|
expect(results).toBeDefined();
|
||||||
|
expect(callCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. OSSProvider: graceful fallback disables history on init failure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("OSSProvider — graceful SQLite fallback", () => {
|
||||||
|
let capturedConfigs: Record<string, unknown>[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedConfigs = [];
|
||||||
|
|
||||||
|
vi.doMock("mem0ai/oss", () => ({
|
||||||
|
Memory: class MockMemory {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
capturedConfigs.push({ ...config });
|
||||||
|
if (!config.disableHistory) {
|
||||||
|
throw new Error("Could not locate the bindings file");
|
||||||
|
}
|
||||||
|
// Succeeds when disableHistory is true
|
||||||
|
}
|
||||||
|
async search() { return { results: [] }; }
|
||||||
|
async get() { return {}; }
|
||||||
|
async getAll() { return []; }
|
||||||
|
async add() { return { results: [] }; }
|
||||||
|
async delete() { }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries with disableHistory: true when initial construction fails", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "open-source",
|
||||||
|
oss: {},
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
// Should succeed — first attempt fails, fallback with disableHistory succeeds
|
||||||
|
const results = await provider.search("test", { user_id: "u1" });
|
||||||
|
expect(results).toBeDefined();
|
||||||
|
|
||||||
|
// Memory constructor was called twice
|
||||||
|
expect(capturedConfigs).toHaveLength(2);
|
||||||
|
expect(capturedConfigs[0].disableHistory).toBeFalsy();
|
||||||
|
expect(capturedConfigs[1].disableHistory).toBe(true);
|
||||||
|
|
||||||
|
// Warning was logged
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[mem0] Memory initialization failed"),
|
||||||
|
expect.stringContaining("bindings file"),
|
||||||
|
);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when disableHistory is already true", async () => {
|
||||||
|
vi.doMock("mem0ai/oss", () => ({
|
||||||
|
Memory: class MockMemory {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
// Fail even with disableHistory (e.g. vector store issue)
|
||||||
|
throw new Error("vector store connection refused");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "open-source",
|
||||||
|
oss: { disableHistory: true },
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
// Should throw — no fallback possible when disableHistory was already set
|
||||||
|
await expect(
|
||||||
|
provider.search("test", { user_id: "u1" }),
|
||||||
|
).rejects.toThrow("vector store connection refused");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. PlatformProvider — initPromise retry after failure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("PlatformProvider — initPromise retry after failure", () => {
|
||||||
|
let callCount: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
callCount = 0;
|
||||||
|
|
||||||
|
vi.doMock("mem0ai", () => ({
|
||||||
|
default: class MockMemoryClient {
|
||||||
|
constructor() {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
throw new Error("Network timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async search() { return []; }
|
||||||
|
async get() { return {}; }
|
||||||
|
async getAll() { return []; }
|
||||||
|
async add() { return { results: [] }; }
|
||||||
|
async delete() { }
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries initialization after a transient failure", async () => {
|
||||||
|
const { createProvider } = await import("./index.ts");
|
||||||
|
const cfg = mem0ConfigSchema.parse({
|
||||||
|
mode: "platform",
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
});
|
||||||
|
const api = { resolvePath: (p: string) => p } as any;
|
||||||
|
const provider = createProvider(cfg, api);
|
||||||
|
|
||||||
|
// First call fails
|
||||||
|
await expect(
|
||||||
|
provider.search("test", { user_id: "u1" }),
|
||||||
|
).rejects.toThrow("Network timeout");
|
||||||
|
|
||||||
|
// Second call should retry (not return cached rejection)
|
||||||
|
const results = await provider.search("test", { user_id: "u1" });
|
||||||
|
expect(results).toBeDefined();
|
||||||
|
expect(callCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0ai"
|
name = "mem0ai"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
description = "Long-term memory for AI Agents"
|
description = "Long-term memory for AI Agents"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Mem0", email = "founders@mem0.ai" }
|
{ name = "Mem0", email = "founders@mem0.ai" }
|
||||||
@@ -20,7 +20,7 @@ dependencies = [
|
|||||||
"posthog>=3.5.0",
|
"posthog>=3.5.0",
|
||||||
"pytz>=2024.1",
|
"pytz>=2024.1",
|
||||||
"sqlalchemy>=2.0.31",
|
"sqlalchemy>=2.0.31",
|
||||||
"protobuf>=5.29.0,<6.0.0",
|
"protobuf>=5.29.6,<7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
"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 the 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 the 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 any 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
|
||||||
|
|
||||||
|
Copyright 2024 Mem0.ai
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Mem0 Skill for Claude
|
||||||
|
|
||||||
|
Add persistent memory to any AI application in minutes using [Mem0 Platform](https://app.mem0.ai).
|
||||||
|
|
||||||
|
## What This Skill Does
|
||||||
|
|
||||||
|
When installed, Claude can:
|
||||||
|
|
||||||
|
- **Set up Mem0** in your Python or TypeScript project
|
||||||
|
- **Integrate memory** into your existing AI app (LangChain, CrewAI, Vercel AI, OpenAI Agents, LangGraph, LlamaIndex, etc.)
|
||||||
|
- **Generate working code** using real API references and tested patterns
|
||||||
|
- **Search live docs** on demand for the latest Mem0 documentation
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Claude.ai
|
||||||
|
|
||||||
|
1. Download this `skills/mem0` folder as a ZIP
|
||||||
|
2. Go to **Settings > Capabilities > Skills**
|
||||||
|
3. Click **Upload skill** and select the ZIP
|
||||||
|
|
||||||
|
### Claude API (Skills API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.anthropic.com/v1/skills \
|
||||||
|
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "mem0", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||||
|
- Python 3.10+ or Node.js 18+
|
||||||
|
- Set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
After installing, just ask Claude:
|
||||||
|
|
||||||
|
- "Set up mem0 in my project"
|
||||||
|
- "Add memory to my chatbot"
|
||||||
|
- "Help me search user memories with filters"
|
||||||
|
- "Integrate mem0 with my LangChain app"
|
||||||
|
- "Add graph memory to track entity relationships"
|
||||||
|
|
||||||
|
## What's Inside
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/mem0/
|
||||||
|
├── SKILL.md # Skill definition and instructions
|
||||||
|
├── README.md # This file
|
||||||
|
├── LICENSE # Apache-2.0
|
||||||
|
├── scripts/
|
||||||
|
│ └── mem0_doc_search.py # Search live Mem0 docs on demand
|
||||||
|
└── references/ # Documentation (loaded on demand)
|
||||||
|
├── quickstart.md # Full quickstart (Python, TS, cURL)
|
||||||
|
├── sdk-guide.md # All SDK methods (Python + TypeScript)
|
||||||
|
├── api-reference.md # REST endpoints, filters, memory object
|
||||||
|
├── architecture.md # Processing pipeline, lifecycle, scoping, performance
|
||||||
|
├── features.md # Retrieval, graph, categories, MCP, webhooks, multimodal
|
||||||
|
├── integration-patterns.md # LangChain, CrewAI, Vercel AI, LangGraph, LlamaIndex, etc.
|
||||||
|
└── use-cases.md # 7 real-world patterns with Python + TypeScript code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Mem0 Platform Dashboard](https://app.mem0.ai)
|
||||||
|
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||||
|
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
|
||||||
|
- [API Reference](https://docs.mem0.ai/api-reference)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: mem0
|
||||||
|
description: >
|
||||||
|
Integrate Mem0 Platform into AI applications for persistent memory, personalization, and semantic search.
|
||||||
|
Use this skill when the user mentions "mem0", "memory layer", "remember user preferences",
|
||||||
|
"persistent context", "personalization", or needs to add long-term memory to chatbots, agents,
|
||||||
|
or AI apps. Covers Python and TypeScript SDKs, framework integrations (LangChain, CrewAI,
|
||||||
|
Vercel AI SDK, OpenAI Agents SDK, Pipecat), and the full Platform API. Use even when the user
|
||||||
|
doesn't explicitly say "mem0" but describes needing conversation memory, user context retention,
|
||||||
|
or knowledge retrieval across sessions.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: mem0ai
|
||||||
|
version: "1.0.0"
|
||||||
|
category: ai-memory
|
||||||
|
tags: "memory, personalization, ai, python, typescript, vector-search"
|
||||||
|
compatibility: Requires Python 3.10+ or Node.js 18+, pip install mem0ai or npm install mem0ai, MEM0_API_KEY env var, and internet access to api.mem0.ai
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mem0 Platform Integration
|
||||||
|
|
||||||
|
Mem0 is a managed memory layer for AI applications. It stores, retrieves, and manages user memories via API — no infrastructure to deploy.
|
||||||
|
|
||||||
|
## Step 1: Install and authenticate
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```bash
|
||||||
|
pip install mem0ai
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript/JavaScript:**
|
||||||
|
```bash
|
||||||
|
npm install mem0ai
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Get an API key at: https://app.mem0.ai/dashboard/api-keys
|
||||||
|
|
||||||
|
## Step 2: Initialize the client
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
client = MemoryClient(api_key="m0-xxx")
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||||
|
```
|
||||||
|
|
||||||
|
For async Python, use `AsyncMemoryClient`.
|
||||||
|
|
||||||
|
## Step 3: Core operations
|
||||||
|
|
||||||
|
Every Mem0 integration follows the same pattern: **retrieve → generate → store**.
|
||||||
|
|
||||||
|
### Add memories
|
||||||
|
```python
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||||
|
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||||
|
]
|
||||||
|
client.add(messages, user_id="alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search memories
|
||||||
|
```python
|
||||||
|
results = client.search("dietary preferences", user_id="alice")
|
||||||
|
for mem in results.get("results", []):
|
||||||
|
print(mem["memory"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get all memories
|
||||||
|
```python
|
||||||
|
all_memories = client.get_all(user_id="alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a memory
|
||||||
|
```python
|
||||||
|
client.update("memory-uuid", text="Updated: vegetarian, nut allergy, prefers organic")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a memory
|
||||||
|
```python
|
||||||
|
client.delete("memory-uuid")
|
||||||
|
client.delete_all(user_id="alice") # delete all for a user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common integration pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
openai = OpenAI()
|
||||||
|
|
||||||
|
def chat(user_input: str, user_id: str) -> str:
|
||||||
|
# 1. Retrieve relevant memories
|
||||||
|
memories = mem0.search(user_input, user_id=user_id)
|
||||||
|
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||||
|
|
||||||
|
# 2. Generate response with memory context
|
||||||
|
response = openai.chat.completions.create(
|
||||||
|
model="gpt-4.1-nano-2025-04-14",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": f"User context:\n{context}"},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reply = response.choices[0].message.content
|
||||||
|
|
||||||
|
# 3. Store interaction for future context
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": user_input}, {"role": "assistant", "content": reply}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common edge cases
|
||||||
|
|
||||||
|
- **Search returns empty:** Memories process asynchronously. Wait 2-3s after `add()` before searching. Also verify `user_id` matches exactly (case-sensitive).
|
||||||
|
- **AND filter with user_id + agent_id returns empty:** Entities are stored separately. Use `OR` instead, or query separately.
|
||||||
|
- **Duplicate memories:** Don't mix `infer=True` (default) and `infer=False` for the same data. Stick to one mode.
|
||||||
|
- **Wrong import:** Always use `from mem0 import MemoryClient` (or `AsyncMemoryClient` for async). Do not use `from mem0 import Memory`.
|
||||||
|
- **Immutable memories:** Cannot be updated or deleted once created. Use `client.history(memory_id)` to track changes over time.
|
||||||
|
|
||||||
|
## Live documentation search
|
||||||
|
|
||||||
|
For the latest docs beyond what's in the references, use the doc search tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/mem0_doc_search.py --query "topic"
|
||||||
|
python scripts/mem0_doc_search.py --page "/platform/features/graph-memory"
|
||||||
|
python scripts/mem0_doc_search.py --index
|
||||||
|
```
|
||||||
|
|
||||||
|
No API key needed — searches docs.mem0.ai directly.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Load these on demand for deeper detail:
|
||||||
|
|
||||||
|
| Topic | File |
|
||||||
|
|-------|------|
|
||||||
|
| Quickstart (Python, TS, cURL) | [references/quickstart.md](references/quickstart.md) |
|
||||||
|
| SDK guide (all methods, both languages) | [references/sdk-guide.md](references/sdk-guide.md) |
|
||||||
|
| API reference (endpoints, filters, object schema) | [references/api-reference.md](references/api-reference.md) |
|
||||||
|
| Architecture (pipeline, lifecycle, scoping, performance) | [references/architecture.md](references/architecture.md) |
|
||||||
|
| Platform features (retrieval, graph, categories, MCP, etc.) | [references/features.md](references/features.md) |
|
||||||
|
| Framework integrations (LangChain, CrewAI, Vercel AI, etc.) | [references/integration-patterns.md](references/integration-patterns.md) |
|
||||||
|
| Use cases & examples (real-world patterns with code) | [references/use-cases.md](references/use-cases.md) |
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Mem0 Platform API Reference
|
||||||
|
|
||||||
|
REST API endpoints for the Mem0 Platform. Base URL: `https://api.mem0.ai`
|
||||||
|
|
||||||
|
All endpoints require: `Authorization: Token <MEM0_API_KEY>`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Operation | Method | URL |
|
||||||
|
|-----------|--------|-----|
|
||||||
|
| Add Memories | `POST` | `/v1/memories/` |
|
||||||
|
| Search Memories | `POST` | `/v2/memories/search/` |
|
||||||
|
| Get All Memories | `POST` | `/v2/memories/` |
|
||||||
|
| Get Single Memory | `GET` | `/v1/memories/{memory_id}/` |
|
||||||
|
| Update Memory | `PUT` | `/v1/memories/{memory_id}/` |
|
||||||
|
| Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` |
|
||||||
|
|
||||||
|
## Memory Object Structure
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | string (UUID) | Unique memory identifier |
|
||||||
|
| `memory` | string | Text content of the memory |
|
||||||
|
| `user_id` | string | Associated user |
|
||||||
|
| `agent_id` | string (nullable) | Agent identifier |
|
||||||
|
| `app_id` | string (nullable) | Application identifier |
|
||||||
|
| `run_id` | string (nullable) | Run/session identifier |
|
||||||
|
| `metadata` | object | Custom key-value pairs |
|
||||||
|
| `categories` | array of strings | Auto-assigned category tags |
|
||||||
|
| `immutable` | boolean | If true, prevents modification |
|
||||||
|
| `expiration_date` | datetime (nullable) | Auto-expiry date |
|
||||||
|
| `hash` | string | Content hash |
|
||||||
|
| `created_at` | datetime | Creation timestamp |
|
||||||
|
| `updated_at` | datetime | Last modification timestamp |
|
||||||
|
|
||||||
|
Search results additionally include `score` (relevance metric).
|
||||||
|
|
||||||
|
## Scoping Identifiers
|
||||||
|
|
||||||
|
Memories can be scoped to different levels:
|
||||||
|
|
||||||
|
| Scope | Parameter | Use Case |
|
||||||
|
|-------|-----------|----------|
|
||||||
|
| User | `user_id` | Per-user memory isolation |
|
||||||
|
| Agent | `agent_id` | Per-agent memory partitioning |
|
||||||
|
| Application | `app_id` | Cross-agent app-level memory |
|
||||||
|
| Run/Session | `run_id` | Session-scoped temporary memory |
|
||||||
|
|
||||||
|
**Critical:** Combining `user_id` and `agent_id` in a single AND filter yields empty results. Entities are stored separately. Use `OR` logic or separate queries.
|
||||||
|
|
||||||
|
## Processing Model
|
||||||
|
|
||||||
|
- Memories are processed **asynchronously by default** (`async_mode=true`)
|
||||||
|
- Add responses return queued events (`ADD`, `UPDATE`, `DELETE`) for tracking
|
||||||
|
- Set `async_mode=false` for synchronous processing when needed
|
||||||
|
- Graph metadata is processed asynchronously -- use `get_all()` for complete graph data
|
||||||
|
|
||||||
|
## Filter System
|
||||||
|
|
||||||
|
Filters use nested JSON with a logical operator at the root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"AND": [
|
||||||
|
{"user_id": "alice"},
|
||||||
|
{"categories": {"contains": "finance"}},
|
||||||
|
{"created_at": {"gte": "2024-01-01"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Root must be `AND`, `OR`, or `NOT`. Simple shorthand `{"user_id": "alice"}` also works.
|
||||||
|
|
||||||
|
### Supported Operators
|
||||||
|
|
||||||
|
| Operator | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `eq` | Equal to (default) |
|
||||||
|
| `ne` | Not equal to |
|
||||||
|
| `in` | Matches any value in array |
|
||||||
|
| `gt`, `gte` | Greater than / greater than or equal |
|
||||||
|
| `lt`, `lte` | Less than / less than or equal |
|
||||||
|
| `contains` | Case-sensitive containment |
|
||||||
|
| `icontains` | Case-insensitive containment |
|
||||||
|
| `*` | Wildcard -- matches any non-null value |
|
||||||
|
|
||||||
|
### Filterable Fields
|
||||||
|
|
||||||
|
| Field | Valid Operators |
|
||||||
|
|-------|-----------------|
|
||||||
|
| `user_id`, `agent_id`, `app_id`, `run_id` | `eq`, `ne`, `in`, `*` |
|
||||||
|
| `created_at`, `updated_at`, `timestamp` | `gt`, `gte`, `lt`, `lte`, `eq`, `ne` |
|
||||||
|
| `categories` | `eq`, `ne`, `in`, `contains` |
|
||||||
|
| `metadata` | `eq`, `ne`, `contains` (top-level keys only) |
|
||||||
|
| `keywords` | `contains`, `icontains` |
|
||||||
|
| `memory_ids` | `in` |
|
||||||
|
|
||||||
|
### Filter Constraints
|
||||||
|
|
||||||
|
1. **Entity scope partitioning:** `user_id` AND `agent_id` in one `AND` block yields empty results.
|
||||||
|
2. **Metadata limitations:** Only top-level keys. Only `eq`, `contains`, `ne`. No `in` or `gt`.
|
||||||
|
3. **Operator syntax:** Use `gte`, `lt`, `ne`. SQL-style (`>=`, `!=`) rejected.
|
||||||
|
4. **Entity filter required for get-all:** At least one of `user_id`, `agent_id`, `app_id`, or `run_id`.
|
||||||
|
5. **Wildcard excludes null:** `*` matches only non-null values.
|
||||||
|
6. **Date format:** ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`). Timezone-naive defaults to UTC.
|
||||||
|
|
||||||
|
## Response Formats
|
||||||
|
|
||||||
|
### Add Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "mem_01JF8ZS4Y0R0SPM13R5R6H32CJ",
|
||||||
|
"event": "ADD",
|
||||||
|
"data": { "memory": "The user moved to Austin in 2025." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Event types: `ADD`, `UPDATE`, `DELETE`. A single add can trigger multiple events.
|
||||||
|
|
||||||
|
### Search Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "ea925981-...",
|
||||||
|
"memory": "Is a vegetarian and allergic to nuts.",
|
||||||
|
"user_id": "user123",
|
||||||
|
"categories": ["food", "health"],
|
||||||
|
"score": 0.89,
|
||||||
|
"created_at": "2024-07-26T10:29:36.630547-07:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With `enable_graph=true`, includes additional `relations` array with entity relationships.
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# Mem0 Platform Architecture
|
||||||
|
|
||||||
|
How Mem0 processes, stores, and retrieves memories under the hood.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Core Concept](#core-concept)
|
||||||
|
- [Memory Processing Pipeline](#memory-processing-pipeline)
|
||||||
|
- [Retrieval Pipeline](#retrieval-pipeline)
|
||||||
|
- [Memory Lifecycle](#memory-lifecycle)
|
||||||
|
- [Memory Object Structure](#memory-object-structure)
|
||||||
|
- [Scoping & Multi-Tenancy](#scoping--multi-tenancy)
|
||||||
|
- [Memory Layers](#memory-layers)
|
||||||
|
- [Performance Characteristics](#performance-characteristics)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept
|
||||||
|
|
||||||
|
Mem0 is a managed memory layer that sits between your AI application and users. Every integration follows the same 3-step loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
User Input → Retrieve relevant memories → Enrich LLM prompt → Generate response → Store new memories
|
||||||
|
```
|
||||||
|
|
||||||
|
Mem0 handles the complexity of extraction, deduplication, conflict resolution, and semantic retrieval so your application only needs to call `search()` and `add()`.
|
||||||
|
|
||||||
|
**Dual storage architecture:**
|
||||||
|
- **Vector store**: Embeddings for semantic similarity search
|
||||||
|
- **Graph store** (optional): Entity nodes and relationship edges for structured knowledge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Processing Pipeline
|
||||||
|
|
||||||
|
### What happens when you call `client.add()`
|
||||||
|
|
||||||
|
```
|
||||||
|
Messages In
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 1. EXTRACTION │ LLM analyzes messages, extracts key facts
|
||||||
|
│ (infer=True) │ If infer=False, stores raw text as-is
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 2. CONFLICT │ Checks existing memories for duplicates
|
||||||
|
│ RESOLUTION │ Latest truth wins (newer overrides older)
|
||||||
|
│ │ Only runs when infer=True
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 3. STORAGE │ Generates embeddings → vector store
|
||||||
|
│ │ Optional: entity extraction → graph store
|
||||||
|
│ │ Indexes metadata, categories, timestamps
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Memory Object
|
||||||
|
(id, memory, categories, structured_attributes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing modes
|
||||||
|
|
||||||
|
**Async (default, `async_mode=True`):**
|
||||||
|
- API returns immediately: `{"status": "PENDING", "event_id": "..."}`
|
||||||
|
- Processing happens in background
|
||||||
|
- Use webhooks for completion notifications
|
||||||
|
- Best for: high-throughput, non-blocking workflows
|
||||||
|
|
||||||
|
**Sync (`async_mode=False`):**
|
||||||
|
- API waits for full processing
|
||||||
|
- Returns complete memory object with `id`, `event`, `memory`
|
||||||
|
- Best for: real-time access immediately after add
|
||||||
|
|
||||||
|
### Extraction modes
|
||||||
|
|
||||||
|
**Inferred (`infer=True`, default):**
|
||||||
|
- LLM extracts structured facts from conversation
|
||||||
|
- Conflict resolution deduplicates and resolves contradictions
|
||||||
|
- Best for: natural conversation → memory
|
||||||
|
|
||||||
|
**Raw (`infer=False`):**
|
||||||
|
- Stores text exactly as provided, no LLM processing
|
||||||
|
- Skips conflict resolution — same fact can be stored twice
|
||||||
|
- Only `user` role messages are stored; `assistant` messages ignored
|
||||||
|
- Best for: bulk imports, pre-structured data, migrations
|
||||||
|
|
||||||
|
**Warning:** Don't mix `infer=True` and `infer=False` for the same data — the same fact will be stored twice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieval Pipeline
|
||||||
|
|
||||||
|
### What happens when you call `client.search()`
|
||||||
|
|
||||||
|
```
|
||||||
|
Query In
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 1. QUERY EMBEDDING │ Convert query to vector representation
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 2. VECTOR SEARCH │ Cosine similarity across stored embeddings
|
||||||
|
│ │ Scoped by filters (user_id, agent_id, etc.)
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼ (optional enhancements)
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 3a. KEYWORD SEARCH │ Expands results with specific terms (+10ms)
|
||||||
|
│ 3b. RERANKING │ Deep semantic reordering (+150-200ms)
|
||||||
|
│ 3c. FILTER MEMORIES │ Precision filtering, removes low-relevance (+200-300ms)
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼ (if enable_graph=True)
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 4. GRAPH LOOKUP │ Finds entity relationships
|
||||||
|
│ │ Appends relations WITHOUT reranking vector results
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Results + Relations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrieval enhancement combinations
|
||||||
|
|
||||||
|
| Configuration | Latency | Best for |
|
||||||
|
|--------------|---------|----------|
|
||||||
|
| Base search only | ~100ms | Simple lookups |
|
||||||
|
| `keyword_search=True` | ~110ms | Entity-heavy queries, broad coverage |
|
||||||
|
| `rerank=True` | ~250-300ms | User-facing results, top-N precision |
|
||||||
|
| `keyword_search=True` + `rerank=True` | ~310ms | Balanced (recommended for most apps) |
|
||||||
|
| `rerank=True` + `filter_memories=True` | ~400-500ms | Safety-critical, production systems |
|
||||||
|
|
||||||
|
### Implicit null scoping
|
||||||
|
|
||||||
|
When you search with `user_id="alice"` only, Mem0 returns memories where `agent_id`, `app_id`, and `run_id` are all null. This prevents cross-scope leakage by default.
|
||||||
|
|
||||||
|
To include memories with non-null fields, use explicit filters:
|
||||||
|
```python
|
||||||
|
# Gets memories for alice regardless of agent/app/run
|
||||||
|
filters={"OR": [{"user_id": "alice"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
CREATE ──→ ACTIVE ──→ UPDATE ──→ ACTIVE
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ EXPIRED EXPIRED
|
||||||
|
│ (still stored, (still stored,
|
||||||
|
│ not retrieved) not retrieved)
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
DELETE DELETE DELETE
|
||||||
|
(permanent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creation
|
||||||
|
- Triggered by `client.add(messages, user_id="...")`
|
||||||
|
- Messages processed through extraction → conflict resolution → storage
|
||||||
|
- Gets unique UUID, `created_at` timestamp
|
||||||
|
- Optional: custom `timestamp`, `expiration_date`, `metadata`, `immutable`
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
- `client.update(memory_id, text="...")` replaces text and reindexes
|
||||||
|
- `client.batch_update([...])` for up to 1000 memories at once
|
||||||
|
- Immutable memories (`immutable=True`) cannot be updated — must delete and re-add
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
- Automatic during `add()` with `infer=True`
|
||||||
|
- Conflict resolution merges duplicate facts
|
||||||
|
- Latest truth wins when contradictions detected
|
||||||
|
- Prevents memory bloat from repeated information
|
||||||
|
|
||||||
|
### Expiration
|
||||||
|
- Optional `expiration_date` parameter (ISO 8601 or `YYYY-MM-DD`)
|
||||||
|
- After expiration: memory NOT returned in searches but remains in storage
|
||||||
|
- Useful for time-sensitive info (events, temporary preferences, session state)
|
||||||
|
|
||||||
|
### Deletion
|
||||||
|
- Single: `client.delete(memory_id)` — permanent, no recovery
|
||||||
|
- Batch: `client.batch_delete([memory_ids])` — up to 1000
|
||||||
|
- Bulk: `client.delete_all(user_id="alice")` — all memories for entity
|
||||||
|
- `delete_all()` without filters raises error to prevent accidental data loss
|
||||||
|
|
||||||
|
### History tracking
|
||||||
|
- `client.history(memory_id)` returns version timeline
|
||||||
|
- Shows all changes: `{previous_value, new_value, action, timestamps}`
|
||||||
|
- Useful for audit trails and debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Object Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid-string",
|
||||||
|
"memory": "Extracted memory text",
|
||||||
|
"user_id": "user-identifier",
|
||||||
|
"agent_id": null,
|
||||||
|
"app_id": null,
|
||||||
|
"run_id": null,
|
||||||
|
"metadata": { "source": "chat", "priority": "high" },
|
||||||
|
"categories": ["health", "preferences"],
|
||||||
|
"created_at": "2025-03-12T12:34:56Z",
|
||||||
|
"updated_at": "2025-03-12T12:34:56Z",
|
||||||
|
"expiration_date": null,
|
||||||
|
"immutable": false,
|
||||||
|
"structured_attributes": {
|
||||||
|
"day": 12, "month": 3, "year": 2025,
|
||||||
|
"hour": 12, "minute": 34,
|
||||||
|
"day_of_week": "wednesday",
|
||||||
|
"is_weekend": false,
|
||||||
|
"quarter": 1, "week_of_year": 11
|
||||||
|
},
|
||||||
|
"score": 0.85
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | UUID | Unique identifier, used for update/delete |
|
||||||
|
| `memory` | string | Extracted or stored text content |
|
||||||
|
| `user_id` | string | Primary entity scope |
|
||||||
|
| `agent_id` | string | Agent scope |
|
||||||
|
| `app_id` | string | Application scope |
|
||||||
|
| `run_id` | string | Session/run scope |
|
||||||
|
| `metadata` | object | Custom key-value pairs for filtering |
|
||||||
|
| `categories` | array | Auto-assigned or custom category tags |
|
||||||
|
| `created_at` | datetime | Creation timestamp |
|
||||||
|
| `updated_at` | datetime | Last modification timestamp |
|
||||||
|
| `expiration_date` | datetime | Auto-expiry date (stops retrieval, data persists) |
|
||||||
|
| `immutable` | boolean | If true, prevents modification |
|
||||||
|
| `structured_attributes` | object | Temporal breakdown for time-based queries |
|
||||||
|
| `score` | float | Semantic similarity (search results only, 0-1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoping & Multi-Tenancy
|
||||||
|
|
||||||
|
Mem0 separates memories across four dimensions to prevent data mixing:
|
||||||
|
|
||||||
|
| Dimension | Field | Purpose | Example |
|
||||||
|
|-----------|-------|---------|---------|
|
||||||
|
| User | `user_id` | Persistent persona or account | `"customer_6412"` |
|
||||||
|
| Agent | `agent_id` | Distinct agent or tool | `"meal_planner"` |
|
||||||
|
| App | `app_id` | Product surface or deployment | `"ios_retail_app"` |
|
||||||
|
| Session | `run_id` | Short-lived flow or thread | `"ticket-9241"` |
|
||||||
|
|
||||||
|
### Storage model
|
||||||
|
|
||||||
|
Each entity combination creates separate records. A memory with `user_id="alice"` is stored separately from one with `user_id="alice"` + `agent_id="bot"`.
|
||||||
|
|
||||||
|
### Critical: cross-entity queries
|
||||||
|
|
||||||
|
```python
|
||||||
|
# This returns NOTHING — user and agent memories are stored separately
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||||
|
|
||||||
|
# Use OR to query multiple scopes
|
||||||
|
filters={"OR": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||||
|
|
||||||
|
# Use wildcard to include any non-null value
|
||||||
|
filters={"AND": [{"user_id": "*"}]} # All users (excludes null)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended scoping patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
# User-level: persistent preferences
|
||||||
|
client.add(messages, user_id="alice")
|
||||||
|
|
||||||
|
# Session-level: temporary context
|
||||||
|
client.add(messages, user_id="alice", run_id="session_123")
|
||||||
|
# Clean up when done: client.delete_all(run_id="session_123")
|
||||||
|
|
||||||
|
# Agent-level: agent-specific knowledge
|
||||||
|
client.add(messages, agent_id="support_bot", app_id="helpdesk")
|
||||||
|
|
||||||
|
# Multi-tenant: full isolation
|
||||||
|
client.add(messages, user_id="alice", agent_id="bot", app_id="acme_corp", run_id="ticket_42")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Layers
|
||||||
|
|
||||||
|
Mem0 supports three layers of memory, from shortest to longest lived:
|
||||||
|
|
||||||
|
### Conversation memory
|
||||||
|
- In-flight messages within a single turn
|
||||||
|
- Tool calls, chain-of-thought reasoning
|
||||||
|
- **Lifetime:** Single response — lost after turn finishes
|
||||||
|
- **Managed by:** Your application, not Mem0
|
||||||
|
|
||||||
|
### Session memory
|
||||||
|
- Short-lived facts for current task or channel
|
||||||
|
- Multi-step flows (onboarding, debugging, support tickets)
|
||||||
|
- **Lifetime:** Minutes to hours
|
||||||
|
- **Managed by:** Mem0 via `run_id` parameter
|
||||||
|
- Clean up with `client.delete_all(run_id="session_id")`
|
||||||
|
|
||||||
|
### User memory
|
||||||
|
- Long-lived knowledge tied to a person or account
|
||||||
|
- Personal preferences, account state, compliance details
|
||||||
|
- **Lifetime:** Weeks to forever
|
||||||
|
- **Managed by:** Mem0 via `user_id` parameter
|
||||||
|
- Persists across all sessions and interactions
|
||||||
|
|
||||||
|
### How layering works in practice
|
||||||
|
|
||||||
|
```python
|
||||||
|
def chat(user_input: str, user_id: str, session_id: str) -> str:
|
||||||
|
# 1. Retrieve user memories (long-term preferences)
|
||||||
|
user_mems = mem0.search(user_input, user_id=user_id)
|
||||||
|
|
||||||
|
# 2. Retrieve session memories (current task context)
|
||||||
|
session_mems = mem0.search(user_input, filters={
|
||||||
|
"AND": [{"user_id": user_id}, {"run_id": session_id}]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Combine both layers for LLM context
|
||||||
|
context = format_memories(user_mems) + format_memories(session_mems)
|
||||||
|
|
||||||
|
# 4. Generate response
|
||||||
|
response = llm.generate(context=context, input=user_input)
|
||||||
|
|
||||||
|
# 5. Store in session scope (temporary) + user scope (persistent)
|
||||||
|
messages = [{"role": "user", "content": user_input}, {"role": "assistant", "content": response}]
|
||||||
|
mem0.add(messages, user_id=user_id, run_id=session_id)
|
||||||
|
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
### Latency
|
||||||
|
|
||||||
|
| Operation | Typical Latency |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Base vector search | ~100ms |
|
||||||
|
| + keyword_search | +10ms |
|
||||||
|
| + reranking | +150-200ms |
|
||||||
|
| + filter_memories | +200-300ms |
|
||||||
|
| Add (async, default) | < 50ms response, background processing |
|
||||||
|
| Add (sync) | 500ms-2s depending on extraction complexity |
|
||||||
|
| Graph operations | Slight overhead for large stores |
|
||||||
|
|
||||||
|
### Processing
|
||||||
|
|
||||||
|
- **Async mode (default):** Returns immediately, processes in background
|
||||||
|
- **Sync mode:** Waits for full extraction + storage pipeline
|
||||||
|
- **Batch operations:** Up to 1000 memories per batch_update/batch_delete
|
||||||
|
- **Webhooks:** Real-time notifications when async processing completes
|
||||||
|
|
||||||
|
### Scoping strategy for performance
|
||||||
|
|
||||||
|
- Use `user_id` for all user-facing queries (most common, fastest)
|
||||||
|
- Add `run_id` for session isolation (narrows search space)
|
||||||
|
- Avoid wildcard `"*"` filters on large datasets (scans all non-null records)
|
||||||
|
- Use `top_k` to limit result count when you only need a few memories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison with Alternatives
|
||||||
|
|
||||||
|
| Approach | Pros | Cons |
|
||||||
|
|----------|------|------|
|
||||||
|
| **Raw vector DB** | Fast, full control | No extraction, no dedup, no conflict resolution |
|
||||||
|
| **In-memory chat history** | Zero latency | Lost on restart, no cross-session, grows unbounded |
|
||||||
|
| **RAG over documents** | Good for static knowledge | No personalization, no memory updates |
|
||||||
|
| **Mem0 Platform** | Managed extraction + dedup + graph + scoping | External dependency, async processing delay |
|
||||||
|
|
||||||
|
Mem0 combines the best of vector search (semantic retrieval) with automatic extraction (LLM-powered), conflict resolution (deduplication), and structured scoping (multi-tenancy) — in a single managed API.
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
# Platform Features -- Mem0 Platform
|
||||||
|
|
||||||
|
Additional platform capabilities beyond core CRUD operations.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Advanced Retrieval](#advanced-retrieval)
|
||||||
|
- [Graph Memory](#graph-memory)
|
||||||
|
- [Custom Categories](#custom-categories)
|
||||||
|
- [Custom Instructions](#custom-instructions)
|
||||||
|
- [Criteria Retrieval](#criteria-retrieval)
|
||||||
|
- [Feedback Mechanism](#feedback-mechanism)
|
||||||
|
- [Memory Export](#memory-export)
|
||||||
|
- [Group Chat](#group-chat)
|
||||||
|
- [MCP Integration](#mcp-integration)
|
||||||
|
- [Webhooks](#webhooks)
|
||||||
|
- [Multimodal Support](#multimodal-support)
|
||||||
|
|
||||||
|
## Advanced Retrieval
|
||||||
|
|
||||||
|
Three enhancement options for tuning search precision, recall, and latency.
|
||||||
|
|
||||||
|
### Keyword Search (`keyword_search=True`)
|
||||||
|
|
||||||
|
Expands results to include memories with specific terms, names, and technical keywords.
|
||||||
|
|
||||||
|
- Latency: +10ms
|
||||||
|
- Recall: Significantly increased
|
||||||
|
- Best for: entity-heavy queries, comprehensive coverage
|
||||||
|
|
||||||
|
### Reranking (`rerank=True`)
|
||||||
|
|
||||||
|
Deep semantic reordering of results — most relevant first.
|
||||||
|
|
||||||
|
- Latency: +150-200ms
|
||||||
|
- Accuracy: Significantly improved
|
||||||
|
- Best for: user-facing results, top-N precision
|
||||||
|
|
||||||
|
### Filter Memories (`filter_memories=True`)
|
||||||
|
|
||||||
|
Precision filtering — removes low-relevance results entirely.
|
||||||
|
|
||||||
|
- Latency: +200-300ms
|
||||||
|
- Precision: Maximized
|
||||||
|
- Best for: safety-critical applications, production systems
|
||||||
|
|
||||||
|
### Recommended Combinations
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
# Fast & broad
|
||||||
|
results = client.search(query, keyword_search=True, user_id="user123")
|
||||||
|
|
||||||
|
# Balanced (recommended for most apps)
|
||||||
|
results = client.search(query, keyword_search=True, rerank=True, user_id="user123")
|
||||||
|
|
||||||
|
# High precision (critical apps)
|
||||||
|
results = client.search(query, rerank=True, filter_memories=True, user_id="user123")
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const results = await client.search(query, {
|
||||||
|
user_id: 'user123',
|
||||||
|
keyword_search: true,
|
||||||
|
rerank: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Graph Memory
|
||||||
|
|
||||||
|
Entity-level knowledge graph that creates relationships between memories.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Extraction**: LLM analyzes conversation and identifies entities and relationships
|
||||||
|
2. **Storage**: Embeddings go to vector store; entity nodes and edges go to graph store
|
||||||
|
3. **Retrieval**: Vector search returns semantic matches; graph relations are appended to results
|
||||||
|
|
||||||
|
Graph relations **augment** vector results without reordering them. Vector similarity always determines hit sequence.
|
||||||
|
|
||||||
|
### Enabling Graph Memory
|
||||||
|
|
||||||
|
**Per request:**
|
||||||
|
```python
|
||||||
|
client.add(messages, user_id="alice", enable_graph=True)
|
||||||
|
client.search("query", user_id="alice", enable_graph=True)
|
||||||
|
client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Project-level (default for all operations):**
|
||||||
|
```python
|
||||||
|
client.project.update(enable_graph=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await client.updateProject({ enable_graph: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relation Structure
|
||||||
|
|
||||||
|
Each relation in the response contains:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `source` | string | Source entity name |
|
||||||
|
| `source_type` | string | Source entity type (e.g., "Person") |
|
||||||
|
| `relationship` | string | Relationship label (e.g., "lives_in") |
|
||||||
|
| `target` | string | Target entity name |
|
||||||
|
| `target_type` | string | Target entity type (e.g., "City") |
|
||||||
|
| `score` | number | Confidence score |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"source": "Joseph",
|
||||||
|
"source_type": "Person",
|
||||||
|
"relationship": "lives_in",
|
||||||
|
"target": "Seattle",
|
||||||
|
"target_type": "City",
|
||||||
|
"score": 0.92
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technical Notes
|
||||||
|
|
||||||
|
- Graph Memory adds processing time; see docs for current plan availability
|
||||||
|
- Works optimally with rich conversation histories containing entity relationships
|
||||||
|
- Best suited for long-running assistants tracking evolving information
|
||||||
|
- Graph writes and reads toggle independently per request
|
||||||
|
- Multi-agent context supported via `user_id`, `agent_id`, `run_id` scoping
|
||||||
|
- Add operations are asynchronous; graph metadata may not be immediately available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Categories
|
||||||
|
|
||||||
|
Replace Mem0's default 15 labels with domain-specific categories. The system automatically tags memories to the closest matching category.
|
||||||
|
|
||||||
|
### Default Categories (15)
|
||||||
|
|
||||||
|
`personal_details`, `family`, `professional_details`, `sports`, `travel`, `food`, `music`, `health`, `technology`, `hobbies`, `fashion`, `entertainment`, `milestones`, `user_preferences`, `misc`
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**Set project-level categories:**
|
||||||
|
```python
|
||||||
|
new_categories = [
|
||||||
|
{"lifestyle_management": "Tracks daily routines, habits, wellness activities"},
|
||||||
|
{"seeking_structure": "Documents goals around creating routines and systems"},
|
||||||
|
{"personal_information": "Basic information about the user"}
|
||||||
|
]
|
||||||
|
client.project.update(custom_categories=new_categories)
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await client.updateProject({ custom_categories: new_categories });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retrieve active categories:**
|
||||||
|
```python
|
||||||
|
categories = client.project.get(fields=["custom_categories"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Constraint
|
||||||
|
|
||||||
|
Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Instructions
|
||||||
|
|
||||||
|
Natural language filters that control what information Mem0 extracts when creating memories.
|
||||||
|
|
||||||
|
### Set Instructions
|
||||||
|
|
||||||
|
```python
|
||||||
|
client.project.update(custom_instructions="Your guidelines here...")
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await client.updateProject({ custom_instructions: "Your guidelines here..." });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Structure
|
||||||
|
|
||||||
|
1. **Task Description** -- brief extraction overview
|
||||||
|
2. **Information Categories** -- numbered sections with specific details to capture
|
||||||
|
3. **Processing Guidelines** -- quality and handling rules
|
||||||
|
4. **Exclusion List** -- sensitive/irrelevant data to filter out
|
||||||
|
|
||||||
|
### Domain Examples
|
||||||
|
|
||||||
|
**E-commerce:** Capture product issues, preferences, service experience; exclude payment data.
|
||||||
|
|
||||||
|
**Education:** Extract learning progress, student preferences, performance patterns; exclude specific grades.
|
||||||
|
|
||||||
|
**Finance:** Track financial goals, life events, investment interests; exclude account numbers and SSNs.
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Start simply, test with sample messages, iterate based on results
|
||||||
|
- Avoid overly lengthy instructions
|
||||||
|
- Be specific about what to include AND exclude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criteria Retrieval
|
||||||
|
|
||||||
|
Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Define criteria at project level
|
||||||
|
retrieval_criteria = [
|
||||||
|
{"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3},
|
||||||
|
{"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2},
|
||||||
|
{"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4},
|
||||||
|
]
|
||||||
|
client.project.update(retrieval_criteria=retrieval_criteria)
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.updateProject({
|
||||||
|
retrieval_criteria: [
|
||||||
|
{ name: 'joy', description: 'Positive emotions', weight: 3 },
|
||||||
|
{ name: 'urgency', description: 'Time-sensitive items', weight: 4 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Once configured, `client.search()` automatically applies criteria ranking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Criteria-weighted results returned automatically
|
||||||
|
results = client.search("Why am I feeling happy?", filters={"user_id": "alice"})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:** Wellness assistants, tutoring platforms, productivity tools — any app needing intent-aware retrieval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback Mechanism
|
||||||
|
|
||||||
|
Provide feedback on extracted memories to improve system quality over time.
|
||||||
|
|
||||||
|
### Feedback Types
|
||||||
|
|
||||||
|
| Type | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `POSITIVE` | Memory is useful and accurate |
|
||||||
|
| `NEGATIVE` | Memory is not useful |
|
||||||
|
| `VERY_NEGATIVE` | Memory is harmful or completely wrong |
|
||||||
|
| `None` | Clear existing feedback |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
client.feedback(
|
||||||
|
memory_id="mem-123",
|
||||||
|
feedback="POSITIVE",
|
||||||
|
feedback_reason="Accurately captured dietary preference"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bulk feedback
|
||||||
|
for item in feedback_data:
|
||||||
|
client.feedback(**item)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
await client.feedback('mem-123', {
|
||||||
|
feedback: 'POSITIVE',
|
||||||
|
feedback_reason: 'Accurately captured dietary preference',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Export
|
||||||
|
|
||||||
|
Create structured exports of memories using customizable schemas with filters.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Define export schema
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"preferences": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"health_info": {"type": "string"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create export
|
||||||
|
response = client.create_memory_export(
|
||||||
|
schema=json.dumps(schema),
|
||||||
|
filters={"user_id": "alice"},
|
||||||
|
export_instructions="Create comprehensive profile based on all memories"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve export (may take a moment to process)
|
||||||
|
result = client.get_memory_export(memory_export_id=response["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:** Data analytics, user profile generation, compliance audits, CRM sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Chat
|
||||||
|
|
||||||
|
Process multi-participant conversations and automatically attribute memories to individual speakers.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "name": "Alice", "content": "I think we should use React for the frontend"},
|
||||||
|
{"role": "user", "name": "Bob", "content": "I prefer Vue.js, it's simpler for our use case"},
|
||||||
|
{"role": "assistant", "content": "Both are great choices. Let me note your preferences."},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mem0 automatically attributes memories to each speaker
|
||||||
|
response = client.add(messages, run_id="team_meeting_1")
|
||||||
|
|
||||||
|
# Retrieve Alice's memories from that session
|
||||||
|
alice_mems = client.get_all(
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"run_id": "team_meeting_1"}]}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `name` field in messages to identify speakers. Mem0 maps names to entity scopes automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mem0": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mem0-mcp-server"],
|
||||||
|
"env": {
|
||||||
|
"MEM0_API_KEY": "m0-your-api-key",
|
||||||
|
"MEM0_DEFAULT_USER_ID": "your-user-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available MCP Tools
|
||||||
|
|
||||||
|
The MCP server exposes 9 memory tools that AI agents can use autonomously:
|
||||||
|
- Add, search, get, update, delete memories
|
||||||
|
- Get history, list users, delete users
|
||||||
|
- Search Mem0 documentation
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. Configure the MCP server in your AI client
|
||||||
|
2. The agent autonomously decides when to store/retrieve memories
|
||||||
|
3. No manual API calls needed — the agent manages memory as part of its reasoning
|
||||||
|
|
||||||
|
**Best for:** Universal AI client integration — one protocol works everywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
Real-time event notifications for memory operations.
|
||||||
|
|
||||||
|
### Supported Events
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|-------|---------|
|
||||||
|
| `memory_add` | Memory created |
|
||||||
|
| `memory_update` | Memory modified |
|
||||||
|
| `memory_delete` | Memory removed |
|
||||||
|
| `memory_categorize` | Memory tagged |
|
||||||
|
|
||||||
|
### Create Webhook
|
||||||
|
|
||||||
|
Note: `project_id` here refers to the Mem0 dashboard project scope for webhooks — not the deprecated client init parameter.
|
||||||
|
|
||||||
|
```python
|
||||||
|
webhook = client.create_webhook(
|
||||||
|
url="https://your-app.com/webhook",
|
||||||
|
name="Memory Logger",
|
||||||
|
project_id="proj_123",
|
||||||
|
event_types=["memory_add", "memory_categorize"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manage Webhooks
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Retrieve
|
||||||
|
webhooks = client.get_webhooks(project_id="proj_123")
|
||||||
|
|
||||||
|
# Update
|
||||||
|
client.update_webhook(
|
||||||
|
name="Updated Logger",
|
||||||
|
url="https://your-app.com/new-webhook",
|
||||||
|
event_types=["memory_update", "memory_add"],
|
||||||
|
webhook_id="wh_123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
client.delete_webhook(webhook_id="wh_123")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payload Structure
|
||||||
|
|
||||||
|
Memory events contain: ID, data object with memory content, event type (`ADD`/`UPDATE`/`DELETE`).
|
||||||
|
Categorization events contain: memory ID, event type (`CATEGORIZE`), assigned category labels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multimodal Support
|
||||||
|
|
||||||
|
Mem0 can process images and documents alongside text.
|
||||||
|
|
||||||
|
### Supported Media Types
|
||||||
|
|
||||||
|
- Images: JPG, PNG
|
||||||
|
- Documents: MDX, TXT, PDF
|
||||||
|
|
||||||
|
### Image via URL
|
||||||
|
|
||||||
|
```python
|
||||||
|
image_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/image.jpg"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.add([image_message], user_id="alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image via Base64
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
with open("photo.jpg", "rb") as f:
|
||||||
|
base64_image = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
image_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.add([image_message], user_id="alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document (MDX/TXT)
|
||||||
|
|
||||||
|
```python
|
||||||
|
doc_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": {"type": "mdx_url", "mdx_url": {"url": document_url}}
|
||||||
|
}
|
||||||
|
client.add([doc_message], user_id="alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDF Document
|
||||||
|
|
||||||
|
```python
|
||||||
|
pdf_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": {"type": "pdf_url", "pdf_url": {"url": pdf_url}}
|
||||||
|
}
|
||||||
|
client.add([pdf_message], user_id="alice")
|
||||||
|
```
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
# Mem0 Integration Patterns
|
||||||
|
|
||||||
|
Working code examples for integrating Mem0 Platform with popular AI frameworks.
|
||||||
|
All examples use `MemoryClient` (Platform API key).
|
||||||
|
|
||||||
|
Code examples are sourced from official Mem0 integration docs at docs.mem0.ai, simplified for quick reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pattern
|
||||||
|
|
||||||
|
Every integration follows the same 3-step loop:
|
||||||
|
|
||||||
|
1. **Retrieve** -- search relevant memories before generating a response
|
||||||
|
2. **Generate** -- include memories as context in the LLM prompt
|
||||||
|
3. **Store** -- save the interaction back to Mem0 for future use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LangChain
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/langchain](https://docs.mem0.ai/integrations/langchain)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langchain_core.messages import SystemMessage, HumanMessage
|
||||||
|
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
llm = ChatOpenAI(model="gpt-4.1-nano-2025-04-14")
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
|
||||||
|
prompt = ChatPromptTemplate.from_messages([
|
||||||
|
SystemMessage(content="You are a helpful travel agent AI. Use the provided context to personalize your responses."),
|
||||||
|
MessagesPlaceholder(variable_name="context"),
|
||||||
|
HumanMessage(content="{input}")
|
||||||
|
])
|
||||||
|
|
||||||
|
def retrieve_context(query: str, user_id: str):
|
||||||
|
"""Retrieve relevant memories from Mem0"""
|
||||||
|
memories = mem0.search(query, user_id=user_id)
|
||||||
|
memory_list = memories['results']
|
||||||
|
serialized = ' '.join([m["memory"] for m in memory_list])
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": f"Relevant information: {serialized}"},
|
||||||
|
{"role": "user", "content": query}
|
||||||
|
]
|
||||||
|
|
||||||
|
def chat_turn(user_input: str, user_id: str) -> str:
|
||||||
|
# 1. Retrieve
|
||||||
|
context = retrieve_context(user_input, user_id)
|
||||||
|
# 2. Generate
|
||||||
|
chain = prompt | llm
|
||||||
|
response = chain.invoke({"context": context, "input": user_input})
|
||||||
|
# 3. Store
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": user_input}, {"role": "assistant", "content": response.content}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CrewAI
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/crewai](https://docs.mem0.ai/integrations/crewai)
|
||||||
|
|
||||||
|
CrewAI has native Mem0 integration via `memory_config`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crewai import Agent, Task, Crew, Process
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
client = MemoryClient()
|
||||||
|
|
||||||
|
# Store user preferences first
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "I am more of a beach person than a mountain person."},
|
||||||
|
{"role": "assistant", "content": "Noted! I'll recommend beach destinations."},
|
||||||
|
{"role": "user", "content": "I like Airbnb more than hotels."},
|
||||||
|
]
|
||||||
|
client.add(messages, user_id="crew_user_1")
|
||||||
|
|
||||||
|
# Create agent
|
||||||
|
travel_agent = Agent(
|
||||||
|
role="Personalized Travel Planner",
|
||||||
|
goal="Plan personalized travel itineraries",
|
||||||
|
backstory="You are a seasoned travel planner.",
|
||||||
|
memory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create task
|
||||||
|
task = Task(
|
||||||
|
description="Find places to live, eat, and visit in San Francisco.",
|
||||||
|
expected_output="A detailed list of places to live, eat, and visit.",
|
||||||
|
agent=travel_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup crew with Mem0 memory
|
||||||
|
crew = Crew(
|
||||||
|
agents=[travel_agent],
|
||||||
|
tasks=[task],
|
||||||
|
process=Process.sequential,
|
||||||
|
memory=True,
|
||||||
|
memory_config={
|
||||||
|
"provider": "mem0",
|
||||||
|
"config": {"user_id": "crew_user_1"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = crew.kickoff()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vercel AI SDK
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/vercel-ai-sdk](https://docs.mem0.ai/integrations/vercel-ai-sdk)
|
||||||
|
|
||||||
|
Install: `npm install @mem0/vercel-ai-provider`
|
||||||
|
|
||||||
|
### Basic Text Generation with Memory
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { generateText } from "ai";
|
||||||
|
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||||
|
|
||||||
|
const mem0 = createMem0({
|
||||||
|
provider: "openai",
|
||||||
|
mem0ApiKey: "m0-xxx",
|
||||||
|
apiKey: "openai-api-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||||
|
prompt: "Suggest me a good car to buy!",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming with Memory
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { streamText } from "ai";
|
||||||
|
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||||
|
|
||||||
|
const mem0 = createMem0();
|
||||||
|
|
||||||
|
const { textStream } = streamText({
|
||||||
|
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||||
|
prompt: "Suggest me a good car to buy!",
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const textPart of textStream) {
|
||||||
|
process.stdout.write(textPart);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Memory Utilities Standalone
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
import { generateText } from "ai";
|
||||||
|
import { retrieveMemories, addMemories } from "@mem0/vercel-ai-provider";
|
||||||
|
|
||||||
|
// Retrieve memories and inject into any provider
|
||||||
|
const prompt = "Suggest me a good car to buy.";
|
||||||
|
const memories = await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: openai("gpt-4-turbo"),
|
||||||
|
prompt: prompt,
|
||||||
|
system: memories,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store new memories
|
||||||
|
await addMemories(
|
||||||
|
[{ role: "user", content: [{ type: "text", text: "I love red cars." }] }],
|
||||||
|
{ user_id: "borat", mem0ApiKey: "m0-xxx" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Providers
|
||||||
|
|
||||||
|
`openai`, `anthropic`, `google`, `groq`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAI Agents SDK
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/openai-agents-sdk](https://docs.mem0.ai/integrations/openai-agents-sdk)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agents import Agent, Runner, function_tool
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
|
||||||
|
@function_tool
|
||||||
|
def search_memory(query: str, user_id: str) -> str:
|
||||||
|
"""Search through past conversations and memories"""
|
||||||
|
memories = mem0.search(query, user_id=user_id, top_k=3)
|
||||||
|
if memories and memories.get('results'):
|
||||||
|
return "\n".join([f"- {mem['memory']}" for mem in memories['results']])
|
||||||
|
return "No relevant memories found."
|
||||||
|
|
||||||
|
@function_tool
|
||||||
|
def save_memory(content: str, user_id: str) -> str:
|
||||||
|
"""Save important information to memory"""
|
||||||
|
mem0.add([{"role": "user", "content": content}], user_id=user_id)
|
||||||
|
return "Information saved to memory."
|
||||||
|
|
||||||
|
agent = Agent(
|
||||||
|
name="Personal Assistant",
|
||||||
|
instructions="""You are a helpful personal assistant with memory capabilities.
|
||||||
|
Use search_memory to recall past conversations.
|
||||||
|
Use save_memory to store important information.""",
|
||||||
|
tools=[search_memory, save_memory],
|
||||||
|
model="gpt-4.1-nano-2025-04-14"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Runner.run_sync(agent, "I love Italian food and I'm planning a trip to Rome next month")
|
||||||
|
print(result.final_output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Agent with Handoffs
|
||||||
|
|
||||||
|
```python
|
||||||
|
from agents import Agent, Runner, function_tool
|
||||||
|
|
||||||
|
travel_agent = Agent(
|
||||||
|
name="Travel Planner",
|
||||||
|
instructions="You are a travel planning specialist. Use search_memory and save_memory tools.",
|
||||||
|
tools=[search_memory, save_memory],
|
||||||
|
model="gpt-4.1-nano-2025-04-14"
|
||||||
|
)
|
||||||
|
|
||||||
|
health_agent = Agent(
|
||||||
|
name="Health Advisor",
|
||||||
|
instructions="You are a health and wellness advisor. Use search_memory and save_memory tools.",
|
||||||
|
tools=[search_memory, save_memory],
|
||||||
|
model="gpt-4.1-nano-2025-04-14"
|
||||||
|
)
|
||||||
|
|
||||||
|
triage_agent = Agent(
|
||||||
|
name="Personal Assistant",
|
||||||
|
instructions="""Route travel questions to Travel Planner, health questions to Health Advisor.""",
|
||||||
|
handoffs=[travel_agent, health_agent],
|
||||||
|
model="gpt-4.1-nano-2025-04-14"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Runner.run_sync(triage_agent, "Plan a healthy meal for my Italy trip")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipecat (Voice / Real-Time)
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/pipecat](https://docs.mem0.ai/integrations/pipecat)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pipecat.services.mem0 import Mem0MemoryService
|
||||||
|
|
||||||
|
memory = Mem0MemoryService(
|
||||||
|
api_key=os.getenv("MEM0_API_KEY"),
|
||||||
|
user_id="alice",
|
||||||
|
agent_id="voice_bot",
|
||||||
|
params={
|
||||||
|
"search_limit": 10,
|
||||||
|
"search_threshold": 0.1,
|
||||||
|
"system_prompt": "Here are your past memories:",
|
||||||
|
"add_as_system_message": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use in pipeline
|
||||||
|
pipeline = Pipeline([
|
||||||
|
transport.input(),
|
||||||
|
stt,
|
||||||
|
user_context,
|
||||||
|
memory, # Memory enhances context automatically
|
||||||
|
llm,
|
||||||
|
transport.output(),
|
||||||
|
assistant_context
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LangGraph
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/langgraph](https://docs.mem0.ai/integrations/langgraph)
|
||||||
|
|
||||||
|
State-based agent workflows with memory persistence. Best for complex conversation flows with branching logic.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Annotated, TypedDict, List
|
||||||
|
from langgraph.graph import StateGraph, START
|
||||||
|
from langgraph.graph.message import add_messages
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
||||||
|
|
||||||
|
llm = ChatOpenAI(model="gpt-4")
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
|
||||||
|
class State(TypedDict):
|
||||||
|
messages: Annotated[List[HumanMessage | AIMessage], add_messages]
|
||||||
|
mem0_user_id: str
|
||||||
|
|
||||||
|
def chatbot(state: State):
|
||||||
|
messages = state["messages"]
|
||||||
|
user_id = state["mem0_user_id"]
|
||||||
|
|
||||||
|
# Retrieve relevant memories
|
||||||
|
memories = mem0.search(messages[-1].content, user_id=user_id)
|
||||||
|
context = "Relevant context:\n"
|
||||||
|
for memory in memories["results"]:
|
||||||
|
context += f"- {memory['memory']}\n"
|
||||||
|
|
||||||
|
system_message = SystemMessage(content=f"""You are a helpful support assistant.
|
||||||
|
{context}""")
|
||||||
|
|
||||||
|
response = llm.invoke([system_message] + messages)
|
||||||
|
|
||||||
|
# Store the interaction
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": messages[-1].content},
|
||||||
|
{"role": "assistant", "content": response.content}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
return {"messages": [response]}
|
||||||
|
|
||||||
|
graph = StateGraph(State)
|
||||||
|
graph.add_node("chatbot", chatbot)
|
||||||
|
graph.add_edge(START, "chatbot")
|
||||||
|
app = graph.compile()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
result = app.invoke({
|
||||||
|
"messages": [HumanMessage(content="I need help with my order")],
|
||||||
|
"mem0_user_id": "customer_123"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LlamaIndex
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/llama-index](https://docs.mem0.ai/integrations/llama-index)
|
||||||
|
|
||||||
|
Install: `pip install llama-index-core llama-index-memory-mem0`
|
||||||
|
|
||||||
|
LlamaIndex has native Mem0 support via `Mem0Memory`. Works with ReAct and FunctionCalling agents.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from llama_index.memory.mem0 import Mem0Memory
|
||||||
|
|
||||||
|
context = {"user_id": "alice", "agent_id": "llama_agent_1"}
|
||||||
|
memory = Mem0Memory.from_client(
|
||||||
|
context=context,
|
||||||
|
search_msg_limit=4, # messages from chat history used for retrieval (default: 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use with LlamaIndex agent
|
||||||
|
from llama_index.core.agent import FunctionCallingAgent
|
||||||
|
from llama_index.llms.openai import OpenAI
|
||||||
|
|
||||||
|
llm = OpenAI(model="gpt-4")
|
||||||
|
agent = FunctionCallingAgent.from_tools(
|
||||||
|
tools=[],
|
||||||
|
llm=llm,
|
||||||
|
memory=memory,
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = agent.chat("I prefer vegetarian restaurants")
|
||||||
|
# Memory automatically stores and retrieves context
|
||||||
|
response = agent.chat("What kind of food do I like?")
|
||||||
|
# Agent retrieves the vegetarian preference from Mem0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AutoGen
|
||||||
|
|
||||||
|
Source: [docs.mem0.ai/integrations/autogen](https://docs.mem0.ai/integrations/autogen)
|
||||||
|
|
||||||
|
Install: `pip install autogen mem0ai`
|
||||||
|
|
||||||
|
Multi-agent conversational systems with memory persistence.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from autogen import ConversableAgent
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
memory_client = MemoryClient()
|
||||||
|
USER_ID = "alice"
|
||||||
|
|
||||||
|
agent = ConversableAgent(
|
||||||
|
"chatbot",
|
||||||
|
llm_config={"config_list": [{"model": "gpt-4", "api_key": os.environ["OPENAI_API_KEY"]}]},
|
||||||
|
code_execution_config=False,
|
||||||
|
human_input_mode="NEVER",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_aware_response(question: str) -> str:
|
||||||
|
# Retrieve memories for context
|
||||||
|
relevant_memories = memory_client.search(question, user_id=USER_ID)
|
||||||
|
context = "\n".join([m["memory"] for m in relevant_memories.get("results", [])])
|
||||||
|
|
||||||
|
prompt = f"""Answer considering previous interactions:
|
||||||
|
Previous context: {context}
|
||||||
|
Question: {question}"""
|
||||||
|
|
||||||
|
reply = agent.generate_reply(messages=[{"content": prompt, "role": "user"}])
|
||||||
|
|
||||||
|
# Store the new interaction
|
||||||
|
memory_client.add(
|
||||||
|
[{"role": "user", "content": question}, {"role": "assistant", "content": reply}],
|
||||||
|
user_id=USER_ID
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## All Supported Frameworks
|
||||||
|
|
||||||
|
Beyond the examples above, Mem0 integrates with:
|
||||||
|
|
||||||
|
| Framework | Type | Install |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| [Mastra](https://docs.mem0.ai/integrations/mastra) | TS agent framework | `npm install @mastra/mem0` |
|
||||||
|
| [ElevenLabs](https://docs.mem0.ai/integrations/elevenlabs) | Voice AI | `pip install elevenlabs mem0ai` |
|
||||||
|
| [LiveKit](https://docs.mem0.ai/integrations/livekit) | Real-time voice/video | `pip install livekit-agents mem0ai` |
|
||||||
|
| [Camel AI](https://docs.mem0.ai/integrations/camel-ai) | Multi-agent framework | `pip install camel-ai[all] mem0ai` |
|
||||||
|
| [AWS Bedrock](https://docs.mem0.ai/integrations/aws-bedrock) | Cloud LLM provider | `pip install boto3 mem0ai` |
|
||||||
|
| [Dify](https://docs.mem0.ai/integrations/dify) | Low-code AI platform | Plugin-based |
|
||||||
|
| [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) | Google agent framework | `pip install google-adk mem0ai` |
|
||||||
|
|
||||||
|
For the general Python pattern (no framework), see the "Common integration pattern" in [SKILL.md](../SKILL.md).
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Mem0 Platform Quickstart
|
||||||
|
|
||||||
|
Get running with Mem0 in 2 minutes. No infrastructure to deploy -- just an API key.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+ or Node.js 18+
|
||||||
|
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||||
|
|
||||||
|
## Python Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mem0ai
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
client = MemoryClient(api_key="your-api-key")
|
||||||
|
|
||||||
|
# Add a memory
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||||
|
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||||
|
]
|
||||||
|
client.add(messages, user_id="user123")
|
||||||
|
|
||||||
|
# Search memories
|
||||||
|
results = client.search("What are my dietary restrictions?", user_id="user123")
|
||||||
|
print(results)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Client
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import AsyncMemoryClient
|
||||||
|
|
||||||
|
client = AsyncMemoryClient(api_key="your-api-key")
|
||||||
|
|
||||||
|
await client.add(messages, user_id="user123")
|
||||||
|
results = await client.search("query", user_id="user123")
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript / JavaScript Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install mem0ai
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: 'your-api-key' });
|
||||||
|
|
||||||
|
// Add a memory
|
||||||
|
const messages = [
|
||||||
|
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||||
|
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||||
|
];
|
||||||
|
await client.add(messages, { user_id: "user123" });
|
||||||
|
|
||||||
|
// Search memories
|
||||||
|
const results = await client.search("What are my dietary restrictions?", {
|
||||||
|
user_id: "user123"
|
||||||
|
});
|
||||||
|
console.log(results);
|
||||||
|
```
|
||||||
|
|
||||||
|
## cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MEM0_API_KEY="m0-your-api-key"
|
||||||
|
|
||||||
|
# Add memory
|
||||||
|
curl -X POST https://api.mem0.ai/v1/memories/ \
|
||||||
|
-H "Authorization: Token $MEM0_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "I am a vegetarian and allergic to nuts."},
|
||||||
|
{"role": "assistant", "content": "Got it! I will remember your dietary preferences."}
|
||||||
|
],
|
||||||
|
"user_id": "user123"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Search memories
|
||||||
|
curl -X POST https://api.mem0.ai/v2/memories/search/ \
|
||||||
|
-H "Authorization: Token $MEM0_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"query": "What are my dietary restrictions?",
|
||||||
|
"filters": {"user_id": "user123"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d",
|
||||||
|
"memory": "Allergic to nuts",
|
||||||
|
"user_id": "user123",
|
||||||
|
"categories": ["health"],
|
||||||
|
"created_at": "2025-10-22T04:40:22.864647-07:00",
|
||||||
|
"score": 0.30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [SDK Guide](sdk-guide.md) -- all methods for Python and TypeScript
|
||||||
|
- [API Reference](api-reference.md) -- REST endpoints and memory object structure
|
||||||
|
- [Integration Patterns](integration-patterns.md) -- LangChain, CrewAI, Vercel AI, etc.
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# Mem0 SDK Guide
|
||||||
|
|
||||||
|
Complete SDK reference for Python and TypeScript. All methods use `MemoryClient` (Platform API).
|
||||||
|
|
||||||
|
## Initialization
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
client = MemoryClient(api_key="m0-your-api-key")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python (Async):**
|
||||||
|
```python
|
||||||
|
from mem0 import AsyncMemoryClient
|
||||||
|
client = AsyncMemoryClient(api_key="m0-your-api-key")
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
const client = new MemoryClient({ apiKey: 'm0-your-api-key' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Constructor accepts `apiKey` (required) and `host` (optional, default: `https://api.mem0.ai`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## add() -- Store Memories
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||||
|
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||||
|
]
|
||||||
|
client.add(messages, user_id="alice")
|
||||||
|
|
||||||
|
# With metadata
|
||||||
|
client.add(messages, user_id="alice", metadata={"source": "onboarding"})
|
||||||
|
|
||||||
|
# With graph memory
|
||||||
|
client.add(messages, user_id="alice", enable_graph=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
await client.add(messages, { user_id: "alice" });
|
||||||
|
await client.add(messages, { user_id: "alice", metadata: { source: "onboarding" } });
|
||||||
|
await client.add(messages, { user_id: "alice", enable_graph: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `messages` | array | `[{"role": "user", "content": "..."}]` |
|
||||||
|
| `user_id` | string | User identifier (recommended) |
|
||||||
|
| `agent_id` | string | Agent identifier |
|
||||||
|
| `run_id` | string | Session identifier |
|
||||||
|
| `metadata` | object | Custom key-value pairs |
|
||||||
|
| `enable_graph` | boolean | Activate knowledge graph |
|
||||||
|
| `infer` | boolean | If `false`, store raw text without inference (default: `true`) |
|
||||||
|
| `immutable` | boolean | Prevents modification after creation |
|
||||||
|
| `expiration_date` | string | Auto-expiry date (`YYYY-MM-DD`) |
|
||||||
|
| `includes` | string | Preference filters for inclusion |
|
||||||
|
| `excludes` | string | Preference filters for exclusion |
|
||||||
|
| `async_mode` | boolean | Async processing (default: `true`). Set `false` to wait |
|
||||||
|
|
||||||
|
### Advanced Add Options
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Immutable -- cannot be modified or overwritten
|
||||||
|
client.add(messages, user_id="alice", immutable=True)
|
||||||
|
|
||||||
|
# Expiring memory
|
||||||
|
client.add(messages, user_id="alice", expiration_date="2025-12-31")
|
||||||
|
|
||||||
|
# Selective extraction
|
||||||
|
client.add(messages, user_id="alice", includes="dietary preferences", excludes="payment info")
|
||||||
|
|
||||||
|
# Agent + session scoping
|
||||||
|
client.add(messages, user_id="alice", agent_id="nutrition-agent", run_id="session-456")
|
||||||
|
|
||||||
|
# Synchronous processing (wait for completion)
|
||||||
|
client.add(messages, user_id="alice", async_mode=False)
|
||||||
|
|
||||||
|
# Raw text -- skip LLM inference
|
||||||
|
client.add(
|
||||||
|
[{"role": "user", "content": "User prefers dark mode."}],
|
||||||
|
user_id="alice",
|
||||||
|
infer=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## search() -- Find Memories
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
results = client.search("dietary preferences?", user_id="alice")
|
||||||
|
|
||||||
|
# With filters and reranking
|
||||||
|
results = client.search(
|
||||||
|
query="work experience",
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"categories": {"contains": "professional_details"}}]},
|
||||||
|
top_k=5,
|
||||||
|
rerank=True,
|
||||||
|
threshold=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
# With graph relations
|
||||||
|
results = client.search("colleagues", user_id="alice", enable_graph=True)
|
||||||
|
|
||||||
|
# Keyword search
|
||||||
|
results = client.search("vegetarian", user_id="alice", keyword_search=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const results = await client.search("dietary preferences", { user_id: "alice" });
|
||||||
|
const results = await client.search("work experience", {
|
||||||
|
filters: { AND: [{ user_id: "alice" }, { categories: { contains: "professional_details" } }] },
|
||||||
|
top_k: 5,
|
||||||
|
rerank: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `query` | string | Natural language search query |
|
||||||
|
| `user_id` | string | Filter by user |
|
||||||
|
| `filters` | object | V2 filter object (AND/OR operators) |
|
||||||
|
| `top_k` | number | Number of results (default: 10) |
|
||||||
|
| `rerank` | boolean | Enable reranking for better relevance |
|
||||||
|
| `threshold` | number | Minimum similarity score (default: 0.3) |
|
||||||
|
| `keyword_search` | boolean | Use keyword-based search |
|
||||||
|
| `enable_graph` | boolean | Include graph relations |
|
||||||
|
|
||||||
|
### Common Filter Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Single user (shorthand)
|
||||||
|
client.search("query", user_id="alice")
|
||||||
|
|
||||||
|
# OR across agents
|
||||||
|
filters={"OR": [{"user_id": "alice"}, {"agent_id": {"in": ["travel-agent", "sports-agent"]}}]}
|
||||||
|
|
||||||
|
# Category filtering (partial match)
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"categories": {"contains": "finance"}}]}
|
||||||
|
|
||||||
|
# Category filtering (exact match)
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"categories": {"in": ["personal_information"]}}]}
|
||||||
|
|
||||||
|
# Wildcard (match any non-null run)
|
||||||
|
filters={"AND": [{"user_id": "alice"}, {"run_id": "*"}]}
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
filters={"AND": [
|
||||||
|
{"user_id": "alice"},
|
||||||
|
{"created_at": {"gte": "2024-01-01T00:00:00Z"}},
|
||||||
|
{"created_at": {"lt": "2024-02-01T00:00:00Z"}}
|
||||||
|
]}
|
||||||
|
|
||||||
|
# Exclude categories with NOT
|
||||||
|
filters={"AND": [{"user_id": "user_123"}, {"NOT": {"categories": {"in": ["spam", "test"]}}}]}
|
||||||
|
|
||||||
|
# Multi-dimensional query
|
||||||
|
filters={"AND": [
|
||||||
|
{"user_id": "user_123"},
|
||||||
|
{"keywords": {"icontains": "invoice"}},
|
||||||
|
{"categories": {"in": ["finance"]}},
|
||||||
|
{"created_at": {"gte": "2024-01-01T00:00:00Z"}}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## get() / getAll() -- Retrieve Memories
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
# Single memory by ID
|
||||||
|
memory = client.get(memory_id="ea925981-...")
|
||||||
|
|
||||||
|
# All memories for a user
|
||||||
|
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]})
|
||||||
|
|
||||||
|
# With date range
|
||||||
|
memories = client.get_all(
|
||||||
|
filters={"AND": [
|
||||||
|
{"user_id": "alex"},
|
||||||
|
{"created_at": {"gte": "2024-07-01", "lte": "2024-07-31"}}
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
|
||||||
|
# With graph data
|
||||||
|
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const memory = await client.get("ea925981-...");
|
||||||
|
const memories = await client.getAll({ filters: { AND: [{ user_id: "alice" }] } });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `get_all` requires at least one of `user_id`, `agent_id`, `app_id`, or `run_id` in filters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## update() -- Modify Memories
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
client.update(memory_id="ea925981-...", text="Updated: vegan since 2024")
|
||||||
|
client.update(memory_id="ea925981-...", text="Updated", metadata={"verified": True})
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
await client.update("ea925981-...", { text: "Updated: vegan since 2024" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Cannot update immutable memories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## delete() / deleteAll() -- Remove Memories
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
client.delete(memory_id="ea925981-...")
|
||||||
|
client.delete_all(user_id="alice") # Irreversible bulk delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
await client.delete("ea925981-...");
|
||||||
|
await client.deleteAll({ user_id: "alice" });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## history() -- Track Changes
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
history = client.history(memory_id="ea925981-...")
|
||||||
|
# Returns: [{previous_value, new_value, action, timestamps}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const history = await client.history("ea925981-...");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Batch Operations (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Batch update
|
||||||
|
await client.batchUpdate([
|
||||||
|
{ memoryId: "uuid-1", text: "Updated text" },
|
||||||
|
{ memoryId: "uuid-2", text: "Another updated text" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Batch delete
|
||||||
|
await client.batchDelete(["uuid-1", "uuid-2", "uuid-3"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
# List all users/agents/sessions with memories
|
||||||
|
users = client.users()
|
||||||
|
|
||||||
|
# Delete a user/agent entity
|
||||||
|
client.delete_users(user_id="alice")
|
||||||
|
|
||||||
|
# Submit feedback on a memory
|
||||||
|
client.feedback(memory_id="...", feedback="POSITIVE", feedback_reason="Accurate extraction")
|
||||||
|
|
||||||
|
# Export memories
|
||||||
|
export = client.create_memory_export(filters={"AND": [{"user_id": "alice"}]})
|
||||||
|
data = client.get_memory_export(memory_export_id=export["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Entity cross-filtering fails silently** -- `AND` with `user_id` + `agent_id` returns empty. Use `OR`.
|
||||||
|
2. **SQL operators rejected** -- use `gte`, `lt`, etc. Not `>=`, `<`.
|
||||||
|
3. **Metadata filtering is limited** -- only top-level keys with `eq`, `contains`, `ne`.
|
||||||
|
4. **Wildcard `*` excludes null** -- only matches non-null values.
|
||||||
|
5. **Default threshold is 0.3** -- increase for stricter matching.
|
||||||
|
6. **Async processing** -- memories process asynchronously. Wait 2-3s after `add()` before searching.
|
||||||
|
7. **Immutable memories** -- cannot be updated or deleted once created.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
Python uses `snake_case` (`user_id`, `memory_id`, `get_all`). TypeScript uses `camelCase` for methods (`getAll`, `deleteAll`, `batchUpdate`) but `snake_case` for API parameters (`user_id`, `agent_id`).
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
# Mem0 Use Cases & Examples
|
||||||
|
|
||||||
|
Real-world implementation patterns for Mem0 Platform. Each use case includes complete, runnable code in both Python and TypeScript.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Personalized AI Companion](#1-personalized-ai-companion)
|
||||||
|
- [Customer Support with Categories](#2-customer-support-with-categories)
|
||||||
|
- [Healthcare Coach](#3-healthcare-coach)
|
||||||
|
- [Content Creation Workflow](#4-content-creation-workflow)
|
||||||
|
- [Multi-Agent / Multi-Tenant](#5-multi-agent--multi-tenant)
|
||||||
|
- [Personalized Search](#6-personalized-search)
|
||||||
|
- [Email Intelligence](#7-email-intelligence)
|
||||||
|
- [Common Patterns Across Use Cases](#common-patterns-across-use-cases)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Personalized AI Companion
|
||||||
|
|
||||||
|
A fitness coach that remembers goals, preferences, and progress across sessions. Mem0 persists context across app restarts — no session state needed.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
openai_client = OpenAI()
|
||||||
|
|
||||||
|
def chat(user_input: str, user_id: str) -> str:
|
||||||
|
# 1. Retrieve relevant memories
|
||||||
|
memories = mem0.search(user_input, user_id=user_id)
|
||||||
|
context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||||
|
|
||||||
|
# 2. Generate response with memory context
|
||||||
|
system_prompt = f"""You are Ray, a personal fitness coach.
|
||||||
|
Use these known facts about the user to personalize your response:
|
||||||
|
{context if context else 'No prior context yet.'}"""
|
||||||
|
|
||||||
|
response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4.1-nano-2025-04-14",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reply = response.choices[0].message.content
|
||||||
|
|
||||||
|
# 3. Store interaction for future context
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": user_input}, {"role": "assistant", "content": reply}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
chat("I want to run a marathon in under 4 hours", user_id="max")
|
||||||
|
# Next day, app restarted:
|
||||||
|
chat("What should I focus on today?", user_id="max")
|
||||||
|
# Ray remembers the sub-4 marathon goal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
const openai = new OpenAI();
|
||||||
|
|
||||||
|
async function chat(userInput: string, userId: string): Promise<string> {
|
||||||
|
// 1. Retrieve relevant memories
|
||||||
|
const memories = await mem0.search(userInput, { user_id: userId });
|
||||||
|
const context = memories.results
|
||||||
|
?.map((m: any) => `- ${m.memory}`)
|
||||||
|
.join('\n') || 'No prior context yet.';
|
||||||
|
|
||||||
|
// 2. Generate response with memory context
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4.1-nano-2025-04-14',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: `You are Ray, a personal fitness coach.\nUser context:\n${context}` },
|
||||||
|
{ role: 'user', content: userInput },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reply = response.choices[0].message.content!;
|
||||||
|
|
||||||
|
// 3. Store interaction
|
||||||
|
await mem0.add(
|
||||||
|
[{ role: 'user', content: userInput }, { role: 'assistant', content: reply }],
|
||||||
|
{ user_id: userId }
|
||||||
|
);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Context persists across app restarts — no session management needed
|
||||||
|
- Memories are automatically deduplicated and updated
|
||||||
|
- Works with any LLM provider (OpenAI, Anthropic, etc.)
|
||||||
|
|
||||||
|
**Best for:** Fitness coaches, tutors, therapists — any assistant that needs to remember goals across sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Customer Support with Categories
|
||||||
|
|
||||||
|
Auto-categorize support data so teams retrieve the right facts fast. Uses custom categories for structured retrieval.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
client = MemoryClient()
|
||||||
|
|
||||||
|
# 1. Define categories at the project level (one-time setup)
|
||||||
|
custom_categories = [
|
||||||
|
{"support_tickets": "Customer issues and resolutions"},
|
||||||
|
{"account_info": "Account details and preferences"},
|
||||||
|
{"billing": "Payment history and billing questions"},
|
||||||
|
{"product_feedback": "Feature requests and feedback"},
|
||||||
|
]
|
||||||
|
client.project.update(custom_categories=custom_categories)
|
||||||
|
|
||||||
|
# 2. Store interactions — auto-classified into categories
|
||||||
|
def log_support_interaction(user_id: str, message: str, priority: str = "normal"):
|
||||||
|
client.add(
|
||||||
|
[{"role": "user", "content": message}],
|
||||||
|
user_id=user_id,
|
||||||
|
metadata={"priority": priority, "source": "support_chat"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Retrieve by category
|
||||||
|
def get_billing_issues(user_id: str):
|
||||||
|
return client.get_all(
|
||||||
|
filters={
|
||||||
|
"AND": [
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"categories": {"in": ["billing"]}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_support_history(user_id: str, query: str):
|
||||||
|
return client.search(
|
||||||
|
query,
|
||||||
|
filters={
|
||||||
|
"AND": [
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"categories": {"contains": "support_tickets"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
top_k=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
log_support_interaction("maria", "I was charged twice for last month's subscription", priority="high")
|
||||||
|
log_support_interaction("maria", "The dashboard is loading slowly on mobile")
|
||||||
|
billing = get_billing_issues("maria") # Returns only billing-related memories
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
|
||||||
|
// Setup categories (one-time)
|
||||||
|
await client.updateProject({
|
||||||
|
custom_categories: [
|
||||||
|
{ support_tickets: 'Customer issues and resolutions' },
|
||||||
|
{ billing: 'Payment history and billing questions' },
|
||||||
|
{ product_feedback: 'Feature requests and feedback' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function logInteraction(userId: string, message: string, priority = 'normal') {
|
||||||
|
await client.add(
|
||||||
|
[{ role: 'user', content: message }],
|
||||||
|
{ user_id: userId, metadata: { priority, source: 'support_chat' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBillingIssues(userId: string) {
|
||||||
|
return client.getAll({
|
||||||
|
filters: { AND: [{ user_id: userId }, { categories: { in: ['billing'] } }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Automatic categorization — no manual tagging
|
||||||
|
- Filter by category for structured retrieval
|
||||||
|
- Metadata (`priority`, `source`) enables multi-dimensional queries
|
||||||
|
|
||||||
|
**Best for:** Help desks, SaaS support, e-commerce — structured retrieval by category eliminates manual scanning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Healthcare Coach
|
||||||
|
|
||||||
|
Guide patients with an assistant that remembers medical history. Uses high `threshold` for confident retrieval in safety-critical contexts.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
openai_client = OpenAI()
|
||||||
|
|
||||||
|
def save_patient_info(user_id: str, information: str):
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": information}],
|
||||||
|
user_id=user_id,
|
||||||
|
run_id="healthcare_session",
|
||||||
|
metadata={"type": "patient_information"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def consult(user_id: str, question: str) -> str:
|
||||||
|
# High threshold for medical accuracy
|
||||||
|
memories = mem0.search(question, user_id=user_id, top_k=5, threshold=0.7)
|
||||||
|
context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||||
|
|
||||||
|
response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4.1-nano-2025-04-14",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": f"You are a health coach. Patient context:\n{context}"},
|
||||||
|
{"role": "user", "content": question},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reply = response.choices[0].message.content
|
||||||
|
|
||||||
|
# Store the interaction
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": question}, {"role": "assistant", "content": reply}],
|
||||||
|
user_id=user_id,
|
||||||
|
run_id="healthcare_session",
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
save_patient_info("alex", "I'm allergic to penicillin and take metformin for type 2 diabetes")
|
||||||
|
consult("alex", "Can I take amoxicillin for my sore throat?")
|
||||||
|
# Remembers penicillin allergy — amoxicillin is a penicillin-type antibiotic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
const openai = new OpenAI();
|
||||||
|
|
||||||
|
async function savePatientInfo(userId: string, info: string) {
|
||||||
|
await mem0.add(
|
||||||
|
[{ role: 'user', content: info }],
|
||||||
|
{ user_id: userId, run_id: 'healthcare_session', metadata: { type: 'patient_information' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consult(userId: string, question: string): Promise<string> {
|
||||||
|
const memories = await mem0.search(question, {
|
||||||
|
user_id: userId,
|
||||||
|
top_k: 5,
|
||||||
|
threshold: 0.7,
|
||||||
|
});
|
||||||
|
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4.1-nano-2025-04-14',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: `You are a health coach. Patient context:\n${context}` },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reply = response.choices[0].message.content!;
|
||||||
|
|
||||||
|
await mem0.add(
|
||||||
|
[{ role: 'user', content: question }, { role: 'assistant', content: reply }],
|
||||||
|
{ user_id: userId, run_id: 'healthcare_session' }
|
||||||
|
);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- High threshold (0.7) ensures only confident matches for safety-critical retrieval
|
||||||
|
- Session scoping via `run_id` groups related health interactions
|
||||||
|
- Metadata tagging separates patient info from conversation history
|
||||||
|
|
||||||
|
**Best for:** Telehealth, wellness apps, patient management — persistent health context across visits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Content Creation Workflow
|
||||||
|
|
||||||
|
Store voice guidelines once and apply them across every draft. Uses `run_id` and `metadata` to scope writing preferences per session.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
openai_client = OpenAI()
|
||||||
|
|
||||||
|
def store_writing_preferences(user_id: str, preferences: str):
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": preferences}],
|
||||||
|
user_id=user_id,
|
||||||
|
run_id="editing_session",
|
||||||
|
metadata={"type": "preferences", "category": "writing_style"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def draft_content(user_id: str, topic: str) -> str:
|
||||||
|
# Retrieve writing preferences
|
||||||
|
prefs = mem0.search(
|
||||||
|
"writing style preferences",
|
||||||
|
filters={"AND": [{"user_id": user_id}, {"run_id": "editing_session"}]}
|
||||||
|
)
|
||||||
|
style_context = "\n".join([f"- {m['memory']}" for m in prefs.get("results", [])])
|
||||||
|
|
||||||
|
response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4.1-nano-2025-04-14",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": f"Write content matching these style preferences:\n{style_context}"},
|
||||||
|
{"role": "user", "content": f"Write a blog post about: {topic}"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
store_writing_preferences("writer_01", "I prefer short sentences. Active voice. No jargon. Use analogies.")
|
||||||
|
draft_content("writer_01", "Why AI memory matters for chatbots")
|
||||||
|
# Drafts content matching the stored voice guidelines
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
const openai = new OpenAI();
|
||||||
|
|
||||||
|
async function storePreferences(userId: string, preferences: string) {
|
||||||
|
await mem0.add(
|
||||||
|
[{ role: 'user', content: preferences }],
|
||||||
|
{ user_id: userId, run_id: 'editing_session', metadata: { type: 'preferences' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draftContent(userId: string, topic: string): Promise<string> {
|
||||||
|
const prefs = await mem0.search('writing style preferences', {
|
||||||
|
filters: { AND: [{ user_id: userId }, { run_id: 'editing_session' }] },
|
||||||
|
});
|
||||||
|
const styleContext = prefs.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4.1-nano-2025-04-14',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: `Write content matching these preferences:\n${styleContext}` },
|
||||||
|
{ role: 'user', content: `Write a blog post about: ${topic}` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return response.choices[0].message.content!;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Voice consistency across all content without repeating guidelines
|
||||||
|
- Scoped sessions let you maintain different style profiles
|
||||||
|
- Preferences update automatically as you refine them
|
||||||
|
|
||||||
|
**Best for:** Marketing teams, technical writers, agencies — consistent voice across all content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Multi-Agent / Multi-Tenant
|
||||||
|
|
||||||
|
Keep memories separate using `user_id`, `agent_id`, `app_id`, and `run_id` scoping. Critical for multi-agent workflows and multi-tenant apps.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
client = MemoryClient()
|
||||||
|
|
||||||
|
# Store memories scoped to user + agent + session
|
||||||
|
def store_scoped_memory(messages: list, user_id: str, agent_id: str, run_id: str, app_id: str):
|
||||||
|
client.add(
|
||||||
|
messages,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
run_id=run_id,
|
||||||
|
app_id=app_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query within a specific scope
|
||||||
|
def search_user_session(query: str, user_id: str, app_id: str, run_id: str):
|
||||||
|
"""Search memories for a specific user within a specific session."""
|
||||||
|
return client.search(
|
||||||
|
query,
|
||||||
|
filters={
|
||||||
|
"AND": [
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"app_id": app_id},
|
||||||
|
{"run_id": run_id}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_agent_knowledge(query: str, agent_id: str, app_id: str):
|
||||||
|
"""Search all memories an agent has across all users."""
|
||||||
|
return client.search(
|
||||||
|
query,
|
||||||
|
filters={
|
||||||
|
"AND": [
|
||||||
|
{"agent_id": agent_id},
|
||||||
|
{"app_id": app_id}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage: Travel concierge app with multiple agents
|
||||||
|
store_scoped_memory(
|
||||||
|
[{"role": "user", "content": "I'm vegetarian and prefer window seats"}],
|
||||||
|
user_id="traveler_cam",
|
||||||
|
agent_id="travel_planner",
|
||||||
|
run_id="tokyo-2025",
|
||||||
|
app_id="concierge_app"
|
||||||
|
)
|
||||||
|
|
||||||
|
# User-scoped query: "What does Cam prefer?"
|
||||||
|
user_mems = search_user_session("dietary restrictions?", "traveler_cam", "concierge_app", "tokyo-2025")
|
||||||
|
|
||||||
|
# Agent-scoped query: "What do all travelers prefer?" (across users)
|
||||||
|
agent_mems = search_agent_knowledge("common dietary restrictions?", "travel_planner", "concierge_app")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
|
||||||
|
async function storeScopedMemory(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
userId: string, agentId: string, runId: string, appId: string
|
||||||
|
) {
|
||||||
|
await client.add(messages, {
|
||||||
|
user_id: userId,
|
||||||
|
agent_id: agentId,
|
||||||
|
run_id: runId,
|
||||||
|
app_id: appId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUserSession(query: string, userId: string, appId: string, runId: string) {
|
||||||
|
return client.search(query, {
|
||||||
|
filters: { AND: [{ user_id: userId }, { app_id: appId }, { run_id: runId }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchAgentKnowledge(query: string, agentId: string, appId: string) {
|
||||||
|
return client.search(query, {
|
||||||
|
filters: { AND: [{ agent_id: agentId }, { app_id: appId }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Full isolation between users, agents, sessions, and apps
|
||||||
|
- Query at any scope level — user, agent, session, or app-wide
|
||||||
|
- No memory leakage between tenants
|
||||||
|
|
||||||
|
**Best for:** Multi-agent workflows, multi-tenant SaaS — proper isolation at every level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Personalized Search
|
||||||
|
|
||||||
|
Blend real-time search results with personal context. Uses `custom_instructions` to infer preferences from queries.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
mem0 = MemoryClient()
|
||||||
|
openai_client = OpenAI()
|
||||||
|
|
||||||
|
# One-time setup: configure Mem0 to infer from queries
|
||||||
|
mem0.project.update(
|
||||||
|
custom_instructions="""Infer user preferences and facts from their search queries.
|
||||||
|
Extract dietary preferences, location, interests, and purchase history."""
|
||||||
|
)
|
||||||
|
|
||||||
|
def personalized_search(user_id: str, query: str, search_results: list) -> str:
|
||||||
|
# Get user context from memory
|
||||||
|
memories = mem0.search(query, user_id=user_id, top_k=5)
|
||||||
|
user_context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||||
|
|
||||||
|
response = openai_client.chat.completions.create(
|
||||||
|
model="gpt-4.1-nano-2025-04-14",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": f"Personalize search results using user context:\n{user_context}"},
|
||||||
|
{"role": "user", "content": f"Query: {query}\n\nSearch results:\n{search_results}"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reply = response.choices[0].message.content
|
||||||
|
|
||||||
|
# Store the query to learn preferences over time
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": query}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
personalized_search("user_42", "best restaurants nearby", ["Restaurant A", "Restaurant B"])
|
||||||
|
# Over time, Mem0 learns: "user prefers vegetarian, lives in Austin"
|
||||||
|
# Future searches are automatically personalized
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
const openai = new OpenAI();
|
||||||
|
|
||||||
|
async function personalizedSearch(userId: string, query: string, searchResults: string[]): Promise<string> {
|
||||||
|
const memories = await mem0.search(query, { user_id: userId, top_k: 5 });
|
||||||
|
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||||
|
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4.1-nano-2025-04-14',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: `Personalize results using user context:\n${context}` },
|
||||||
|
{ role: 'user', content: `Query: ${query}\nResults: ${searchResults.join(', ')}` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reply = response.choices[0].message.content!;
|
||||||
|
|
||||||
|
await mem0.add([{ role: 'user', content: query }], { user_id: userId });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Learns preferences from queries automatically via `custom_instructions`
|
||||||
|
- Personalizes any search provider (Tavily, Google, Bing)
|
||||||
|
- Zero manual preference setup — improves over time
|
||||||
|
|
||||||
|
**Best for:** Personalized search engines, recommendation systems — search results tailored to individual users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Email Intelligence
|
||||||
|
|
||||||
|
Capture, categorize, and recall inbox threads using persistent memories with rich metadata.
|
||||||
|
|
||||||
|
### Implementation (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mem0 import MemoryClient
|
||||||
|
|
||||||
|
client = MemoryClient()
|
||||||
|
|
||||||
|
def store_email(user_id: str, sender: str, subject: str, body: str, date: str):
|
||||||
|
client.add(
|
||||||
|
[{"role": "user", "content": f"Email from {sender}: {subject}\n\n{body}"}],
|
||||||
|
user_id=user_id,
|
||||||
|
metadata={"email_type": "incoming", "sender": sender, "subject": subject, "date": date}
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_emails(user_id: str, query: str):
|
||||||
|
return client.search(
|
||||||
|
query,
|
||||||
|
filters={"AND": [{"user_id": user_id}, {"categories": {"contains": "email"}}]},
|
||||||
|
top_k=10
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_emails_from_sender(user_id: str, sender: str):
|
||||||
|
return client.get_all(
|
||||||
|
filters={
|
||||||
|
"AND": [
|
||||||
|
{"user_id": user_id},
|
||||||
|
{"metadata": {"contains": sender}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
store_email("alice", "bob@acme.com", "Q3 Budget Review", "Attached is the Q3 budget...", "2025-01-15")
|
||||||
|
store_email("alice", "carol@acme.com", "Sprint Planning", "Here are the priorities...", "2025-01-16")
|
||||||
|
|
||||||
|
results = search_emails("alice", "budget discussions")
|
||||||
|
sender_emails = get_emails_from_sender("alice", "bob@acme.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation (TypeScript)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import MemoryClient from 'mem0ai';
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||||
|
|
||||||
|
async function storeEmail(userId: string, sender: string, subject: string, body: string, date: string) {
|
||||||
|
await client.add(
|
||||||
|
[{ role: 'user', content: `Email from ${sender}: ${subject}\n\n${body}` }],
|
||||||
|
{ user_id: userId, metadata: { email_type: 'incoming', sender, subject, date } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchEmails(userId: string, query: string) {
|
||||||
|
return client.search(query, {
|
||||||
|
filters: { AND: [{ user_id: userId }, { categories: { contains: 'email' } }] },
|
||||||
|
top_k: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Benefits
|
||||||
|
|
||||||
|
- Rich metadata enables multi-dimensional queries (sender, date, subject)
|
||||||
|
- Category filtering separates emails from other memory types
|
||||||
|
- Semantic search across all email content
|
||||||
|
|
||||||
|
**Best for:** Inbox management, email automation — searchable email memories with metadata filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns Across Use Cases
|
||||||
|
|
||||||
|
### Pattern 1: Retrieve → Generate → Store
|
||||||
|
|
||||||
|
Every use case follows the same 3-step loop:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Retrieve relevant context
|
||||||
|
memories = mem0.search(user_input, user_id=user_id)
|
||||||
|
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||||
|
|
||||||
|
# 2. Generate with context
|
||||||
|
response = llm.generate(system_prompt=f"Context:\n{context}", user_input=user_input)
|
||||||
|
|
||||||
|
# 3. Store the interaction
|
||||||
|
mem0.add(
|
||||||
|
[{"role": "user", "content": user_input}, {"role": "assistant", "content": response}],
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Scope with Entity Identifiers
|
||||||
|
|
||||||
|
Use `user_id`, `agent_id`, `app_id`, and `run_id` to isolate memories:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# User-level: personal preferences
|
||||||
|
client.add(messages, user_id="alice")
|
||||||
|
|
||||||
|
# Session-level: conversation within one session
|
||||||
|
client.add(messages, user_id="alice", run_id="session_123")
|
||||||
|
|
||||||
|
# Agent-level: agent-specific knowledge
|
||||||
|
client.add(messages, agent_id="support_bot", app_id="helpdesk")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Rich Metadata for Filtering
|
||||||
|
|
||||||
|
Attach structured metadata for multi-dimensional queries:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Store with metadata
|
||||||
|
client.add(messages, user_id="alice", metadata={"priority": "high", "source": "phone_call"})
|
||||||
|
|
||||||
|
# Filter by category + metadata
|
||||||
|
client.search("billing issues", filters={
|
||||||
|
"AND": [{"user_id": "alice"}, {"categories": {"contains": "billing"}}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Custom Instructions for Domain-Specific Extraction
|
||||||
|
|
||||||
|
Control what Mem0 extracts from conversations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client.project.update(
|
||||||
|
custom_instructions="Extract medical conditions, medications, and allergies. Exclude billing info."
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## More Examples
|
||||||
|
|
||||||
|
For 30+ cookbooks with complete working code: [docs.mem0.ai/cookbooks](https://docs.mem0.ai/cookbooks)
|
||||||
Executable
+224
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mem0 Documentation Search Agent (Mintlify-based)
|
||||||
|
On-demand search tool for querying Mem0 documentation without storing content locally.
|
||||||
|
|
||||||
|
This tool leverages Mintlify's documentation structure to perform just-in-time
|
||||||
|
retrieval of technical information from docs.mem0.ai.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python mem0_doc_search.py --query "how to add graph memory"
|
||||||
|
python mem0_doc_search.py --query "filter syntax for categories"
|
||||||
|
python mem0_doc_search.py --page "/platform/features/graph-memory"
|
||||||
|
python mem0_doc_search.py --index
|
||||||
|
python mem0_doc_search.py --query "webhook events" --section platform
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Avoid bloating local context with full documentation
|
||||||
|
- Enable just-in-time retrieval of technical details
|
||||||
|
- Query specific documentation pages on demand
|
||||||
|
- Search across the full Mem0 documentation site
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DOCS_BASE = "https://docs.mem0.ai"
|
||||||
|
SEARCH_ENDPOINT = f"{DOCS_BASE}/api/search"
|
||||||
|
LLMS_INDEX = f"{DOCS_BASE}/llms.txt"
|
||||||
|
|
||||||
|
# Known documentation sections for targeted retrieval
|
||||||
|
SECTION_MAP = {
|
||||||
|
"platform": [
|
||||||
|
"/platform/overview",
|
||||||
|
"/platform/quickstart",
|
||||||
|
"/platform/features",
|
||||||
|
"/platform/features/graph-memory",
|
||||||
|
"/platform/features/selective-memory",
|
||||||
|
"/platform/features/custom-categories",
|
||||||
|
"/platform/features/v2-memory-filters",
|
||||||
|
"/platform/features/async-client",
|
||||||
|
"/platform/features/webhooks",
|
||||||
|
"/platform/features/multimodal",
|
||||||
|
],
|
||||||
|
"api": [
|
||||||
|
"/api-reference/memory/add-memories",
|
||||||
|
"/api-reference/memory/v2-search-memories",
|
||||||
|
"/api-reference/memory/v2-get-memories",
|
||||||
|
"/api-reference/memory/get-memory",
|
||||||
|
"/api-reference/memory/update-memory",
|
||||||
|
"/api-reference/memory/delete-memory",
|
||||||
|
],
|
||||||
|
"open-source": [
|
||||||
|
"/open-source/overview",
|
||||||
|
"/open-source/python-quickstart",
|
||||||
|
"/open-source/node-quickstart",
|
||||||
|
"/open-source/features",
|
||||||
|
"/open-source/features/graph-memory",
|
||||||
|
"/open-source/features/rest-api",
|
||||||
|
"/open-source/configure-components",
|
||||||
|
],
|
||||||
|
"openmemory": [
|
||||||
|
"/openmemory/overview",
|
||||||
|
"/openmemory/quickstart",
|
||||||
|
],
|
||||||
|
"sdks": [
|
||||||
|
"/sdks/python",
|
||||||
|
"/sdks/js",
|
||||||
|
],
|
||||||
|
"integrations": [
|
||||||
|
"/integrations",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url: str) -> str:
|
||||||
|
"""Fetch content from a URL."""
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mem0DocSearchAgent/1.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return f"HTTP Error {e.code}: {e.reason}"
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return f"URL Error: {e.reason}"
|
||||||
|
|
||||||
|
|
||||||
|
def search_docs(query: str, section: str | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Search Mem0 documentation using Mintlify's search API.
|
||||||
|
Falls back to the llms.txt index for keyword matching if the API is unavailable.
|
||||||
|
"""
|
||||||
|
# Try Mintlify search API first
|
||||||
|
params = urllib.parse.urlencode({"query": query})
|
||||||
|
search_url = f"{SEARCH_ENDPOINT}?{params}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = fetch_url(search_url)
|
||||||
|
data = json.loads(result)
|
||||||
|
if isinstance(data, dict) and data.get("results"):
|
||||||
|
results = data["results"]
|
||||||
|
if section and section in SECTION_MAP:
|
||||||
|
section_paths = SECTION_MAP[section]
|
||||||
|
results = [r for r in results if any(r.get("url", "").startswith(p) for p in section_paths)]
|
||||||
|
return {"source": "mintlify_search", "results": results}
|
||||||
|
except (json.JSONDecodeError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: search llms.txt index for matching URLs
|
||||||
|
index_content = fetch_url(LLMS_INDEX)
|
||||||
|
query_lower = query.lower()
|
||||||
|
matching_urls = []
|
||||||
|
|
||||||
|
for line in index_content.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if query_lower in line.lower():
|
||||||
|
matching_urls.append(line)
|
||||||
|
|
||||||
|
if section and section in SECTION_MAP:
|
||||||
|
section_paths = SECTION_MAP[section]
|
||||||
|
matching_urls = [u for u in matching_urls if any(p in u for p in section_paths)]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "llms_txt_index",
|
||||||
|
"query": query,
|
||||||
|
"matching_urls": matching_urls[:20],
|
||||||
|
"suggestion": "Fetch specific URLs for detailed content",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_page(page_path: str) -> dict:
|
||||||
|
"""Fetch a specific documentation page."""
|
||||||
|
url = f"{DOCS_BASE}{page_path}" if page_path.startswith("/") else page_path
|
||||||
|
content = fetch_url(url)
|
||||||
|
return {"url": url, "content": content[:10000], "truncated": len(content) > 10000}
|
||||||
|
|
||||||
|
|
||||||
|
def get_index() -> dict:
|
||||||
|
"""Fetch the full documentation index from llms.txt."""
|
||||||
|
content = fetch_url(LLMS_INDEX)
|
||||||
|
urls = [line.strip() for line in content.splitlines() if line.strip() and not line.startswith("#")]
|
||||||
|
return {"total_pages": len(urls), "urls": urls, "sections": list(SECTION_MAP.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
def list_section(section: str) -> dict:
|
||||||
|
"""List all known pages in a documentation section."""
|
||||||
|
if section not in SECTION_MAP:
|
||||||
|
return {"error": f"Unknown section: {section}", "available": list(SECTION_MAP.keys())}
|
||||||
|
return {
|
||||||
|
"section": section,
|
||||||
|
"pages": [f"{DOCS_BASE}{p}" for p in SECTION_MAP[section]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Search Mem0 documentation on demand")
|
||||||
|
parser.add_argument("--query", help="Search query for documentation")
|
||||||
|
parser.add_argument("--page", help="Fetch a specific page path (e.g., /platform/features/graph-memory)")
|
||||||
|
parser.add_argument("--index", action="store_true", help="Show full documentation index")
|
||||||
|
parser.add_argument("--section", help="Filter by section or list section pages")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.index:
|
||||||
|
result = get_index()
|
||||||
|
elif args.section and not args.query:
|
||||||
|
result = list_section(args.section)
|
||||||
|
elif args.page:
|
||||||
|
result = fetch_page(args.page)
|
||||||
|
elif args.query:
|
||||||
|
result = search_docs(args.query, section=args.section)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
if isinstance(result, dict):
|
||||||
|
if "results" in result:
|
||||||
|
print(f"Source: {result.get('source', 'unknown')}")
|
||||||
|
for r in result["results"]:
|
||||||
|
print(f" - {r.get('title', 'N/A')}: {r.get('url', 'N/A')}")
|
||||||
|
if r.get("description"):
|
||||||
|
print(f" {r['description'][:200]}")
|
||||||
|
elif "matching_urls" in result:
|
||||||
|
print(f"Source: {result['source']}")
|
||||||
|
print(f"Query: {result['query']}")
|
||||||
|
for url in result["matching_urls"]:
|
||||||
|
print(f" - {url}")
|
||||||
|
if result.get("suggestion"):
|
||||||
|
print(f"\n{result['suggestion']}")
|
||||||
|
elif "urls" in result:
|
||||||
|
print(f"Total documentation pages: {result['total_pages']}")
|
||||||
|
print(f"Sections: {', '.join(result['sections'])}")
|
||||||
|
for url in result["urls"][:30]:
|
||||||
|
print(f" - {url}")
|
||||||
|
if result["total_pages"] > 30:
|
||||||
|
print(f" ... and {result['total_pages'] - 30} more")
|
||||||
|
elif "pages" in result:
|
||||||
|
print(f"Section: {result['section']}")
|
||||||
|
for page in result["pages"]:
|
||||||
|
print(f" - {page}")
|
||||||
|
elif "content" in result:
|
||||||
|
print(f"URL: {result['url']}")
|
||||||
|
if result.get("truncated"):
|
||||||
|
print("[Content truncated to 10000 chars]")
|
||||||
|
print(result["content"])
|
||||||
|
elif "error" in result:
|
||||||
|
print(f"Error: {result['error']}")
|
||||||
|
if result.get("available"):
|
||||||
|
print(f"Available sections: {', '.join(result['available'])}")
|
||||||
|
else:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
@@ -1,35 +1,114 @@
|
|||||||
import os
|
import threading
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
|
import mem0.memory.telemetry as telemetry_module
|
||||||
|
|
||||||
if isinstance(MEM0_TELEMETRY, str):
|
|
||||||
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
|
|
||||||
|
|
||||||
|
|
||||||
def use_telemetry():
|
class TestTelemetryDisabled:
|
||||||
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
|
"""Verify PostHog is never instantiated when telemetry is disabled."""
|
||||||
return True
|
|
||||||
return False
|
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)
|
class TestTelemetryEnabled:
|
||||||
def reset_env():
|
"""Verify PostHog works normally when telemetry is enabled."""
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
yield
|
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():
|
class TestTelemetryEnvVar:
|
||||||
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
|
"""Verify the MEM0_TELEMETRY env var parsing logic."""
|
||||||
assert use_telemetry() is True
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
def test_telemetry_disabled():
|
"value,expected",
|
||||||
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
|
[
|
||||||
assert use_telemetry() is False
|
("true", True),
|
||||||
|
("True", True),
|
||||||
|
("TRUE", True),
|
||||||
def test_telemetry_default_enabled():
|
("1", True),
|
||||||
assert use_telemetry() is 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
|
||||||
|
|||||||
Reference in New Issue
Block a user