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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user