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
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { captureCompletedTurn } from "../src/capture.js";
import { createFakeStore } from "./helpers.js";
describe("captureCompletedTurn", () => {
it("adds the current turn under the locked scope", async () => {
const store = createFakeStore();
const captured = await captureCompletedTurn({
store,
scopeKey: "scope_abc",
infer: true,
metadata: { source: "eve" },
turnInput: [{ role: "user", content: "I am vegetarian" }],
messages: [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "Got it." },
],
});
expect(captured).toBe(true);
expect(store.added).toEqual([
{
userId: "scope_abc",
messages: [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "Got it." },
],
},
]);
});
it("does not write when the turn has no user text", async () => {
const store = createFakeStore();
const captured = await captureCompletedTurn({
store,
scopeKey: "scope_abc",
infer: true,
metadata: {},
turnInput: [],
messages: [],
});
expect(captured).toBe(false);
expect(store.added).toEqual([]);
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { MemoryMessage, MemoryStore, SearchHit } from "../src/store.js";
export function createFakeStore(hits: SearchHit[] = []): MemoryStore & {
added: Array<{ messages: readonly MemoryMessage[]; userId: string }>;
searched: Array<{ query: string; userId: string }>;
deleted: string[];
} {
const added: Array<{ messages: readonly MemoryMessage[]; userId: string }> = [];
const searched: Array<{ query: string; userId: string }> = [];
const deleted: string[] = [];
return {
added,
searched,
deleted,
async add(messages, input) {
added.push({ messages, userId: input.userId });
return { eventId: "evt_1" };
},
async search(query, input) {
searched.push({ query, userId: input.filters.user_id });
return { results: hits };
},
async delete(memoryId) {
deleted.push(memoryId);
return { message: "ok" };
},
};
}
export function memoryContext(input?: {
scopeKey?: string;
operationId?: string;
turnInput?: Array<{ role: string; content: unknown }>;
messages?: Array<{ role: string; content: unknown }>;
}): {
abortSignal: AbortSignal;
operationId: string;
session: { id: string };
memory: {
scope: { key: string; namespace: string; value: string };
slot: string;
};
messages: Array<{ role: string; content: unknown }>;
turn: {
id: string;
sequence: number;
input: Array<{ role: string; content: unknown }>;
};
} {
return {
abortSignal: new AbortController().signal,
operationId: input?.operationId ?? "op_1",
session: { id: "sess_1" },
memory: {
scope: {
key: input?.scopeKey ?? "scope_abc",
namespace: "default",
value: "user_1",
},
slot: "mem0",
},
messages: input?.messages ?? [
{ role: "user", content: "I am vegetarian" },
{ role: "assistant", content: "I will remember that." },
],
turn: {
id: "turn_1",
sequence: 1,
input: input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }],
},
};
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { createIdempotencyGate } from "../src/idempotency.js";
describe("createIdempotencyGate", () => {
it("runs work once per operation id", async () => {
const once = createIdempotencyGate();
let calls = 0;
const work = async () => {
calls += 1;
};
await Promise.all([once("op_1", work), once("op_1", work)]);
await once("op_1", work);
expect(calls).toBe(1);
});
it("retries after a failure", async () => {
const once = createIdempotencyGate();
let calls = 0;
const work = async () => {
calls += 1;
if (calls === 1) {
throw new Error("nope");
}
};
await expect(once("op_1", work)).rejects.toThrow("nope");
await once("op_1", work);
expect(calls).toBe(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
completedTurnMessages,
conversationMessages,
extractText,
lastUserText,
} from "../src/messages.js";
describe("extractText", () => {
it("reads a string", () => {
expect(extractText(" hello ")).toBe("hello");
});
it("joins text parts", () => {
expect(
extractText([
{ type: "text", text: "I like" },
{ type: "text", text: "tea" },
]),
).toBe("I like\ntea");
});
it("ignores non-text parts", () => {
expect(extractText([{ type: "image", url: "x" }])).toBe("");
});
});
describe("lastUserText", () => {
it("returns the last user message", () => {
expect(
lastUserText([
{ role: "user", content: "first" },
{ role: "assistant", content: "ok" },
{ role: "user", content: "second" },
]),
).toBe("second");
});
it("returns empty when there is no user text", () => {
expect(lastUserText([{ role: "assistant", content: "hi" }])).toBe("");
});
});
describe("conversationMessages", () => {
it("keeps user and assistant text only", () => {
expect(
conversationMessages([
{ role: "system", content: "ignore" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
{ role: "user", content: "" },
]),
).toEqual([
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
]);
});
});
describe("completedTurnMessages", () => {
it("pairs the current user turn with the latest assistant reply", () => {
expect(
completedTurnMessages({
turnInput: [{ role: "user", content: "I like tea" }],
messages: [
{ role: "user", content: "older" },
{ role: "assistant", content: "older reply" },
{ role: "user", content: "I like tea" },
{ role: "assistant", content: "Noted." },
],
}),
).toEqual([
{ role: "user", content: "I like tea" },
{ role: "assistant", content: "Noted." },
]);
});
it("skips capture when the turn has no user text", () => {
expect(
completedTurnMessages({
turnInput: [{ role: "assistant", content: "hello" }],
messages: [{ role: "assistant", content: "hello" }],
}),
).toEqual([]);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveOptions } from "../src/options.js";
import { createFakeStore } from "./helpers.js";
describe("resolveOptions", () => {
it("applies defaults", () => {
expect(resolveOptions({ apiKey: "m0-test" })).toMatchObject({
host: "https://api.mem0.ai",
topK: 5,
threshold: 0.1,
rerank: false,
infer: true,
autoSearch: { enabled: true },
capture: { enabled: true },
});
});
it("rejects an empty api key", () => {
expect(() => resolveOptions({ apiKey: " " })).toThrow(/empty/);
});
it("rejects an invalid topK", () => {
expect(() => resolveOptions({ apiKey: "m0-test", topK: 0 })).toThrow(/topK/);
});
it("keeps an injected store", () => {
const store = createFakeStore();
expect(resolveOptions({ apiKey: "m0-test", store }).store).toBe(store);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type {
MemoryToolsContext,
MemoryTurnCompletedContext,
MemoryTurnStartedContext,
} from "eve/memory";
import { describe, expect, it, vi } from "vitest";
import { mem0Provider } from "../src/provider.js";
import { createFakeStore, memoryContext } from "./helpers.js";
function startedContext(
input?: Parameters<typeof memoryContext>[0],
): MemoryTurnStartedContext {
return memoryContext(input) as unknown as MemoryTurnStartedContext;
}
function completedContext(
input?: Parameters<typeof memoryContext>[0],
): MemoryTurnCompletedContext {
return memoryContext(input) as unknown as MemoryTurnCompletedContext;
}
function toolsContext(
input?: Parameters<typeof memoryContext>[0],
): MemoryToolsContext {
return memoryContext(input) as unknown as MemoryToolsContext;
}
async function runTool(
tool: { execute: (input: never, context: never) => unknown },
input: object,
): Promise<unknown> {
return tool.execute(input as never, {} as never);
}
describe("mem0Provider", () => {
it("recalls memories for the locked scope on turn start", async () => {
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
const provider = mem0Provider({ apiKey: "m0-test", store });
const result = await provider.recall["turn.started"](
startedContext({
turnInput: [{ role: "user", content: "What do I drink?" }],
}),
);
expect(store.searched).toEqual([
{ query: "What do I drink?", userId: "scope_abc" },
]);
expect(result).toEqual({
messages: [{ id: "mem_1", content: "User likes tea" }],
});
});
it("does not fail the turn when search throws", async () => {
const store = createFakeStore();
store.search = async () => {
throw new Error("mem0 down");
};
const provider = mem0Provider({ apiKey: "m0-test", store });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
provider.recall["turn.started"](startedContext()),
).resolves.toBeNull();
error.mockRestore();
});
it("captures a completed turn once per operation id", async () => {
const store = createFakeStore();
const provider = mem0Provider({ apiKey: "m0-test", store });
const capture = provider.capture?.["turn.completed"];
expect(capture).toBeTypeOf("function");
const context = completedContext({ operationId: "op_replay" });
await capture!(context);
await capture!(context);
expect(store.added).toHaveLength(1);
expect(store.added[0]?.userId).toBe("scope_abc");
});
it("omits capture when it is disabled", () => {
const provider = mem0Provider({
apiKey: "m0-test",
store: createFakeStore(),
capture: { enabled: false },
});
expect(provider.capture).toBeUndefined();
});
it("binds tools to the locked scope", async () => {
const store = createFakeStore([{ id: "mem_1", memory: "likes tea" }]);
const provider = mem0Provider({ apiKey: "m0-test", store });
const tools = await provider.tools!(toolsContext());
if (!tools) {
throw new Error("expected tools");
}
const searchTool = tools.search;
const rememberTool = tools.remember;
const forgetTool = tools.forget;
if (!searchTool || !rememberTool || !forgetTool) {
throw new Error("expected search, remember, and forget tools");
}
const search = await runTool(searchTool, { query: "drinks" });
const remember = await runTool(rememberTool, {
text: "Allergic to peanuts",
});
const forget = await runTool(forgetTool, { id: "mem_1" });
expect(search).toEqual({
memories: [{ id: "mem_1", memory: "likes tea" }],
});
expect(remember).toEqual({ saved: true });
expect(forget).toEqual({ deleted: true });
expect(store.searched[0]?.userId).toBe("scope_abc");
expect(store.added[0]?.userId).toBe("scope_abc");
expect(store.deleted).toEqual(["mem_1"]);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { formatRecallMessages, recallMemories } from "../src/recall.js";
import { createFakeStore } from "./helpers.js";
describe("formatRecallMessages", () => {
it("drops empty memories", () => {
expect(
formatRecallMessages([
{ id: "1", memory: " likes tea " },
{ id: "2", memory: " " },
]),
).toEqual([{ id: "1", content: "likes tea" }]);
});
});
describe("recallMemories", () => {
it("searches inside the locked scope and returns keyed messages", async () => {
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
const result = await recallMemories({
store,
scopeKey: "scope_abc",
query: "What does the user drink?",
topK: 5,
threshold: 0.1,
rerank: false,
});
expect(store.searched).toEqual([
{ query: "What does the user drink?", userId: "scope_abc" },
]);
expect(result).toEqual({
messages: [{ id: "mem_1", content: "User likes tea" }],
});
});
it("returns null for an empty query", async () => {
const store = createFakeStore([{ id: "mem_1", memory: "x" }]);
await expect(
recallMemories({
store,
scopeKey: "scope_abc",
query: " ",
topK: 5,
threshold: 0.1,
rerank: false,
}),
).resolves.toBeNull();
expect(store.searched).toEqual([]);
});
});