Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da2ffddf7e | |||
| 06ee1b588c | |||
| dc6122ec3d | |||
| df79a43925 | |||
| a6242710df | |||
| 4e1e4c0c5a | |||
| fa5c85f9f6 | |||
| 7c29eb2645 | |||
| 6f079c313f | |||
| e95090e116 | |||
| 861cbb7289 | |||
| 54aa760720 | |||
| 59c3b050bd | |||
| 5b3acf416b | |||
| 63f587c922 |
@@ -729,6 +729,19 @@ mode: "wide"
|
||||
|
||||
<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:**
|
||||
|
||||
@@ -114,7 +114,7 @@ class MemoryEnabledAgent(Agent):
|
||||
logger.info("About to await mem0_client.search for RAG context")
|
||||
search_results = await mem0_client.search(
|
||||
new_message.text_content,
|
||||
user_id=RAG_USER_ID,
|
||||
filters={"user_id": RAG_USER_ID},
|
||||
)
|
||||
logger.info(f"mem0_client.search returned: {search_results}")
|
||||
if search_results and search_results.get('results', []):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ConfigManager {
|
||||
? userConf.apiKey
|
||||
: defaultConf.apiKey,
|
||||
model: finalModel,
|
||||
baseURL: userConf?.baseURL,
|
||||
url: userConf?.url,
|
||||
embeddingDims: userConf?.embeddingDims,
|
||||
modelProperties:
|
||||
@@ -43,13 +44,23 @@ export class ConfigManager {
|
||||
const defaultConf = DEFAULT_MEMORY_CONFIG.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
|
||||
if (userConf?.client && typeof userConf.client === "object") {
|
||||
return {
|
||||
client: userConf.client,
|
||||
// Include other fields from userConf if necessary, or omit defaults
|
||||
collectionName: userConf.collectionName, // Can be undefined
|
||||
dimension: userConf.dimension || defaultConf.dimension, // Merge dimension
|
||||
collectionName: userConf.collectionName,
|
||||
dimension: explicitDimension,
|
||||
...userConf, // Include any other passthrough fields from user
|
||||
};
|
||||
} else {
|
||||
@@ -57,7 +68,7 @@ export class ConfigManager {
|
||||
return {
|
||||
collectionName:
|
||||
userConf?.collectionName || defaultConf.collectionName,
|
||||
dimension: userConf?.dimension || defaultConf.dimension,
|
||||
dimension: explicitDimension,
|
||||
// Ensure client is not carried over from defaults if not provided by user
|
||||
client: undefined,
|
||||
// Include other passthrough fields from userConf even if no client
|
||||
@@ -95,16 +106,34 @@ export class ConfigManager {
|
||||
})(),
|
||||
},
|
||||
historyDbPath:
|
||||
userConfig.historyDbPath || DEFAULT_MEMORY_CONFIG.historyDbPath,
|
||||
userConfig.historyDbPath ||
|
||||
userConfig.historyStore?.config?.historyDbPath ||
|
||||
DEFAULT_MEMORY_CONFIG.historyStore?.config?.historyDbPath,
|
||||
customPrompt: userConfig.customPrompt,
|
||||
graphStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.graphStore,
|
||||
...userConfig.graphStore,
|
||||
},
|
||||
historyStore: {
|
||||
...DEFAULT_MEMORY_CONFIG.historyStore,
|
||||
...userConfig.historyStore,
|
||||
},
|
||||
historyStore: (() => {
|
||||
const defaultHistoryStore = DEFAULT_MEMORY_CONFIG.historyStore!;
|
||||
const historyProvider =
|
||||
userConfig.historyStore?.provider || defaultHistoryStore.provider;
|
||||
const isSqlite = historyProvider.toLowerCase() === "sqlite";
|
||||
|
||||
// Precedence: explicit historyStore.config > top-level historyDbPath > default
|
||||
return {
|
||||
...defaultHistoryStore,
|
||||
...userConfig.historyStore,
|
||||
provider: historyProvider,
|
||||
config: {
|
||||
...(isSqlite ? defaultHistoryStore.config : {}),
|
||||
...(isSqlite && userConfig.historyDbPath
|
||||
? { historyDbPath: userConfig.historyDbPath }
|
||||
: {}),
|
||||
...userConfig.historyStore?.config,
|
||||
},
|
||||
};
|
||||
})(),
|
||||
disableHistory:
|
||||
userConfig.disableHistory || DEFAULT_MEMORY_CONFIG.disableHistory,
|
||||
enableGraph: userConfig.enableGraph || DEFAULT_MEMORY_CONFIG.enableGraph,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class GoogleEmbedder implements Embedder {
|
||||
const response = await this.google.models.embedContent({
|
||||
model: this.model,
|
||||
contents: texts,
|
||||
config: { outputDimensionality: 768 },
|
||||
config: { outputDimensionality: this.embeddingDims },
|
||||
});
|
||||
return response.embeddings!.map((item) => item.values!);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ export class OpenAIEmbedder implements Embedder {
|
||||
private embeddingDims?: number;
|
||||
|
||||
constructor(config: EmbeddingConfig) {
|
||||
this.openai = new OpenAI({ apiKey: config.apiKey });
|
||||
this.openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL || config.url,
|
||||
});
|
||||
this.model = config.model || "text-embedding-3-small";
|
||||
this.embeddingDims = config.embeddingDims || 1536;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ Memory Format:
|
||||
source -- relationship -- destination
|
||||
|
||||
Provide a list of deletion instructions, each specifying the relationship to be deleted.
|
||||
|
||||
Respond in JSON format.
|
||||
`;
|
||||
|
||||
export function getDeleteMessages(
|
||||
|
||||
@@ -212,7 +212,7 @@ export class MemoryGraph {
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question.`,
|
||||
content: `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${filters["userId"]} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`,
|
||||
},
|
||||
{ role: "user", content: data },
|
||||
],
|
||||
|
||||
@@ -41,7 +41,7 @@ export class Memory {
|
||||
private config: MemoryConfig;
|
||||
private customPrompt: string | undefined;
|
||||
private embedder: Embedder;
|
||||
private vectorStore: VectorStore;
|
||||
private vectorStore!: VectorStore;
|
||||
private llm: LLM;
|
||||
private db: HistoryManager;
|
||||
private collectionName: string | undefined;
|
||||
@@ -49,6 +49,8 @@ export class Memory {
|
||||
private graphMemory?: MemoryGraph;
|
||||
private enableGraph: boolean;
|
||||
telemetryId: string;
|
||||
private _initPromise: Promise<void>;
|
||||
private _initError?: Error;
|
||||
|
||||
constructor(config: Partial<MemoryConfig> = {}) {
|
||||
// Merge and validate config
|
||||
@@ -59,10 +61,9 @@ export class Memory {
|
||||
this.config.embedder.provider,
|
||||
this.config.embedder.config,
|
||||
);
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
// Vector store creation is deferred to _autoInitialize() so that
|
||||
// the embedding dimension can be auto-detected first when not
|
||||
// explicitly configured.
|
||||
this.llm = LLMFactory.create(
|
||||
this.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
@@ -70,20 +71,10 @@ export class Memory {
|
||||
if (this.config.disableHistory) {
|
||||
this.db = new DummyHistoryManager();
|
||||
} else {
|
||||
const defaultConfig = {
|
||||
provider: "sqlite",
|
||||
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.db = HistoryManagerFactory.create(
|
||||
this.config.historyStore!.provider,
|
||||
this.config.historyStore!,
|
||||
);
|
||||
}
|
||||
|
||||
this.collectionName = this.config.vectorStore.config.collectionName;
|
||||
@@ -96,8 +87,67 @@ export class Memory {
|
||||
this.graphMemory = new MemoryGraph(this.config);
|
||||
}
|
||||
|
||||
// Initialize telemetry if vector store is initialized
|
||||
this._initializeTelemetry();
|
||||
// Auto-detect embedding dimension (if needed), create vector store,
|
||||
// and initialize it. All public methods await this before proceeding.
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If no explicit dimension was provided, runs a probe embedding to
|
||||
* detect it. Then creates and initializes the vector store.
|
||||
*/
|
||||
private async _autoInitialize(): Promise<void> {
|
||||
if (!this.config.vectorStore.config.dimension) {
|
||||
try {
|
||||
const probe = await this.embedder.embed("dimension probe");
|
||||
this.config.vectorStore.config.dimension = probe.length;
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to auto-detect embedding dimension from provider '${this.config.embedder.provider}': ${error.message}. ` +
|
||||
`Please set 'dimension' in vectorStore.config or 'embeddingDims' in embedder.config explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.vectorStore = VectorStoreFactory.create(
|
||||
this.config.vectorStore.provider,
|
||||
this.config.vectorStore.config,
|
||||
);
|
||||
|
||||
// The vector store constructor may fire initialize() asynchronously
|
||||
// (e.g. Qdrant). Explicitly await it here to guarantee the backing
|
||||
// store (collections, tables, etc.) is ready before any public method
|
||||
// attempts to read or write.
|
||||
await this.vectorStore.initialize();
|
||||
|
||||
await this._initializeTelemetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that auto-initialization (dimension detection + vector store
|
||||
* creation) has completed before any public method proceeds.
|
||||
* If a previous init attempt failed, retries automatically.
|
||||
*/
|
||||
private async _ensureInitialized(): Promise<void> {
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
// Clear failed state and retry — the embedder or vector store
|
||||
// may have been transiently unavailable at startup.
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
if (this._initError) {
|
||||
throw this._initError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _initializeTelemetry() {
|
||||
@@ -157,6 +207,7 @@ export class Memory {
|
||||
messages: string | Message[],
|
||||
config: AddMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("add", {
|
||||
message_count: Array.isArray(messages) ? messages.length : 1,
|
||||
has_metadata: !!config.metadata,
|
||||
@@ -382,6 +433,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<MemoryItem | null> {
|
||||
await this._ensureInitialized();
|
||||
const memory = await this.vectorStore.get(memoryId);
|
||||
if (!memory) return null;
|
||||
|
||||
@@ -423,6 +475,7 @@ export class Memory {
|
||||
query: string,
|
||||
config: SearchMemoryOptions,
|
||||
): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("search", {
|
||||
query_length: query.length,
|
||||
limit: config.limit,
|
||||
@@ -489,6 +542,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async update(memoryId: string, data: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("update", { memory_id: memoryId });
|
||||
const embedding = await this.embedder.embed(data);
|
||||
await this.updateMemory(memoryId, data, { [data]: embedding });
|
||||
@@ -496,6 +550,7 @@ export class Memory {
|
||||
}
|
||||
|
||||
async delete(memoryId: string): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete", { memory_id: memoryId });
|
||||
await this.deleteMemory(memoryId);
|
||||
return { message: "Memory deleted successfully!" };
|
||||
@@ -504,6 +559,7 @@ export class Memory {
|
||||
async deleteAll(
|
||||
config: DeleteAllMemoryOptions,
|
||||
): Promise<{ message: string }> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("delete_all", {
|
||||
has_user_id: !!config.userId,
|
||||
has_agent_id: !!config.agentId,
|
||||
@@ -531,10 +587,12 @@ export class Memory {
|
||||
}
|
||||
|
||||
async history(memoryId: string): Promise<any[]> {
|
||||
await this._ensureInitialized();
|
||||
return this.db.getHistory(memoryId);
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("reset");
|
||||
await this.db.reset();
|
||||
|
||||
@@ -559,28 +617,30 @@ export class Memory {
|
||||
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.config.embedder.provider,
|
||||
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.config.llm.provider,
|
||||
this.config.llm.config,
|
||||
);
|
||||
// Re-init DB if needed (though db.reset() likely handles its state)
|
||||
// Re-init Graph if needed
|
||||
|
||||
// Re-initialize telemetry
|
||||
this._initializeTelemetry();
|
||||
// Re-create vector store via _autoInitialize (which handles dimension + creation)
|
||||
this._initError = undefined;
|
||||
this._initPromise = this._autoInitialize().catch((error) => {
|
||||
this._initError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
console.error(this._initError);
|
||||
});
|
||||
await this._initPromise;
|
||||
}
|
||||
|
||||
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
|
||||
await this._ensureInitialized();
|
||||
await this._captureEvent("get_all", {
|
||||
limit: config.limit,
|
||||
has_user_id: !!config.userId,
|
||||
|
||||
@@ -278,5 +278,9 @@ export function parseMessages(messages: string[]): string {
|
||||
}
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(/```[^`]*```/g, "");
|
||||
// Extract content inside code fences instead of deleting it.
|
||||
// The old regex /```[^`]*```/g replaced the entire block (including
|
||||
// its content) with an empty string, so when an LLM returned JSON
|
||||
// wrapped in ```json ... ``` the actual payload was discarded.
|
||||
return text.replace(/```(?:\w+)?\n?([\s\S]*?)```/g, "$1").trim();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { HistoryManager } from "./base";
|
||||
import { ensureSQLiteDirectory } from "../utils/sqlite";
|
||||
|
||||
export class SQLiteManager implements HistoryManager {
|
||||
private db: Database.Database;
|
||||
@@ -7,6 +8,7 @@ export class SQLiteManager implements HistoryManager {
|
||||
private stmtSelect!: Database.Statement;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
ensureSQLiteDirectory(dbPath);
|
||||
this.db = new Database(dbPath);
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Backward-compatibility tests for SQLite path handling changes.
|
||||
*
|
||||
* These tests verify that every documented and common usage pattern
|
||||
* from before the fix continues to work identically after the change.
|
||||
*/
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Config merging – existing patterns must keep working
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ConfigManager.mergeConfig", () => {
|
||||
it("empty config returns all expected defaults", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
|
||||
expect(cfg.version).toBe("v1.1");
|
||||
expect(cfg.embedder.provider).toBe("openai");
|
||||
expect(cfg.vectorStore.provider).toBe("memory");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("memories");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(1536);
|
||||
expect(cfg.llm.provider).toBe("openai");
|
||||
expect(cfg.historyStore).toBeDefined();
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("memory.db");
|
||||
expect(cfg.disableHistory).toBe(false);
|
||||
expect(cfg.enableGraph).toBe(false);
|
||||
});
|
||||
|
||||
it("workaround: explicit historyStore still works (existing user pattern)", () => {
|
||||
// This is the documented workaround from all three issues
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/workaround.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore!.config.historyDbPath).toBe("/tmp/workaround.db");
|
||||
});
|
||||
|
||||
it("disableHistory: true still works", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ disableHistory: true });
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
});
|
||||
|
||||
it("supabase historyStore config is preserved", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://abc.supabase.co",
|
||||
supabaseKey: "secret-key",
|
||||
tableName: "custom_history",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore!.provider).toBe("supabase");
|
||||
expect(cfg.historyStore!.config.supabaseUrl).toBe(
|
||||
"https://abc.supabase.co",
|
||||
);
|
||||
expect(cfg.historyStore!.config.supabaseKey).toBe("secret-key");
|
||||
expect(cfg.historyStore!.config.tableName).toBe("custom_history");
|
||||
});
|
||||
|
||||
it("custom embedder, llm, vectorStore configs pass through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", url: "http://localhost:11434" },
|
||||
},
|
||||
llm: {
|
||||
provider: "ollama",
|
||||
config: { model: "llama3.1:8b" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.embedder.provider).toBe("ollama");
|
||||
expect(cfg.embedder.config.model).toBe("nomic-embed-text");
|
||||
expect(cfg.llm.provider).toBe("ollama");
|
||||
expect(cfg.llm.config.model).toBe("llama3.1:8b");
|
||||
expect(cfg.vectorStore.provider).toBe("qdrant");
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("graphStore config passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
enableGraph: true,
|
||||
graphStore: {
|
||||
provider: "neo4j",
|
||||
config: {
|
||||
url: "neo4j://custom:7687",
|
||||
username: "admin",
|
||||
password: "pass",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.enableGraph).toBe(true);
|
||||
expect(cfg.graphStore!.config.url).toBe("neo4j://custom:7687");
|
||||
});
|
||||
|
||||
it("customPrompt passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
customPrompt: "You are a helpful assistant",
|
||||
});
|
||||
expect(cfg.customPrompt).toBe("You are a helpful assistant");
|
||||
});
|
||||
|
||||
it("version override passes through unchanged", () => {
|
||||
const cfg = ConfigManager.mergeConfig({ version: "v1.0" });
|
||||
expect(cfg.version).toBe("v1.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. SQLiteManager – existing behavior preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: SQLiteManager", () => {
|
||||
it("relative path still works (resolves from CWD)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
const manager = new SQLiteManager("memory.db");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(fs.existsSync(path.join(tempDir, "memory.db"))).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("absolute path still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-"));
|
||||
const dbPath = path.join(tempDir, "history.db");
|
||||
|
||||
try {
|
||||
const manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
manager.close();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(":memory: still works", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "value", "ADD");
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
|
||||
it("reset clears history and allows re-use", async () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
await manager.addHistory("m1", null, "val", "ADD");
|
||||
await manager.reset();
|
||||
const history = await manager.getHistory("m1");
|
||||
expect(history).toHaveLength(0);
|
||||
await manager.addHistory("m2", null, "new-val", "ADD");
|
||||
const history2 = await manager.getHistory("m2");
|
||||
expect(history2).toHaveLength(1);
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. MemoryVectorStore – existing API preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: MemoryVectorStore", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("explicit dbPath still works (the existing config.dbPath feature)", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "my_vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert([normalize([1, 0, 0])], ["id1"], [{ text: "hello" }]);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
|
||||
const result = await store.get("id1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("insert, search, get, update, delete, list all work", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
const v1 = normalize([1, 0, 0]);
|
||||
const v2 = normalize([0, 1, 0]);
|
||||
|
||||
// insert
|
||||
await store.insert([v1, v2], ["a", "b"], [{ t: "a" }, { t: "b" }]);
|
||||
|
||||
// get
|
||||
const a = await store.get("a");
|
||||
expect(a!.payload.t).toBe("a");
|
||||
|
||||
// search
|
||||
const results = await store.search(v1, 2);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].id).toBe("a"); // closest to v1
|
||||
|
||||
// update
|
||||
await store.update("a", v2, { t: "updated" });
|
||||
const updated = await store.get("a");
|
||||
expect(updated!.payload.t).toBe("updated");
|
||||
|
||||
// list
|
||||
const [listed, count] = await store.list();
|
||||
expect(count).toBe(2);
|
||||
expect(listed).toHaveLength(2);
|
||||
|
||||
// delete
|
||||
await store.delete("a");
|
||||
const deleted = await store.get("a");
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// deleteCol
|
||||
await store.deleteCol();
|
||||
const [afterDrop] = await store.list();
|
||||
expect(afterDrop).toHaveLength(0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on insert still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(
|
||||
store.insert([[1, 0]], ["id1"], [{ t: "x" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dimension mismatch on search still throws", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await expect(store.search([1, 0], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("default dimension is 1536 when not specified", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const store = new MemoryVectorStore({});
|
||||
// Verify by trying to insert a 1536-dim vector
|
||||
const vec = new Array(1536).fill(0);
|
||||
vec[0] = 1;
|
||||
expect(store.insert([vec], ["id1"], [{ t: "x" }])).resolves.not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("search with filters still works", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-compat-vs-"));
|
||||
const dbPath = path.join(tempDir, "test.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 3, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0]), normalize([0, 1, 0])],
|
||||
["a", "b"],
|
||||
[
|
||||
{ text: "hello", userId: "user1" },
|
||||
{ text: "world", userId: "user2" },
|
||||
],
|
||||
);
|
||||
|
||||
const results = await store.search(normalize([1, 0, 0]), 10, {
|
||||
userId: "user2",
|
||||
});
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("b");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. VectorStoreConfig type – dbPath is optional, existing configs work
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: VectorStoreConfig type", () => {
|
||||
it("config without dbPath still works (no required field breakage)", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 512 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.dbPath).toBeUndefined();
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("test");
|
||||
expect(cfg.vectorStore.config.dimension).toBe(512);
|
||||
});
|
||||
|
||||
it("config with client instance passes through unchanged", () => {
|
||||
const fakeClient = { connect: () => {} };
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { client: fakeClient, dimension: 768 },
|
||||
},
|
||||
});
|
||||
expect(cfg.vectorStore.config.client).toBe(fakeClient);
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. ensureSQLiteDirectory – does not break existing paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("backward compat: ensureSQLiteDirectory", () => {
|
||||
it("no-ops for already existing directory", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-existing-"));
|
||||
try {
|
||||
// Should not throw even though directory already exists
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("handles path with trailing slash gracefully", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-trailing-"));
|
||||
try {
|
||||
// path.dirname of "dir/sub/" is "dir/sub", mkdirSync should handle it
|
||||
expect(() =>
|
||||
ensureSQLiteDirectory(path.join(tempDir, "sub", "test.db")),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { ConfigManager } from "../config/manager";
|
||||
import { SQLiteManager } from "../storage/SQLiteManager";
|
||||
import { MemoryVectorStore } from "../vector_stores/memory";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
function normalize(vector: number[]): number[] {
|
||||
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
||||
return vector.map((value) => value / norm);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config merging – historyDbPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ConfigManager.mergeConfig – historyDbPath handling", () => {
|
||||
it("propagates top-level historyDbPath into historyStore.config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/custom/history.db",
|
||||
});
|
||||
expect(cfg.historyDbPath).toBe("/tmp/custom/history.db");
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe(
|
||||
"/tmp/custom/history.db",
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit historyStore.config.historyDbPath takes precedence over top-level", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/shorthand.db",
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/explicit.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/explicit.db");
|
||||
});
|
||||
|
||||
it("preserves default memory.db when nothing is provided", () => {
|
||||
const cfg = ConfigManager.mergeConfig({});
|
||||
expect(cfg.historyStore?.provider).toBe("sqlite");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("memory.db");
|
||||
});
|
||||
|
||||
it("respects only historyStore.config when top-level is absent", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
config: { historyDbPath: "/tmp/nested-only.db" },
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/nested-only.db");
|
||||
});
|
||||
|
||||
it("does not leak historyDbPath into non-sqlite providers", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
historyDbPath: "/tmp/should-not-apply.db",
|
||||
historyStore: {
|
||||
provider: "supabase",
|
||||
config: {
|
||||
supabaseUrl: "https://x.supabase.co",
|
||||
supabaseKey: "key",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.historyStore?.provider).toBe("supabase");
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disableHistory does not prevent historyStore config from merging", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
disableHistory: true,
|
||||
historyDbPath: "/tmp/disabled.db",
|
||||
});
|
||||
expect(cfg.disableHistory).toBe(true);
|
||||
expect(cfg.historyStore?.config.historyDbPath).toBe("/tmp/disabled.db");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLiteManager – directory creation & DB operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SQLiteManager – directory auto-creation", () => {
|
||||
it("creates nested parent directories and writes to the DB", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-sqlite-"));
|
||||
const dbPath = path.join(tempDir, "a", "b", "c", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
manager = new SQLiteManager(dbPath);
|
||||
await manager.addHistory("mem-1", null, "test value", "ADD");
|
||||
const history = await manager.getHistory("mem-1");
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].new_value).toBe("test value");
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("end-to-end: mergeConfig + SQLiteManager at configured path", async () => {
|
||||
const tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "mem0-history-path-"),
|
||||
);
|
||||
const historyDbPath = path.join(tempDir, "nested", "history.db");
|
||||
let manager: SQLiteManager | undefined;
|
||||
|
||||
try {
|
||||
const mergedConfig = ConfigManager.mergeConfig({ historyDbPath });
|
||||
|
||||
manager = new SQLiteManager(
|
||||
mergedConfig.historyStore!.config.historyDbPath!,
|
||||
);
|
||||
await manager.addHistory("memory-1", null, "remember me", "ADD");
|
||||
|
||||
expect(fs.existsSync(historyDbPath)).toBe(true);
|
||||
} finally {
|
||||
manager?.close();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works with :memory: without attempting directory creation", () => {
|
||||
const manager = new SQLiteManager(":memory:");
|
||||
expect(manager).toBeDefined();
|
||||
manager.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryVectorStore – path handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryVectorStore – path handling", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses ~/.mem0/vector_store.db by default", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
try {
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects explicit dbPath config", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const dbPath = path.join(tempDir, "custom", "vectors.db");
|
||||
|
||||
try {
|
||||
const store = new MemoryVectorStore({ dimension: 4, dbPath });
|
||||
await store.insert(
|
||||
[normalize([1, 0, 0, 0])],
|
||||
["v1"],
|
||||
[{ text: "hello" }],
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dbPath)).toBe(true);
|
||||
const results = await store.search(normalize([1, 0, 0, 0]), 1);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].payload.text).toBe("hello");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("works when CWD is read-only", async () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const readOnlyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ro-"));
|
||||
|
||||
try {
|
||||
fs.chmodSync(readOnlyCwd, 0o555);
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
process.chdir(readOnlyCwd);
|
||||
|
||||
const store = new MemoryVectorStore({ dimension: 4 });
|
||||
await store.insert(
|
||||
[normalize([0, 1, 0, 0])],
|
||||
["v2"],
|
||||
[{ text: "works" }],
|
||||
);
|
||||
|
||||
expect(
|
||||
fs.existsSync(path.join(fakeHome, ".mem0", "vector_store.db")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(readOnlyCwd, "vector_store.db"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.chmodSync(readOnlyCwd, 0o755);
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(readOnlyCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits migration warning when old CWD-based vector_store.db exists", () => {
|
||||
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-home-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
jest.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({ dimension: 4 });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Default vector_store.db location changed"),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT emit migration warning when dbPath is explicitly set", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-vs-"));
|
||||
const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-cwd-"));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(tempCwd, "vector_store.db"), "");
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.chdir(tempCwd);
|
||||
|
||||
new MemoryVectorStore({
|
||||
dimension: 4,
|
||||
dbPath: path.join(tempDir, "explicit.db"),
|
||||
});
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utils
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ensureSQLiteDirectory", () => {
|
||||
it("creates nested directories", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ensure-"));
|
||||
const target = path.join(tempDir, "x", "y", "z", "test.db");
|
||||
try {
|
||||
ensureSQLiteDirectory(target);
|
||||
expect(fs.existsSync(path.join(tempDir, "x", "y", "z"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips :memory:", () => {
|
||||
expect(() => ensureSQLiteDirectory(":memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips file: URIs", () => {
|
||||
expect(() => ensureSQLiteDirectory("file::memory:")).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips empty string", () => {
|
||||
expect(() => ensureSQLiteDirectory("")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultVectorStoreDbPath", () => {
|
||||
it("returns path under homedir/.mem0", () => {
|
||||
const result = getDefaultVectorStoreDbPath();
|
||||
expect(result).toBe(path.join(os.homedir(), ".mem0", "vector_store.db"));
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export interface Message {
|
||||
export interface EmbeddingConfig {
|
||||
apiKey?: string;
|
||||
model?: string | any;
|
||||
baseURL?: string;
|
||||
url?: string;
|
||||
embeddingDims?: number;
|
||||
modelProperties?: Record<string, any>;
|
||||
@@ -23,6 +24,7 @@ export interface EmbeddingConfig {
|
||||
export interface VectorStoreConfig {
|
||||
collectionName?: string;
|
||||
dimension?: number;
|
||||
dbPath?: string;
|
||||
client?: any;
|
||||
instance?: any;
|
||||
[key: string]: any;
|
||||
@@ -129,6 +131,7 @@ export const MemoryConfigSchema = z.object({
|
||||
.object({
|
||||
collectionName: z.string().optional(),
|
||||
dimension: z.number().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
client: z.any().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
export function getDefaultVectorStoreDbPath(): string {
|
||||
return path.join(os.homedir(), ".mem0", "vector_store.db");
|
||||
}
|
||||
|
||||
export function ensureSQLiteDirectory(dbPath: string): void {
|
||||
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export class AzureAISearch implements VectorStore {
|
||||
private readonly hybridSearch: boolean;
|
||||
private readonly vectorFilterMode: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: AzureAISearchConfig) {
|
||||
this.serviceName = config.serviceName;
|
||||
@@ -117,6 +118,13 @@ export class AzureAISearch implements VectorStore {
|
||||
* Initialize the Azure AI Search index if it doesn't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
const collections = await this.listCols();
|
||||
if (!collections.includes(this.indexName)) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
ensureSQLiteDirectory,
|
||||
getDefaultVectorStoreDbPath,
|
||||
} from "../utils/sqlite";
|
||||
|
||||
interface MemoryVector {
|
||||
id: string;
|
||||
@@ -16,10 +21,19 @@ export class MemoryVectorStore implements VectorStore {
|
||||
|
||||
constructor(config: VectorStoreConfig) {
|
||||
this.dimension = config.dimension || 1536; // Default OpenAI dimension
|
||||
this.dbPath = path.join(process.cwd(), "vector_store.db");
|
||||
if (config.dbPath) {
|
||||
this.dbPath = config.dbPath;
|
||||
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
|
||||
|
||||
if (!config.dbPath) {
|
||||
const oldDefault = path.join(process.cwd(), "vector_store.db");
|
||||
if (fs.existsSync(oldDefault) && oldDefault !== this.dbPath) {
|
||||
console.warn(
|
||||
`[mem0] Default vector_store.db location changed from ${oldDefault} to ${this.dbPath}. ` +
|
||||
`Move your existing file or set vectorStore.config.dbPath explicitly.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ensureSQLiteDirectory(this.dbPath);
|
||||
this.db = new Database(this.dbPath);
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class Qdrant implements VectorStore {
|
||||
private client: QdrantClient;
|
||||
private readonly collectionName: string;
|
||||
private dimension: number;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: QdrantConfig) {
|
||||
if (config.client) {
|
||||
@@ -211,22 +212,8 @@ export class Qdrant implements VectorStore {
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
try {
|
||||
// First check if the collection exists
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Ensure collection exists (idempotent — handles race conditions)
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
|
||||
// Now try to get the user ID
|
||||
const result = await this.client.scroll("memory_migrations", {
|
||||
@@ -286,66 +273,58 @@ export class Qdrant implements VectorStore {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
private async ensureCollection(name: string, size: number): Promise<void> {
|
||||
try {
|
||||
// Create collection if it doesn't exist
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(
|
||||
(c) => c.name === this.collectionName,
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: this.dimension,
|
||||
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,
|
||||
);
|
||||
await this.client.createCollection(name, {
|
||||
vectors: {
|
||||
size,
|
||||
distance: "Cosine",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.status === 409) {
|
||||
// Collection already exists — verify configuration for the main collection
|
||||
if (name === this.collectionName) {
|
||||
try {
|
||||
const collectionInfo = await this.client.getCollection(name);
|
||||
const vectorConfig = collectionInfo.config?.params?.vectors;
|
||||
|
||||
if (!vectorConfig || vectorConfig.size !== this.dimension) {
|
||||
if (vectorConfig && vectorConfig.size !== size) {
|
||||
throw new Error(
|
||||
`Collection ${this.collectionName} exists but has wrong configuration. ` +
|
||||
`Expected vector size: ${this.dimension}, got: ${vectorConfig?.size}`,
|
||||
`Collection ${name} exists but has wrong vector size. ` +
|
||||
`Expected: ${size}, got: ${vectorConfig.size}`,
|
||||
);
|
||||
}
|
||||
// Collection exists with correct configuration - we can proceed
|
||||
} else {
|
||||
throw error;
|
||||
} catch (verifyError: any) {
|
||||
// Re-throw dimension mismatch errors
|
||||
if (verifyError?.message?.includes("wrong vector size")) {
|
||||
throw verifyError;
|
||||
}
|
||||
// Transient errors (e.g. 500 while collection is being committed)
|
||||
// are non-fatal — the collection exists per the 409.
|
||||
console.warn(
|
||||
`Collection '${name}' exists (409) but dimension verification failed: ${verifyError?.message || verifyError}. Proceeding anyway.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Otherwise collection exists and is fine — proceed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create memory_migrations collection if it doesn't exist
|
||||
const userExists = collections.collections.some(
|
||||
(c) => c.name === "memory_migrations",
|
||||
);
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
if (!userExists) {
|
||||
try {
|
||||
await this.client.createCollection("memory_migrations", {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.ensureCollection(this.collectionName, this.dimension);
|
||||
await this.ensureCollection("memory_migrations", 1);
|
||||
} catch (error) {
|
||||
console.error("Error initializing Qdrant:", error);
|
||||
throw error;
|
||||
|
||||
@@ -139,6 +139,7 @@ export class RedisDB implements VectorStore {
|
||||
private readonly indexName: string;
|
||||
private readonly indexPrefix: string;
|
||||
private readonly schema: RedisSchema;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: RedisConfig) {
|
||||
this.indexName = config.collectionName;
|
||||
@@ -240,6 +241,13 @@ export class RedisDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
await this.client.connect();
|
||||
console.log("Connected to Redis");
|
||||
|
||||
@@ -86,6 +86,7 @@ export class SupabaseDB implements VectorStore {
|
||||
private readonly tableName: string;
|
||||
private readonly embeddingColumnName: string;
|
||||
private readonly metadataColumnName: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: SupabaseConfig) {
|
||||
this.client = createClient(config.supabaseUrl, config.supabaseKey);
|
||||
@@ -100,6 +101,13 @@ export class SupabaseDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Verify table exists and vector operations work by attempting a test insert
|
||||
const testVector = Array(1536).fill(0);
|
||||
|
||||
@@ -20,6 +20,7 @@ export class VectorizeDB implements VectorStore {
|
||||
private dimensions: number;
|
||||
private indexName: string;
|
||||
private accountId: string;
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: VectorizeConfig) {
|
||||
this.client = new Cloudflare({ apiToken: config.apiKey });
|
||||
@@ -343,6 +344,13 @@ export class VectorizeDB implements VectorStore {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Check if the index already exists
|
||||
let indexFound = false;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/// <reference types="jest" />
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
|
||||
describe("ConfigManager", () => {
|
||||
describe("mergeConfig - dimension handling", () => {
|
||||
const baseLlm = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
|
||||
it("should leave dimension undefined when no explicit dimension or embeddingDims provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "test-key" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// Dimension should be undefined so Memory._autoInitialize() will
|
||||
// auto-detect it via a probe embedding at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims from embedder config when provided", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("should prefer explicit vector store dimension over embedder dims", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("should leave dimension undefined when using a custom client without explicit dims", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
// No embeddingDims and no explicit dimension → should be undefined
|
||||
// for auto-detection at runtime.
|
||||
expect(config.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use embeddingDims when using a custom client", () => {
|
||||
const mockClient = { someMethod: () => {} };
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "test", client: mockClient },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
|
||||
expect(config.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Tests for embedding dimension auto-detection.
|
||||
*
|
||||
* Covers:
|
||||
* - ConfigManager: dimension resolution logic
|
||||
* - Memory class: probe-based auto-detection, lazy init gate, backward compat
|
||||
* - MemoryVectorStore: backward compat with explicit dimensions
|
||||
* - Explicit error messages on probe failure
|
||||
*/
|
||||
|
||||
import { ConfigManager } from "../src/config/manager";
|
||||
import { MemoryVectorStore } from "../src/vector_stores/memory";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. ConfigManager – dimension resolution
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("ConfigManager – dimension resolution", () => {
|
||||
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
|
||||
|
||||
it("leaves dimension undefined when nothing explicit is set", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: { provider: "memory", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims from embedder config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("prefers explicit vectorStore.dimension over embeddingDims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", dimension: 1024 },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(1024);
|
||||
});
|
||||
|
||||
it("leaves dimension undefined for custom client without explicit dims", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses embeddingDims with a custom client", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: { collectionName: "t", client: {} },
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("preserves all other vectorStore config fields", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
collectionName: "my-coll",
|
||||
host: "my-host",
|
||||
port: 6333,
|
||||
apiKey: "qdrant-key",
|
||||
},
|
||||
},
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.collectionName).toBe("my-coll");
|
||||
expect(cfg.vectorStore.config.host).toBe("my-host");
|
||||
expect(cfg.vectorStore.config.port).toBe(6333);
|
||||
expect(cfg.vectorStore.config.apiKey).toBe("qdrant-key");
|
||||
});
|
||||
|
||||
it("leaves dimension undefined with empty config", () => {
|
||||
const cfg = ConfigManager.mergeConfig({
|
||||
embedder: { provider: "openai", config: {} },
|
||||
vectorStore: { provider: "memory", config: {} },
|
||||
llm: baseLlm,
|
||||
});
|
||||
expect(cfg.vectorStore.config.dimension).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. MemoryVectorStore – backward compat with explicit dimensions
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("MemoryVectorStore – backward compat", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("defaults to dimension 1536 when not specified", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension=1536 still works", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const vector = new Array(1536).fill(0.1);
|
||||
await store.insert([vector], ["id-1"], [{ data: "hello" }]);
|
||||
const result = await store.get("id-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("explicit dimension rejects mismatched vectors", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 1536,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const wrongVector = new Array(768).fill(0.1);
|
||||
await expect(
|
||||
store.insert([wrongVector], ["id-1"], [{ data: "hello" }]),
|
||||
).rejects.toThrow("Vector dimension mismatch");
|
||||
});
|
||||
|
||||
it("search validates dimension", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 4,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await expect(store.search([1, 2, 3], 1)).rejects.toThrow(
|
||||
"Query dimension mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("custom dimension=768 works end-to-end", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dimension: 768,
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[1, ...new Array(767).fill(0)],
|
||||
[0, 1, ...new Array(766).fill(0)],
|
||||
],
|
||||
["a", "b"],
|
||||
[{ data: "alpha" }, { data: "beta" }],
|
||||
);
|
||||
|
||||
const results = await store.search([1, ...new Array(767).fill(0)], 2);
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe("a");
|
||||
});
|
||||
|
||||
it("getUserId and setUserId still work", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
const userId = await store.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
|
||||
await store.setUserId("custom-user");
|
||||
const newUserId = await store.getUserId();
|
||||
expect(newUserId).toBe("custom-user");
|
||||
});
|
||||
|
||||
it("initialize() is idempotent", async () => {
|
||||
const store = new MemoryVectorStore({
|
||||
collectionName: "test",
|
||||
dbPath: path.join(tmpDir, "vs.db"),
|
||||
});
|
||||
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
await store.initialize();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. Memory class – auto-init with probe, lazy gate, backward compat
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describe("Memory – auto-initialization", () => {
|
||||
let mockEmbedderFactory: any;
|
||||
let mockVectorStoreFactory: any;
|
||||
let mockLlmFactory: any;
|
||||
let mockHistoryFactory: any;
|
||||
let MemoryClass: any;
|
||||
|
||||
function createMockEmbedder(dims: number) {
|
||||
return {
|
||||
embed: jest.fn().mockResolvedValue(new Array(dims).fill(0)),
|
||||
embedBatch: jest.fn().mockResolvedValue([new Array(dims).fill(0)]),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockVectorStore() {
|
||||
return {
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
search: jest.fn().mockResolvedValue([]),
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteCol: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn().mockResolvedValue([[], 0]),
|
||||
getUserId: jest.fn().mockResolvedValue("test-user-id"),
|
||||
setUserId: jest.fn().mockResolvedValue(undefined),
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
|
||||
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
|
||||
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
|
||||
mockLlmFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
};
|
||||
mockHistoryFactory = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
jest.doMock("../src/utils/factory", () => ({
|
||||
EmbedderFactory: mockEmbedderFactory,
|
||||
VectorStoreFactory: mockVectorStoreFactory,
|
||||
LLMFactory: mockLlmFactory,
|
||||
HistoryManagerFactory: mockHistoryFactory,
|
||||
}));
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
MemoryClass = require("../src/memory").Memory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("probes embedder to detect dimension when none set", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// Should have called embed("dimension probe") to detect dimension
|
||||
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory should have been called with detected dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("skips probe when explicit dimension provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "openai", config: { apiKey: "k" } },
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test", dimension: 1536 },
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// embed should NOT have been called for probing
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// VectorStoreFactory gets the explicit dimension
|
||||
const vsCreateCall = mockVectorStoreFactory.create.mock.calls[0];
|
||||
expect(vsCreateCall[1].dimension).toBe(1536);
|
||||
});
|
||||
|
||||
it("skips probe when embeddingDims provided", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
|
||||
// ConfigManager resolves dimension from embeddingDims → no probe needed
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("all public methods wait for initialization", async () => {
|
||||
let resolveProbe: () => void;
|
||||
let probeCallCount = 0;
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockImplementation(() => {
|
||||
probeCallCount++;
|
||||
if (probeCallCount === 1) {
|
||||
// First call is the dimension probe — hang until manually resolved
|
||||
return new Promise<number[]>((resolve) => {
|
||||
resolveProbe = () => resolve(new Array(768).fill(0));
|
||||
});
|
||||
}
|
||||
// Subsequent calls (from search, etc.) resolve immediately
|
||||
return Promise.resolve(new Array(768).fill(0));
|
||||
}),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "test" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "t" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
let getAllDone = false;
|
||||
let searchDone = false;
|
||||
let getDone = false;
|
||||
|
||||
const getAllP = mem.getAll({ userId: "u" }).then(() => (getAllDone = true));
|
||||
const searchP = mem
|
||||
.search("q", { userId: "u" })
|
||||
.then(() => (searchDone = true));
|
||||
const getP = mem.get("id").then(() => (getDone = true));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(getAllDone).toBe(false);
|
||||
expect(searchDone).toBe(false);
|
||||
expect(getDone).toBe(false);
|
||||
|
||||
// Resolve the probe — init completes — methods unblock
|
||||
resolveProbe!();
|
||||
await Promise.all([getAllP, searchP, getP]);
|
||||
expect(getAllDone).toBe(true);
|
||||
expect(searchDone).toBe(true);
|
||||
expect(getDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reset re-creates vector store with correct dimension", async () => {
|
||||
const mockEmbedder = createMockEmbedder(768);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset should re-create vector store
|
||||
const mockVStore2 = createMockVectorStore();
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore2);
|
||||
await mem.reset();
|
||||
expect(mockVectorStoreFactory.create).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second creation should still have dimension=768 (cached from first probe)
|
||||
const secondCall = mockVectorStoreFactory.create.mock.calls[1];
|
||||
expect(secondCall[1].dimension).toBe(768);
|
||||
});
|
||||
|
||||
it("backward compat: full explicit config works without probe", async () => {
|
||||
const mockEmbedder = createMockEmbedder(1536);
|
||||
const mockVStore = createMockVectorStore();
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
mockVectorStoreFactory.create.mockReturnValue(mockVStore);
|
||||
|
||||
const mem = new MemoryClass({
|
||||
version: "v1.1",
|
||||
embedder: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "text-embedding-3-small" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test-memories", dimension: 1536 },
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
|
||||
},
|
||||
historyDbPath: ":memory:",
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "u1" });
|
||||
expect(mockEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
});
|
||||
|
||||
it("throws explicit error when probe fails", async () => {
|
||||
const mockEmbedder = {
|
||||
embed: jest.fn().mockRejectedValue(new Error("Connection refused")),
|
||||
embedBatch: jest.fn(),
|
||||
};
|
||||
mockEmbedderFactory.create.mockReturnValue(mockEmbedder);
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const mem = new MemoryClass({
|
||||
embedder: { provider: "ollama", config: { model: "nomic-embed-text" } },
|
||||
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
|
||||
llm: { provider: "openai", config: { apiKey: "k" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
// getAll should reject with the init error
|
||||
await expect(mem.getAll({ userId: "u1" })).rejects.toThrow(
|
||||
"auto-detect embedding dimension",
|
||||
);
|
||||
|
||||
// Verify the error was logged and contains helpful information
|
||||
const errorCall = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[0] instanceof Error &&
|
||||
call[0].message.includes("auto-detect embedding dimension"),
|
||||
);
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorMsg = (errorCall![0] as Error).message;
|
||||
expect(errorMsg).toContain("ollama");
|
||||
expect(errorMsg).toContain("Connection refused");
|
||||
expect(errorMsg).toContain("dimension");
|
||||
expect(errorMsg).toContain("embeddingDims");
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Regression tests for graph_memory.ts response parsing (issue #4248).
|
||||
*
|
||||
* Exercises the three json_object call sites in MemoryGraph with a mocked LLM:
|
||||
* 1. _retrieveNodesFromData → entity extraction
|
||||
* 2. _establishNodesRelationsFromData → relation extraction
|
||||
* 3. _getDeleteEntitiesFromSearchOutput → deletion identification
|
||||
*
|
||||
* Covers: malformed LLM responses, missing fields, bad JSON in toolCalls,
|
||||
* string-only responses, empty tool calls, and prompt construction.
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
import { MemoryGraph } from "../src/memory/graph_memory";
|
||||
import {
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
getDeleteMessages,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks – we replace heavy dependencies so tests run without Neo4j / OpenAI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock neo4j-driver: provides a fake Driver with a no-op session
|
||||
jest.mock("neo4j-driver", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
driver: jest.fn(() => ({
|
||||
session: () => ({
|
||||
run: jest.fn().mockResolvedValue({ records: [] }),
|
||||
close: jest.fn(),
|
||||
}),
|
||||
})),
|
||||
auth: { basic: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock factory so constructor doesn't try to instantiate real LLMs / embedders
|
||||
const mockGenerateResponse = jest.fn();
|
||||
const mockGenerateChat = jest.fn();
|
||||
const mockEmbed = jest.fn().mockResolvedValue([0.1, 0.2, 0.3]);
|
||||
|
||||
jest.mock("../src/utils/factory", () => ({
|
||||
LLMFactory: {
|
||||
create: jest.fn(() => ({
|
||||
generateResponse: mockGenerateResponse,
|
||||
generateChat: mockGenerateChat,
|
||||
})),
|
||||
},
|
||||
EmbedderFactory: {
|
||||
create: jest.fn(() => ({
|
||||
embed: mockEmbed,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Minimal config that satisfies the MemoryGraph constructor
|
||||
function makeConfig(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: { provider: "openai", config: {} },
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Helper to access private methods via `any` cast
|
||||
function graph(overrides: Record<string, any> = {}): any {
|
||||
return new MemoryGraph(makeConfig(overrides));
|
||||
}
|
||||
|
||||
const FILTERS = { userId: "test-user" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. _retrieveNodesFromData – entity extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_retrieveNodesFromData", () => {
|
||||
it("parses a well-formed extract_entities tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ entity: "Alice", entity_type: "person" },
|
||||
{ entity: "Pizza", entity_type: "food" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ alice: "person", pizza: "food" });
|
||||
});
|
||||
|
||||
it("returns empty map when LLM returns a plain string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("I am a string, not an object");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is undefined", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty map when toolCalls is an empty array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles malformed JSON in tool call arguments gracefully", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{ name: "extract_entities", arguments: "NOT VALID JSON {{{" },
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// Should not throw — the catch block in the source logs the error
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles missing entities array in arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({ wrong_key: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// args.entities is undefined → for..of on undefined throws → caught
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("skips tool calls with unrelated names", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "some_other_tool",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "X", entity_type: "Y" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("normalises entity names to lowercase with underscores", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "extract_entities",
|
||||
arguments: JSON.stringify({
|
||||
entities: [{ entity: "New York City", entity_type: "City Name" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._retrieveNodesFromData("anything", FILTERS);
|
||||
expect(result).toEqual({ new_york_city: "city_name" });
|
||||
});
|
||||
|
||||
it("passes json_object response format and the correct system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemMsg = messages[0].content as string;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
expect(systemMsg).toContain("test-user");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. _establishNodesRelationsFromData – relation extraction
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_establishNodesRelationsFromData", () => {
|
||||
it("parses a well-formed establish_relationships tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{ source: "Alice", relationship: "likes", destination: "Pizza" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"Alice likes pizza",
|
||||
FILTERS,
|
||||
{ alice: "person", pizza: "food" },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("just a string");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when toolCalls is empty", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when entities key is missing from arguments", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({ not_entities: [] }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData("x", FILTERS, {});
|
||||
// args.entities is undefined → falls back to []
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on malformed JSON in tool call arguments (no try/catch in source)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [{ name: "establish_relationships", arguments: "<<BROKEN>>" }],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
// _establishNodesRelationsFromData does JSON.parse without try/catch
|
||||
await expect(
|
||||
mg._establishNodesRelationsFromData("x", FILTERS, {}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("appends JSON format suffix to system prompt (no custom prompt)", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, { a: "b" });
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
// CUSTOM_PROMPT placeholder stays when no custom prompt is configured
|
||||
// (only replaced when config.graphStore.customPrompt is set)
|
||||
});
|
||||
|
||||
it("appends JSON format suffix and custom prompt when configured", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph({ customPrompt: "Focus on food relationships only." });
|
||||
await mg._establishNodesRelationsFromData("data", FILTERS, {});
|
||||
|
||||
const [messages] = mockGenerateResponse.mock.calls[0];
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("Focus on food relationships only.");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. _getDeleteEntitiesFromSearchOutput – deletion identification
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_getDeleteEntitiesFromSearchOutput", () => {
|
||||
const SEARCH_OUTPUT = [
|
||||
{
|
||||
source: "alice",
|
||||
source_id: "1",
|
||||
relationship: "likes",
|
||||
relation_id: "r1",
|
||||
destination: "pizza",
|
||||
destination_id: "2",
|
||||
similarity: 0.95,
|
||||
},
|
||||
];
|
||||
|
||||
it("parses a well-formed delete_graph_memory tool call", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "Alice",
|
||||
relationship: "likes",
|
||||
destination: "Pizza",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"Alice hates pizza",
|
||||
FILTERS,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ source: "alice", relationship: "likes", destination: "pizza" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when LLM returns a string", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce("string response");
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when no tool calls are present", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-delete_graph_memory tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "noop",
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects multiple delete tool calls", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "A",
|
||||
relationship: "r1",
|
||||
destination: "B",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "delete_graph_memory",
|
||||
arguments: JSON.stringify({
|
||||
source: "C",
|
||||
relationship: "r2",
|
||||
destination: "D",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
SEARCH_OUTPUT,
|
||||
"x",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe("a");
|
||||
expect(result[1].source).toBe("c");
|
||||
});
|
||||
|
||||
it("passes json_object format and includes 'json' in system prompt", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput(SEARCH_OUTPUT, "data", FILTERS);
|
||||
|
||||
const [messages, responseFormat] = mockGenerateResponse.mock.calls[0];
|
||||
expect(responseFormat).toEqual({ type: "json_object" });
|
||||
|
||||
const systemContent = messages[0].content as string;
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
expect(systemContent).toContain("test-user");
|
||||
expect(systemContent).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles empty searchOutput array", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._getDeleteEntitiesFromSearchOutput(
|
||||
[],
|
||||
"data",
|
||||
FILTERS,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Prompt construction — JSON keyword present in every json_object site
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("Prompt construction — all json_object sites include 'json'", () => {
|
||||
it("_retrieveNodesFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars", "ユーザー"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._retrieveNodesFromData("test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_establishNodesRelationsFromData system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._establishNodesRelationsFromData("test", { userId }, {});
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
|
||||
it("_getDeleteEntitiesFromSearchOutput system message includes 'json' for any userId", async () => {
|
||||
for (const userId of ["", "user-1", "special<>chars"]) {
|
||||
mockGenerateResponse.mockResolvedValueOnce({ toolCalls: [] });
|
||||
const mg = graph();
|
||||
await mg._getDeleteEntitiesFromSearchOutput([], "test", { userId });
|
||||
|
||||
const systemMsg = mockGenerateResponse.mock.calls.at(-1)![0][0].content;
|
||||
expect(systemMsg.toLowerCase()).toContain("json");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
|
||||
it("normalises spaces and case in entity source/relationship/destination", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
toolCalls: [
|
||||
{
|
||||
name: "establish_relationships",
|
||||
arguments: JSON.stringify({
|
||||
entities: [
|
||||
{
|
||||
source: "New York",
|
||||
relationship: "Capital Of",
|
||||
destination: "United States",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mg = graph();
|
||||
const result = await mg._establishNodesRelationsFromData(
|
||||
"test",
|
||||
FILTERS,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: "new_york",
|
||||
relationship: "capital_of",
|
||||
destination: "united_states",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
DELETE_RELATIONS_SYSTEM_PROMPT,
|
||||
EXTRACT_RELATIONS_PROMPT,
|
||||
UPDATE_GRAPH_PROMPT,
|
||||
getDeleteMessages,
|
||||
formatEntities,
|
||||
} from "../src/graphs/utils";
|
||||
|
||||
/**
|
||||
* Regression tests for graph prompts (issue #4248).
|
||||
*
|
||||
* When response_format: { type: "json_object" } is used, OpenAI requires
|
||||
* the word "json" (case-insensitive) to appear in at least one message.
|
||||
* Missing it produces a 400 error.
|
||||
*
|
||||
* Three call sites use json_object today:
|
||||
* 1. _getDeleteEntitiesFromSearchOutput → DELETE_RELATIONS_SYSTEM_PROMPT
|
||||
* 2. _retrieveNodesFromData → inline prompt (graph_memory.ts)
|
||||
* 3. _getRelatedEntities → EXTRACT_RELATIONS_PROMPT + suffix
|
||||
*
|
||||
* See: https://github.com/mem0ai/mem0/issues/4248
|
||||
*/
|
||||
|
||||
// ─── JSON keyword presence ────────────────────────────────────────────────────
|
||||
|
||||
describe("Graph prompts — JSON keyword requirement", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains 'json'", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT produces a message containing 'json' once the suffix is appended", () => {
|
||||
// graph_memory.ts appends "\nPlease provide your response in JSON format."
|
||||
const withSuffix =
|
||||
EXTRACT_RELATIONS_PROMPT +
|
||||
"\nPlease provide your response in JSON format.";
|
||||
expect(withSuffix.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("getDeleteMessages system message contains 'json' after USER_ID substitution", () => {
|
||||
const [systemContent] = getDeleteMessages(
|
||||
"alice -- loves -- pizza",
|
||||
"Alice now hates pizza",
|
||||
"user-42",
|
||||
);
|
||||
expect(systemContent.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("entity extraction inline prompt contains 'json' (simulated from graph_memory.ts)", () => {
|
||||
// Mirrors the template in _retrieveNodesFromData()
|
||||
const userId = "user-1";
|
||||
const prompt = `You are a smart assistant who understands entities and their types in a given text. If user message contains self reference such as 'I', 'me', 'my' etc. then use ${userId} as the source entity. Extract all the entities from the text. ***DO NOT*** answer the question itself if the given text is a question. Respond in JSON format.`;
|
||||
expect(prompt.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDeleteMessages ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getDeleteMessages", () => {
|
||||
it("replaces USER_ID with the provided userId in the system prompt", () => {
|
||||
const [system] = getDeleteMessages("mem", "data", "alice-123");
|
||||
expect(system).toContain("alice-123");
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("includes existing memories and new data in the user prompt", () => {
|
||||
const existing = "bob -- knows -- carol";
|
||||
const newData = "Bob no longer knows Carol";
|
||||
const [, user] = getDeleteMessages(existing, newData, "u1");
|
||||
expect(user).toContain(existing);
|
||||
expect(user).toContain(newData);
|
||||
});
|
||||
|
||||
it("returns a 2-tuple [system, user]", () => {
|
||||
const result = getDeleteMessages("a", "b", "c");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(typeof result[0]).toBe("string");
|
||||
expect(typeof result[1]).toBe("string");
|
||||
});
|
||||
|
||||
// — Malformed / edge-case inputs —
|
||||
|
||||
it("handles empty strings without throwing", () => {
|
||||
expect(() => getDeleteMessages("", "", "")).not.toThrow();
|
||||
const [system, user] = getDeleteMessages("", "", "");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
expect(typeof user).toBe("string");
|
||||
});
|
||||
|
||||
it("handles special characters in userId (e.g. angle brackets, quotes)", () => {
|
||||
const [system] = getDeleteMessages(
|
||||
"mem",
|
||||
"data",
|
||||
'<script>alert("xss")</script>',
|
||||
);
|
||||
expect(system).toContain('<script>alert("xss")</script>');
|
||||
expect(system).not.toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("handles unicode input", () => {
|
||||
const [system, user] = getDeleteMessages(
|
||||
"日本語メモリ",
|
||||
"新しい情報",
|
||||
"ユーザー1",
|
||||
);
|
||||
expect(system).toContain("ユーザー1");
|
||||
expect(user).toContain("日本語メモリ");
|
||||
expect(user).toContain("新しい情報");
|
||||
});
|
||||
|
||||
it("handles very long input strings", () => {
|
||||
const longStr = "x".repeat(100_000);
|
||||
expect(() => getDeleteMessages(longStr, longStr, "u")).not.toThrow();
|
||||
const [system] = getDeleteMessages(longStr, longStr, "u");
|
||||
expect(system.toLowerCase()).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatEntities ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("formatEntities", () => {
|
||||
it("formats a single entity triplet", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "Alice", relationship: "knows", destination: "Bob" },
|
||||
]);
|
||||
expect(result).toBe("Alice -- knows -- Bob");
|
||||
});
|
||||
|
||||
it("joins multiple entities with newlines", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "A", relationship: "r1", destination: "B" },
|
||||
{ source: "C", relationship: "r2", destination: "D" },
|
||||
]);
|
||||
expect(result).toBe("A -- r1 -- B\nC -- r2 -- D");
|
||||
});
|
||||
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(formatEntities([])).toBe("");
|
||||
});
|
||||
|
||||
it("preserves special characters in entity fields", () => {
|
||||
const result = formatEntities([
|
||||
{ source: "O'Brien", relationship: 'said "hello"', destination: "café" },
|
||||
]);
|
||||
expect(result).toContain("O'Brien");
|
||||
expect(result).toContain('said "hello"');
|
||||
expect(result).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prompt structural invariants ─────────────────────────────────────────────
|
||||
|
||||
describe("Prompt structural invariants", () => {
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains USER_ID placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("USER_ID");
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT contains CUSTOM_PROMPT placeholder", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT).toContain("CUSTOM_PROMPT");
|
||||
});
|
||||
|
||||
it("UPDATE_GRAPH_PROMPT contains memory template placeholders", () => {
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{existing_memories}");
|
||||
expect(UPDATE_GRAPH_PROMPT).toContain("{new_memories}");
|
||||
});
|
||||
|
||||
it("DELETE_RELATIONS_SYSTEM_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(DELETE_RELATIONS_SYSTEM_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("EXTRACT_RELATIONS_PROMPT is non-empty and reasonably sized", () => {
|
||||
expect(EXTRACT_RELATIONS_PROMPT.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* End-to-end tests for Qdrant dimension mismatch fix.
|
||||
*
|
||||
* Requires a running Qdrant instance at localhost:6333 (v1.13.x).
|
||||
* These tests replicate the exact scenarios from issues #4212, #4173, #4056.
|
||||
*
|
||||
* Skipped automatically when Qdrant is not available.
|
||||
*
|
||||
* Run: npx jest --config jest.config.js src/oss/tests/qdrant-e2e.test.ts --forceExit
|
||||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { Qdrant } from "../src/vector_stores/qdrant";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const QDRANT_HOST = "localhost";
|
||||
const QDRANT_PORT = 6333;
|
||||
|
||||
// Check if Qdrant is reachable synchronously at load time using
|
||||
// a sync check via child_process so describe.skip works correctly.
|
||||
function isQdrantAvailable(): boolean {
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`node -e "const s=require('net').createConnection({host:'${QDRANT_HOST}',port:${QDRANT_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||
{ timeout: 3000, stdio: "ignore" },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const qdrantAvailable = isQdrantAvailable();
|
||||
if (!qdrantAvailable) {
|
||||
console.warn("Qdrant not available at localhost:6333 — skipping e2e tests");
|
||||
}
|
||||
|
||||
let qdrantClient: QdrantClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!qdrantAvailable) return;
|
||||
qdrantClient = new QdrantClient({ host: QDRANT_HOST, port: QDRANT_PORT });
|
||||
const collections = await qdrantClient.getCollections();
|
||||
expect(collections).toBeDefined();
|
||||
});
|
||||
|
||||
// Helper: delete a collection if it exists
|
||||
async function deleteCollectionIfExists(name: string) {
|
||||
try {
|
||||
await qdrantClient.deleteCollection(name);
|
||||
} catch {
|
||||
// Collection doesn't exist — fine
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: create a fake embedder that produces vectors of a given dimension
|
||||
function createFakeEmbedder(dims: number) {
|
||||
return {
|
||||
embed: jest.fn().mockImplementation(async (_text: string) => {
|
||||
const vec = new Array(dims).fill(0);
|
||||
for (let i = 0; i < _text.length && i < dims; i++) {
|
||||
vec[i] = _text.charCodeAt(i) / 255;
|
||||
}
|
||||
return vec;
|
||||
}),
|
||||
embedBatch: jest.fn().mockImplementation(async (texts: string[]) => {
|
||||
return Promise.all(
|
||||
texts.map(async (t) => {
|
||||
const vec = new Array(dims).fill(0);
|
||||
for (let i = 0; i < t.length && i < dims; i++) {
|
||||
vec[i] = t.charCodeAt(i) / 255;
|
||||
}
|
||||
return vec;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Conditionally skip tests when Qdrant is unavailable
|
||||
const describeIfQdrant = qdrantAvailable ? describe : describe.skip;
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteCollectionIfExists("e2e_test_768");
|
||||
await deleteCollectionIfExists("e2e_test_1536");
|
||||
await deleteCollectionIfExists("e2e_test_race");
|
||||
await deleteCollectionIfExists("e2e_test_race2");
|
||||
await deleteCollectionIfExists("e2e_test_noexplicit");
|
||||
await deleteCollectionIfExists("e2e_test_explicit");
|
||||
await deleteCollectionIfExists("e2e_test_embdims");
|
||||
await deleteCollectionIfExists("e2e_test_autodetect");
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. Reproduce #4212 / #4173: dimension mismatch with 768-dim embedder
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4212/#4173: Qdrant dimension mismatch", () => {
|
||||
it("BEFORE FIX scenario: 768-dim vector into 1536-dim collection → Bad Request", async () => {
|
||||
const collectionName = "e2e_test_1536";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await qdrantClient.createCollection(collectionName, {
|
||||
vectors: { size: 1536, distance: "Cosine" },
|
||||
});
|
||||
|
||||
// Insert a 768-dim vector — this is what nomic-embed-text produces
|
||||
const vector768 = new Array(768).fill(0.1);
|
||||
try {
|
||||
await qdrantClient.upsert(collectionName, {
|
||||
points: [
|
||||
{ id: "test-1", vector: vector768, payload: { data: "hello" } },
|
||||
],
|
||||
});
|
||||
fail("Expected Qdrant to reject 768-dim vector into 1536-dim collection");
|
||||
} catch (error: any) {
|
||||
// This is the exact "Bad Request" error users were hitting
|
||||
expect(error.status).toBe(400);
|
||||
}
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
});
|
||||
|
||||
it("AFTER FIX: Qdrant store with dimension=768 works end-to-end", async () => {
|
||||
const collectionName = "e2e_test_768";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
// Create Qdrant store with correct dimension (what our auto-detect provides)
|
||||
const store = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify collection was created with 768 dims
|
||||
const info = await qdrantClient.getCollection(collectionName);
|
||||
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// Insert 768-dim vectors (what nomic-embed-text produces)
|
||||
const vec1 = new Array(768).fill(0);
|
||||
vec1[0] = 1.0;
|
||||
const vec2 = new Array(768).fill(0);
|
||||
vec2[1] = 1.0;
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
|
||||
await store.insert(
|
||||
[vec1, vec2],
|
||||
[id1, id2],
|
||||
[
|
||||
{ data: "hello", userId: "u1" },
|
||||
{ data: "world", userId: "u1" },
|
||||
],
|
||||
);
|
||||
|
||||
// Search with 768-dim query — this USED TO fail with Bad Request
|
||||
const results = await store.search(vec1, 2, { userId: "u1" });
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0].id).toBe(id1); // Most similar to itself
|
||||
expect(results[0].score).toBeGreaterThan(0.9);
|
||||
|
||||
// Get by ID
|
||||
const item = await store.get(id1);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.payload.data).toBe("hello");
|
||||
|
||||
// Update with 768-dim vector
|
||||
const vec3 = new Array(768).fill(0);
|
||||
vec3[2] = 1.0;
|
||||
await store.update(id1, vec3, { data: "updated", userId: "u1" });
|
||||
const updated = await store.get(id1);
|
||||
expect(updated!.payload.data).toBe("updated");
|
||||
|
||||
// Delete
|
||||
await store.delete(id2);
|
||||
const deleted = await store.get(id2);
|
||||
expect(deleted).toBeNull();
|
||||
|
||||
// List
|
||||
const [listed, count] = await store.list({ userId: "u1" });
|
||||
expect(count).toBe(1);
|
||||
expect(listed[0].payload.data).toBe("updated");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
it("AFTER FIX: Memory auto-detects 768 dims via probe (full integration)", async () => {
|
||||
const collectionName = "e2e_test_autodetect";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
// Mock only the non-Qdrant factories to avoid Google SDK import crash
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
// Import Qdrant directly (avoids loading Google embedder via factory)
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
// This is the EXACT config from issue #4212 — NO dimension specified
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
// This triggers init — probe should detect 768 dims
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Verify the probe was called
|
||||
expect(fakeEmbedder.embed).toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
// Verify Qdrant collection was created with auto-detected 768 dims
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// Search should work (this used to throw Bad Request)
|
||||
const searchResult = await mem.search("hello world", {
|
||||
userId: "test-user",
|
||||
});
|
||||
expect(searchResult).toBeDefined();
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("AFTER FIX: explicit dimension=768 skips probe (backward compat)", async () => {
|
||||
const collectionName = "e2e_test_explicit";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
// Workaround config from #4212 — explicit dimension
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text" },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
dimension: 768,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Probe should NOT have been called
|
||||
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("AFTER FIX: embeddingDims in embedder config skips probe", async () => {
|
||||
const collectionName = "e2e_test_embdims";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const fakeEmbedder = createFakeEmbedder(768);
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/utils/factory", () => {
|
||||
const { Qdrant: QdrantStore } = require("../src/vector_stores/qdrant");
|
||||
return {
|
||||
EmbedderFactory: { create: jest.fn().mockReturnValue(fakeEmbedder) },
|
||||
VectorStoreFactory: {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((_provider: string, config: any) => {
|
||||
return new QdrantStore(config);
|
||||
}),
|
||||
},
|
||||
LLMFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
|
||||
}),
|
||||
},
|
||||
HistoryManagerFactory: {
|
||||
create: jest.fn().mockReturnValue({
|
||||
addHistory: jest.fn().mockResolvedValue(undefined),
|
||||
getHistory: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.doMock("../src/utils/telemetry", () => ({
|
||||
captureClientEvent: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { Memory } = require("../src/memory");
|
||||
|
||||
const mem = new Memory({
|
||||
embedder: {
|
||||
provider: "ollama",
|
||||
config: { model: "nomic-embed-text", embeddingDims: 768 },
|
||||
},
|
||||
vectorStore: {
|
||||
provider: "qdrant",
|
||||
config: {
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
},
|
||||
},
|
||||
llm: { provider: "openai", config: { apiKey: "fake" } },
|
||||
disableHistory: true,
|
||||
});
|
||||
|
||||
await mem.getAll({ userId: "test-user" });
|
||||
|
||||
// Probe should NOT have been called — dimension inferred from embeddingDims
|
||||
expect(fakeEmbedder.embed).not.toHaveBeenCalledWith("dimension probe");
|
||||
|
||||
const collectionInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(collectionInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
jest.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. Reproduce #4056 issue 1: Collection creation race condition
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4056: Qdrant race condition", () => {
|
||||
it("concurrent ensureCollection calls don't crash (no 409 error leak)", async () => {
|
||||
const collectionName = "e2e_test_race";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
// Create 5 Qdrant instances concurrently — this simulates the race
|
||||
// that caused "Collection memory_migrations already exists!" in #4056
|
||||
const instances = Array.from(
|
||||
{ length: 5 },
|
||||
() =>
|
||||
new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
}),
|
||||
);
|
||||
|
||||
// All should initialize without throwing 409 Conflict
|
||||
await Promise.all(instances.map((inst) => inst.initialize()));
|
||||
|
||||
// Verify collection exists with correct dimension
|
||||
const info = await qdrantClient.getCollection(collectionName);
|
||||
expect(info.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// memory_migrations should also exist (created by initialize)
|
||||
const migrInfo = await qdrantClient.getCollection("memory_migrations");
|
||||
expect(migrInfo.config?.params?.vectors?.size).toBe(1);
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
|
||||
it("getUserId works after concurrent initialization", async () => {
|
||||
const collectionName = "e2e_test_race2";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const instance = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
|
||||
await instance.initialize();
|
||||
|
||||
// getUserId should work without 409 crash
|
||||
const userId = await instance.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
|
||||
// setUserId + getUserId roundtrip
|
||||
await instance.setUserId("custom-e2e-user");
|
||||
const updated = await instance.getUserId();
|
||||
expect(updated).toBe("custom-e2e-user");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. Reproduce #4056 issue 2: memory_migrations dimension isolation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfQdrant("Issue #4056: memory_migrations dimension isolation", () => {
|
||||
it("memory_migrations uses dim=1 independently of main collection dim=768", async () => {
|
||||
const collectionName = "e2e_test_noexplicit";
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
|
||||
const instance = new Qdrant({
|
||||
host: QDRANT_HOST,
|
||||
port: QDRANT_PORT,
|
||||
collectionName,
|
||||
embeddingModelDims: 768,
|
||||
dimension: 768,
|
||||
});
|
||||
|
||||
await instance.initialize();
|
||||
|
||||
// Allow Qdrant a moment to fully commit collections
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Main collection should be 768
|
||||
const mainInfo = await qdrantClient.getCollection(collectionName);
|
||||
expect(mainInfo.config?.params?.vectors?.size).toBe(768);
|
||||
|
||||
// memory_migrations should be 1 (NOT 768!)
|
||||
// This was the bug in #4056 issue 2 — telemetry used wrong dimension
|
||||
const migrationsInfo =
|
||||
await qdrantClient.getCollection("memory_migrations");
|
||||
expect(migrationsInfo.config?.params?.vectors?.size).toBe(1);
|
||||
|
||||
// getUserId should work — vector dim=1 in memory_migrations
|
||||
const userId = await instance.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
|
||||
// setUserId should also work
|
||||
await instance.setUserId("custom-test-user");
|
||||
const newUserId = await instance.getUserId();
|
||||
expect(newUserId).toBe("custom-test-user");
|
||||
|
||||
await deleteCollectionIfExists(collectionName);
|
||||
await deleteCollectionIfExists("memory_migrations");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* End-to-end tests for Redis vector store with init guard fix.
|
||||
*
|
||||
* Requires a running Redis Stack instance at localhost:6379.
|
||||
* Skipped automatically when Redis is not available.
|
||||
*
|
||||
* Run: npx jest --config jest.config.js src/oss/tests/redis-e2e.test.ts --forceExit
|
||||
*/
|
||||
|
||||
import { createClient } from "redis";
|
||||
import { RedisDB } from "../src/vector_stores/redis";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const REDIS_HOST = "localhost";
|
||||
const REDIS_PORT = 6379;
|
||||
const REDIS_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
|
||||
const COLLECTION_NAME = "e2e_redis_test";
|
||||
|
||||
// Check if Redis is reachable synchronously at load time
|
||||
function isRedisAvailable(): boolean {
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`node -e "const s=require('net').createConnection({host:'${REDIS_HOST}',port:${REDIS_PORT}});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('error',()=>process.exit(1));s.setTimeout(2000,()=>process.exit(1))"`,
|
||||
{ timeout: 3000, stdio: "ignore" },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const redisAvailable = isRedisAvailable();
|
||||
if (!redisAvailable) {
|
||||
console.warn("Redis not available at localhost:6379 — skipping e2e tests");
|
||||
}
|
||||
|
||||
// Standalone client for cleanup
|
||||
let cleanupClient: ReturnType<typeof createClient>;
|
||||
|
||||
async function cleanupRedis() {
|
||||
if (!redisAvailable) return;
|
||||
try {
|
||||
// Drop the index if it exists
|
||||
await cleanupClient.ft.dropIndex(COLLECTION_NAME);
|
||||
} catch {
|
||||
// Index doesn't exist — fine
|
||||
}
|
||||
|
||||
// Delete all keys with our prefix
|
||||
const keys = await cleanupClient.keys(`mem0:${COLLECTION_NAME}:*`);
|
||||
if (keys.length > 0) {
|
||||
await cleanupClient.del(keys);
|
||||
}
|
||||
|
||||
// Clean up memory_migrations key
|
||||
await cleanupClient.del("memory_migrations:1");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!redisAvailable) return;
|
||||
|
||||
cleanupClient = createClient({ url: REDIS_URL });
|
||||
await cleanupClient.connect();
|
||||
|
||||
// Verify Redis Stack is running with search module
|
||||
const modules = (await cleanupClient.moduleList()) as unknown as any[];
|
||||
const hasSearch = modules.some((mod: any[]) => {
|
||||
const moduleMap = new Map();
|
||||
for (let i = 0; i < mod.length; i += 2) {
|
||||
moduleMap.set(mod[i], mod[i + 1]);
|
||||
}
|
||||
return moduleMap.get("name")?.toLowerCase() === "search";
|
||||
});
|
||||
expect(hasSearch).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!redisAvailable) return;
|
||||
await cleanupRedis();
|
||||
await cleanupClient.quit();
|
||||
});
|
||||
|
||||
// Conditionally skip tests when Redis is unavailable
|
||||
const describeIfRedis = redisAvailable ? describe : describe.skip;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. Basic initialization and idempotent init guard
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: initialization", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("initializes successfully and creates index", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 128,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify the index was created by querying index info
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
expect(info).toBeDefined();
|
||||
expect(info.indexName).toBe(COLLECTION_NAME);
|
||||
|
||||
await store.close();
|
||||
});
|
||||
|
||||
it("idempotent initialize() — multiple calls don't crash", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 128,
|
||||
});
|
||||
|
||||
// Call initialize multiple times concurrently
|
||||
await Promise.all([
|
||||
store.initialize(),
|
||||
store.initialize(),
|
||||
store.initialize(),
|
||||
]);
|
||||
|
||||
// Should still work fine
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
expect(info).toBeDefined();
|
||||
|
||||
await store.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. Full CRUD operations
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: CRUD operations", () => {
|
||||
let store: RedisDB;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupRedis();
|
||||
store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 4, // Small dims for testing
|
||||
});
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.close();
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("insert and search vectors", async () => {
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const vec1 = [1.0, 0.0, 0.0, 0.0];
|
||||
const vec2 = [0.0, 1.0, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec1, vec2],
|
||||
[id1, id2],
|
||||
[
|
||||
{
|
||||
data: "hello world",
|
||||
hash: "h1",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "goodbye world",
|
||||
hash: "h2",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Search — vec1 should be most similar to itself
|
||||
const results = await store.search(vec1, 2, { userId: "user1" });
|
||||
expect(results.length).toBe(2);
|
||||
// The first result should be closest to the query
|
||||
expect(results[0].id).toBe(id1);
|
||||
expect(results[0].score).toBeDefined();
|
||||
expect(results[0].payload).toBeDefined();
|
||||
});
|
||||
|
||||
it("get vector by ID", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [0.5, 0.5, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "test memory",
|
||||
hash: "h-test",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(id);
|
||||
expect(result!.payload.data).toBe("test memory");
|
||||
expect(result!.payload.hash).toBe("h-test");
|
||||
});
|
||||
|
||||
it("get non-existent vector returns null", async () => {
|
||||
const result = await store.get("non-existent-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("update vector and payload", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [1.0, 0.0, 0.0, 0.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "original",
|
||||
hash: "h-orig",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Update with new vector and payload
|
||||
const newVec = [0.0, 0.0, 1.0, 0.0];
|
||||
await store.update(id, newVec, {
|
||||
data: "updated memory",
|
||||
hash: "h-updated",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.payload.data).toBe("updated memory");
|
||||
expect(result!.payload.hash).toBe("h-updated");
|
||||
});
|
||||
|
||||
it("delete vector", async () => {
|
||||
const id = uuidv4();
|
||||
const vec = [0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "to be deleted",
|
||||
hash: "h-del",
|
||||
userId: "user1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Verify it exists
|
||||
const before = await store.get(id);
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Delete
|
||||
await store.delete(id);
|
||||
|
||||
// Verify it's gone
|
||||
const after = await store.get(id);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it("list vectors with filters", async () => {
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const id3 = uuidv4();
|
||||
|
||||
await store.insert(
|
||||
[
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
],
|
||||
[id1, id2, id3],
|
||||
[
|
||||
{
|
||||
data: "mem1",
|
||||
hash: "h1",
|
||||
userId: "usera",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "mem2",
|
||||
hash: "h2",
|
||||
userId: "usera",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
data: "mem3",
|
||||
hash: "h3",
|
||||
userId: "userb",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// List all
|
||||
const [all, allCount] = await store.list();
|
||||
expect(allCount).toBe(3);
|
||||
expect(all.length).toBe(3);
|
||||
|
||||
// List with filter
|
||||
const [filtered, filteredCount] = await store.list({
|
||||
userId: "usera",
|
||||
});
|
||||
expect(filteredCount).toBe(2);
|
||||
expect(filtered.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. getUserId / setUserId
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: getUserId / setUserId", () => {
|
||||
let store: RedisDB;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupRedis();
|
||||
store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 4,
|
||||
});
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.close();
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("getUserId generates random ID if none exists", async () => {
|
||||
const userId = await store.getUserId();
|
||||
expect(typeof userId).toBe("string");
|
||||
expect(userId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("setUserId + getUserId roundtrip", async () => {
|
||||
await store.setUserId("custom-redis-user");
|
||||
const retrieved = await store.getUserId();
|
||||
expect(retrieved).toBe("custom-redis-user");
|
||||
});
|
||||
|
||||
it("getUserId returns same value on subsequent calls", async () => {
|
||||
const first = await store.getUserId();
|
||||
const second = await store.getUserId();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4. Dimension handling (our fix ensures correct dims from Memory)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
describeIfRedis("Redis: dimension handling", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupRedis();
|
||||
});
|
||||
|
||||
it("creates index with correct dimensions from config", async () => {
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: 768,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
// Verify the index has the right dimension in its schema
|
||||
const info = await cleanupClient.ft.info(COLLECTION_NAME);
|
||||
// Check that the vector field has DIM=768
|
||||
const attributes = info.attributes as any[];
|
||||
const vectorAttr = attributes.find(
|
||||
(a: any) => a.identifier === "embedding" || a.attribute === "embedding",
|
||||
);
|
||||
expect(vectorAttr).toBeDefined();
|
||||
|
||||
await store.close();
|
||||
});
|
||||
|
||||
it("insert with matching dimension succeeds", async () => {
|
||||
const dims = 128;
|
||||
const store = new RedisDB({
|
||||
redisUrl: REDIS_URL,
|
||||
collectionName: COLLECTION_NAME,
|
||||
embeddingModelDims: dims,
|
||||
});
|
||||
await store.initialize();
|
||||
|
||||
const id = uuidv4();
|
||||
const vec = new Array(dims).fill(0.1);
|
||||
|
||||
await store.insert(
|
||||
[vec],
|
||||
[id],
|
||||
[
|
||||
{
|
||||
data: "test",
|
||||
hash: "h1",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await store.get(id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(id);
|
||||
|
||||
await store.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { removeCodeBlocks } from "../src/prompts";
|
||||
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("extracts JSON from ```json code fence", () => {
|
||||
const input = '```json\n{"facts": ["hello"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("extracts content from bare ``` code fence", () => {
|
||||
const input = '```\n{"key": "value"}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"key": "value"}');
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
const input = '{"facts": ["hello"]}';
|
||||
expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}');
|
||||
});
|
||||
|
||||
it("handles multiple code blocks", () => {
|
||||
const input = '```json\n{"a":1}\n```\nsome text\n```json\n{"b":2}\n```';
|
||||
expect(removeCodeBlocks(input)).toBe('{"a":1}\n\nsome text\n{"b":2}');
|
||||
});
|
||||
|
||||
it("handles Claude-style response with surrounding text", () => {
|
||||
const input =
|
||||
'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```';
|
||||
expect(removeCodeBlocks(input)).toContain('"facts"');
|
||||
expect(removeCodeBlocks(input)).not.toContain("```");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,10 @@ class MemoryGraph:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.graph = Neo4jGraph(
|
||||
self.config.graph_store.config.url,
|
||||
self.config.graph_store.config.username,
|
||||
self.config.graph_store.config.password,
|
||||
self.config.graph_store.config.database,
|
||||
url=self.config.graph_store.config.url,
|
||||
username=self.config.graph_store.config.username,
|
||||
password=self.config.graph_store.config.password,
|
||||
database=self.config.graph_store.config.database,
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package-manager-strict-version=false
|
||||
approve-builds=esbuild
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
|
||||
## How it works
|
||||
|
||||
<p align="center">
|
||||
<img src="../docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
**Auto-Recall** — Before the agent responds, the plugin searches Mem0 for memories that match the current message and injects them into context.
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ class PlatformProvider implements Mem0Provider {
|
||||
|
||||
private async _init(): Promise<void> {
|
||||
const { default: MemoryClient } = await import("mem0ai");
|
||||
const opts: Record<string, string> = { apiKey: this.apiKey };
|
||||
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
|
||||
if (this.orgId) opts.org_id = this.orgId;
|
||||
if (this.projectId) opts.project_id = this.projectId;
|
||||
this.client = new MemoryClient(opts);
|
||||
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
declare module "openclaw/plugin-sdk" {
|
||||
export interface OpenClawPluginApi {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: {
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
debug(msg: string): void;
|
||||
};
|
||||
resolvePath(p: string): string;
|
||||
registerTool(
|
||||
definition: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void;
|
||||
on(
|
||||
event: string,
|
||||
handler: (event: any, ctx: any) => any,
|
||||
): void;
|
||||
registerCli(
|
||||
handler: (context: { program: any }) => void,
|
||||
options?: Record<string, unknown>,
|
||||
): void;
|
||||
registerService(service: {
|
||||
id: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}): void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/openclaw-mem0",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
|
||||
"license": "Apache-2.0",
|
||||
@@ -11,7 +11,20 @@
|
||||
"mem0",
|
||||
"long-term-memory"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,10 +33,13 @@
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
"./dist/index.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4105
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
approveBuilds: esbuild
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- protobufjs
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
});
|
||||
+1
-1
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"posthog>=3.5.0",
|
||||
"pytz>=2024.1",
|
||||
"sqlalchemy>=2.0.31",
|
||||
"protobuf>=5.29.0,<6.0.0",
|
||||
"protobuf>=5.29.6,<7.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but not
|
||||
limited to compiled object code, generated documentation, and
|
||||
conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Mem0.ai
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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
|
||||
|
||||
### CLI (Claude Code, OpenCode, OpenClaw, or any tool that supports skills)
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
```
|
||||
|
||||
### Claude.ai
|
||||
|
||||
1. Download this `skills/mem0` folder as a ZIP
|
||||
2. Go to **Settings > Capabilities > Skills**
|
||||
3. Click **Upload skill** and select the ZIP
|
||||
|
||||
### Claude API (Skills API)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.anthropic.com/v1/skills \
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "mem0", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0"}'
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||
- Python 3.10+ or Node.js 18+
|
||||
- Set the environment variable:
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
After installing, just ask Claude:
|
||||
|
||||
- "Set up mem0 in my project"
|
||||
- "Add memory to my chatbot"
|
||||
- "Help me search user memories with filters"
|
||||
- "Integrate mem0 with my LangChain app"
|
||||
- "Add graph memory to track entity relationships"
|
||||
|
||||
## What's Inside
|
||||
|
||||
```text
|
||||
skills/mem0/
|
||||
├── SKILL.md # Skill definition and instructions
|
||||
├── README.md # This file
|
||||
├── LICENSE # Apache-2.0
|
||||
├── scripts/
|
||||
│ └── mem0_doc_search.py # Search live Mem0 docs on demand
|
||||
└── references/ # Documentation (loaded on demand)
|
||||
├── quickstart.md # Full quickstart (Python, TS, cURL)
|
||||
├── sdk-guide.md # All SDK methods (Python + TypeScript)
|
||||
├── api-reference.md # REST endpoints, filters, memory object
|
||||
├── architecture.md # Processing pipeline, lifecycle, scoping, performance
|
||||
├── features.md # Retrieval, graph, categories, MCP, webhooks, multimodal
|
||||
├── integration-patterns.md # LangChain, CrewAI, Vercel AI, LangGraph, LlamaIndex, etc.
|
||||
└── use-cases.md # 7 real-world patterns with Python + TypeScript code
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Mem0 Platform Dashboard](https://app.mem0.ai)
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
|
||||
- [API Reference](https://docs.mem0.ai/api-reference)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: mem0
|
||||
description: >
|
||||
Integrate Mem0 Platform into AI applications for persistent memory, personalization, and semantic search.
|
||||
Use this skill when the user mentions "mem0", "memory layer", "remember user preferences",
|
||||
"persistent context", "personalization", or needs to add long-term memory to chatbots, agents,
|
||||
or AI apps. Covers Python and TypeScript SDKs, framework integrations (LangChain, CrewAI,
|
||||
Vercel AI SDK, OpenAI Agents SDK, Pipecat), and the full Platform API. Use even when the user
|
||||
doesn't explicitly say "mem0" but describes needing conversation memory, user context retention,
|
||||
or knowledge retrieval across sessions.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: mem0ai
|
||||
version: "1.0.0"
|
||||
category: ai-memory
|
||||
tags: "memory, personalization, ai, python, typescript, vector-search"
|
||||
compatibility: Requires Python 3.10+ or Node.js 18+, pip install mem0ai or npm install mem0ai, MEM0_API_KEY env var, and internet access to api.mem0.ai
|
||||
---
|
||||
|
||||
# Mem0 Platform Integration
|
||||
|
||||
Mem0 is a managed memory layer for AI applications. It stores, retrieves, and manages user memories via API — no infrastructure to deploy.
|
||||
|
||||
## Step 1: Install and authenticate
|
||||
|
||||
**Python:**
|
||||
```bash
|
||||
pip install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
**TypeScript/JavaScript:**
|
||||
```bash
|
||||
npm install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
Get an API key at: https://app.mem0.ai/dashboard/api-keys
|
||||
|
||||
## Step 2: Initialize the client
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
client = MemoryClient(api_key="m0-xxx")
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||
```
|
||||
|
||||
For async Python, use `AsyncMemoryClient`.
|
||||
|
||||
## Step 3: Core operations
|
||||
|
||||
Every Mem0 integration follows the same pattern: **retrieve → generate → store**.
|
||||
|
||||
### Add memories
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||
]
|
||||
client.add(messages, user_id="alice")
|
||||
```
|
||||
|
||||
### Search memories
|
||||
```python
|
||||
results = client.search("dietary preferences", user_id="alice")
|
||||
for mem in results.get("results", []):
|
||||
print(mem["memory"])
|
||||
```
|
||||
|
||||
### Get all memories
|
||||
```python
|
||||
all_memories = client.get_all(user_id="alice")
|
||||
```
|
||||
|
||||
### Update a memory
|
||||
```python
|
||||
client.update("memory-uuid", text="Updated: vegetarian, nut allergy, prefers organic")
|
||||
```
|
||||
|
||||
### Delete a memory
|
||||
```python
|
||||
client.delete("memory-uuid")
|
||||
client.delete_all(user_id="alice") # delete all for a user
|
||||
```
|
||||
|
||||
## Common integration pattern
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai = OpenAI()
|
||||
|
||||
def chat(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve relevant memories
|
||||
memories = mem0.search(user_input, user_id=user_id)
|
||||
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||
|
||||
# 2. Generate response with memory context
|
||||
response = openai.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": f"User context:\n{context}"},
|
||||
{"role": "user", "content": user_input},
|
||||
]
|
||||
)
|
||||
reply = response.choices[0].message.content
|
||||
|
||||
# 3. Store interaction for future context
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": reply}],
|
||||
user_id=user_id
|
||||
)
|
||||
return reply
|
||||
```
|
||||
|
||||
## Common edge cases
|
||||
|
||||
- **Search returns empty:** Memories process asynchronously. Wait 2-3s after `add()` before searching. Also verify `user_id` matches exactly (case-sensitive).
|
||||
- **AND filter with user_id + agent_id returns empty:** Entities are stored separately. Use `OR` instead, or query separately.
|
||||
- **Duplicate memories:** Don't mix `infer=True` (default) and `infer=False` for the same data. Stick to one mode.
|
||||
- **Wrong import:** Always use `from mem0 import MemoryClient` (or `AsyncMemoryClient` for async). Do not use `from mem0 import Memory`.
|
||||
- **Immutable memories:** Cannot be updated or deleted once created. Use `client.history(memory_id)` to track changes over time.
|
||||
|
||||
## Live documentation search
|
||||
|
||||
For the latest docs beyond what's in the references, use the doc search tool:
|
||||
|
||||
```bash
|
||||
python scripts/mem0_doc_search.py --query "topic"
|
||||
python scripts/mem0_doc_search.py --page "/platform/features/graph-memory"
|
||||
python scripts/mem0_doc_search.py --index
|
||||
```
|
||||
|
||||
No API key needed — searches docs.mem0.ai directly.
|
||||
|
||||
## References
|
||||
|
||||
Load these on demand for deeper detail:
|
||||
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Quickstart (Python, TS, cURL) | [references/quickstart.md](references/quickstart.md) |
|
||||
| SDK guide (all methods, both languages) | [references/sdk-guide.md](references/sdk-guide.md) |
|
||||
| API reference (endpoints, filters, object schema) | [references/api-reference.md](references/api-reference.md) |
|
||||
| Architecture (pipeline, lifecycle, scoping, performance) | [references/architecture.md](references/architecture.md) |
|
||||
| Platform features (retrieval, graph, categories, MCP, etc.) | [references/features.md](references/features.md) |
|
||||
| Framework integrations (LangChain, CrewAI, Vercel AI, etc.) | [references/integration-patterns.md](references/integration-patterns.md) |
|
||||
| Use cases & examples (real-world patterns with code) | [references/use-cases.md](references/use-cases.md) |
|
||||
@@ -0,0 +1,140 @@
|
||||
# Mem0 Platform API Reference
|
||||
|
||||
REST API endpoints for the Mem0 Platform. Base URL: `https://api.mem0.ai`
|
||||
|
||||
All endpoints require: `Authorization: Token <MEM0_API_KEY>`
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Operation | Method | URL |
|
||||
|-----------|--------|-----|
|
||||
| Add Memories | `POST` | `/v1/memories/` |
|
||||
| Search Memories | `POST` | `/v2/memories/search/` |
|
||||
| Get All Memories | `POST` | `/v2/memories/` |
|
||||
| Get Single Memory | `GET` | `/v1/memories/{memory_id}/` |
|
||||
| Update Memory | `PUT` | `/v1/memories/{memory_id}/` |
|
||||
| Delete Memory | `DELETE` | `/v1/memories/{memory_id}/` |
|
||||
|
||||
## Memory Object Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string (UUID) | Unique memory identifier |
|
||||
| `memory` | string | Text content of the memory |
|
||||
| `user_id` | string | Associated user |
|
||||
| `agent_id` | string (nullable) | Agent identifier |
|
||||
| `app_id` | string (nullable) | Application identifier |
|
||||
| `run_id` | string (nullable) | Run/session identifier |
|
||||
| `metadata` | object | Custom key-value pairs |
|
||||
| `categories` | array of strings | Auto-assigned category tags |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `expiration_date` | datetime (nullable) | Auto-expiry date |
|
||||
| `hash` | string | Content hash |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
|
||||
Search results additionally include `score` (relevance metric).
|
||||
|
||||
## Scoping Identifiers
|
||||
|
||||
Memories can be scoped to different levels:
|
||||
|
||||
| Scope | Parameter | Use Case |
|
||||
|-------|-----------|----------|
|
||||
| User | `user_id` | Per-user memory isolation |
|
||||
| Agent | `agent_id` | Per-agent memory partitioning |
|
||||
| Application | `app_id` | Cross-agent app-level memory |
|
||||
| Run/Session | `run_id` | Session-scoped temporary memory |
|
||||
|
||||
**Critical:** Combining `user_id` and `agent_id` in a single AND filter yields empty results. Entities are stored separately. Use `OR` logic or separate queries.
|
||||
|
||||
## Processing Model
|
||||
|
||||
- Memories are processed **asynchronously by default** (`async_mode=true`)
|
||||
- Add responses return queued events (`ADD`, `UPDATE`, `DELETE`) for tracking
|
||||
- Set `async_mode=false` for synchronous processing when needed
|
||||
- Graph metadata is processed asynchronously -- use `get_all()` for complete graph data
|
||||
|
||||
## Filter System
|
||||
|
||||
Filters use nested JSON with a logical operator at the root:
|
||||
|
||||
```json
|
||||
{
|
||||
"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"categories": {"contains": "finance"}},
|
||||
{"created_at": {"gte": "2024-01-01"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Root must be `AND`, `OR`, or `NOT`. Simple shorthand `{"user_id": "alice"}` also works.
|
||||
|
||||
### Supported Operators
|
||||
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `eq` | Equal to (default) |
|
||||
| `ne` | Not equal to |
|
||||
| `in` | Matches any value in array |
|
||||
| `gt`, `gte` | Greater than / greater than or equal |
|
||||
| `lt`, `lte` | Less than / less than or equal |
|
||||
| `contains` | Case-sensitive containment |
|
||||
| `icontains` | Case-insensitive containment |
|
||||
| `*` | Wildcard -- matches any non-null value |
|
||||
|
||||
### Filterable Fields
|
||||
|
||||
| Field | Valid Operators |
|
||||
|-------|-----------------|
|
||||
| `user_id`, `agent_id`, `app_id`, `run_id` | `eq`, `ne`, `in`, `*` |
|
||||
| `created_at`, `updated_at`, `timestamp` | `gt`, `gte`, `lt`, `lte`, `eq`, `ne` |
|
||||
| `categories` | `eq`, `ne`, `in`, `contains` |
|
||||
| `metadata` | `eq`, `ne`, `contains` (top-level keys only) |
|
||||
| `keywords` | `contains`, `icontains` |
|
||||
| `memory_ids` | `in` |
|
||||
|
||||
### Filter Constraints
|
||||
|
||||
1. **Entity scope partitioning:** `user_id` AND `agent_id` in one `AND` block yields empty results.
|
||||
2. **Metadata limitations:** Only top-level keys. Only `eq`, `contains`, `ne`. No `in` or `gt`.
|
||||
3. **Operator syntax:** Use `gte`, `lt`, `ne`. SQL-style (`>=`, `!=`) rejected.
|
||||
4. **Entity filter required for get-all:** At least one of `user_id`, `agent_id`, `app_id`, or `run_id`.
|
||||
5. **Wildcard excludes null:** `*` matches only non-null values.
|
||||
6. **Date format:** ISO 8601 (`YYYY-MM-DDTHH:MM:SSZ`). Timezone-naive defaults to UTC.
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Add Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "mem_01JF8ZS4Y0R0SPM13R5R6H32CJ",
|
||||
"event": "ADD",
|
||||
"data": { "memory": "The user moved to Austin in 2025." }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Event types: `ADD`, `UPDATE`, `DELETE`. A single add can trigger multiple events.
|
||||
|
||||
### Search Response
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "ea925981-...",
|
||||
"memory": "Is a vegetarian and allergic to nuts.",
|
||||
"user_id": "user123",
|
||||
"categories": ["food", "health"],
|
||||
"score": 0.89,
|
||||
"created_at": "2024-07-26T10:29:36.630547-07:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With `enable_graph=true`, includes additional `relations` array with entity relationships.
|
||||
@@ -0,0 +1,386 @@
|
||||
# Mem0 Platform Architecture
|
||||
|
||||
How Mem0 processes, stores, and retrieves memories under the hood.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Concept](#core-concept)
|
||||
- [Memory Processing Pipeline](#memory-processing-pipeline)
|
||||
- [Retrieval Pipeline](#retrieval-pipeline)
|
||||
- [Memory Lifecycle](#memory-lifecycle)
|
||||
- [Memory Object Structure](#memory-object-structure)
|
||||
- [Scoping & Multi-Tenancy](#scoping--multi-tenancy)
|
||||
- [Memory Layers](#memory-layers)
|
||||
- [Performance Characteristics](#performance-characteristics)
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
Mem0 is a managed memory layer that sits between your AI application and users. Every integration follows the same 3-step loop:
|
||||
|
||||
```
|
||||
User Input → Retrieve relevant memories → Enrich LLM prompt → Generate response → Store new memories
|
||||
```
|
||||
|
||||
Mem0 handles the complexity of extraction, deduplication, conflict resolution, and semantic retrieval so your application only needs to call `search()` and `add()`.
|
||||
|
||||
**Dual storage architecture:**
|
||||
- **Vector store**: Embeddings for semantic similarity search
|
||||
- **Graph store** (optional): Entity nodes and relationship edges for structured knowledge
|
||||
|
||||
---
|
||||
|
||||
## Memory Processing Pipeline
|
||||
|
||||
### What happens when you call `client.add()`
|
||||
|
||||
```
|
||||
Messages In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. EXTRACTION │ LLM analyzes messages, extracts key facts
|
||||
│ (infer=True) │ If infer=False, stores raw text as-is
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. CONFLICT │ Checks existing memories for duplicates
|
||||
│ RESOLUTION │ Latest truth wins (newer overrides older)
|
||||
│ │ Only runs when infer=True
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 3. STORAGE │ Generates embeddings → vector store
|
||||
│ │ Optional: entity extraction → graph store
|
||||
│ │ Indexes metadata, categories, timestamps
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Memory Object
|
||||
(id, memory, categories, structured_attributes)
|
||||
```
|
||||
|
||||
### Processing modes
|
||||
|
||||
**Async (default, `async_mode=True`):**
|
||||
- API returns immediately: `{"status": "PENDING", "event_id": "..."}`
|
||||
- Processing happens in background
|
||||
- Use webhooks for completion notifications
|
||||
- Best for: high-throughput, non-blocking workflows
|
||||
|
||||
**Sync (`async_mode=False`):**
|
||||
- API waits for full processing
|
||||
- Returns complete memory object with `id`, `event`, `memory`
|
||||
- Best for: real-time access immediately after add
|
||||
|
||||
### Extraction modes
|
||||
|
||||
**Inferred (`infer=True`, default):**
|
||||
- LLM extracts structured facts from conversation
|
||||
- Conflict resolution deduplicates and resolves contradictions
|
||||
- Best for: natural conversation → memory
|
||||
|
||||
**Raw (`infer=False`):**
|
||||
- Stores text exactly as provided, no LLM processing
|
||||
- Skips conflict resolution — same fact can be stored twice
|
||||
- Only `user` role messages are stored; `assistant` messages ignored
|
||||
- Best for: bulk imports, pre-structured data, migrations
|
||||
|
||||
**Warning:** Don't mix `infer=True` and `infer=False` for the same data — the same fact will be stored twice.
|
||||
|
||||
---
|
||||
|
||||
## Retrieval Pipeline
|
||||
|
||||
### What happens when you call `client.search()`
|
||||
|
||||
```
|
||||
Query In
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1. QUERY EMBEDDING │ Convert query to vector representation
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2. VECTOR SEARCH │ Cosine similarity across stored embeddings
|
||||
│ │ Scoped by filters (user_id, agent_id, etc.)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (optional enhancements)
|
||||
┌─────────────────────┐
|
||||
│ 3a. KEYWORD SEARCH │ Expands results with specific terms (+10ms)
|
||||
│ 3b. RERANKING │ Deep semantic reordering (+150-200ms)
|
||||
│ 3c. FILTER MEMORIES │ Precision filtering, removes low-relevance (+200-300ms)
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼ (if enable_graph=True)
|
||||
┌─────────────────────┐
|
||||
│ 4. GRAPH LOOKUP │ Finds entity relationships
|
||||
│ │ Appends relations WITHOUT reranking vector results
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
Results + Relations
|
||||
```
|
||||
|
||||
### Retrieval enhancement combinations
|
||||
|
||||
| Configuration | Latency | Best for |
|
||||
|--------------|---------|----------|
|
||||
| Base search only | ~100ms | Simple lookups |
|
||||
| `keyword_search=True` | ~110ms | Entity-heavy queries, broad coverage |
|
||||
| `rerank=True` | ~250-300ms | User-facing results, top-N precision |
|
||||
| `keyword_search=True` + `rerank=True` | ~310ms | Balanced (recommended for most apps) |
|
||||
| `rerank=True` + `filter_memories=True` | ~400-500ms | Safety-critical, production systems |
|
||||
|
||||
### Implicit null scoping
|
||||
|
||||
When you search with `user_id="alice"` only, Mem0 returns memories where `agent_id`, `app_id`, and `run_id` are all null. This prevents cross-scope leakage by default.
|
||||
|
||||
To include memories with non-null fields, use explicit filters:
|
||||
```python
|
||||
# Gets memories for alice regardless of agent/app/run
|
||||
filters={"OR": [{"user_id": "alice"}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Lifecycle
|
||||
|
||||
```
|
||||
CREATE ──→ ACTIVE ──→ UPDATE ──→ ACTIVE
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ EXPIRED EXPIRED
|
||||
│ (still stored, (still stored,
|
||||
│ not retrieved) not retrieved)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
DELETE DELETE DELETE
|
||||
(permanent)
|
||||
```
|
||||
|
||||
### Creation
|
||||
- Triggered by `client.add(messages, user_id="...")`
|
||||
- Messages processed through extraction → conflict resolution → storage
|
||||
- Gets unique UUID, `created_at` timestamp
|
||||
- Optional: custom `timestamp`, `expiration_date`, `metadata`, `immutable`
|
||||
|
||||
### Updates
|
||||
- `client.update(memory_id, text="...")` replaces text and reindexes
|
||||
- `client.batch_update([...])` for up to 1000 memories at once
|
||||
- Immutable memories (`immutable=True`) cannot be updated — must delete and re-add
|
||||
|
||||
### Deduplication
|
||||
- Automatic during `add()` with `infer=True`
|
||||
- Conflict resolution merges duplicate facts
|
||||
- Latest truth wins when contradictions detected
|
||||
- Prevents memory bloat from repeated information
|
||||
|
||||
### Expiration
|
||||
- Optional `expiration_date` parameter (ISO 8601 or `YYYY-MM-DD`)
|
||||
- After expiration: memory NOT returned in searches but remains in storage
|
||||
- Useful for time-sensitive info (events, temporary preferences, session state)
|
||||
|
||||
### Deletion
|
||||
- Single: `client.delete(memory_id)` — permanent, no recovery
|
||||
- Batch: `client.batch_delete([memory_ids])` — up to 1000
|
||||
- Bulk: `client.delete_all(user_id="alice")` — all memories for entity
|
||||
- `delete_all()` without filters raises error to prevent accidental data loss
|
||||
|
||||
### History tracking
|
||||
- `client.history(memory_id)` returns version timeline
|
||||
- Shows all changes: `{previous_value, new_value, action, timestamps}`
|
||||
- Useful for audit trails and debugging
|
||||
|
||||
---
|
||||
|
||||
## Memory Object Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"memory": "Extracted memory text",
|
||||
"user_id": "user-identifier",
|
||||
"agent_id": null,
|
||||
"app_id": null,
|
||||
"run_id": null,
|
||||
"metadata": { "source": "chat", "priority": "high" },
|
||||
"categories": ["health", "preferences"],
|
||||
"created_at": "2025-03-12T12:34:56Z",
|
||||
"updated_at": "2025-03-12T12:34:56Z",
|
||||
"expiration_date": null,
|
||||
"immutable": false,
|
||||
"structured_attributes": {
|
||||
"day": 12, "month": 3, "year": 2025,
|
||||
"hour": 12, "minute": 34,
|
||||
"day_of_week": "wednesday",
|
||||
"is_weekend": false,
|
||||
"quarter": 1, "week_of_year": 11
|
||||
},
|
||||
"score": 0.85
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | UUID | Unique identifier, used for update/delete |
|
||||
| `memory` | string | Extracted or stored text content |
|
||||
| `user_id` | string | Primary entity scope |
|
||||
| `agent_id` | string | Agent scope |
|
||||
| `app_id` | string | Application scope |
|
||||
| `run_id` | string | Session/run scope |
|
||||
| `metadata` | object | Custom key-value pairs for filtering |
|
||||
| `categories` | array | Auto-assigned or custom category tags |
|
||||
| `created_at` | datetime | Creation timestamp |
|
||||
| `updated_at` | datetime | Last modification timestamp |
|
||||
| `expiration_date` | datetime | Auto-expiry date (stops retrieval, data persists) |
|
||||
| `immutable` | boolean | If true, prevents modification |
|
||||
| `structured_attributes` | object | Temporal breakdown for time-based queries |
|
||||
| `score` | float | Semantic similarity (search results only, 0-1) |
|
||||
|
||||
---
|
||||
|
||||
## Scoping & Multi-Tenancy
|
||||
|
||||
Mem0 separates memories across four dimensions to prevent data mixing:
|
||||
|
||||
| Dimension | Field | Purpose | Example |
|
||||
|-----------|-------|---------|---------|
|
||||
| User | `user_id` | Persistent persona or account | `"customer_6412"` |
|
||||
| Agent | `agent_id` | Distinct agent or tool | `"meal_planner"` |
|
||||
| App | `app_id` | Product surface or deployment | `"ios_retail_app"` |
|
||||
| Session | `run_id` | Short-lived flow or thread | `"ticket-9241"` |
|
||||
|
||||
### Storage model
|
||||
|
||||
Each entity combination creates separate records. A memory with `user_id="alice"` is stored separately from one with `user_id="alice"` + `agent_id="bot"`.
|
||||
|
||||
### Critical: cross-entity queries
|
||||
|
||||
```python
|
||||
# This returns NOTHING — user and agent memories are stored separately
|
||||
filters={"AND": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||
|
||||
# Use OR to query multiple scopes
|
||||
filters={"OR": [{"user_id": "alice"}, {"agent_id": "bot"}]}
|
||||
|
||||
# Use wildcard to include any non-null value
|
||||
filters={"AND": [{"user_id": "*"}]} # All users (excludes null)
|
||||
```
|
||||
|
||||
### Recommended scoping patterns
|
||||
|
||||
```python
|
||||
# User-level: persistent preferences
|
||||
client.add(messages, user_id="alice")
|
||||
|
||||
# Session-level: temporary context
|
||||
client.add(messages, user_id="alice", run_id="session_123")
|
||||
# Clean up when done: client.delete_all(run_id="session_123")
|
||||
|
||||
# Agent-level: agent-specific knowledge
|
||||
client.add(messages, agent_id="support_bot", app_id="helpdesk")
|
||||
|
||||
# Multi-tenant: full isolation
|
||||
client.add(messages, user_id="alice", agent_id="bot", app_id="acme_corp", run_id="ticket_42")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Layers
|
||||
|
||||
Mem0 supports three layers of memory, from shortest to longest lived:
|
||||
|
||||
### Conversation memory
|
||||
- In-flight messages within a single turn
|
||||
- Tool calls, chain-of-thought reasoning
|
||||
- **Lifetime:** Single response — lost after turn finishes
|
||||
- **Managed by:** Your application, not Mem0
|
||||
|
||||
### Session memory
|
||||
- Short-lived facts for current task or channel
|
||||
- Multi-step flows (onboarding, debugging, support tickets)
|
||||
- **Lifetime:** Minutes to hours
|
||||
- **Managed by:** Mem0 via `run_id` parameter
|
||||
- Clean up with `client.delete_all(run_id="session_id")`
|
||||
|
||||
### User memory
|
||||
- Long-lived knowledge tied to a person or account
|
||||
- Personal preferences, account state, compliance details
|
||||
- **Lifetime:** Weeks to forever
|
||||
- **Managed by:** Mem0 via `user_id` parameter
|
||||
- Persists across all sessions and interactions
|
||||
|
||||
### How layering works in practice
|
||||
|
||||
```python
|
||||
def chat(user_input: str, user_id: str, session_id: str) -> str:
|
||||
# 1. Retrieve user memories (long-term preferences)
|
||||
user_mems = mem0.search(user_input, user_id=user_id)
|
||||
|
||||
# 2. Retrieve session memories (current task context)
|
||||
session_mems = mem0.search(user_input, filters={
|
||||
"AND": [{"user_id": user_id}, {"run_id": session_id}]
|
||||
})
|
||||
|
||||
# 3. Combine both layers for LLM context
|
||||
context = format_memories(user_mems) + format_memories(session_mems)
|
||||
|
||||
# 4. Generate response
|
||||
response = llm.generate(context=context, input=user_input)
|
||||
|
||||
# 5. Store in session scope (temporary) + user scope (persistent)
|
||||
messages = [{"role": "user", "content": user_input}, {"role": "assistant", "content": response}]
|
||||
mem0.add(messages, user_id=user_id, run_id=session_id)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Latency
|
||||
|
||||
| Operation | Typical Latency |
|
||||
|-----------|----------------|
|
||||
| Base vector search | ~100ms |
|
||||
| + keyword_search | +10ms |
|
||||
| + reranking | +150-200ms |
|
||||
| + filter_memories | +200-300ms |
|
||||
| Add (async, default) | < 50ms response, background processing |
|
||||
| Add (sync) | 500ms-2s depending on extraction complexity |
|
||||
| Graph operations | Slight overhead for large stores |
|
||||
|
||||
### Processing
|
||||
|
||||
- **Async mode (default):** Returns immediately, processes in background
|
||||
- **Sync mode:** Waits for full extraction + storage pipeline
|
||||
- **Batch operations:** Up to 1000 memories per batch_update/batch_delete
|
||||
- **Webhooks:** Real-time notifications when async processing completes
|
||||
|
||||
### Scoping strategy for performance
|
||||
|
||||
- Use `user_id` for all user-facing queries (most common, fastest)
|
||||
- Add `run_id` for session isolation (narrows search space)
|
||||
- Avoid wildcard `"*"` filters on large datasets (scans all non-null records)
|
||||
- Use `top_k` to limit result count when you only need a few memories
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Alternatives
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
|----------|------|------|
|
||||
| **Raw vector DB** | Fast, full control | No extraction, no dedup, no conflict resolution |
|
||||
| **In-memory chat history** | Zero latency | Lost on restart, no cross-session, grows unbounded |
|
||||
| **RAG over documents** | Good for static knowledge | No personalization, no memory updates |
|
||||
| **Mem0 Platform** | Managed extraction + dedup + graph + scoping | External dependency, async processing delay |
|
||||
|
||||
Mem0 combines the best of vector search (semantic retrieval) with automatic extraction (LLM-powered), conflict resolution (deduplication), and structured scoping (multi-tenancy) — in a single managed API.
|
||||
@@ -0,0 +1,496 @@
|
||||
# Platform Features -- Mem0 Platform
|
||||
|
||||
Additional platform capabilities beyond core CRUD operations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Advanced Retrieval](#advanced-retrieval)
|
||||
- [Graph Memory](#graph-memory)
|
||||
- [Custom Categories](#custom-categories)
|
||||
- [Custom Instructions](#custom-instructions)
|
||||
- [Criteria Retrieval](#criteria-retrieval)
|
||||
- [Feedback Mechanism](#feedback-mechanism)
|
||||
- [Memory Export](#memory-export)
|
||||
- [Group Chat](#group-chat)
|
||||
- [MCP Integration](#mcp-integration)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Multimodal Support](#multimodal-support)
|
||||
|
||||
## Advanced Retrieval
|
||||
|
||||
Three enhancement options for tuning search precision, recall, and latency.
|
||||
|
||||
### Keyword Search (`keyword_search=True`)
|
||||
|
||||
Expands results to include memories with specific terms, names, and technical keywords.
|
||||
|
||||
- Latency: +10ms
|
||||
- Recall: Significantly increased
|
||||
- Best for: entity-heavy queries, comprehensive coverage
|
||||
|
||||
### Reranking (`rerank=True`)
|
||||
|
||||
Deep semantic reordering of results — most relevant first.
|
||||
|
||||
- Latency: +150-200ms
|
||||
- Accuracy: Significantly improved
|
||||
- Best for: user-facing results, top-N precision
|
||||
|
||||
### Filter Memories (`filter_memories=True`)
|
||||
|
||||
Precision filtering — removes low-relevance results entirely.
|
||||
|
||||
- Latency: +200-300ms
|
||||
- Precision: Maximized
|
||||
- Best for: safety-critical applications, production systems
|
||||
|
||||
### Recommended Combinations
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
# Fast & broad
|
||||
results = client.search(query, keyword_search=True, user_id="user123")
|
||||
|
||||
# Balanced (recommended for most apps)
|
||||
results = client.search(query, keyword_search=True, rerank=True, user_id="user123")
|
||||
|
||||
# High precision (critical apps)
|
||||
results = client.search(query, rerank=True, filter_memories=True, user_id="user123")
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const results = await client.search(query, {
|
||||
user_id: 'user123',
|
||||
keyword_search: true,
|
||||
rerank: true,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graph Memory
|
||||
|
||||
Entity-level knowledge graph that creates relationships between memories.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Extraction**: LLM analyzes conversation and identifies entities and relationships
|
||||
2. **Storage**: Embeddings go to vector store; entity nodes and edges go to graph store
|
||||
3. **Retrieval**: Vector search returns semantic matches; graph relations are appended to results
|
||||
|
||||
Graph relations **augment** vector results without reordering them. Vector similarity always determines hit sequence.
|
||||
|
||||
### Enabling Graph Memory
|
||||
|
||||
**Per request:**
|
||||
```python
|
||||
client.add(messages, user_id="alice", enable_graph=True)
|
||||
client.search("query", user_id="alice", enable_graph=True)
|
||||
client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||
```
|
||||
|
||||
**Project-level (default for all operations):**
|
||||
```python
|
||||
client.project.update(enable_graph=True)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ enable_graph: true });
|
||||
```
|
||||
|
||||
### Relation Structure
|
||||
|
||||
Each relation in the response contains:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `source` | string | Source entity name |
|
||||
| `source_type` | string | Source entity type (e.g., "Person") |
|
||||
| `relationship` | string | Relationship label (e.g., "lives_in") |
|
||||
| `target` | string | Target entity name |
|
||||
| `target_type` | string | Target entity type (e.g., "City") |
|
||||
| `score` | number | Confidence score |
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"relations": [
|
||||
{
|
||||
"source": "Joseph",
|
||||
"source_type": "Person",
|
||||
"relationship": "lives_in",
|
||||
"target": "Seattle",
|
||||
"target_type": "City",
|
||||
"score": 0.92
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- Graph Memory adds processing time; see docs for current plan availability
|
||||
- Works optimally with rich conversation histories containing entity relationships
|
||||
- Best suited for long-running assistants tracking evolving information
|
||||
- Graph writes and reads toggle independently per request
|
||||
- Multi-agent context supported via `user_id`, `agent_id`, `run_id` scoping
|
||||
- Add operations are asynchronous; graph metadata may not be immediately available
|
||||
|
||||
---
|
||||
|
||||
## Custom Categories
|
||||
|
||||
Replace Mem0's default 15 labels with domain-specific categories. The system automatically tags memories to the closest matching category.
|
||||
|
||||
### Default Categories (15)
|
||||
|
||||
`personal_details`, `family`, `professional_details`, `sports`, `travel`, `food`, `music`, `health`, `technology`, `hobbies`, `fashion`, `entertainment`, `milestones`, `user_preferences`, `misc`
|
||||
|
||||
### Configuration
|
||||
|
||||
**Set project-level categories:**
|
||||
```python
|
||||
new_categories = [
|
||||
{"lifestyle_management": "Tracks daily routines, habits, wellness activities"},
|
||||
{"seeking_structure": "Documents goals around creating routines and systems"},
|
||||
{"personal_information": "Basic information about the user"}
|
||||
]
|
||||
client.project.update(custom_categories=new_categories)
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_categories: new_categories });
|
||||
```
|
||||
|
||||
**Retrieve active categories:**
|
||||
```python
|
||||
categories = client.project.get(fields=["custom_categories"])
|
||||
```
|
||||
|
||||
### Key Constraint
|
||||
|
||||
Per-request overrides (`custom_categories=...` on `client.add`) are **not supported** on the managed API. Only project-level configuration works. Workaround: store ad-hoc labels in `metadata` field.
|
||||
|
||||
---
|
||||
|
||||
## Custom Instructions
|
||||
|
||||
Natural language filters that control what information Mem0 extracts when creating memories.
|
||||
|
||||
### Set Instructions
|
||||
|
||||
```python
|
||||
client.project.update(custom_instructions="Your guidelines here...")
|
||||
```
|
||||
|
||||
```javascript
|
||||
await client.updateProject({ custom_instructions: "Your guidelines here..." });
|
||||
```
|
||||
|
||||
### Template Structure
|
||||
|
||||
1. **Task Description** -- brief extraction overview
|
||||
2. **Information Categories** -- numbered sections with specific details to capture
|
||||
3. **Processing Guidelines** -- quality and handling rules
|
||||
4. **Exclusion List** -- sensitive/irrelevant data to filter out
|
||||
|
||||
### Domain Examples
|
||||
|
||||
**E-commerce:** Capture product issues, preferences, service experience; exclude payment data.
|
||||
|
||||
**Education:** Extract learning progress, student preferences, performance patterns; exclude specific grades.
|
||||
|
||||
**Finance:** Track financial goals, life events, investment interests; exclude account numbers and SSNs.
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Start simply, test with sample messages, iterate based on results
|
||||
- Avoid overly lengthy instructions
|
||||
- Be specific about what to include AND exclude
|
||||
|
||||
---
|
||||
|
||||
## Criteria Retrieval
|
||||
|
||||
Custom attribute-based memory ranking using LLM-evaluated criteria with weights. Goes beyond semantic similarity to prioritize memories based on domain-specific signals.
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# Define criteria at project level
|
||||
retrieval_criteria = [
|
||||
{"name": "joy", "description": "Positive emotions like happiness and excitement", "weight": 3},
|
||||
{"name": "curiosity", "description": "Inquisitiveness and desire to learn", "weight": 2},
|
||||
{"name": "urgency", "description": "Time-sensitive or high-priority items", "weight": 4},
|
||||
]
|
||||
client.project.update(retrieval_criteria=retrieval_criteria)
|
||||
```
|
||||
|
||||
```typescript
|
||||
await client.updateProject({
|
||||
retrieval_criteria: [
|
||||
{ name: 'joy', description: 'Positive emotions', weight: 3 },
|
||||
{ name: 'urgency', description: 'Time-sensitive items', weight: 4 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Once configured, `client.search()` automatically applies criteria ranking:
|
||||
|
||||
```python
|
||||
# Criteria-weighted results returned automatically
|
||||
results = client.search("Why am I feeling happy?", filters={"user_id": "alice"})
|
||||
```
|
||||
|
||||
**Best for:** Wellness assistants, tutoring platforms, productivity tools — any app needing intent-aware retrieval.
|
||||
|
||||
---
|
||||
|
||||
## Feedback Mechanism
|
||||
|
||||
Provide feedback on extracted memories to improve system quality over time.
|
||||
|
||||
### Feedback Types
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `POSITIVE` | Memory is useful and accurate |
|
||||
| `NEGATIVE` | Memory is not useful |
|
||||
| `VERY_NEGATIVE` | Memory is harmful or completely wrong |
|
||||
| `None` | Clear existing feedback |
|
||||
|
||||
### Usage
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
client.feedback(
|
||||
memory_id="mem-123",
|
||||
feedback="POSITIVE",
|
||||
feedback_reason="Accurately captured dietary preference"
|
||||
)
|
||||
|
||||
# Bulk feedback
|
||||
for item in feedback_data:
|
||||
client.feedback(**item)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.feedback('mem-123', {
|
||||
feedback: 'POSITIVE',
|
||||
feedback_reason: 'Accurately captured dietary preference',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Export
|
||||
|
||||
Create structured exports of memories using customizable schemas with filters.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
# Define export schema
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"preferences": {"type": "array", "items": {"type": "string"}},
|
||||
"health_info": {"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
# Create export
|
||||
response = client.create_memory_export(
|
||||
schema=json.dumps(schema),
|
||||
filters={"user_id": "alice"},
|
||||
export_instructions="Create comprehensive profile based on all memories"
|
||||
)
|
||||
|
||||
# Retrieve export (may take a moment to process)
|
||||
result = client.get_memory_export(memory_export_id=response["id"])
|
||||
```
|
||||
|
||||
**Best for:** Data analytics, user profile generation, compliance audits, CRM sync.
|
||||
|
||||
---
|
||||
|
||||
## Group Chat
|
||||
|
||||
Process multi-participant conversations and automatically attribute memories to individual speakers.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "name": "Alice", "content": "I think we should use React for the frontend"},
|
||||
{"role": "user", "name": "Bob", "content": "I prefer Vue.js, it's simpler for our use case"},
|
||||
{"role": "assistant", "content": "Both are great choices. Let me note your preferences."},
|
||||
]
|
||||
|
||||
# Mem0 automatically attributes memories to each speaker
|
||||
response = client.add(messages, run_id="team_meeting_1")
|
||||
|
||||
# Retrieve Alice's memories from that session
|
||||
alice_mems = client.get_all(
|
||||
filters={"AND": [{"user_id": "alice"}, {"run_id": "team_meeting_1"}]}
|
||||
)
|
||||
```
|
||||
|
||||
Use the `name` field in messages to identify speakers. Mem0 maps names to entity scopes automatically.
|
||||
|
||||
---
|
||||
|
||||
## MCP Integration
|
||||
|
||||
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
|
||||
|
||||
### Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-your-api-key",
|
||||
"MEM0_DEFAULT_USER_ID": "your-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available MCP Tools
|
||||
|
||||
The MCP server exposes 9 memory tools that AI agents can use autonomously:
|
||||
- Add, search, get, update, delete memories
|
||||
- Get history, list users, delete users
|
||||
- Search Mem0 documentation
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Configure the MCP server in your AI client
|
||||
2. The agent autonomously decides when to store/retrieve memories
|
||||
3. No manual API calls needed — the agent manages memory as part of its reasoning
|
||||
|
||||
**Best for:** Universal AI client integration — one protocol works everywhere.
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
Real-time event notifications for memory operations.
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event | Trigger |
|
||||
|-------|---------|
|
||||
| `memory_add` | Memory created |
|
||||
| `memory_update` | Memory modified |
|
||||
| `memory_delete` | Memory removed |
|
||||
| `memory_categorize` | Memory tagged |
|
||||
|
||||
### Create Webhook
|
||||
|
||||
Note: `project_id` here refers to the Mem0 dashboard project scope for webhooks — not the deprecated client init parameter.
|
||||
|
||||
```python
|
||||
webhook = client.create_webhook(
|
||||
url="https://your-app.com/webhook",
|
||||
name="Memory Logger",
|
||||
project_id="proj_123",
|
||||
event_types=["memory_add", "memory_categorize"]
|
||||
)
|
||||
```
|
||||
|
||||
### Manage Webhooks
|
||||
|
||||
```python
|
||||
# Retrieve
|
||||
webhooks = client.get_webhooks(project_id="proj_123")
|
||||
|
||||
# Update
|
||||
client.update_webhook(
|
||||
name="Updated Logger",
|
||||
url="https://your-app.com/new-webhook",
|
||||
event_types=["memory_update", "memory_add"],
|
||||
webhook_id="wh_123"
|
||||
)
|
||||
|
||||
# Delete
|
||||
client.delete_webhook(webhook_id="wh_123")
|
||||
```
|
||||
|
||||
### Payload Structure
|
||||
|
||||
Memory events contain: ID, data object with memory content, event type (`ADD`/`UPDATE`/`DELETE`).
|
||||
Categorization events contain: memory ID, event type (`CATEGORIZE`), assigned category labels.
|
||||
|
||||
---
|
||||
|
||||
## Multimodal Support
|
||||
|
||||
Mem0 can process images and documents alongside text.
|
||||
|
||||
### Supported Media Types
|
||||
|
||||
- Images: JPG, PNG
|
||||
- Documents: MDX, TXT, PDF
|
||||
|
||||
### Image via URL
|
||||
|
||||
```python
|
||||
image_message = {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/image.jpg"}
|
||||
}
|
||||
}
|
||||
client.add([image_message], user_id="alice")
|
||||
```
|
||||
|
||||
### Image via Base64
|
||||
|
||||
```python
|
||||
import base64
|
||||
with open("photo.jpg", "rb") as f:
|
||||
base64_image = base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
image_message = {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
|
||||
}
|
||||
}
|
||||
client.add([image_message], user_id="alice")
|
||||
```
|
||||
|
||||
### Document (MDX/TXT)
|
||||
|
||||
```python
|
||||
doc_message = {
|
||||
"role": "user",
|
||||
"content": {"type": "mdx_url", "mdx_url": {"url": document_url}}
|
||||
}
|
||||
client.add([doc_message], user_id="alice")
|
||||
```
|
||||
|
||||
### PDF Document
|
||||
|
||||
```python
|
||||
pdf_message = {
|
||||
"role": "user",
|
||||
"content": {"type": "pdf_url", "pdf_url": {"url": pdf_url}}
|
||||
}
|
||||
client.add([pdf_message], user_id="alice")
|
||||
```
|
||||
@@ -0,0 +1,444 @@
|
||||
# Mem0 Integration Patterns
|
||||
|
||||
Working code examples for integrating Mem0 Platform with popular AI frameworks.
|
||||
All examples use `MemoryClient` (Platform API key).
|
||||
|
||||
Code examples are sourced from official Mem0 integration docs at docs.mem0.ai, simplified for quick reference.
|
||||
|
||||
---
|
||||
|
||||
## Common Pattern
|
||||
|
||||
Every integration follows the same 3-step loop:
|
||||
|
||||
1. **Retrieve** -- search relevant memories before generating a response
|
||||
2. **Generate** -- include memories as context in the LLM prompt
|
||||
3. **Store** -- save the interaction back to Mem0 for future use
|
||||
|
||||
---
|
||||
|
||||
## LangChain
|
||||
|
||||
Source: [docs.mem0.ai/integrations/langchain](https://docs.mem0.ai/integrations/langchain)
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from mem0 import MemoryClient
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4.1-nano-2025-04-14")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
SystemMessage(content="You are a helpful travel agent AI. Use the provided context to personalize your responses."),
|
||||
MessagesPlaceholder(variable_name="context"),
|
||||
HumanMessage(content="{input}")
|
||||
])
|
||||
|
||||
def retrieve_context(query: str, user_id: str):
|
||||
"""Retrieve relevant memories from Mem0"""
|
||||
memories = mem0.search(query, user_id=user_id)
|
||||
memory_list = memories['results']
|
||||
serialized = ' '.join([m["memory"] for m in memory_list])
|
||||
return [
|
||||
{"role": "system", "content": f"Relevant information: {serialized}"},
|
||||
{"role": "user", "content": query}
|
||||
]
|
||||
|
||||
def chat_turn(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve
|
||||
context = retrieve_context(user_input, user_id)
|
||||
# 2. Generate
|
||||
chain = prompt | llm
|
||||
response = chain.invoke({"context": context, "input": user_input})
|
||||
# 3. Store
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": response.content}],
|
||||
user_id=user_id
|
||||
)
|
||||
return response.content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CrewAI
|
||||
|
||||
Source: [docs.mem0.ai/integrations/crewai](https://docs.mem0.ai/integrations/crewai)
|
||||
|
||||
CrewAI has native Mem0 integration via `memory_config`:
|
||||
|
||||
```python
|
||||
from crewai import Agent, Task, Crew, Process
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
# Store user preferences first
|
||||
messages = [
|
||||
{"role": "user", "content": "I am more of a beach person than a mountain person."},
|
||||
{"role": "assistant", "content": "Noted! I'll recommend beach destinations."},
|
||||
{"role": "user", "content": "I like Airbnb more than hotels."},
|
||||
]
|
||||
client.add(messages, user_id="crew_user_1")
|
||||
|
||||
# Create agent
|
||||
travel_agent = Agent(
|
||||
role="Personalized Travel Planner",
|
||||
goal="Plan personalized travel itineraries",
|
||||
backstory="You are a seasoned travel planner.",
|
||||
memory=True,
|
||||
)
|
||||
|
||||
# Create task
|
||||
task = Task(
|
||||
description="Find places to live, eat, and visit in San Francisco.",
|
||||
expected_output="A detailed list of places to live, eat, and visit.",
|
||||
agent=travel_agent,
|
||||
)
|
||||
|
||||
# Setup crew with Mem0 memory
|
||||
crew = Crew(
|
||||
agents=[travel_agent],
|
||||
tasks=[task],
|
||||
process=Process.sequential,
|
||||
memory=True,
|
||||
memory_config={
|
||||
"provider": "mem0",
|
||||
"config": {"user_id": "crew_user_1"},
|
||||
}
|
||||
)
|
||||
|
||||
result = crew.kickoff()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vercel AI SDK
|
||||
|
||||
Source: [docs.mem0.ai/integrations/vercel-ai-sdk](https://docs.mem0.ai/integrations/vercel-ai-sdk)
|
||||
|
||||
Install: `npm install @mem0/vercel-ai-provider`
|
||||
|
||||
### Basic Text Generation with Memory
|
||||
|
||||
```typescript
|
||||
import { generateText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0({
|
||||
provider: "openai",
|
||||
mem0ApiKey: "m0-xxx",
|
||||
apiKey: "openai-api-key",
|
||||
});
|
||||
|
||||
const { text } = await generateText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
```
|
||||
|
||||
### Streaming with Memory
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { createMem0 } from "@mem0/vercel-ai-provider";
|
||||
|
||||
const mem0 = createMem0();
|
||||
|
||||
const { textStream } = streamText({
|
||||
model: mem0("gpt-4-turbo", { user_id: "borat" }),
|
||||
prompt: "Suggest me a good car to buy!",
|
||||
});
|
||||
|
||||
for await (const textPart of textStream) {
|
||||
process.stdout.write(textPart);
|
||||
}
|
||||
```
|
||||
|
||||
### Using Memory Utilities Standalone
|
||||
|
||||
```typescript
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText } from "ai";
|
||||
import { retrieveMemories, addMemories } from "@mem0/vercel-ai-provider";
|
||||
|
||||
// Retrieve memories and inject into any provider
|
||||
const prompt = "Suggest me a good car to buy.";
|
||||
const memories = await retrieveMemories(prompt, { user_id: "borat", mem0ApiKey: "m0-xxx" });
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4-turbo"),
|
||||
prompt: prompt,
|
||||
system: memories,
|
||||
});
|
||||
|
||||
// Store new memories
|
||||
await addMemories(
|
||||
[{ role: "user", content: [{ type: "text", text: "I love red cars." }] }],
|
||||
{ user_id: "borat", mem0ApiKey: "m0-xxx" }
|
||||
);
|
||||
```
|
||||
|
||||
### Supported Providers
|
||||
|
||||
`openai`, `anthropic`, `google`, `groq`
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Agents SDK
|
||||
|
||||
Source: [docs.mem0.ai/integrations/openai-agents-sdk](https://docs.mem0.ai/integrations/openai-agents-sdk)
|
||||
|
||||
```python
|
||||
from agents import Agent, Runner, function_tool
|
||||
from mem0 import MemoryClient
|
||||
|
||||
mem0 = MemoryClient()
|
||||
|
||||
@function_tool
|
||||
def search_memory(query: str, user_id: str) -> str:
|
||||
"""Search through past conversations and memories"""
|
||||
memories = mem0.search(query, user_id=user_id, top_k=3)
|
||||
if memories and memories.get('results'):
|
||||
return "\n".join([f"- {mem['memory']}" for mem in memories['results']])
|
||||
return "No relevant memories found."
|
||||
|
||||
@function_tool
|
||||
def save_memory(content: str, user_id: str) -> str:
|
||||
"""Save important information to memory"""
|
||||
mem0.add([{"role": "user", "content": content}], user_id=user_id)
|
||||
return "Information saved to memory."
|
||||
|
||||
agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="""You are a helpful personal assistant with memory capabilities.
|
||||
Use search_memory to recall past conversations.
|
||||
Use save_memory to store important information.""",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(agent, "I love Italian food and I'm planning a trip to Rome next month")
|
||||
print(result.final_output)
|
||||
```
|
||||
|
||||
### Multi-Agent with Handoffs
|
||||
|
||||
```python
|
||||
from agents import Agent, Runner, function_tool
|
||||
|
||||
travel_agent = Agent(
|
||||
name="Travel Planner",
|
||||
instructions="You are a travel planning specialist. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
health_agent = Agent(
|
||||
name="Health Advisor",
|
||||
instructions="You are a health and wellness advisor. Use search_memory and save_memory tools.",
|
||||
tools=[search_memory, save_memory],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
triage_agent = Agent(
|
||||
name="Personal Assistant",
|
||||
instructions="""Route travel questions to Travel Planner, health questions to Health Advisor.""",
|
||||
handoffs=[travel_agent, health_agent],
|
||||
model="gpt-4.1-nano-2025-04-14"
|
||||
)
|
||||
|
||||
result = Runner.run_sync(triage_agent, "Plan a healthy meal for my Italy trip")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipecat (Voice / Real-Time)
|
||||
|
||||
Source: [docs.mem0.ai/integrations/pipecat](https://docs.mem0.ai/integrations/pipecat)
|
||||
|
||||
```python
|
||||
from pipecat.services.mem0 import Mem0MemoryService
|
||||
|
||||
memory = Mem0MemoryService(
|
||||
api_key=os.getenv("MEM0_API_KEY"),
|
||||
user_id="alice",
|
||||
agent_id="voice_bot",
|
||||
params={
|
||||
"search_limit": 10,
|
||||
"search_threshold": 0.1,
|
||||
"system_prompt": "Here are your past memories:",
|
||||
"add_as_system_message": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Use in pipeline
|
||||
pipeline = Pipeline([
|
||||
transport.input(),
|
||||
stt,
|
||||
user_context,
|
||||
memory, # Memory enhances context automatically
|
||||
llm,
|
||||
transport.output(),
|
||||
assistant_context
|
||||
])
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## LangGraph
|
||||
|
||||
Source: [docs.mem0.ai/integrations/langgraph](https://docs.mem0.ai/integrations/langgraph)
|
||||
|
||||
State-based agent workflows with memory persistence. Best for complex conversation flows with branching logic.
|
||||
|
||||
```python
|
||||
from typing import Annotated, TypedDict, List
|
||||
from langgraph.graph import StateGraph, START
|
||||
from langgraph.graph.message import add_messages
|
||||
from langchain_openai import ChatOpenAI
|
||||
from mem0 import MemoryClient
|
||||
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
mem0 = MemoryClient()
|
||||
|
||||
class State(TypedDict):
|
||||
messages: Annotated[List[HumanMessage | AIMessage], add_messages]
|
||||
mem0_user_id: str
|
||||
|
||||
def chatbot(state: State):
|
||||
messages = state["messages"]
|
||||
user_id = state["mem0_user_id"]
|
||||
|
||||
# Retrieve relevant memories
|
||||
memories = mem0.search(messages[-1].content, user_id=user_id)
|
||||
context = "Relevant context:\n"
|
||||
for memory in memories["results"]:
|
||||
context += f"- {memory['memory']}\n"
|
||||
|
||||
system_message = SystemMessage(content=f"""You are a helpful support assistant.
|
||||
{context}""")
|
||||
|
||||
response = llm.invoke([system_message] + messages)
|
||||
|
||||
# Store the interaction
|
||||
mem0.add(
|
||||
[{"role": "user", "content": messages[-1].content},
|
||||
{"role": "assistant", "content": response.content}],
|
||||
user_id=user_id
|
||||
)
|
||||
return {"messages": [response]}
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("chatbot", chatbot)
|
||||
graph.add_edge(START, "chatbot")
|
||||
app = graph.compile()
|
||||
|
||||
# Usage
|
||||
result = app.invoke({
|
||||
"messages": [HumanMessage(content="I need help with my order")],
|
||||
"mem0_user_id": "customer_123"
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LlamaIndex
|
||||
|
||||
Source: [docs.mem0.ai/integrations/llama-index](https://docs.mem0.ai/integrations/llama-index)
|
||||
|
||||
Install: `pip install llama-index-core llama-index-memory-mem0`
|
||||
|
||||
LlamaIndex has native Mem0 support via `Mem0Memory`. Works with ReAct and FunctionCalling agents.
|
||||
|
||||
```python
|
||||
from llama_index.memory.mem0 import Mem0Memory
|
||||
|
||||
context = {"user_id": "alice", "agent_id": "llama_agent_1"}
|
||||
memory = Mem0Memory.from_client(
|
||||
context=context,
|
||||
search_msg_limit=4, # messages from chat history used for retrieval (default: 5)
|
||||
)
|
||||
|
||||
# Use with LlamaIndex agent
|
||||
from llama_index.core.agent import FunctionCallingAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
llm = OpenAI(model="gpt-4")
|
||||
agent = FunctionCallingAgent.from_tools(
|
||||
tools=[],
|
||||
llm=llm,
|
||||
memory=memory,
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
response = agent.chat("I prefer vegetarian restaurants")
|
||||
# Memory automatically stores and retrieves context
|
||||
response = agent.chat("What kind of food do I like?")
|
||||
# Agent retrieves the vegetarian preference from Mem0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AutoGen
|
||||
|
||||
Source: [docs.mem0.ai/integrations/autogen](https://docs.mem0.ai/integrations/autogen)
|
||||
|
||||
Install: `pip install autogen mem0ai`
|
||||
|
||||
Multi-agent conversational systems with memory persistence.
|
||||
|
||||
```python
|
||||
from autogen import ConversableAgent
|
||||
from mem0 import MemoryClient
|
||||
|
||||
memory_client = MemoryClient()
|
||||
USER_ID = "alice"
|
||||
|
||||
agent = ConversableAgent(
|
||||
"chatbot",
|
||||
llm_config={"config_list": [{"model": "gpt-4", "api_key": os.environ["OPENAI_API_KEY"]}]},
|
||||
code_execution_config=False,
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
def get_context_aware_response(question: str) -> str:
|
||||
# Retrieve memories for context
|
||||
relevant_memories = memory_client.search(question, user_id=USER_ID)
|
||||
context = "\n".join([m["memory"] for m in relevant_memories.get("results", [])])
|
||||
|
||||
prompt = f"""Answer considering previous interactions:
|
||||
Previous context: {context}
|
||||
Question: {question}"""
|
||||
|
||||
reply = agent.generate_reply(messages=[{"content": prompt, "role": "user"}])
|
||||
|
||||
# Store the new interaction
|
||||
memory_client.add(
|
||||
[{"role": "user", "content": question}, {"role": "assistant", "content": reply}],
|
||||
user_id=USER_ID
|
||||
)
|
||||
return reply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Supported Frameworks
|
||||
|
||||
Beyond the examples above, Mem0 integrates with:
|
||||
|
||||
| Framework | Type | Install |
|
||||
|-----------|------|---------|
|
||||
| [Mastra](https://docs.mem0.ai/integrations/mastra) | TS agent framework | `npm install @mastra/mem0` |
|
||||
| [ElevenLabs](https://docs.mem0.ai/integrations/elevenlabs) | Voice AI | `pip install elevenlabs mem0ai` |
|
||||
| [LiveKit](https://docs.mem0.ai/integrations/livekit) | Real-time voice/video | `pip install livekit-agents mem0ai` |
|
||||
| [Camel AI](https://docs.mem0.ai/integrations/camel-ai) | Multi-agent framework | `pip install camel-ai[all] mem0ai` |
|
||||
| [AWS Bedrock](https://docs.mem0.ai/integrations/aws-bedrock) | Cloud LLM provider | `pip install boto3 mem0ai` |
|
||||
| [Dify](https://docs.mem0.ai/integrations/dify) | Low-code AI platform | Plugin-based |
|
||||
| [Google AI ADK](https://docs.mem0.ai/integrations/google-ai-adk) | Google agent framework | `pip install google-adk mem0ai` |
|
||||
|
||||
For the general Python pattern (no framework), see the "Common integration pattern" in [SKILL.md](../SKILL.md).
|
||||
@@ -0,0 +1,119 @@
|
||||
# Mem0 Platform Quickstart
|
||||
|
||||
Get running with Mem0 in 2 minutes. No infrastructure to deploy -- just an API key.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+ or Node.js 18+
|
||||
- A Mem0 Platform API key ([Get one here](https://app.mem0.ai/dashboard/api-keys))
|
||||
|
||||
## Python Setup
|
||||
|
||||
```bash
|
||||
pip install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Add a memory
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
]
|
||||
client.add(messages, user_id="user123")
|
||||
|
||||
# Search memories
|
||||
results = client.search("What are my dietary restrictions?", user_id="user123")
|
||||
print(results)
|
||||
```
|
||||
|
||||
### Async Client
|
||||
|
||||
```python
|
||||
from mem0 import AsyncMemoryClient
|
||||
|
||||
client = AsyncMemoryClient(api_key="your-api-key")
|
||||
|
||||
await client.add(messages, user_id="user123")
|
||||
results = await client.search("query", user_id="user123")
|
||||
```
|
||||
|
||||
## TypeScript / JavaScript Setup
|
||||
|
||||
```bash
|
||||
npm install mem0ai
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
```
|
||||
|
||||
```javascript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: 'your-api-key' });
|
||||
|
||||
// Add a memory
|
||||
const messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember your dietary preferences."}
|
||||
];
|
||||
await client.add(messages, { user_id: "user123" });
|
||||
|
||||
// Search memories
|
||||
const results = await client.search("What are my dietary restrictions?", {
|
||||
user_id: "user123"
|
||||
});
|
||||
console.log(results);
|
||||
```
|
||||
|
||||
## cURL
|
||||
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-your-api-key"
|
||||
|
||||
# Add memory
|
||||
curl -X POST https://api.mem0.ai/v1/memories/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{"role": "user", "content": "I am a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I will remember your dietary preferences."}
|
||||
],
|
||||
"user_id": "user123"
|
||||
}'
|
||||
|
||||
# Search memories
|
||||
curl -X POST https://api.mem0.ai/v2/memories/search/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "What are my dietary restrictions?",
|
||||
"filters": {"user_id": "user123"}
|
||||
}'
|
||||
```
|
||||
|
||||
## Sample Response
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "14e1b28a-2014-40ad-ac42-69c9ef42193d",
|
||||
"memory": "Allergic to nuts",
|
||||
"user_id": "user123",
|
||||
"categories": ["health"],
|
||||
"created_at": "2025-10-22T04:40:22.864647-07:00",
|
||||
"score": 0.30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [SDK Guide](sdk-guide.md) -- all methods for Python and TypeScript
|
||||
- [API Reference](api-reference.md) -- REST endpoints and memory object structure
|
||||
- [Integration Patterns](integration-patterns.md) -- LangChain, CrewAI, Vercel AI, etc.
|
||||
@@ -0,0 +1,308 @@
|
||||
# Mem0 SDK Guide
|
||||
|
||||
Complete SDK reference for Python and TypeScript. All methods use `MemoryClient` (Platform API).
|
||||
|
||||
## Initialization
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
client = MemoryClient(api_key="m0-your-api-key")
|
||||
```
|
||||
|
||||
**Python (Async):**
|
||||
```python
|
||||
from mem0 import AsyncMemoryClient
|
||||
client = AsyncMemoryClient(api_key="m0-your-api-key")
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'm0-your-api-key' });
|
||||
```
|
||||
|
||||
Constructor accepts `apiKey` (required) and `host` (optional, default: `https://api.mem0.ai`).
|
||||
|
||||
---
|
||||
|
||||
## add() -- Store Memories
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm a vegetarian and allergic to nuts."},
|
||||
{"role": "assistant", "content": "Got it! I'll remember that."}
|
||||
]
|
||||
client.add(messages, user_id="alice")
|
||||
|
||||
# With metadata
|
||||
client.add(messages, user_id="alice", metadata={"source": "onboarding"})
|
||||
|
||||
# With graph memory
|
||||
client.add(messages, user_id="alice", enable_graph=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.add(messages, { user_id: "alice" });
|
||||
await client.add(messages, { user_id: "alice", metadata: { source: "onboarding" } });
|
||||
await client.add(messages, { user_id: "alice", enable_graph: true });
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `messages` | array | `[{"role": "user", "content": "..."}]` |
|
||||
| `user_id` | string | User identifier (recommended) |
|
||||
| `agent_id` | string | Agent identifier |
|
||||
| `run_id` | string | Session identifier |
|
||||
| `metadata` | object | Custom key-value pairs |
|
||||
| `enable_graph` | boolean | Activate knowledge graph |
|
||||
| `infer` | boolean | If `false`, store raw text without inference (default: `true`) |
|
||||
| `immutable` | boolean | Prevents modification after creation |
|
||||
| `expiration_date` | string | Auto-expiry date (`YYYY-MM-DD`) |
|
||||
| `includes` | string | Preference filters for inclusion |
|
||||
| `excludes` | string | Preference filters for exclusion |
|
||||
| `async_mode` | boolean | Async processing (default: `true`). Set `false` to wait |
|
||||
|
||||
### Advanced Add Options
|
||||
|
||||
```python
|
||||
# Immutable -- cannot be modified or overwritten
|
||||
client.add(messages, user_id="alice", immutable=True)
|
||||
|
||||
# Expiring memory
|
||||
client.add(messages, user_id="alice", expiration_date="2025-12-31")
|
||||
|
||||
# Selective extraction
|
||||
client.add(messages, user_id="alice", includes="dietary preferences", excludes="payment info")
|
||||
|
||||
# Agent + session scoping
|
||||
client.add(messages, user_id="alice", agent_id="nutrition-agent", run_id="session-456")
|
||||
|
||||
# Synchronous processing (wait for completion)
|
||||
client.add(messages, user_id="alice", async_mode=False)
|
||||
|
||||
# Raw text -- skip LLM inference
|
||||
client.add(
|
||||
[{"role": "user", "content": "User prefers dark mode."}],
|
||||
user_id="alice",
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## search() -- Find Memories
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
results = client.search("dietary preferences?", user_id="alice")
|
||||
|
||||
# With filters and reranking
|
||||
results = client.search(
|
||||
query="work experience",
|
||||
filters={"AND": [{"user_id": "alice"}, {"categories": {"contains": "professional_details"}}]},
|
||||
top_k=5,
|
||||
rerank=True,
|
||||
threshold=0.5
|
||||
)
|
||||
|
||||
# With graph relations
|
||||
results = client.search("colleagues", user_id="alice", enable_graph=True)
|
||||
|
||||
# Keyword search
|
||||
results = client.search("vegetarian", user_id="alice", keyword_search=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const results = await client.search("dietary preferences", { user_id: "alice" });
|
||||
const results = await client.search("work experience", {
|
||||
filters: { AND: [{ user_id: "alice" }, { categories: { contains: "professional_details" } }] },
|
||||
top_k: 5,
|
||||
rerank: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `query` | string | Natural language search query |
|
||||
| `user_id` | string | Filter by user |
|
||||
| `filters` | object | V2 filter object (AND/OR operators) |
|
||||
| `top_k` | number | Number of results (default: 10) |
|
||||
| `rerank` | boolean | Enable reranking for better relevance |
|
||||
| `threshold` | number | Minimum similarity score (default: 0.3) |
|
||||
| `keyword_search` | boolean | Use keyword-based search |
|
||||
| `enable_graph` | boolean | Include graph relations |
|
||||
|
||||
### Common Filter Patterns
|
||||
|
||||
```python
|
||||
# Single user (shorthand)
|
||||
client.search("query", user_id="alice")
|
||||
|
||||
# OR across agents
|
||||
filters={"OR": [{"user_id": "alice"}, {"agent_id": {"in": ["travel-agent", "sports-agent"]}}]}
|
||||
|
||||
# Category filtering (partial match)
|
||||
filters={"AND": [{"user_id": "alice"}, {"categories": {"contains": "finance"}}]}
|
||||
|
||||
# Category filtering (exact match)
|
||||
filters={"AND": [{"user_id": "alice"}, {"categories": {"in": ["personal_information"]}}]}
|
||||
|
||||
# Wildcard (match any non-null run)
|
||||
filters={"AND": [{"user_id": "alice"}, {"run_id": "*"}]}
|
||||
|
||||
# Date range
|
||||
filters={"AND": [
|
||||
{"user_id": "alice"},
|
||||
{"created_at": {"gte": "2024-01-01T00:00:00Z"}},
|
||||
{"created_at": {"lt": "2024-02-01T00:00:00Z"}}
|
||||
]}
|
||||
|
||||
# Exclude categories with NOT
|
||||
filters={"AND": [{"user_id": "user_123"}, {"NOT": {"categories": {"in": ["spam", "test"]}}}]}
|
||||
|
||||
# Multi-dimensional query
|
||||
filters={"AND": [
|
||||
{"user_id": "user_123"},
|
||||
{"keywords": {"icontains": "invoice"}},
|
||||
{"categories": {"in": ["finance"]}},
|
||||
{"created_at": {"gte": "2024-01-01T00:00:00Z"}}
|
||||
]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## get() / getAll() -- Retrieve Memories
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
# Single memory by ID
|
||||
memory = client.get(memory_id="ea925981-...")
|
||||
|
||||
# All memories for a user
|
||||
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]})
|
||||
|
||||
# With date range
|
||||
memories = client.get_all(
|
||||
filters={"AND": [
|
||||
{"user_id": "alex"},
|
||||
{"created_at": {"gte": "2024-07-01", "lte": "2024-07-31"}}
|
||||
]}
|
||||
)
|
||||
|
||||
# With graph data
|
||||
memories = client.get_all(filters={"AND": [{"user_id": "alice"}]}, enable_graph=True)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const memory = await client.get("ea925981-...");
|
||||
const memories = await client.getAll({ filters: { AND: [{ user_id: "alice" }] } });
|
||||
```
|
||||
|
||||
**Note:** `get_all` requires at least one of `user_id`, `agent_id`, `app_id`, or `run_id` in filters.
|
||||
|
||||
---
|
||||
|
||||
## update() -- Modify Memories
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
client.update(memory_id="ea925981-...", text="Updated: vegan since 2024")
|
||||
client.update(memory_id="ea925981-...", text="Updated", metadata={"verified": True})
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.update("ea925981-...", { text: "Updated: vegan since 2024" });
|
||||
```
|
||||
|
||||
Cannot update immutable memories.
|
||||
|
||||
---
|
||||
|
||||
## delete() / deleteAll() -- Remove Memories
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
client.delete(memory_id="ea925981-...")
|
||||
client.delete_all(user_id="alice") # Irreversible bulk delete
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
await client.delete("ea925981-...");
|
||||
await client.deleteAll({ user_id: "alice" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## history() -- Track Changes
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
history = client.history(memory_id="ea925981-...")
|
||||
# Returns: [{previous_value, new_value, action, timestamps}]
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const history = await client.history("ea925981-...");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch Operations (TypeScript)
|
||||
|
||||
```typescript
|
||||
// Batch update
|
||||
await client.batchUpdate([
|
||||
{ memoryId: "uuid-1", text: "Updated text" },
|
||||
{ memoryId: "uuid-2", text: "Another updated text" },
|
||||
]);
|
||||
|
||||
// Batch delete
|
||||
await client.batchDelete(["uuid-1", "uuid-2", "uuid-3"]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Methods
|
||||
|
||||
```python
|
||||
# List all users/agents/sessions with memories
|
||||
users = client.users()
|
||||
|
||||
# Delete a user/agent entity
|
||||
client.delete_users(user_id="alice")
|
||||
|
||||
# Submit feedback on a memory
|
||||
client.feedback(memory_id="...", feedback="POSITIVE", feedback_reason="Accurate extraction")
|
||||
|
||||
# Export memories
|
||||
export = client.create_memory_export(filters={"AND": [{"user_id": "alice"}]})
|
||||
data = client.get_memory_export(memory_export_id=export["id"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Entity cross-filtering fails silently** -- `AND` with `user_id` + `agent_id` returns empty. Use `OR`.
|
||||
2. **SQL operators rejected** -- use `gte`, `lt`, etc. Not `>=`, `<`.
|
||||
3. **Metadata filtering is limited** -- only top-level keys with `eq`, `contains`, `ne`.
|
||||
4. **Wildcard `*` excludes null** -- only matches non-null values.
|
||||
5. **Default threshold is 0.3** -- increase for stricter matching.
|
||||
6. **Async processing** -- memories process asynchronously. Wait 2-3s after `add()` before searching.
|
||||
7. **Immutable memories** -- cannot be updated or deleted once created.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Python uses `snake_case` (`user_id`, `memory_id`, `get_all`). TypeScript uses `camelCase` for methods (`getAll`, `deleteAll`, `batchUpdate`) but `snake_case` for API parameters (`user_id`, `agent_id`).
|
||||
@@ -0,0 +1,720 @@
|
||||
# Mem0 Use Cases & Examples
|
||||
|
||||
Real-world implementation patterns for Mem0 Platform. Each use case includes complete, runnable code in both Python and TypeScript.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Personalized AI Companion](#1-personalized-ai-companion)
|
||||
- [Customer Support with Categories](#2-customer-support-with-categories)
|
||||
- [Healthcare Coach](#3-healthcare-coach)
|
||||
- [Content Creation Workflow](#4-content-creation-workflow)
|
||||
- [Multi-Agent / Multi-Tenant](#5-multi-agent--multi-tenant)
|
||||
- [Personalized Search](#6-personalized-search)
|
||||
- [Email Intelligence](#7-email-intelligence)
|
||||
- [Common Patterns Across Use Cases](#common-patterns-across-use-cases)
|
||||
|
||||
---
|
||||
|
||||
## 1. Personalized AI Companion
|
||||
|
||||
A fitness coach that remembers goals, preferences, and progress across sessions. Mem0 persists context across app restarts — no session state needed.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai_client = OpenAI()
|
||||
|
||||
def chat(user_input: str, user_id: str) -> str:
|
||||
# 1. Retrieve relevant memories
|
||||
memories = mem0.search(user_input, user_id=user_id)
|
||||
context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||
|
||||
# 2. Generate response with memory context
|
||||
system_prompt = f"""You are Ray, a personal fitness coach.
|
||||
Use these known facts about the user to personalize your response:
|
||||
{context if context else 'No prior context yet.'}"""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_input},
|
||||
]
|
||||
)
|
||||
reply = response.choices[0].message.content
|
||||
|
||||
# 3. Store interaction for future context
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": reply}],
|
||||
user_id=user_id
|
||||
)
|
||||
return reply
|
||||
|
||||
# Usage
|
||||
chat("I want to run a marathon in under 4 hours", user_id="max")
|
||||
# Next day, app restarted:
|
||||
chat("What should I focus on today?", user_id="max")
|
||||
# Ray remembers the sub-4 marathon goal
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
const openai = new OpenAI();
|
||||
|
||||
async function chat(userInput: string, userId: string): Promise<string> {
|
||||
// 1. Retrieve relevant memories
|
||||
const memories = await mem0.search(userInput, { user_id: userId });
|
||||
const context = memories.results
|
||||
?.map((m: any) => `- ${m.memory}`)
|
||||
.join('\n') || 'No prior context yet.';
|
||||
|
||||
// 2. Generate response with memory context
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
messages: [
|
||||
{ role: 'system', content: `You are Ray, a personal fitness coach.\nUser context:\n${context}` },
|
||||
{ role: 'user', content: userInput },
|
||||
],
|
||||
});
|
||||
const reply = response.choices[0].message.content!;
|
||||
|
||||
// 3. Store interaction
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: userInput }, { role: 'assistant', content: reply }],
|
||||
{ user_id: userId }
|
||||
);
|
||||
return reply;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Context persists across app restarts — no session management needed
|
||||
- Memories are automatically deduplicated and updated
|
||||
- Works with any LLM provider (OpenAI, Anthropic, etc.)
|
||||
|
||||
**Best for:** Fitness coaches, tutors, therapists — any assistant that needs to remember goals across sessions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Customer Support with Categories
|
||||
|
||||
Auto-categorize support data so teams retrieve the right facts fast. Uses custom categories for structured retrieval.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
# 1. Define categories at the project level (one-time setup)
|
||||
custom_categories = [
|
||||
{"support_tickets": "Customer issues and resolutions"},
|
||||
{"account_info": "Account details and preferences"},
|
||||
{"billing": "Payment history and billing questions"},
|
||||
{"product_feedback": "Feature requests and feedback"},
|
||||
]
|
||||
client.project.update(custom_categories=custom_categories)
|
||||
|
||||
# 2. Store interactions — auto-classified into categories
|
||||
def log_support_interaction(user_id: str, message: str, priority: str = "normal"):
|
||||
client.add(
|
||||
[{"role": "user", "content": message}],
|
||||
user_id=user_id,
|
||||
metadata={"priority": priority, "source": "support_chat"}
|
||||
)
|
||||
|
||||
# 3. Retrieve by category
|
||||
def get_billing_issues(user_id: str):
|
||||
return client.get_all(
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"categories": {"in": ["billing"]}}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
def search_support_history(user_id: str, query: str):
|
||||
return client.search(
|
||||
query,
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"categories": {"contains": "support_tickets"}}
|
||||
]
|
||||
},
|
||||
top_k=5
|
||||
)
|
||||
|
||||
# Usage
|
||||
log_support_interaction("maria", "I was charged twice for last month's subscription", priority="high")
|
||||
log_support_interaction("maria", "The dashboard is loading slowly on mobile")
|
||||
billing = get_billing_issues("maria") # Returns only billing-related memories
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
|
||||
// Setup categories (one-time)
|
||||
await client.updateProject({
|
||||
custom_categories: [
|
||||
{ support_tickets: 'Customer issues and resolutions' },
|
||||
{ billing: 'Payment history and billing questions' },
|
||||
{ product_feedback: 'Feature requests and feedback' },
|
||||
],
|
||||
});
|
||||
|
||||
async function logInteraction(userId: string, message: string, priority = 'normal') {
|
||||
await client.add(
|
||||
[{ role: 'user', content: message }],
|
||||
{ user_id: userId, metadata: { priority, source: 'support_chat' } }
|
||||
);
|
||||
}
|
||||
|
||||
async function getBillingIssues(userId: string) {
|
||||
return client.getAll({
|
||||
filters: { AND: [{ user_id: userId }, { categories: { in: ['billing'] } }] },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Automatic categorization — no manual tagging
|
||||
- Filter by category for structured retrieval
|
||||
- Metadata (`priority`, `source`) enables multi-dimensional queries
|
||||
|
||||
**Best for:** Help desks, SaaS support, e-commerce — structured retrieval by category eliminates manual scanning.
|
||||
|
||||
---
|
||||
|
||||
## 3. Healthcare Coach
|
||||
|
||||
Guide patients with an assistant that remembers medical history. Uses high `threshold` for confident retrieval in safety-critical contexts.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai_client = OpenAI()
|
||||
|
||||
def save_patient_info(user_id: str, information: str):
|
||||
mem0.add(
|
||||
[{"role": "user", "content": information}],
|
||||
user_id=user_id,
|
||||
run_id="healthcare_session",
|
||||
metadata={"type": "patient_information"}
|
||||
)
|
||||
|
||||
def consult(user_id: str, question: str) -> str:
|
||||
# High threshold for medical accuracy
|
||||
memories = mem0.search(question, user_id=user_id, top_k=5, threshold=0.7)
|
||||
context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": f"You are a health coach. Patient context:\n{context}"},
|
||||
{"role": "user", "content": question},
|
||||
]
|
||||
)
|
||||
reply = response.choices[0].message.content
|
||||
|
||||
# Store the interaction
|
||||
mem0.add(
|
||||
[{"role": "user", "content": question}, {"role": "assistant", "content": reply}],
|
||||
user_id=user_id,
|
||||
run_id="healthcare_session",
|
||||
)
|
||||
return reply
|
||||
|
||||
# Usage
|
||||
save_patient_info("alex", "I'm allergic to penicillin and take metformin for type 2 diabetes")
|
||||
consult("alex", "Can I take amoxicillin for my sore throat?")
|
||||
# Remembers penicillin allergy — amoxicillin is a penicillin-type antibiotic
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
const openai = new OpenAI();
|
||||
|
||||
async function savePatientInfo(userId: string, info: string) {
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: info }],
|
||||
{ user_id: userId, run_id: 'healthcare_session', metadata: { type: 'patient_information' } }
|
||||
);
|
||||
}
|
||||
|
||||
async function consult(userId: string, question: string): Promise<string> {
|
||||
const memories = await mem0.search(question, {
|
||||
user_id: userId,
|
||||
top_k: 5,
|
||||
threshold: 0.7,
|
||||
});
|
||||
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
messages: [
|
||||
{ role: 'system', content: `You are a health coach. Patient context:\n${context}` },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
});
|
||||
const reply = response.choices[0].message.content!;
|
||||
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: question }, { role: 'assistant', content: reply }],
|
||||
{ user_id: userId, run_id: 'healthcare_session' }
|
||||
);
|
||||
return reply;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- High threshold (0.7) ensures only confident matches for safety-critical retrieval
|
||||
- Session scoping via `run_id` groups related health interactions
|
||||
- Metadata tagging separates patient info from conversation history
|
||||
|
||||
**Best for:** Telehealth, wellness apps, patient management — persistent health context across visits.
|
||||
|
||||
---
|
||||
|
||||
## 4. Content Creation Workflow
|
||||
|
||||
Store voice guidelines once and apply them across every draft. Uses `run_id` and `metadata` to scope writing preferences per session.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai_client = OpenAI()
|
||||
|
||||
def store_writing_preferences(user_id: str, preferences: str):
|
||||
mem0.add(
|
||||
[{"role": "user", "content": preferences}],
|
||||
user_id=user_id,
|
||||
run_id="editing_session",
|
||||
metadata={"type": "preferences", "category": "writing_style"}
|
||||
)
|
||||
|
||||
def draft_content(user_id: str, topic: str) -> str:
|
||||
# Retrieve writing preferences
|
||||
prefs = mem0.search(
|
||||
"writing style preferences",
|
||||
filters={"AND": [{"user_id": user_id}, {"run_id": "editing_session"}]}
|
||||
)
|
||||
style_context = "\n".join([f"- {m['memory']}" for m in prefs.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": f"Write content matching these style preferences:\n{style_context}"},
|
||||
{"role": "user", "content": f"Write a blog post about: {topic}"},
|
||||
]
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
# Usage
|
||||
store_writing_preferences("writer_01", "I prefer short sentences. Active voice. No jargon. Use analogies.")
|
||||
draft_content("writer_01", "Why AI memory matters for chatbots")
|
||||
# Drafts content matching the stored voice guidelines
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
const openai = new OpenAI();
|
||||
|
||||
async function storePreferences(userId: string, preferences: string) {
|
||||
await mem0.add(
|
||||
[{ role: 'user', content: preferences }],
|
||||
{ user_id: userId, run_id: 'editing_session', metadata: { type: 'preferences' } }
|
||||
);
|
||||
}
|
||||
|
||||
async function draftContent(userId: string, topic: string): Promise<string> {
|
||||
const prefs = await mem0.search('writing style preferences', {
|
||||
filters: { AND: [{ user_id: userId }, { run_id: 'editing_session' }] },
|
||||
});
|
||||
const styleContext = prefs.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
messages: [
|
||||
{ role: 'system', content: `Write content matching these preferences:\n${styleContext}` },
|
||||
{ role: 'user', content: `Write a blog post about: ${topic}` },
|
||||
],
|
||||
});
|
||||
return response.choices[0].message.content!;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Voice consistency across all content without repeating guidelines
|
||||
- Scoped sessions let you maintain different style profiles
|
||||
- Preferences update automatically as you refine them
|
||||
|
||||
**Best for:** Marketing teams, technical writers, agencies — consistent voice across all content.
|
||||
|
||||
---
|
||||
|
||||
## 5. Multi-Agent / Multi-Tenant
|
||||
|
||||
Keep memories separate using `user_id`, `agent_id`, `app_id`, and `run_id` scoping. Critical for multi-agent workflows and multi-tenant apps.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
# Store memories scoped to user + agent + session
|
||||
def store_scoped_memory(messages: list, user_id: str, agent_id: str, run_id: str, app_id: str):
|
||||
client.add(
|
||||
messages,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
run_id=run_id,
|
||||
app_id=app_id
|
||||
)
|
||||
|
||||
# Query within a specific scope
|
||||
def search_user_session(query: str, user_id: str, app_id: str, run_id: str):
|
||||
"""Search memories for a specific user within a specific session."""
|
||||
return client.search(
|
||||
query,
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"app_id": app_id},
|
||||
{"run_id": run_id}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
def search_agent_knowledge(query: str, agent_id: str, app_id: str):
|
||||
"""Search all memories an agent has across all users."""
|
||||
return client.search(
|
||||
query,
|
||||
filters={
|
||||
"AND": [
|
||||
{"agent_id": agent_id},
|
||||
{"app_id": app_id}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Usage: Travel concierge app with multiple agents
|
||||
store_scoped_memory(
|
||||
[{"role": "user", "content": "I'm vegetarian and prefer window seats"}],
|
||||
user_id="traveler_cam",
|
||||
agent_id="travel_planner",
|
||||
run_id="tokyo-2025",
|
||||
app_id="concierge_app"
|
||||
)
|
||||
|
||||
# User-scoped query: "What does Cam prefer?"
|
||||
user_mems = search_user_session("dietary restrictions?", "traveler_cam", "concierge_app", "tokyo-2025")
|
||||
|
||||
# Agent-scoped query: "What do all travelers prefer?" (across users)
|
||||
agent_mems = search_agent_knowledge("common dietary restrictions?", "travel_planner", "concierge_app")
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
|
||||
async function storeScopedMemory(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
userId: string, agentId: string, runId: string, appId: string
|
||||
) {
|
||||
await client.add(messages, {
|
||||
user_id: userId,
|
||||
agent_id: agentId,
|
||||
run_id: runId,
|
||||
app_id: appId,
|
||||
});
|
||||
}
|
||||
|
||||
async function searchUserSession(query: string, userId: string, appId: string, runId: string) {
|
||||
return client.search(query, {
|
||||
filters: { AND: [{ user_id: userId }, { app_id: appId }, { run_id: runId }] },
|
||||
});
|
||||
}
|
||||
|
||||
async function searchAgentKnowledge(query: string, agentId: string, appId: string) {
|
||||
return client.search(query, {
|
||||
filters: { AND: [{ agent_id: agentId }, { app_id: appId }] },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Full isolation between users, agents, sessions, and apps
|
||||
- Query at any scope level — user, agent, session, or app-wide
|
||||
- No memory leakage between tenants
|
||||
|
||||
**Best for:** Multi-agent workflows, multi-tenant SaaS — proper isolation at every level.
|
||||
|
||||
---
|
||||
|
||||
## 6. Personalized Search
|
||||
|
||||
Blend real-time search results with personal context. Uses `custom_instructions` to infer preferences from queries.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
from openai import OpenAI
|
||||
|
||||
mem0 = MemoryClient()
|
||||
openai_client = OpenAI()
|
||||
|
||||
# One-time setup: configure Mem0 to infer from queries
|
||||
mem0.project.update(
|
||||
custom_instructions="""Infer user preferences and facts from their search queries.
|
||||
Extract dietary preferences, location, interests, and purchase history."""
|
||||
)
|
||||
|
||||
def personalized_search(user_id: str, query: str, search_results: list) -> str:
|
||||
# Get user context from memory
|
||||
memories = mem0.search(query, user_id=user_id, top_k=5)
|
||||
user_context = "\n".join([f"- {m['memory']}" for m in memories.get("results", [])])
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4.1-nano-2025-04-14",
|
||||
messages=[
|
||||
{"role": "system", "content": f"Personalize search results using user context:\n{user_context}"},
|
||||
{"role": "user", "content": f"Query: {query}\n\nSearch results:\n{search_results}"},
|
||||
]
|
||||
)
|
||||
reply = response.choices[0].message.content
|
||||
|
||||
# Store the query to learn preferences over time
|
||||
mem0.add(
|
||||
[{"role": "user", "content": query}],
|
||||
user_id=user_id
|
||||
)
|
||||
return reply
|
||||
|
||||
# Usage
|
||||
personalized_search("user_42", "best restaurants nearby", ["Restaurant A", "Restaurant B"])
|
||||
# Over time, Mem0 learns: "user prefers vegetarian, lives in Austin"
|
||||
# Future searches are automatically personalized
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
const openai = new OpenAI();
|
||||
|
||||
async function personalizedSearch(userId: string, query: string, searchResults: string[]): Promise<string> {
|
||||
const memories = await mem0.search(query, { user_id: userId, top_k: 5 });
|
||||
const context = memories.results?.map((m: any) => `- ${m.memory}`).join('\n') || '';
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4.1-nano-2025-04-14',
|
||||
messages: [
|
||||
{ role: 'system', content: `Personalize results using user context:\n${context}` },
|
||||
{ role: 'user', content: `Query: ${query}\nResults: ${searchResults.join(', ')}` },
|
||||
],
|
||||
});
|
||||
const reply = response.choices[0].message.content!;
|
||||
|
||||
await mem0.add([{ role: 'user', content: query }], { user_id: userId });
|
||||
return reply;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Learns preferences from queries automatically via `custom_instructions`
|
||||
- Personalizes any search provider (Tavily, Google, Bing)
|
||||
- Zero manual preference setup — improves over time
|
||||
|
||||
**Best for:** Personalized search engines, recommendation systems — search results tailored to individual users.
|
||||
|
||||
---
|
||||
|
||||
## 7. Email Intelligence
|
||||
|
||||
Capture, categorize, and recall inbox threads using persistent memories with rich metadata.
|
||||
|
||||
### Implementation (Python)
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient()
|
||||
|
||||
def store_email(user_id: str, sender: str, subject: str, body: str, date: str):
|
||||
client.add(
|
||||
[{"role": "user", "content": f"Email from {sender}: {subject}\n\n{body}"}],
|
||||
user_id=user_id,
|
||||
metadata={"email_type": "incoming", "sender": sender, "subject": subject, "date": date}
|
||||
)
|
||||
|
||||
def search_emails(user_id: str, query: str):
|
||||
return client.search(
|
||||
query,
|
||||
filters={"AND": [{"user_id": user_id}, {"categories": {"contains": "email"}}]},
|
||||
top_k=10
|
||||
)
|
||||
|
||||
def get_emails_from_sender(user_id: str, sender: str):
|
||||
return client.get_all(
|
||||
filters={
|
||||
"AND": [
|
||||
{"user_id": user_id},
|
||||
{"metadata": {"contains": sender}}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Usage
|
||||
store_email("alice", "bob@acme.com", "Q3 Budget Review", "Attached is the Q3 budget...", "2025-01-15")
|
||||
store_email("alice", "carol@acme.com", "Sprint Planning", "Here are the priorities...", "2025-01-16")
|
||||
|
||||
results = search_emails("alice", "budget discussions")
|
||||
sender_emails = get_emails_from_sender("alice", "bob@acme.com")
|
||||
```
|
||||
|
||||
### Implementation (TypeScript)
|
||||
|
||||
```typescript
|
||||
import MemoryClient from 'mem0ai';
|
||||
|
||||
const client = new MemoryClient({ apiKey: process.env.MEM0_API_KEY! });
|
||||
|
||||
async function storeEmail(userId: string, sender: string, subject: string, body: string, date: string) {
|
||||
await client.add(
|
||||
[{ role: 'user', content: `Email from ${sender}: ${subject}\n\n${body}` }],
|
||||
{ user_id: userId, metadata: { email_type: 'incoming', sender, subject, date } }
|
||||
);
|
||||
}
|
||||
|
||||
async function searchEmails(userId: string, query: string) {
|
||||
return client.search(query, {
|
||||
filters: { AND: [{ user_id: userId }, { categories: { contains: 'email' } }] },
|
||||
top_k: 10,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Rich metadata enables multi-dimensional queries (sender, date, subject)
|
||||
- Category filtering separates emails from other memory types
|
||||
- Semantic search across all email content
|
||||
|
||||
**Best for:** Inbox management, email automation — searchable email memories with metadata filtering.
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns Across Use Cases
|
||||
|
||||
### Pattern 1: Retrieve → Generate → Store
|
||||
|
||||
Every use case follows the same 3-step loop:
|
||||
|
||||
```python
|
||||
# 1. Retrieve relevant context
|
||||
memories = mem0.search(user_input, user_id=user_id)
|
||||
context = "\n".join([m["memory"] for m in memories.get("results", [])])
|
||||
|
||||
# 2. Generate with context
|
||||
response = llm.generate(system_prompt=f"Context:\n{context}", user_input=user_input)
|
||||
|
||||
# 3. Store the interaction
|
||||
mem0.add(
|
||||
[{"role": "user", "content": user_input}, {"role": "assistant", "content": response}],
|
||||
user_id=user_id
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 2: Scope with Entity Identifiers
|
||||
|
||||
Use `user_id`, `agent_id`, `app_id`, and `run_id` to isolate memories:
|
||||
|
||||
```python
|
||||
# User-level: personal preferences
|
||||
client.add(messages, user_id="alice")
|
||||
|
||||
# Session-level: conversation within one session
|
||||
client.add(messages, user_id="alice", run_id="session_123")
|
||||
|
||||
# Agent-level: agent-specific knowledge
|
||||
client.add(messages, agent_id="support_bot", app_id="helpdesk")
|
||||
```
|
||||
|
||||
### Pattern 3: Rich Metadata for Filtering
|
||||
|
||||
Attach structured metadata for multi-dimensional queries:
|
||||
|
||||
```python
|
||||
# Store with metadata
|
||||
client.add(messages, user_id="alice", metadata={"priority": "high", "source": "phone_call"})
|
||||
|
||||
# Filter by category + metadata
|
||||
client.search("billing issues", filters={
|
||||
"AND": [{"user_id": "alice"}, {"categories": {"contains": "billing"}}]
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 4: Custom Instructions for Domain-Specific Extraction
|
||||
|
||||
Control what Mem0 extracts from conversations:
|
||||
|
||||
```python
|
||||
client.project.update(
|
||||
custom_instructions="Extract medical conditions, medications, and allergies. Exclude billing info."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## More Examples
|
||||
|
||||
For 30+ cookbooks with complete working code: [docs.mem0.ai/cookbooks](https://docs.mem0.ai/cookbooks)
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mem0 Documentation Search Agent (Mintlify-based)
|
||||
On-demand search tool for querying Mem0 documentation without storing content locally.
|
||||
|
||||
This tool leverages Mintlify's documentation structure to perform just-in-time
|
||||
retrieval of technical information from docs.mem0.ai.
|
||||
|
||||
Usage:
|
||||
python mem0_doc_search.py --query "how to add graph memory"
|
||||
python mem0_doc_search.py --query "filter syntax for categories"
|
||||
python mem0_doc_search.py --page "/platform/features/graph-memory"
|
||||
python mem0_doc_search.py --index
|
||||
python mem0_doc_search.py --query "webhook events" --section platform
|
||||
|
||||
Purpose:
|
||||
- Avoid bloating local context with full documentation
|
||||
- Enable just-in-time retrieval of technical details
|
||||
- Query specific documentation pages on demand
|
||||
- Search across the full Mem0 documentation site
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DOCS_BASE = "https://docs.mem0.ai"
|
||||
SEARCH_ENDPOINT = f"{DOCS_BASE}/api/search"
|
||||
LLMS_INDEX = f"{DOCS_BASE}/llms.txt"
|
||||
|
||||
# Known documentation sections for targeted retrieval
|
||||
SECTION_MAP = {
|
||||
"platform": [
|
||||
"/platform/overview",
|
||||
"/platform/quickstart",
|
||||
"/platform/features",
|
||||
"/platform/features/graph-memory",
|
||||
"/platform/features/selective-memory",
|
||||
"/platform/features/custom-categories",
|
||||
"/platform/features/v2-memory-filters",
|
||||
"/platform/features/async-client",
|
||||
"/platform/features/webhooks",
|
||||
"/platform/features/multimodal",
|
||||
],
|
||||
"api": [
|
||||
"/api-reference/memory/add-memories",
|
||||
"/api-reference/memory/v2-search-memories",
|
||||
"/api-reference/memory/v2-get-memories",
|
||||
"/api-reference/memory/get-memory",
|
||||
"/api-reference/memory/update-memory",
|
||||
"/api-reference/memory/delete-memory",
|
||||
],
|
||||
"open-source": [
|
||||
"/open-source/overview",
|
||||
"/open-source/python-quickstart",
|
||||
"/open-source/node-quickstart",
|
||||
"/open-source/features",
|
||||
"/open-source/features/graph-memory",
|
||||
"/open-source/features/rest-api",
|
||||
"/open-source/configure-components",
|
||||
],
|
||||
"openmemory": [
|
||||
"/openmemory/overview",
|
||||
"/openmemory/quickstart",
|
||||
],
|
||||
"sdks": [
|
||||
"/sdks/python",
|
||||
"/sdks/js",
|
||||
],
|
||||
"integrations": [
|
||||
"/integrations",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def fetch_url(url: str) -> str:
|
||||
"""Fetch content from a URL."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mem0DocSearchAgent/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as e:
|
||||
return f"HTTP Error {e.code}: {e.reason}"
|
||||
except urllib.error.URLError as e:
|
||||
return f"URL Error: {e.reason}"
|
||||
|
||||
|
||||
def search_docs(query: str, section: str | None = None) -> dict:
|
||||
"""
|
||||
Search Mem0 documentation using Mintlify's search API.
|
||||
Falls back to the llms.txt index for keyword matching if the API is unavailable.
|
||||
"""
|
||||
# Try Mintlify search API first
|
||||
params = urllib.parse.urlencode({"query": query})
|
||||
search_url = f"{SEARCH_ENDPOINT}?{params}"
|
||||
|
||||
try:
|
||||
result = fetch_url(search_url)
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict) and data.get("results"):
|
||||
results = data["results"]
|
||||
if section and section in SECTION_MAP:
|
||||
section_paths = SECTION_MAP[section]
|
||||
results = [r for r in results if any(r.get("url", "").startswith(p) for p in section_paths)]
|
||||
return {"source": "mintlify_search", "results": results}
|
||||
except (json.JSONDecodeError, Exception):
|
||||
pass
|
||||
|
||||
# Fallback: search llms.txt index for matching URLs
|
||||
index_content = fetch_url(LLMS_INDEX)
|
||||
query_lower = query.lower()
|
||||
matching_urls = []
|
||||
|
||||
for line in index_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if query_lower in line.lower():
|
||||
matching_urls.append(line)
|
||||
|
||||
if section and section in SECTION_MAP:
|
||||
section_paths = SECTION_MAP[section]
|
||||
matching_urls = [u for u in matching_urls if any(p in u for p in section_paths)]
|
||||
|
||||
return {
|
||||
"source": "llms_txt_index",
|
||||
"query": query,
|
||||
"matching_urls": matching_urls[:20],
|
||||
"suggestion": "Fetch specific URLs for detailed content",
|
||||
}
|
||||
|
||||
|
||||
def fetch_page(page_path: str) -> dict:
|
||||
"""Fetch a specific documentation page."""
|
||||
url = f"{DOCS_BASE}{page_path}" if page_path.startswith("/") else page_path
|
||||
content = fetch_url(url)
|
||||
return {"url": url, "content": content[:10000], "truncated": len(content) > 10000}
|
||||
|
||||
|
||||
def get_index() -> dict:
|
||||
"""Fetch the full documentation index from llms.txt."""
|
||||
content = fetch_url(LLMS_INDEX)
|
||||
urls = [line.strip() for line in content.splitlines() if line.strip() and not line.startswith("#")]
|
||||
return {"total_pages": len(urls), "urls": urls, "sections": list(SECTION_MAP.keys())}
|
||||
|
||||
|
||||
def list_section(section: str) -> dict:
|
||||
"""List all known pages in a documentation section."""
|
||||
if section not in SECTION_MAP:
|
||||
return {"error": f"Unknown section: {section}", "available": list(SECTION_MAP.keys())}
|
||||
return {
|
||||
"section": section,
|
||||
"pages": [f"{DOCS_BASE}{p}" for p in SECTION_MAP[section]],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Search Mem0 documentation on demand")
|
||||
parser.add_argument("--query", help="Search query for documentation")
|
||||
parser.add_argument("--page", help="Fetch a specific page path (e.g., /platform/features/graph-memory)")
|
||||
parser.add_argument("--index", action="store_true", help="Show full documentation index")
|
||||
parser.add_argument("--section", help="Filter by section or list section pages")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.index:
|
||||
result = get_index()
|
||||
elif args.section and not args.query:
|
||||
result = list_section(args.section)
|
||||
elif args.page:
|
||||
result = fetch_page(args.page)
|
||||
elif args.query:
|
||||
result = search_docs(args.query, section=args.section)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
if isinstance(result, dict):
|
||||
if "results" in result:
|
||||
print(f"Source: {result.get('source', 'unknown')}")
|
||||
for r in result["results"]:
|
||||
print(f" - {r.get('title', 'N/A')}: {r.get('url', 'N/A')}")
|
||||
if r.get("description"):
|
||||
print(f" {r['description'][:200]}")
|
||||
elif "matching_urls" in result:
|
||||
print(f"Source: {result['source']}")
|
||||
print(f"Query: {result['query']}")
|
||||
for url in result["matching_urls"]:
|
||||
print(f" - {url}")
|
||||
if result.get("suggestion"):
|
||||
print(f"\n{result['suggestion']}")
|
||||
elif "urls" in result:
|
||||
print(f"Total documentation pages: {result['total_pages']}")
|
||||
print(f"Sections: {', '.join(result['sections'])}")
|
||||
for url in result["urls"][:30]:
|
||||
print(f" - {url}")
|
||||
if result["total_pages"] > 30:
|
||||
print(f" ... and {result['total_pages'] - 30} more")
|
||||
elif "pages" in result:
|
||||
print(f"Section: {result['section']}")
|
||||
for page in result["pages"]:
|
||||
print(f" - {page}")
|
||||
elif "content" in result:
|
||||
print(f"URL: {result['url']}")
|
||||
if result.get("truncated"):
|
||||
print("[Content truncated to 10000 chars]")
|
||||
print(result["content"])
|
||||
elif "error" in result:
|
||||
print(f"Error: {result['error']}")
|
||||
if result.get("available"):
|
||||
print(f"Available sections: {', '.join(result['available'])}")
|
||||
else:
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user