Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e2bca647 | ||
|
|
c0069f0a59 |
@@ -7,11 +7,6 @@ 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:**
|
||||||
@@ -729,36 +724,6 @@ 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:**
|
||||||
|
|||||||
@@ -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,
|
||||||
filters={"user_id": RAG_USER_ID},
|
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,20 +25,6 @@ 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>
|
||||||
@@ -51,7 +37,7 @@ Add to your `openclaw.json`:
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"config": {
|
"config": {
|
||||||
"apiKey": "${MEM0_API_KEY}",
|
"apiKey": "${MEM0_API_KEY}",
|
||||||
"userId": "alice" // any unique identifier you choose for this user
|
"userId": "your-user-id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -65,7 +51,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": "alice" // any unique identifier you choose for this user
|
"userId": "your-user-id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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="/open-source/features/rest-api"
|
href="/cookbooks/companions/local-companion-ollama"
|
||||||
/>
|
/>
|
||||||
</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="/open-source/features/rest-api">
|
<Card title="Deploy with Docker Compose" icon="server" href="/cookbooks/companions/local-companion-ollama">
|
||||||
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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0ai",
|
"name": "mem0ai",
|
||||||
"version": "2.4.0",
|
"version": "2.2.3",
|
||||||
"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,7 +77,6 @@
|
|||||||
"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",
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
"typescript": "5.5.4"
|
"typescript": "5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.13.6",
|
"axios": "1.7.7",
|
||||||
"openai": "^4.93.0",
|
"openai": "^4.93.0",
|
||||||
"uuid": "9.0.1",
|
"uuid": "9.0.1",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
@@ -109,13 +108,14 @@
|
|||||||
"@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",
|
||||||
"better-sqlite3": "^12.6.2",
|
"@types/sqlite3": "3.1.11",
|
||||||
"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",
|
||||||
"better-sqlite3"
|
"sqlite3"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+775
-54
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"@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",
|
||||||
@@ -26,7 +27,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",
|
||||||
"better-sqlite3": "^12.6.2",
|
"sqlite3": "^5.1.7",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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:
|
||||||
@@ -44,23 +43,13 @@ 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,
|
||||||
collectionName: userConf.collectionName,
|
// Include other fields from userConf if necessary, or omit defaults
|
||||||
dimension: explicitDimension,
|
collectionName: userConf.collectionName, // Can be undefined
|
||||||
|
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 {
|
||||||
@@ -68,7 +57,7 @@ export class ConfigManager {
|
|||||||
return {
|
return {
|
||||||
collectionName:
|
collectionName:
|
||||||
userConf?.collectionName || defaultConf.collectionName,
|
userConf?.collectionName || defaultConf.collectionName,
|
||||||
dimension: explicitDimension,
|
dimension: userConf?.dimension || defaultConf.dimension,
|
||||||
// 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
|
||||||
@@ -106,34 +95,16 @@ export class ConfigManager {
|
|||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
historyDbPath:
|
historyDbPath:
|
||||||
userConfig.historyDbPath ||
|
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.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: {
|
||||||
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
|
...DEFAULT_MEMORY_CONFIG.historyStore,
|
||||||
const historyProvider =
|
...userConfig.historyStore,
|
||||||
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: this.embeddingDims },
|
config: { outputDimensionality: 768 },
|
||||||
});
|
});
|
||||||
return response.embeddings!.map((item) => item.values!);
|
return response.embeddings!.map((item) => item.values!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export class OpenAIEmbedder implements Embedder {
|
|||||||
private embeddingDims?: number;
|
private embeddingDims?: number;
|
||||||
|
|
||||||
constructor(config: EmbeddingConfig) {
|
constructor(config: EmbeddingConfig) {
|
||||||
this.openai = new OpenAI({
|
this.openai = new OpenAI({ apiKey: config.apiKey });
|
||||||
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,8 +88,6 @@ 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. Respond in JSON format.`,
|
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.`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: data },
|
{ role: "user", content: data },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -41,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;
|
||||||
@@ -49,8 +49,6 @@ 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
|
||||||
@@ -61,9 +59,10 @@ export class Memory {
|
|||||||
this.config.embedder.provider,
|
this.config.embedder.provider,
|
||||||
this.config.embedder.config,
|
this.config.embedder.config,
|
||||||
);
|
);
|
||||||
// Vector store creation is deferred to _autoInitialize() so that
|
this.vectorStore = VectorStoreFactory.create(
|
||||||
// the embedding dimension can be auto-detected first when not
|
this.config.vectorStore.provider,
|
||||||
// explicitly configured.
|
this.config.vectorStore.config,
|
||||||
|
);
|
||||||
this.llm = LLMFactory.create(
|
this.llm = LLMFactory.create(
|
||||||
this.config.llm.provider,
|
this.config.llm.provider,
|
||||||
this.config.llm.config,
|
this.config.llm.config,
|
||||||
@@ -71,10 +70,20 @@ export class Memory {
|
|||||||
if (this.config.disableHistory) {
|
if (this.config.disableHistory) {
|
||||||
this.db = new DummyHistoryManager();
|
this.db = new DummyHistoryManager();
|
||||||
} else {
|
} else {
|
||||||
this.db = HistoryManagerFactory.create(
|
const defaultConfig = {
|
||||||
this.config.historyStore!.provider,
|
provider: "sqlite",
|
||||||
this.config.historyStore!,
|
config: {
|
||||||
);
|
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;
|
||||||
@@ -87,67 +96,8 @@ export class Memory {
|
|||||||
this.graphMemory = new MemoryGraph(this.config);
|
this.graphMemory = new MemoryGraph(this.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect embedding dimension (if needed), create vector store,
|
// Initialize telemetry if vector store is initialized
|
||||||
// and initialize it. All public methods await this before proceeding.
|
this._initializeTelemetry();
|
||||||
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() {
|
||||||
@@ -207,7 +157,6 @@ 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,
|
||||||
@@ -433,7 +382,6 @@ 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;
|
||||||
|
|
||||||
@@ -475,7 +423,6 @@ 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,
|
||||||
@@ -542,7 +489,6 @@ 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 });
|
||||||
@@ -550,7 +496,6 @@ 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!" };
|
||||||
@@ -559,7 +504,6 @@ 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,
|
||||||
@@ -587,12 +531,10 @@ 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();
|
||||||
|
|
||||||
@@ -617,30 +559,28 @@ 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-create vector store via _autoInitialize (which handles dimension + creation)
|
// Re-initialize telemetry
|
||||||
this._initError = undefined;
|
this._initializeTelemetry();
|
||||||
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,
|
||||||
|
|||||||
@@ -278,9 +278,5 @@ export function parseMessages(messages: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeCodeBlocks(text: string): string {
|
export function removeCodeBlocks(text: string): string {
|
||||||
// Extract content inside code fences instead of deleting it.
|
return text.replace(/```[^`]*```/g, "");
|
||||||
// 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,20 +1,16 @@
|
|||||||
import Database from "better-sqlite3";
|
import sqlite3 from "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: Database.Database;
|
private db: sqlite3.Database;
|
||||||
private stmtInsert!: Database.Statement;
|
|
||||||
private stmtSelect!: Database.Statement;
|
|
||||||
|
|
||||||
constructor(dbPath: string) {
|
constructor(dbPath: string) {
|
||||||
ensureSQLiteDirectory(dbPath);
|
this.db = new sqlite3.Database(dbPath);
|
||||||
this.db = new Database(dbPath);
|
this.init().catch(console.error);
|
||||||
this.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(): void {
|
private async init() {
|
||||||
this.db.exec(`
|
await this.run(`
|
||||||
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,
|
||||||
@@ -26,14 +22,24 @@ export class SQLiteManager implements HistoryManager {
|
|||||||
is_deleted INTEGER DEFAULT 0
|
is_deleted INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
this.stmtInsert = this.db.prepare(
|
}
|
||||||
`INSERT INTO memory_history
|
|
||||||
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
private async run(sql: string, params: any[] = []): Promise<void> {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
return new Promise((resolve, reject) => {
|
||||||
);
|
this.db.run(sql, params, (err) => {
|
||||||
this.stmtSelect = this.db.prepare(
|
if (err) reject(err);
|
||||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addHistory(
|
async addHistory(
|
||||||
@@ -45,24 +51,32 @@ export class SQLiteManager implements HistoryManager {
|
|||||||
updatedAt?: string,
|
updatedAt?: string,
|
||||||
isDeleted: number = 0,
|
isDeleted: number = 0,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.stmtInsert.run(
|
await this.run(
|
||||||
memoryId,
|
`INSERT INTO memory_history
|
||||||
previousValue,
|
(memory_id, previous_value, new_value, action, created_at, updated_at, is_deleted)
|
||||||
newValue,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
action,
|
[
|
||||||
createdAt ?? null,
|
memoryId,
|
||||||
updatedAt ?? null,
|
previousValue,
|
||||||
isDeleted,
|
newValue,
|
||||||
|
action,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
isDeleted,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(memoryId: string): Promise<any[]> {
|
async getHistory(memoryId: string): Promise<any[]> {
|
||||||
return this.stmtSelect.all(memoryId) as any[];
|
return this.all(
|
||||||
|
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id DESC",
|
||||||
|
[memoryId],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reset(): Promise<void> {
|
async reset(): Promise<void> {
|
||||||
this.db.exec("DROP TABLE IF EXISTS memory_history");
|
await this.run("DROP TABLE IF EXISTS memory_history");
|
||||||
this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
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,7 +15,6 @@ 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>;
|
||||||
@@ -24,7 +23,6 @@ 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;
|
||||||
@@ -131,7 +129,6 @@ 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(),
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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,7 +81,6 @@ 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;
|
||||||
@@ -118,13 +117,6 @@ 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,12 +1,7 @@
|
|||||||
import { VectorStore } from "./base";
|
import { VectorStore } from "./base";
|
||||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||||
import Database from "better-sqlite3";
|
import sqlite3 from "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;
|
||||||
@@ -15,31 +10,22 @@ interface MemoryVector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MemoryVectorStore implements VectorStore {
|
export class MemoryVectorStore implements VectorStore {
|
||||||
private db: Database.Database;
|
private db: sqlite3.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 = config.dbPath || getDefaultVectorStoreDbPath();
|
this.dbPath = path.join(process.cwd(), "vector_store.db");
|
||||||
|
if (config.dbPath) {
|
||||||
if (!config.dbPath) {
|
this.dbPath = 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);
|
||||||
ensureSQLiteDirectory(this.dbPath);
|
this.init().catch(console.error);
|
||||||
this.db = new Database(this.dbPath);
|
|
||||||
this.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(): void {
|
private async init() {
|
||||||
this.db.exec(`
|
await this.run(`
|
||||||
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,
|
||||||
@@ -47,7 +33,7 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
this.db.exec(`
|
await this.run(`
|
||||||
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
|
||||||
@@ -55,6 +41,33 @@ 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;
|
||||||
@@ -79,23 +92,18 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
ids: string[],
|
ids: string[],
|
||||||
payloads: Record<string, any>[],
|
payloads: Record<string, any>[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const stmt = this.db.prepare(
|
for (let i = 0; i < vectors.length; i++) {
|
||||||
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
if (vectors[i].length !== this.dimension) {
|
||||||
);
|
throw new Error(
|
||||||
const insertMany = this.db.transaction(
|
`Vector dimension mismatch. Expected ${this.dimension}, got ${vectors[i].length}`,
|
||||||
(vecs: number[][], vIds: string[], vPayloads: Record<string, any>[]) => {
|
);
|
||||||
for (let i = 0; i < vecs.length; i++) {
|
}
|
||||||
if (vecs[i].length !== this.dimension) {
|
const vectorBuffer = Buffer.from(new Float32Array(vectors[i]).buffer);
|
||||||
throw new Error(
|
await this.run(
|
||||||
`Vector dimension mismatch. Expected ${this.dimension}, got ${vecs[i].length}`,
|
`INSERT OR REPLACE INTO vectors (id, vector, payload) VALUES (?, ?, ?)`,
|
||||||
);
|
[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(
|
||||||
@@ -109,15 +117,11 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
|
const rows = await this.all(`SELECT * FROM vectors`);
|
||||||
const results: VectorStoreResult[] = [];
|
const results: VectorStoreResult[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const vector = new Float32Array(
|
const vector = new Float32Array(row.vector.buffer);
|
||||||
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,
|
||||||
@@ -140,9 +144,9 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||||
const row = this.db
|
const row = await this.getOne(`SELECT * FROM vectors WHERE id = ?`, [
|
||||||
.prepare(`SELECT * FROM vectors WHERE id = ?`)
|
vectorId,
|
||||||
.get(vectorId) as any;
|
]);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const payload = JSON.parse(row.payload);
|
const payload = JSON.parse(row.payload);
|
||||||
@@ -163,38 +167,34 @@ export class MemoryVectorStore implements VectorStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
|
const vectorBuffer = Buffer.from(new Float32Array(vector).buffer);
|
||||||
this.db
|
await this.run(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`, [
|
||||||
.prepare(`UPDATE vectors SET vector = ?, payload = ? WHERE id = ?`)
|
vectorBuffer,
|
||||||
.run(vectorBuffer, JSON.stringify(payload), vectorId);
|
JSON.stringify(payload),
|
||||||
|
vectorId,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(vectorId: string): Promise<void> {
|
async delete(vectorId: string): Promise<void> {
|
||||||
this.db.prepare(`DELETE FROM vectors WHERE id = ?`).run(vectorId);
|
await this.run(`DELETE FROM vectors WHERE id = ?`, [vectorId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCol(): Promise<void> {
|
async deleteCol(): Promise<void> {
|
||||||
this.db.exec(`DROP TABLE IF EXISTS vectors`);
|
await this.run(`DROP TABLE IF EXISTS vectors`);
|
||||||
this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
filters?: SearchFilters,
|
filters?: SearchFilters,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
): Promise<[VectorStoreResult[], number]> {
|
): Promise<[VectorStoreResult[], number]> {
|
||||||
const rows = this.db.prepare(`SELECT * FROM vectors`).all() as any[];
|
const rows = await this.all(`SELECT * FROM vectors`);
|
||||||
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(
|
vector: Array.from(new Float32Array(row.vector.buffer)),
|
||||||
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 = this.db
|
const row = await this.getOne(
|
||||||
.prepare(`SELECT user_id FROM memory_migrations LIMIT 1`)
|
`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);
|
||||||
this.db
|
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
||||||
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
randomUserId,
|
||||||
.run(randomUserId);
|
]);
|
||||||
return randomUserId;
|
return randomUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setUserId(userId: string): Promise<void> {
|
async setUserId(userId: string): Promise<void> {
|
||||||
this.db.prepare(`DELETE FROM memory_migrations`).run();
|
await this.run(`DELETE FROM memory_migrations`);
|
||||||
this.db
|
await this.run(`INSERT INTO memory_migrations (user_id) VALUES (?)`, [
|
||||||
.prepare(`INSERT INTO memory_migrations (user_id) VALUES (?)`)
|
userId,
|
||||||
.run(userId);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.init();
|
await this.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ 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) {
|
||||||
@@ -212,8 +211,22 @@ export class Qdrant implements VectorStore {
|
|||||||
|
|
||||||
async getUserId(): Promise<string> {
|
async getUserId(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Ensure collection exists (idempotent — handles race conditions)
|
// First check if the collection exists
|
||||||
await this.ensureCollection("memory_migrations", 1);
|
const collections = await this.client.getCollections();
|
||||||
|
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", {
|
||||||
@@ -273,58 +286,66 @@ export class Qdrant implements VectorStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureCollection(name: string, size: number): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.client.createCollection(name, {
|
// Create collection if it doesn't exist
|
||||||
vectors: {
|
const collections = await this.client.getCollections();
|
||||||
size,
|
const exists = collections.collections.some(
|
||||||
distance: "Cosine",
|
(c) => c.name === this.collectionName,
|
||||||
},
|
);
|
||||||
});
|
|
||||||
} catch (error: any) {
|
if (!exists) {
|
||||||
if (error?.status === 409) {
|
try {
|
||||||
// Collection already exists — verify configuration for the main collection
|
await this.client.createCollection(this.collectionName, {
|
||||||
if (name === this.collectionName) {
|
vectors: {
|
||||||
try {
|
size: this.dimension,
|
||||||
const collectionInfo = await this.client.getCollection(name);
|
distance: "Cosine",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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 !== size) {
|
if (!vectorConfig || vectorConfig.size !== this.dimension) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Collection ${name} exists but has wrong vector size. ` +
|
`Collection ${this.collectionName} exists but has wrong configuration. ` +
|
||||||
`Expected: ${size}, got: ${vectorConfig.size}`,
|
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (verifyError: any) {
|
// Collection exists with correct configuration - we can proceed
|
||||||
// Re-throw dimension mismatch errors
|
} else {
|
||||||
if (verifyError?.message?.includes("wrong vector size")) {
|
throw error;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
// Create memory_migrations collection if it doesn't exist
|
||||||
if (!this._initPromise) {
|
const userExists = collections.collections.some(
|
||||||
this._initPromise = this._doInitialize();
|
(c) => c.name === "memory_migrations",
|
||||||
}
|
);
|
||||||
return this._initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _doInitialize(): Promise<void> {
|
if (!userExists) {
|
||||||
try {
|
try {
|
||||||
await this.ensureCollection(this.collectionName, this.dimension);
|
await this.client.createCollection("memory_migrations", {
|
||||||
await this.ensureCollection("memory_migrations", 1);
|
vectors: {
|
||||||
|
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,7 +139,6 @@ 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;
|
||||||
@@ -241,13 +240,6 @@ 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,7 +86,6 @@ 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);
|
||||||
@@ -101,13 +100,6 @@ 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,7 +20,6 @@ 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 });
|
||||||
@@ -344,13 +343,6 @@ 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;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
/// <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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
/// <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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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",
|
||||||
"better-sqlite3",
|
"sqlite3",
|
||||||
"@qdrant/js-client-rest",
|
"@qdrant/js-client-rest",
|
||||||
"redis",
|
"redis",
|
||||||
];
|
];
|
||||||
|
|||||||
+27
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -8,6 +9,7 @@ except ImportError:
|
|||||||
from mem0.configs.llms.base import BaseLlmConfig
|
from mem0.configs.llms.base import BaseLlmConfig
|
||||||
from mem0.configs.llms.ollama import OllamaConfig
|
from mem0.configs.llms.ollama import OllamaConfig
|
||||||
from mem0.llms.base import LLMBase
|
from mem0.llms.base import LLMBase
|
||||||
|
from mem0.memory.utils import extract_json
|
||||||
|
|
||||||
|
|
||||||
class OllamaLLM(LLMBase):
|
class OllamaLLM(LLMBase):
|
||||||
@@ -61,7 +63,28 @@ class OllamaLLM(LLMBase):
|
|||||||
"tool_calls": [],
|
"tool_calls": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama doesn't support tool calls in the same way, so we return the content
|
if isinstance(response, dict):
|
||||||
|
raw_calls = response.get("message", {}).get("tool_calls") or []
|
||||||
|
else:
|
||||||
|
raw_calls = getattr(response.message, "tool_calls", None) or []
|
||||||
|
|
||||||
|
for tool_call in raw_calls:
|
||||||
|
if isinstance(tool_call, dict):
|
||||||
|
fn = tool_call.get("function", {})
|
||||||
|
name = fn.get("name", "")
|
||||||
|
arguments = fn.get("arguments", {})
|
||||||
|
else:
|
||||||
|
fn = getattr(tool_call, "function", None)
|
||||||
|
name = getattr(fn, "name", "") if fn else ""
|
||||||
|
arguments = getattr(fn, "arguments", {}) if fn else {}
|
||||||
|
|
||||||
|
if isinstance(arguments, str):
|
||||||
|
arguments = json.loads(extract_json(arguments))
|
||||||
|
|
||||||
|
processed_response["tool_calls"].append(
|
||||||
|
{"name": name, "arguments": arguments}
|
||||||
|
)
|
||||||
|
|
||||||
return processed_response
|
return processed_response
|
||||||
else:
|
else:
|
||||||
return content
|
return content
|
||||||
@@ -113,5 +136,8 @@ class OllamaLLM(LLMBase):
|
|||||||
# Remove OpenAI-specific parameters that Ollama doesn't support
|
# Remove OpenAI-specific parameters that Ollama doesn't support
|
||||||
params.pop("max_tokens", None) # Ollama uses different parameter names
|
params.pop("max_tokens", None) # Ollama uses different parameter names
|
||||||
|
|
||||||
|
if tools:
|
||||||
|
params["tools"] = tools
|
||||||
|
|
||||||
response = self.client.chat(**params)
|
response = self.client.chat(**params)
|
||||||
return self._parse_response(response, tools)
|
return self._parse_response(response, tools)
|
||||||
|
|||||||
@@ -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(
|
||||||
url=self.config.graph_store.config.url,
|
self.config.graph_store.config.url,
|
||||||
username=self.config.graph_store.config.username,
|
self.config.graph_store.config.username,
|
||||||
password=self.config.graph_store.config.password,
|
self.config.graph_store.config.password,
|
||||||
database=self.config.graph_store.config.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,8 +219,7 @@ 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"]:
|
||||||
if "entity" in item and "entity_type" in item:
|
entity_type_map[item["entity"]] = item["entity_type"]
|
||||||
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}"
|
||||||
@@ -264,8 +263,8 @@ class MemoryGraph:
|
|||||||
)
|
)
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
if extracted_entities and extracted_entities.get("tool_calls"):
|
if extracted_entities["tool_calls"]:
|
||||||
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
|
entities = extracted_entities["tool_calls"][0]["arguments"]["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,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
|
|||||||
|
|
||||||
class AnonymousTelemetry:
|
class AnonymousTelemetry:
|
||||||
def __init__(self, vector_store=None):
|
def __init__(self, vector_store=None):
|
||||||
if not MEM0_TELEMETRY:
|
|
||||||
self.posthog = None
|
|
||||||
self.user_id = None
|
|
||||||
return
|
|
||||||
|
|
||||||
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
|
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)
|
||||||
|
|
||||||
def capture_event(self, event_name, properties=None, user_email=None):
|
if not MEM0_TELEMETRY:
|
||||||
if self.posthog is None:
|
self.posthog.disabled = True
|
||||||
return
|
|
||||||
|
|
||||||
|
def capture_event(self, event_name, properties=None, user_email=None):
|
||||||
if properties is None:
|
if properties is None:
|
||||||
properties = {}
|
properties = {}
|
||||||
properties = {
|
properties = {
|
||||||
@@ -53,17 +49,13 @@ 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):
|
||||||
if self.posthog is not None:
|
self.posthog.shutdown()
|
||||||
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")
|
||||||
@@ -89,9 +81,6 @@ 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__}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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
|
|
||||||
+8
-48
@@ -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="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
<img src="../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,46 +30,12 @@ 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`:
|
||||||
@@ -80,7 +46,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": "alice" // any unique identifier you choose for this user
|
"userId": "your-user-id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -94,7 +60,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": "alice" // any unique identifier you choose for this user
|
"userId": "your-user-id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -121,11 +87,11 @@ The agent gets five tools it can call during conversations:
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
|
| `memory_search` | Search memories by natural language |
|
||||||
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
|
| `memory_list` | List all stored memories for a user |
|
||||||
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
|
| `memory_store` | Explicitly save a fact |
|
||||||
| `memory_get` | Retrieve a memory by ID |
|
| `memory_get` | Retrieve a memory by ID |
|
||||||
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
|
| `memory_forget` | Delete by ID or by query |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -141,12 +107,6 @@ 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
|
||||||
@@ -156,7 +116,7 @@ openclaw mem0 stats --agent researcher
|
|||||||
| 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"` | 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. |
|
| `userId` | `string` | `"default"` | Scope memories per user |
|
||||||
| `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 |
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+35
-157
@@ -10,8 +10,6 @@
|
|||||||
* - 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)
|
||||||
*/
|
*/
|
||||||
@@ -41,7 +39,6 @@ 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;
|
||||||
@@ -133,10 +130,7 @@ 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().catch((err) => {
|
this.initPromise = this._init();
|
||||||
this.initPromise = null;
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,17 +163,13 @@ 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 filters: Record<string, unknown> = { user_id: options.user_id };
|
const opts: Record<string, unknown> = { user_id: options.user_id };
|
||||||
if (options.run_id) filters.run_id = options.run_id;
|
if (options.run_id) opts.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.rerank = options.reranking;
|
if (options.reranking != null) opts.reranking = options.reranking;
|
||||||
|
if (options.source) opts.source = options.source;
|
||||||
|
|
||||||
const results = await this.client.search(query, opts);
|
const results = await this.client.search(query, opts);
|
||||||
return normalizeSearchResults(results);
|
return normalizeSearchResults(results);
|
||||||
@@ -229,10 +219,7 @@ 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().catch((err) => {
|
this.initPromise = this._init();
|
||||||
this.initPromise = null;
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,30 +240,9 @@ 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;
|
||||||
|
|
||||||
try {
|
this.memory = new Memory(config);
|
||||||
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(
|
||||||
@@ -549,7 +515,7 @@ function assertAllowedKeys(
|
|||||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mem0ConfigSchema = {
|
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");
|
||||||
@@ -615,7 +581,7 @@ export const mem0ConfigSchema = {
|
|||||||
// Provider Factory
|
// Provider Factory
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export function createProvider(
|
function createProvider(
|
||||||
cfg: Mem0Config,
|
cfg: Mem0Config,
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
): Mem0Provider {
|
): Mem0Provider {
|
||||||
@@ -639,50 +605,6 @@ 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -702,23 +624,14 @@ 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, sessionKey?: string): AddOptions {
|
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
|
||||||
const opts: AddOptions = {
|
const opts: AddOptions = {
|
||||||
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
user_id: userIdOverride || cfg.userId,
|
||||||
source: "OPENCLAW",
|
source: "OPENCLAW",
|
||||||
};
|
};
|
||||||
if (runId) opts.run_id = runId;
|
if (runId) opts.run_id = runId;
|
||||||
@@ -736,10 +649,9 @@ 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 || _effectiveUserId(sessionKey),
|
user_id: userIdOverride || cfg.userId,
|
||||||
top_k: limit ?? cfg.topK,
|
top_k: limit ?? cfg.topK,
|
||||||
limit: limit ?? cfg.topK,
|
limit: limit ?? cfg.topK,
|
||||||
threshold: cfg.searchThreshold,
|
threshold: cfg.searchThreshold,
|
||||||
@@ -774,12 +686,6 @@ 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"),
|
||||||
@@ -792,41 +698,39 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { query, limit, userId, agentId, scope = "all" } = params as {
|
const { query, limit, userId, 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(uid, limit, currentSessionId),
|
buildSearchOptions(userId, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (scope === "long-term") {
|
} else if (scope === "long-term") {
|
||||||
results = await provider.search(
|
results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(uid, limit),
|
buildSearchOptions(userId, 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(uid, limit),
|
buildSearchOptions(userId, limit),
|
||||||
);
|
);
|
||||||
let sessionResults: MemoryItem[] = [];
|
let sessionResults: MemoryItem[] = [];
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(uid, limit, currentSessionId),
|
buildSearchOptions(userId, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Deduplicate by ID, preferring long-term
|
// Deduplicate by ID, preferring long-term
|
||||||
@@ -899,12 +803,6 @@ 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",
|
||||||
@@ -918,20 +816,18 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { text, userId, agentId, longTerm = true } = params as {
|
const { text, userId, 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(uid, runId, currentSessionId),
|
buildAddOptions(userId, runId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const added =
|
const added =
|
||||||
@@ -1023,7 +919,7 @@ const memoryPlugin = {
|
|||||||
name: "memory_list",
|
name: "memory_list",
|
||||||
label: "Memory List",
|
label: "Memory List",
|
||||||
description:
|
description:
|
||||||
"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.",
|
"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.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.Optional(
|
userId: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -1031,12 +927,6 @@ 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"),
|
||||||
@@ -1049,11 +939,11 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
|
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let memories: MemoryItem[] = [];
|
let memories: MemoryItem[] = [];
|
||||||
const uid = _resolveUserId({ agentId, userId });
|
const uid = userId || cfg.userId;
|
||||||
|
|
||||||
if (scope === "session") {
|
if (scope === "session") {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
@@ -1136,7 +1026,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. Supports agent-scoped deletion. GDPR-compliant.",
|
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. GDPR-compliant.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
query: Type.Optional(
|
query: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -1146,18 +1036,11 @@ 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, agentId } = params as {
|
const { query, memoryId } = params as {
|
||||||
query?: string;
|
query?: string;
|
||||||
memoryId?: string;
|
memoryId?: string;
|
||||||
agentId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1172,10 +1055,9 @@ const memoryPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const uid = _resolveUserId({ agentId });
|
|
||||||
const results = await provider.search(
|
const results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(uid, 5),
|
buildSearchOptions(undefined, 5),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
if (!results || results.length === 0) {
|
||||||
@@ -1266,12 +1148,10 @@ 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")
|
||||||
.option("--agent <agentId>", "Search a specific agent's memory namespace")
|
.action(async (query: string, opts: { limit: string; scope: string }) => {
|
||||||
.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[] = [];
|
||||||
|
|
||||||
@@ -1279,7 +1159,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const sessionResults = await provider.search(
|
const sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(uid, limit, currentSessionId),
|
buildSearchOptions(undefined, 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 })));
|
||||||
@@ -1293,7 +1173,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(uid, limit),
|
buildSearchOptions(undefined, 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 })));
|
||||||
@@ -1332,16 +1212,14 @@ const memoryPlugin = {
|
|||||||
mem0
|
mem0
|
||||||
.command("stats")
|
.command("stats")
|
||||||
.description("Show memory statistics from Mem0")
|
.description("Show memory statistics from Mem0")
|
||||||
.option("--agent <agentId>", "Show stats for a specific agent")
|
.action(async () => {
|
||||||
.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: uid,
|
user_id: cfg.userId,
|
||||||
source: "OPENCLAW",
|
source: "OPENCLAW",
|
||||||
});
|
});
|
||||||
console.log(`Mode: ${cfg.mode}`);
|
console.log(`Mode: ${cfg.mode}`);
|
||||||
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
|
console.log(`User: ${cfg.userId}`);
|
||||||
console.log(
|
console.log(
|
||||||
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
||||||
);
|
);
|
||||||
@@ -1371,10 +1249,10 @@ const memoryPlugin = {
|
|||||||
if (sessionId) currentSessionId = sessionId;
|
if (sessionId) currentSessionId = sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search long-term memories (user-scoped, isolated per agent)
|
// Search long-term memories (user-scoped)
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(undefined, undefined, undefined, sessionId),
|
buildSearchOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search session memories (session-scoped) if we have a session ID
|
// Search session memories (session-scoped) if we have a session ID
|
||||||
@@ -1382,7 +1260,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
|
buildSearchOptions(undefined, undefined, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,7 +1365,7 @@ const memoryPlugin = {
|
|||||||
|
|
||||||
if (formattedMessages.length === 0) return;
|
if (formattedMessages.length === 0) return;
|
||||||
|
|
||||||
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
|
const addOpts = buildAddOptions(undefined, currentSessionId);
|
||||||
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.3.1",
|
"version": "0.1.2",
|
||||||
"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,9 +11,6 @@
|
|||||||
"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"
|
||||||
@@ -22,8 +19,5 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.5"
|
version = "1.0.4"
|
||||||
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.6,<7.0.0",
|
"protobuf>=5.29.0,<6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
---
|
|
||||||
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) |
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
# 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")
|
|
||||||
```
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,720 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -32,3 +32,111 @@ def test_generate_response_without_tools(mock_ollama_client):
|
|||||||
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
|
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
|
||||||
)
|
)
|
||||||
assert response == "I'm doing well, thank you for asking!"
|
assert response == "I'm doing well, thank you for asking!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
|
||||||
|
"""Tools should be forwarded to ollama client.chat()."""
|
||||||
|
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||||
|
llm = OllamaLLM(config)
|
||||||
|
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "extract_entities",
|
||||||
|
"description": "Extract entities",
|
||||||
|
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = {
|
||||||
|
"message": {
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "extract_entities",
|
||||||
|
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_ollama_client.chat.return_value = mock_response
|
||||||
|
|
||||||
|
response = llm.generate_response(messages, tools=tools)
|
||||||
|
|
||||||
|
# Verify tools were passed to client.chat
|
||||||
|
call_kwargs = mock_ollama_client.chat.call_args
|
||||||
|
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
|
||||||
|
assert call_kwargs[1]["tools"] == tools
|
||||||
|
|
||||||
|
# Verify tool_calls were parsed correctly
|
||||||
|
assert response["tool_calls"] == [
|
||||||
|
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
|
||||||
|
"""When model returns content without tool_calls, tool_calls should be empty list."""
|
||||||
|
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||||
|
llm = OllamaLLM(config)
|
||||||
|
messages = [{"role": "user", "content": "Hello"}]
|
||||||
|
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
|
||||||
|
|
||||||
|
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
|
||||||
|
mock_ollama_client.chat.return_value = mock_response
|
||||||
|
|
||||||
|
response = llm.generate_response(messages, tools=tools)
|
||||||
|
|
||||||
|
assert response["content"] == "I cannot use tools for this."
|
||||||
|
assert response["tool_calls"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
|
||||||
|
"""When tool_call arguments come as JSON string, they should be parsed."""
|
||||||
|
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
|
||||||
|
llm = OllamaLLM(config)
|
||||||
|
messages = [{"role": "user", "content": "test"}]
|
||||||
|
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
|
||||||
|
|
||||||
|
mock_response = {
|
||||||
|
"message": {
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_ollama_client.chat.return_value = mock_response
|
||||||
|
|
||||||
|
response = llm.generate_response(messages, tools=tools)
|
||||||
|
|
||||||
|
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_with_tools_object_style(mock_ollama_client):
|
||||||
|
"""Test _parse_response with object-style response (non-dict)."""
|
||||||
|
config = OllamaConfig(model="llama3.1:70b")
|
||||||
|
llm = OllamaLLM(config)
|
||||||
|
|
||||||
|
# Simulate object-style response
|
||||||
|
mock_fn = Mock()
|
||||||
|
mock_fn.name = "extract"
|
||||||
|
mock_fn.arguments = {"entities": ["Alice"]}
|
||||||
|
|
||||||
|
mock_tool_call = Mock()
|
||||||
|
mock_tool_call.function = mock_fn
|
||||||
|
|
||||||
|
mock_message = Mock()
|
||||||
|
mock_message.content = ""
|
||||||
|
mock_message.tool_calls = [mock_tool_call]
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.message = mock_message
|
||||||
|
|
||||||
|
tools = [{"type": "function", "function": {"name": "extract"}}]
|
||||||
|
result = llm._parse_response(mock_response, tools)
|
||||||
|
|
||||||
|
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
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"
|
|
||||||
+25
-104
@@ -1,114 +1,35 @@
|
|||||||
import threading
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import mem0.memory.telemetry as telemetry_module
|
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
|
||||||
|
|
||||||
|
if isinstance(MEM0_TELEMETRY, str):
|
||||||
|
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetryDisabled:
|
def use_telemetry():
|
||||||
"""Verify PostHog is never instantiated when telemetry is disabled."""
|
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
|
||||||
|
return True
|
||||||
def test_posthog_not_created_when_disabled(self):
|
return False
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetryEnabled:
|
@pytest.fixture(autouse=True)
|
||||||
"""Verify PostHog works normally when telemetry is enabled."""
|
def reset_env():
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
def test_posthog_created_when_enabled(self):
|
yield
|
||||||
"""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()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTelemetryEnvVar:
|
def test_telemetry_enabled():
|
||||||
"""Verify the MEM0_TELEMETRY env var parsing logic."""
|
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
|
||||||
|
assert use_telemetry() is True
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"value,expected",
|
def test_telemetry_disabled():
|
||||||
[
|
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
|
||||||
("true", True),
|
assert use_telemetry() is False
|
||||||
("True", True),
|
|
||||||
("TRUE", True),
|
|
||||||
("1", True),
|
def test_telemetry_default_enabled():
|
||||||
("yes", True),
|
assert use_telemetry() is 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