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:
co-authored by
Cursor
parent
dae67f74f5
commit
1d83bb0835
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown Mem0 error";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user