feat(integrations): add @mem0/eve memory provider

Give Eve agents a first-class Mem0 recall/capture provider so they can use Mem0 in the agent loop instead of MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Himanshu-Sangshetti
2026-09-09 10:01:26 +08:00
co-authored by Cursor
parent dae67f74f5
commit 1d83bb0835
37 changed files with 5063 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { completedTurnMessages, type ConversationMessage } from "./messages.js";
import type { MemoryStore } from "./store.js";
export async function captureCompletedTurn(input: {
store: MemoryStore;
scopeKey: string;
infer: boolean;
metadata: Readonly<Record<string, unknown>>;
messages: readonly ConversationMessage[];
turnInput: readonly ConversationMessage[];
}): Promise<boolean> {
const messages = completedTurnMessages({
messages: input.messages,
turnInput: input.turnInput,
});
if (messages.length === 0) {
return false;
}
await input.store.add(messages, {
userId: input.scopeKey,
infer: input.infer,
metadata: input.metadata,
});
return true;
}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown Mem0 error";
}
+26
View File
@@ -0,0 +1,26 @@
const DEFAULT_LIMIT = 256;
export function createIdempotencyGate(limit = DEFAULT_LIMIT) {
const seen = new Map<string, Promise<void>>();
return function once(operationId: string, work: () => Promise<void>): Promise<void> {
const existing = seen.get(operationId);
if (existing) {
return existing;
}
const pending = work().catch((error: unknown) => {
seen.delete(operationId);
throw error;
});
seen.set(operationId, pending);
if (seen.size > limit) {
const oldest = seen.keys().next().value;
if (oldest && oldest !== operationId) {
seen.delete(oldest);
}
}
return pending;
};
}
+3
View File
@@ -0,0 +1,3 @@
export { mem0Provider, mem0Provider as default } from "./provider.js";
export type { Mem0ApiKey, Mem0ProviderOptions } from "./options.js";
export type { MemoryStore, MemoryMessage, SearchHit } from "./store.js";
+79
View File
@@ -0,0 +1,79 @@
import type { MemoryMessage } from "./store.js";
export interface ConversationMessage {
readonly role: string;
readonly content: unknown;
}
export function extractText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object" && "text" in part) {
const text = (part as { text: unknown }).text;
return typeof text === "string" ? text : "";
}
return "";
})
.join("\n")
.trim();
}
export function lastUserText(messages: readonly ConversationMessage[]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== "user") {
continue;
}
const text = extractText(message.content);
if (text.length > 0) {
return text;
}
}
return "";
}
export function conversationMessages(
messages: readonly ConversationMessage[],
): MemoryMessage[] {
const result: MemoryMessage[] = [];
for (const message of messages) {
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
const text = extractText(message.content);
if (text.length === 0) {
continue;
}
result.push({ role: message.role, content: text });
}
return result;
}
export function completedTurnMessages(input: {
readonly messages: readonly ConversationMessage[];
readonly turnInput: readonly ConversationMessage[];
}): MemoryMessage[] {
const users = conversationMessages(input.turnInput).filter(
(message) => message.role === "user",
);
if (users.length === 0) {
return [];
}
const history = conversationMessages(input.messages);
const lastAssistant = [...history]
.reverse()
.find((message) => message.role === "assistant");
return lastAssistant ? [...users, lastAssistant] : users;
}
+74
View File
@@ -0,0 +1,74 @@
import type { MemoryStore } from "./store.js";
export type Mem0ApiKey = string | (() => string | Promise<string>);
export interface Mem0ProviderOptions {
readonly apiKey: Mem0ApiKey;
readonly host?: string;
readonly topK?: number;
readonly threshold?: number;
readonly rerank?: boolean;
readonly infer?: boolean;
readonly autoSearch?: {
readonly enabled?: boolean;
};
readonly capture?: {
readonly enabled?: boolean;
};
readonly metadata?: Readonly<Record<string, string | number | boolean>>;
readonly store?: MemoryStore;
}
export interface ResolvedMem0ProviderOptions {
readonly apiKey: Mem0ApiKey;
readonly host: string;
readonly topK: number;
readonly threshold: number;
readonly rerank: boolean;
readonly infer: boolean;
readonly autoSearch: {
readonly enabled: boolean;
};
readonly capture: {
readonly enabled: boolean;
};
readonly metadata: Readonly<Record<string, string | number | boolean>>;
readonly store?: MemoryStore;
}
export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0ProviderOptions {
const apiKey = options.apiKey;
if (typeof apiKey === "string") {
const trimmed = apiKey.trim();
if (trimmed.length === 0) {
throw new Error("Mem0 API key cannot be empty");
}
}
const topK = options.topK ?? 5;
if (!Number.isInteger(topK) || topK < 1 || topK > 100) {
throw new Error("topK must be an integer between 1 and 100");
}
const threshold = options.threshold ?? 0.1;
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
throw new Error("threshold must be a number between 0 and 1");
}
return {
apiKey,
host: options.host ?? "https://api.mem0.ai",
topK,
threshold,
rerank: options.rerank ?? false,
infer: options.infer ?? true,
autoSearch: {
enabled: options.autoSearch?.enabled ?? true,
},
capture: {
enabled: options.capture?.enabled ?? true,
},
metadata: options.metadata ?? {},
...(options.store ? { store: options.store } : {}),
};
}
+120
View File
@@ -0,0 +1,120 @@
import { defineMemoryProvider, type MemoryProvider } from "eve/memory";
import { captureCompletedTurn } from "./capture.js";
import { errorMessage } from "./errors.js";
import { createIdempotencyGate } from "./idempotency.js";
import { lastUserText, type ConversationMessage } from "./messages.js";
import { resolveOptions, type Mem0ProviderOptions } from "./options.js";
import { recallMemories } from "./recall.js";
import { createMem0Store, type MemoryStore } from "./store.js";
import { createMem0Tools } from "./tools.js";
function asConversation(messages: readonly unknown[]): ConversationMessage[] {
return messages.filter((message): message is ConversationMessage => {
if (message === null || typeof message !== "object") {
return false;
}
return (
"role" in message && typeof (message as { role: unknown }).role === "string"
);
});
}
export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
const config = resolveOptions(options);
const once = createIdempotencyGate();
let storePromise: Promise<MemoryStore> | undefined;
const getStore = async (): Promise<MemoryStore> => {
if (config.store) {
return config.store;
}
storePromise ??= createMem0Store({
apiKey: config.apiKey,
host: config.host,
});
return storePromise;
};
const recall = async (context: {
abortSignal: AbortSignal;
messages: readonly unknown[];
session: { id: string };
memory: { scope: { key: string } };
turn?: { input: readonly unknown[] } | null;
}) => {
if (!config.autoSearch.enabled) {
return null;
}
const querySource = context.turn?.input ?? context.messages;
const query = lastUserText(asConversation(querySource));
try {
const store = await getStore();
return await recallMemories({
store,
scopeKey: context.memory.scope.key,
query,
topK: config.topK,
threshold: config.threshold,
rerank: config.rerank,
});
} catch (error) {
if (context.abortSignal.aborted) {
throw error;
}
console.error("[@mem0/eve] recall failed", {
error: errorMessage(error),
sessionId: context.session.id,
});
return null;
}
};
return defineMemoryProvider({
recall: {
"turn.started": recall,
"compaction.completed": recall,
},
...(config.capture.enabled
? {
capture: {
async "turn.completed"(context) {
try {
const store = await getStore();
await once(context.operationId, async () => {
await captureCompletedTurn({
store,
scopeKey: context.memory.scope.key,
infer: config.infer,
metadata: {
...config.metadata,
source: "eve",
operation_id: context.operationId,
session_id: context.session.id,
slot: context.memory.slot,
},
messages: asConversation(context.messages),
turnInput: asConversation(context.turn.input),
});
});
} catch (error) {
if (context.abortSignal.aborted) {
throw error;
}
console.error("[@mem0/eve] capture failed", {
error: errorMessage(error),
sessionId: context.session.id,
turnId: context.turn.id,
});
}
},
},
}
: {}),
async tools(context) {
const store = await getStore();
return createMem0Tools(store, context.memory.scope.key);
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import type { SearchHit, MemoryStore } from "./store.js";
export interface RecallMessage {
readonly id: string;
readonly content: string;
}
export function formatRecallMessages(hits: readonly SearchHit[]): RecallMessage[] {
return hits.flatMap((hit) => {
const content = hit.memory.trim();
return content.length > 0 ? [{ id: hit.id, content }] : [];
});
}
export async function recallMemories(input: {
store: MemoryStore;
scopeKey: string;
query: string;
topK: number;
threshold: number;
rerank: boolean;
}): Promise<{ messages: RecallMessage[] } | null> {
const query = input.query.trim();
if (query.length === 0) {
return null;
}
const { results } = await input.store.search(query, {
filters: { user_id: input.scopeKey },
topK: input.topK,
threshold: input.threshold,
rerank: input.rerank,
});
const messages = formatRecallMessages(results);
return messages.length > 0 ? { messages } : null;
}
+83
View File
@@ -0,0 +1,83 @@
import type { Mem0ApiKey } from "./options.js";
export type MemoryRole = "user" | "assistant";
export interface MemoryMessage {
readonly role: MemoryRole;
readonly content: string;
}
export interface SearchHit {
readonly id: string;
readonly memory: string;
}
export interface AddMemoryInput {
readonly userId: string;
readonly infer: boolean;
readonly metadata: Readonly<Record<string, unknown>>;
}
export interface SearchMemoryInput {
readonly filters: {
readonly user_id: string;
};
readonly topK: number;
readonly threshold: number;
readonly rerank: boolean;
}
export interface MemoryStore {
add(messages: readonly MemoryMessage[], input: AddMemoryInput): Promise<unknown>;
search(query: string, input: SearchMemoryInput): Promise<{ results: readonly SearchHit[] }>;
delete(memoryId: string): Promise<unknown>;
}
export async function resolveApiKey(apiKey: Mem0ApiKey): Promise<string> {
const value = typeof apiKey === "function" ? await apiKey() : apiKey;
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new Error("Mem0 API key cannot be empty");
}
return trimmed;
}
export async function createMem0Store(input: {
apiKey: Mem0ApiKey;
host: string;
}): Promise<MemoryStore> {
const MemoryClient = (await import("mem0ai")).default;
const client = new MemoryClient({
apiKey: await resolveApiKey(input.apiKey),
host: input.host,
});
return {
async add(messages, options) {
return client.add([...messages], {
userId: options.userId,
infer: options.infer,
metadata: { ...options.metadata },
});
},
async search(query, options) {
const response = await client.search(query, {
filters: options.filters,
topK: options.topK,
threshold: options.threshold,
rerank: options.rerank,
});
const results = Array.isArray(response?.results) ? response.results : [];
return {
results: results.flatMap((item) => {
const id = typeof item.id === "string" ? item.id : "";
const memory = typeof item.memory === "string" ? item.memory : "";
return id && memory ? [{ id, memory }] : [];
}),
};
},
async delete(memoryId) {
return client.delete(memoryId);
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import { defineTool } from "eve/tools";
import { z } from "zod";
import type { MemoryStore } from "./store.js";
export function createMem0Tools(store: MemoryStore, scopeKey: string) {
return {
search: defineTool({
description: "Search long-term memories for the current caller.",
inputSchema: z.object({
query: z.string().min(1).max(2000),
}),
async execute({ query }) {
const { results } = await store.search(query, {
filters: { user_id: scopeKey },
topK: 8,
threshold: 0.1,
rerank: false,
});
return {
memories: results.map((hit) => ({
id: hit.id,
memory: hit.memory,
})),
};
},
}),
remember: defineTool({
description:
"Save one durable fact or preference about the current caller.",
inputSchema: z.object({
text: z.string().min(1).max(4000),
}),
async execute({ text }) {
await store.add([{ role: "user", content: text }], {
userId: scopeKey,
infer: true,
metadata: { source: "eve-tool" },
});
return { saved: true };
},
}),
forget: defineTool({
description: "Delete one memory belonging to the current caller by id.",
inputSchema: z.object({
id: z.string().min(1),
}),
async execute({ id }) {
await store.delete(id);
return { deleted: true };
},
}),
};
}