diff --git a/docs/integrations/eve.mdx b/docs/integrations/eve.mdx index e3a8c1e86..f3b78b5ac 100644 --- a/docs/integrations/eve.mdx +++ b/docs/integrations/eve.mdx @@ -5,7 +5,7 @@ description: "Use Mem0 as a first-class memory provider for Vercel Eve agents." [Eve](https://eve.dev/docs/memory) is Vercel's agent framework. A memory provider sits in the agent loop: Eve recalls context before each turn, captures completed turns, and exposes provider tools. `@mem0/eve` is Mem0's implementation of that contract. -This is not the Mem0 MCP connection. MCP gives the model tools it may or may not call. The memory provider runs automatically. +This is not the Mem0 MCP connection. MCP gives the model tools it may or may not call. The memory provider runs automatically. The live MCP install is `eve add connection/mem0`. ## Install @@ -31,19 +31,15 @@ export default defineMemory({ }); ``` -After Eve lists Mem0 in the official registry, the same slot is created by: - -```bash -eve add memory/mem0 -``` +`eve add memory/mem0` is not in Eve's catalog yet. After Mem0 is listed in the official registry, that command will create the same slot file. ## How it works -1. **Recall.** Before each turn, Mem0 searches memories for the locked Eve scope and returns them as attributed user-role messages. -2. **Capture.** After a successful turn, Mem0 adds the user and assistant messages and extracts durable facts. +1. **Recall.** Before each turn and after compaction, Mem0 searches memories for the locked Eve scope and returns `{ id, content }` messages. Eve injects those as user-role messages attributed to the slot. +2. **Capture.** After a successful turn, Mem0 adds the user messages from that turn and, when present, the latest assistant reply. By default (`infer: true`) Mem0 extracts durable facts. 3. **Tools.** The model can call `search`, `remember`, and `forget`. Eve qualifies them as `mem0__search`, `mem0__remember`, and `mem0__forget` when the slot file is `mem0.ts`. -Eve owns namespace, scope, and when recall/capture run. Mem0 owns storage, ranking, and extraction. Every read and write is partitioned by `memory.scope.key`. +Eve owns namespace, scope, and when recall/capture run. Mem0 owns storage, ranking, and extraction. Search, capture, remember, and forget are partitioned by `memory.scope.key`. Forget deletes by id only after confirming the memory belongs to that key. ## Options diff --git a/docs/llms.txt b/docs/llms.txt index 79166ce8c..66fa71410 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -263,7 +263,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st - [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript). - [OpenClaw](https://docs.mem0.ai/integrations/openclaw) [Both]: Use when wiring Mem0 into Claude Code or editors via OpenClaw. - [Vercel AI SDK](https://docs.mem0.ai/integrations/vercel-ai-sdk) [Both]: Use when the user is on the Vercel AI SDK. -- [Eve](https://docs.mem0.ai/integrations/eve) [Platform]: Use when adding Mem0 as a first-class Eve memory provider (`@mem0/eve`, `mem0Provider`, `eve add memory/mem0`). Not the Mem0 MCP connection. +- [Eve](https://docs.mem0.ai/integrations/eve) [Platform]: Use when adding Mem0 as a first-class Eve memory provider (`@mem0/eve`, `mem0Provider`, hand-written `agent/memory/mem0.ts`). `eve add memory/mem0` is not available yet. Not the Mem0 MCP connection (`eve add connection/mem0`). - [Vercel](https://docs.mem0.ai/integrations/vercel) [Platform]: Use when deploying on Vercel and installing Mem0 from the Vercel Marketplace. - [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore. diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index 8e683ae65..4cd7fbd66 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -47,7 +47,7 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc - ## What each one is -- **`eve/`** is the Eve memory provider (`mem0Provider`). Eve calls recall before each turn and capture after each turn. This is not the Mem0 MCP connection. +- **`eve/`** is the Eve memory provider (`mem0Provider`). Eve calls recall on `turn.started` and `compaction.completed`, capture on `turn.completed` when enabled, and exposes `search` / `remember` / `forget` tools. This is not the Mem0 MCP connection. - **`vercel-ai-sdk/`** wraps the Vercel AI SDK through a `createMem0` provider. Integrations for AI-SDK repos go through this wrapper, not raw `MemoryClient`. - **`claude-code-plugin/`** is the Claude Code plugin (v0.3.0, installs as `mem0@mem0-plugins`): local evidence capture via lifecycle hooks, background memory extraction to the Mem0 Platform, a local `search_memories` MCP tool, six `/mem0:*` skills, and the `mem0:sidekick` agent. Pure-stdlib Python — no dependencies to install. Its `core/` + `adapters/claude/` split marks engine vs. harness glue; future per-harness plugins start by copying `core/` and keeping the contract tests verbatim (see its `docs/CONTRACT.md`). - **`mem0-plugin/`** connects Cursor, Codex, Kimi, Antigravity, and OpenCode to the MCP server at `mcp.mem0.ai` and installs lifecycle hooks for automatic memory capture. Exposes 9 MCP tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`. The Claude Code plugin moved to [`claude-code-plugin/`](claude-code-plugin/) in v0.3.0 (installs as `mem0@mem0-plugins`); do not run both at the same time. diff --git a/integrations/eve/README.md b/integrations/eve/README.md index 90b4f8212..37d129e51 100644 --- a/integrations/eve/README.md +++ b/integrations/eve/README.md @@ -26,25 +26,27 @@ npm install @mem0/eve `eve` is a peer dependency. Get an API key from the [Mem0 dashboard](https://app.mem0.ai/dashboard/api-keys). -Until Eve lists Mem0 in the official registry, add the slot file yourself as `agent/memory/mem0.ts`. After the registry PR lands, this becomes: +Until Eve lists Mem0 in the official registry, add the slot file yourself as `agent/memory/mem0.ts`. `eve add memory/mem0` is not available yet. After the registry PR lands, that command will create the same file. -```bash -eve add memory/mem0 -``` +The live MCP path today is `eve add connection/mem0`. That is a different integration: tools the model may call, not automatic recall/capture. ## What it does | Eve hook | Mem0 behavior | |---|---| | `recall["turn.started"]` | Semantic search over memories for the locked scope | -| `recall["compaction.completed"]` | Same search after Eve compacting history | -| `capture["turn.completed"]` | Add the completed user/assistant turn | +| `recall["compaction.completed"]` | Same search after Eve compacting history (`turn` may be null) | +| `capture["turn.completed"]` | Add the completed user turn and, when present, the latest assistant reply | | `tools()` | `search`, `remember`, `forget` | -Every read and write is partitioned by `memory.scope.key`. The model cannot choose another caller. Replay uses `operationId` as an idempotency key. +Search, capture, remember, and forget are partitioned by `memory.scope.key`. Forget loads the memory first and deletes only when `userId` matches that key. + +Capture uses `operationId` as an idempotency key: an in-process gate plus a durable `metadata.operation_id` lookup so Eve replays after restart do not write twice. If the slot file is named `mem0.ts`, Eve qualifies tools as `mem0__search`, `mem0__remember`, and `mem0__forget`. +A throwing `recall` fails the Eve turn. A throwing `capture` is logged by Eve and does not fail the turn. Tool `execute` errors become failed tool results the model can see. + ## Options ```ts @@ -63,15 +65,16 @@ mem0Provider({ | Option | Default | Purpose | |---|---|---| -| `apiKey` | required | String or async getter | -| `host` | `https://api.mem0.ai` | Mem0 Platform host | -| `topK` | `5` | Memories recalled per turn | -| `threshold` | `0.1` | Minimum similarity | -| `rerank` | `false` | Mem0 reranking | -| `infer` | `true` | Extract facts on capture | -| `autoSearch.enabled` | `true` | Recall before each turn | +| `apiKey` | required without `store` | String or async getter. Function keys are validated on first use. | +| `host` | `https://api.mem0.ai` | Mem0 Platform host (`http:` or `https:` URL) | +| `topK` | `5` | Result count for automatic recall and the `search` tool | +| `threshold` | `0.1` | Minimum similarity for automatic recall and the `search` tool | +| `rerank` | `false` | Mem0 reranking for automatic recall and the `search` tool | +| `infer` | `true` | Extract facts on capture and on `remember` | +| `autoSearch.enabled` | `true` | Recall on `turn.started` and `compaction.completed` | | `capture.enabled` | `true` | Write after each successful turn | | `metadata` | `{}` | Extra metadata on captured turns | +| `store` | unset | Test / advanced injection. Replaces the Mem0 client and must honor `userId`. | ## Official registry diff --git a/integrations/eve/registry/README.md b/integrations/eve/registry/README.md index c369f5a78..80a2d8b75 100644 --- a/integrations/eve/registry/README.md +++ b/integrations/eve/registry/README.md @@ -2,7 +2,7 @@ These files are the Mem0 side of `eve add memory/mem0`. They are not published in the npm tarball. Copy them into a PR against [vercel/eve](https://github.com/vercel/eve) after `@mem0/eve` is on npm. -Supermemory's version of this change is [vercel/eve#2775](https://github.com/vercel/eve/pull/2775). Eve already asked Mem0 to own the package and open that PR. +Supermemory's version of this change is [vercel/eve#2775](https://github.com/vercel/eve/pull/2775). Use that PR as the catalog/shape reference. ## What the Eve PR should do diff --git a/integrations/eve/src/capture.ts b/integrations/eve/src/capture.ts index ab91eb486..d28ecf73c 100644 --- a/integrations/eve/src/capture.ts +++ b/integrations/eve/src/capture.ts @@ -1,14 +1,23 @@ import { completedTurnMessages, type ConversationMessage } from "./messages.js"; -import type { MemoryStore } from "./store.js"; +import type { MemoryStore, StoreMetadata } from "./store.js"; export async function captureCompletedTurn(input: { store: MemoryStore; scopeKey: string; + operationId: string; infer: boolean; - metadata: Readonly>; + metadata: StoreMetadata; messages: readonly ConversationMessage[]; turnInput: readonly ConversationMessage[]; }): Promise { + const existing = await input.store.listByMetadata({ + userId: input.scopeKey, + metadata: { operation_id: input.operationId }, + }); + if (existing.length > 0) { + return false; + } + const messages = completedTurnMessages({ messages: input.messages, turnInput: input.turnInput, diff --git a/integrations/eve/src/errors.ts b/integrations/eve/src/errors.ts index 422a60a4a..d6f95e364 100644 --- a/integrations/eve/src/errors.ts +++ b/integrations/eve/src/errors.ts @@ -1,3 +1,39 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unknown Mem0 error"; } + +export function isSetupError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const text = `${error.name} ${error.message}`.toLowerCase(); + return ( + text.includes("api key") || + text.includes("cannot be empty") || + text.includes("authentication") || + text.includes("unauthorized") || + text.includes("401") || + text.includes("403") + ); +} + +export function isNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const text = `${error.name} ${error.message}`.toLowerCase(); + return text.includes("not found") || text.includes("404"); +} + +export function logProviderError( + scope: string, + error: unknown, + extra: Readonly>, +): void { + console.error(`[@mem0/eve] ${scope}`, { + error, + name: error instanceof Error ? error.name : undefined, + message: errorMessage(error), + ...extra, + }); +} diff --git a/integrations/eve/src/idempotency.ts b/integrations/eve/src/idempotency.ts index 6f41726e0..c1a8eea47 100644 --- a/integrations/eve/src/idempotency.ts +++ b/integrations/eve/src/idempotency.ts @@ -1,5 +1,7 @@ const DEFAULT_LIMIT = 256; +// In-process fast path only. Durable replay after restart is handled by +// listing memories with metadata.operation_id before add. export function createIdempotencyGate(limit = DEFAULT_LIMIT) { const seen = new Map>(); diff --git a/integrations/eve/src/index.ts b/integrations/eve/src/index.ts index 024ecfaee..bf1c81c1b 100644 --- a/integrations/eve/src/index.ts +++ b/integrations/eve/src/index.ts @@ -1,3 +1,8 @@ export { mem0Provider, mem0Provider as default } from "./provider.js"; -export type { Mem0ApiKey, Mem0ProviderOptions } from "./options.js"; +export type { + Mem0ApiKey, + Mem0InjectedProviderOptions, + Mem0ProviderOptions, + Mem0RemoteProviderOptions, +} from "./options.js"; export type { MemoryStore, MemoryMessage, SearchHit } from "./store.js"; diff --git a/integrations/eve/src/messages.ts b/integrations/eve/src/messages.ts index ef76ca4e0..18d7dd36f 100644 --- a/integrations/eve/src/messages.ts +++ b/integrations/eve/src/messages.ts @@ -5,27 +5,29 @@ export interface ConversationMessage { readonly content: unknown; } +function textFromPart(part: unknown): string { + 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 ""; +} + export function extractText(content: unknown): string { if (typeof content === "string") { return content.trim(); } + if (content && typeof content === "object" && !Array.isArray(content)) { + return textFromPart(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(); + return content.map(textFromPart).join("\n").trim(); } export function lastUserText(messages: readonly ConversationMessage[]): string { @@ -70,6 +72,7 @@ export function completedTurnMessages(input: { return []; } + // Pair this turn's users with the latest assistant in the settled history. const history = conversationMessages(input.messages); const lastAssistant = [...history] .reverse() diff --git a/integrations/eve/src/options.ts b/integrations/eve/src/options.ts index 3e37c1417..c370bd4c2 100644 --- a/integrations/eve/src/options.ts +++ b/integrations/eve/src/options.ts @@ -2,9 +2,7 @@ import type { MemoryStore } from "./store.js"; export type Mem0ApiKey = string | (() => string | Promise); -export interface Mem0ProviderOptions { - readonly apiKey: Mem0ApiKey; - readonly host?: string; +export interface Mem0SharedProviderOptions { readonly topK?: number; readonly threshold?: number; readonly rerank?: boolean; @@ -16,11 +14,23 @@ export interface Mem0ProviderOptions { readonly enabled?: boolean; }; readonly metadata?: Readonly>; - readonly store?: MemoryStore; } -export interface ResolvedMem0ProviderOptions { +export interface Mem0RemoteProviderOptions extends Mem0SharedProviderOptions { readonly apiKey: Mem0ApiKey; + readonly host?: string; +} + +export interface Mem0InjectedProviderOptions extends Mem0SharedProviderOptions { + readonly store: MemoryStore; + readonly apiKey?: Mem0ApiKey; + readonly host?: string; +} + +export type Mem0ProviderOptions = Mem0RemoteProviderOptions | Mem0InjectedProviderOptions; + +export interface ResolvedMem0ProviderOptions { + readonly apiKey?: Mem0ApiKey; readonly host: string; readonly topK: number; readonly threshold: number; @@ -36,13 +46,38 @@ export interface ResolvedMem0ProviderOptions { readonly store?: MemoryStore; } +function validateApiKey(apiKey: Mem0ApiKey): void { + if (typeof apiKey === "string" && apiKey.trim().length === 0) { + throw new Error("Mem0 API key cannot be empty"); + } +} + +function resolveHost(host: string | undefined): string { + const trimmed = host?.trim() ?? ""; + if (trimmed.length === 0) { + return "https://api.mem0.ai"; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("host must be an http or https URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("host must be an http or https URL"); + } + return trimmed; +} + export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0ProviderOptions { + const store = "store" in options ? options.store : undefined; 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"); - } + if (!store && apiKey === undefined) { + throw new Error("apiKey is required unless a store is provided"); + } + if (apiKey !== undefined) { + validateApiKey(apiKey); } const topK = options.topK ?? 5; @@ -56,8 +91,8 @@ export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0Provid } return { - apiKey, - host: options.host ?? "https://api.mem0.ai", + ...(apiKey !== undefined ? { apiKey } : {}), + host: resolveHost(options.host), topK, threshold, rerank: options.rerank ?? false, @@ -69,6 +104,6 @@ export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0Provid enabled: options.capture?.enabled ?? true, }, metadata: options.metadata ?? {}, - ...(options.store ? { store: options.store } : {}), + ...(store ? { store } : {}), }; } diff --git a/integrations/eve/src/provider.ts b/integrations/eve/src/provider.ts index c2e08a129..232450fc2 100644 --- a/integrations/eve/src/provider.ts +++ b/integrations/eve/src/provider.ts @@ -1,11 +1,11 @@ import { defineMemoryProvider, type MemoryProvider } from "eve/memory"; import { captureCompletedTurn } from "./capture.js"; -import { errorMessage } from "./errors.js"; +import { isSetupError, logProviderError } 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 { createLazyStore, createMem0Store, type MemoryStore } from "./store.js"; import { createMem0Tools } from "./tools.js"; function asConversation(messages: readonly unknown[]): ConversationMessage[] { @@ -14,7 +14,9 @@ function asConversation(messages: readonly unknown[]): ConversationMessage[] { return false; } return ( - "role" in message && typeof (message as { role: unknown }).role === "string" + "role" in message && + typeof (message as { role: unknown }).role === "string" && + "content" in message ); }); } @@ -22,17 +24,21 @@ function asConversation(messages: readonly unknown[]): ConversationMessage[] { export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider { const config = resolveOptions(options); const once = createIdempotencyGate(); - let storePromise: Promise | undefined; + const loadRemoteStore = createLazyStore(async () => { + if (!config.apiKey) { + throw new Error("Mem0 API key cannot be empty"); + } + return createMem0Store({ + apiKey: config.apiKey, + host: config.host, + }); + }); const getStore = async (): Promise => { if (config.store) { return config.store; } - storePromise ??= createMem0Store({ - apiKey: config.apiKey, - host: config.host, - }); - return storePromise; + return loadRemoteStore(); }; const recall = async (context: { @@ -47,7 +53,13 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider { } const querySource = context.turn?.input ?? context.messages; - const query = lastUserText(asConversation(querySource)); + const conversation = asConversation(querySource); + if (querySource.length > 0 && conversation.length === 0) { + console.error("[@mem0/eve] dropped all recall messages; no searchable user text", { + sessionId: context.session.id, + }); + } + const query = lastUserText(conversation); try { const store = await getStore(); @@ -60,14 +72,13 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider { rerank: config.rerank, }); } catch (error) { - if (context.abortSignal.aborted) { + if (context.abortSignal.aborted || isSetupError(error)) { throw error; } - console.error("[@mem0/eve] recall failed", { - error: errorMessage(error), + logProviderError("recall failed", error, { sessionId: context.session.id, }); - return null; + throw error; } }; @@ -80,41 +91,36 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider { ? { 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), - }); + const store = await getStore(); + await once(context.operationId, async () => { + await captureCompletedTurn({ + store, + scopeKey: context.memory.scope.key, + operationId: context.operationId, + 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); + return createMem0Tools(store, context.memory.scope.key, { + topK: config.topK, + threshold: config.threshold, + rerank: config.rerank, + infer: config.infer, + }); }, }); } diff --git a/integrations/eve/src/recall.ts b/integrations/eve/src/recall.ts index 18a39baf7..e2eec2ec4 100644 --- a/integrations/eve/src/recall.ts +++ b/integrations/eve/src/recall.ts @@ -7,8 +7,9 @@ export interface RecallMessage { export function formatRecallMessages(hits: readonly SearchHit[]): RecallMessage[] { return hits.flatMap((hit) => { + const id = hit.id.trim(); const content = hit.memory.trim(); - return content.length > 0 ? [{ id: hit.id, content }] : []; + return id.length > 0 && content.length > 0 ? [{ id, content }] : []; }); } @@ -26,7 +27,7 @@ export async function recallMemories(input: { } const { results } = await input.store.search(query, { - filters: { user_id: input.scopeKey }, + userId: input.scopeKey, topK: input.topK, threshold: input.threshold, rerank: input.rerank, diff --git a/integrations/eve/src/store.ts b/integrations/eve/src/store.ts index bf0148e9d..9d7d27b42 100644 --- a/integrations/eve/src/store.ts +++ b/integrations/eve/src/store.ts @@ -1,4 +1,5 @@ import type { Mem0ApiKey } from "./options.js"; +import { isNotFoundError } from "./errors.js"; export type MemoryRole = "user" | "assistant"; @@ -12,25 +13,94 @@ export interface SearchHit { readonly memory: string; } -export interface AddMemoryInput { +export interface MemoryRecord { + readonly id: string; readonly userId: string; - readonly infer: boolean; readonly metadata: Readonly>; } +export type StoreMetadata = Readonly>; + +export interface AddMemoryInput { + readonly userId: string; + readonly infer: boolean; + readonly metadata: StoreMetadata; +} + export interface SearchMemoryInput { - readonly filters: { - readonly user_id: string; - }; + readonly userId: string; readonly topK: number; readonly threshold: number; readonly rerank: boolean; } export interface MemoryStore { - add(messages: readonly MemoryMessage[], input: AddMemoryInput): Promise; + add(messages: readonly MemoryMessage[], input: AddMemoryInput): Promise; search(query: string, input: SearchMemoryInput): Promise<{ results: readonly SearchHit[] }>; - delete(memoryId: string): Promise; + get(memoryId: string): Promise; + listByMetadata(input: { + readonly userId: string; + readonly metadata: Readonly>; + }): Promise; + delete(memoryId: string, userId: string): Promise; +} + +export function parseSearchHit(item: unknown): SearchHit | null { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + const nested = + record.data && typeof record.data === "object" + ? (record.data as Record) + : undefined; + const idValue = record.id ?? record.memory_id ?? nested?.id; + const id = + typeof idValue === "string" + ? idValue.trim() + : typeof idValue === "number" + ? String(idValue) + : ""; + const memoryValue = + record.memory ?? record.text ?? nested?.memory ?? nested?.text; + const memory = typeof memoryValue === "string" ? memoryValue : ""; + if (id.length === 0 || memory.trim().length === 0) { + return null; + } + return { id, memory }; +} + +export function parseMemoryRecord(item: unknown): MemoryRecord | null { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + const idValue = record.id ?? record.memory_id; + const id = typeof idValue === "string" ? idValue.trim() : ""; + const userIdValue = record.userId ?? record.user_id; + const userId = typeof userIdValue === "string" ? userIdValue : ""; + if (id.length === 0 || userId.length === 0) { + return null; + } + const metadata = + record.metadata && typeof record.metadata === "object" + ? (record.metadata as Record) + : {}; + return { id, userId, metadata }; +} + +function asItemList(response: unknown, operation: string): unknown[] { + if (Array.isArray(response)) { + return response; + } + if (response && typeof response === "object") { + const record = response as Record; + const raw = record.results ?? record.memories; + if (Array.isArray(raw)) { + return raw; + } + } + throw new Error(`Mem0 ${operation} returned an unexpected response shape`); } export async function resolveApiKey(apiKey: Mem0ApiKey): Promise { @@ -42,6 +112,20 @@ export async function resolveApiKey(apiKey: Mem0ApiKey): Promise { return trimmed; } +export function createLazyStore(factory: () => Promise) { + let pending: Promise | undefined; + + return async (): Promise => { + if (!pending) { + pending = factory().catch((error: unknown) => { + pending = undefined; + throw error; + }); + } + return pending; + }; +} + export async function createMem0Store(input: { apiKey: Mem0ApiKey; host: string; @@ -52,9 +136,9 @@ export async function createMem0Store(input: { host: input.host, }); - return { + const store: MemoryStore = { async add(messages, options) { - return client.add([...messages], { + await client.add([...messages], { userId: options.userId, infer: options.infer, metadata: { ...options.metadata }, @@ -62,22 +146,47 @@ export async function createMem0Store(input: { }, async search(query, options) { const response = await client.search(query, { - filters: options.filters, + filters: { user_id: options.userId }, 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 }] : []; + results: asItemList(response, "search").flatMap((item) => { + const hit = parseSearchHit(item); + return hit ? [hit] : []; }), }; }, - async delete(memoryId) { - return client.delete(memoryId); + async get(memoryId) { + try { + return parseMemoryRecord(await client.get(memoryId)); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + }, + async listByMetadata({ userId, metadata }) { + const response = await client.getAll({ + filters: { + AND: [{ user_id: userId }, { metadata }], + }, + pageSize: 5, + }); + return asItemList(response, "getAll").flatMap((item) => { + const record = parseMemoryRecord(item); + return record && record.userId === userId ? [record] : []; + }); + }, + async delete(memoryId, userId) { + const record = await store.get(memoryId); + if (!record || record.userId !== userId) { + throw new Error("Memory not found"); + } + await client.delete(memoryId); }, }; + return store; } diff --git a/integrations/eve/src/tools.ts b/integrations/eve/src/tools.ts index c8bd9eddc..3d65b1ede 100644 --- a/integrations/eve/src/tools.ts +++ b/integrations/eve/src/tools.ts @@ -2,7 +2,18 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import type { MemoryStore } from "./store.js"; -export function createMem0Tools(store: MemoryStore, scopeKey: string) { +export interface Mem0ToolOptions { + readonly topK: number; + readonly threshold: number; + readonly rerank: boolean; + readonly infer: boolean; +} + +export function createMem0Tools( + store: MemoryStore, + scopeKey: string, + options: Mem0ToolOptions, +) { return { search: defineTool({ description: "Search long-term memories for the current caller.", @@ -11,10 +22,10 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) { }), async execute({ query }) { const { results } = await store.search(query, { - filters: { user_id: scopeKey }, - topK: 8, - threshold: 0.1, - rerank: false, + userId: scopeKey, + topK: options.topK, + threshold: options.threshold, + rerank: options.rerank, }); return { memories: results.map((hit) => ({ @@ -33,7 +44,7 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) { async execute({ text }) { await store.add([{ role: "user", content: text }], { userId: scopeKey, - infer: true, + infer: options.infer, metadata: { source: "eve-tool" }, }); return { saved: true }; @@ -45,7 +56,7 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) { id: z.string().min(1), }), async execute({ id }) { - await store.delete(id); + await store.delete(id, scopeKey); return { deleted: true }; }, }), diff --git a/integrations/eve/tests/capture.test.ts b/integrations/eve/tests/capture.test.ts index da0351a17..d2a653162 100644 --- a/integrations/eve/tests/capture.test.ts +++ b/integrations/eve/tests/capture.test.ts @@ -8,8 +8,9 @@ describe("captureCompletedTurn", () => { const captured = await captureCompletedTurn({ store, scopeKey: "scope_abc", - infer: true, - metadata: { source: "eve" }, + operationId: "op_1", + infer: false, + metadata: { source: "eve", operation_id: "op_1" }, turnInput: [{ role: "user", content: "I am vegetarian" }], messages: [ { role: "user", content: "I am vegetarian" }, @@ -21,6 +22,8 @@ describe("captureCompletedTurn", () => { expect(store.added).toEqual([ { userId: "scope_abc", + infer: false, + metadata: { source: "eve", operation_id: "op_1" }, messages: [ { role: "user", content: "I am vegetarian" }, { role: "assistant", content: "Got it." }, @@ -34,12 +37,38 @@ describe("captureCompletedTurn", () => { const captured = await captureCompletedTurn({ store, scopeKey: "scope_abc", + operationId: "op_1", infer: true, - metadata: {}, + metadata: { operation_id: "op_1" }, turnInput: [], messages: [], }); expect(captured).toBe(false); expect(store.added).toEqual([]); }); + + it("skips add when the operation id was already captured", async () => { + const store = createFakeStore([ + { + id: "mem_existing", + memory: "already stored", + metadata: { operation_id: "op_replay" }, + }, + ]); + const captured = await captureCompletedTurn({ + store, + scopeKey: "scope_abc", + operationId: "op_replay", + infer: true, + metadata: { operation_id: "op_replay" }, + turnInput: [{ role: "user", content: "I am vegetarian" }], + messages: [ + { role: "user", content: "I am vegetarian" }, + { role: "assistant", content: "Got it." }, + ], + }); + + expect(captured).toBe(false); + expect(store.added).toEqual([]); + }); }); diff --git a/integrations/eve/tests/errors.test.ts b/integrations/eve/tests/errors.test.ts new file mode 100644 index 000000000..d9e672eb1 --- /dev/null +++ b/integrations/eve/tests/errors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; +import { + errorMessage, + isNotFoundError, + isSetupError, + logProviderError, +} from "../src/errors.js"; + +describe("errors", () => { + it("classifies setup and not-found errors", () => { + expect(isSetupError(new Error("Mem0 API key cannot be empty"))).toBe(true); + expect(isSetupError(Object.assign(new Error("denied"), { name: "AuthenticationError" }))).toBe( + true, + ); + expect(isSetupError(new Error("mem0 down"))).toBe(false); + expect(isNotFoundError(new Error("Memory not found"))).toBe(true); + expect(isNotFoundError(new Error("HTTP 404"))).toBe(true); + expect(errorMessage("x")).toBe("Unknown Mem0 error"); + }); + + it("logs the original error object", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const failure = Object.assign(new Error("HTTP 401"), { + name: "AuthenticationError", + }); + try { + logProviderError("recall failed", failure, { sessionId: "sess_1" }); + expect(error).toHaveBeenCalledWith("[@mem0/eve] recall failed", { + error: failure, + name: "AuthenticationError", + message: "HTTP 401", + sessionId: "sess_1", + }); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/integrations/eve/tests/helpers.ts b/integrations/eve/tests/helpers.ts index e50440709..f84555ee7 100644 --- a/integrations/eve/tests/helpers.ts +++ b/integrations/eve/tests/helpers.ts @@ -1,29 +1,86 @@ -import type { MemoryMessage, MemoryStore, SearchHit } from "../src/store.js"; +import type { + AddMemoryInput, + MemoryMessage, + MemoryRecord, + MemoryStore, + SearchHit, + SearchMemoryInput, +} from "../src/store.js"; -export function createFakeStore(hits: SearchHit[] = []): MemoryStore & { - added: Array<{ messages: readonly MemoryMessage[]; userId: string }>; - searched: Array<{ query: string; userId: string }>; +export type FakeHit = SearchHit & { + readonly userId?: string; + readonly metadata?: Readonly>; +}; + +export type FakeAdd = { + messages: readonly MemoryMessage[]; + userId: string; + infer: boolean; + metadata: Readonly>; +}; + +export type FakeSearch = SearchMemoryInput & { + query: string; +}; + +export function createFakeStore(hits: FakeHit[] = []): MemoryStore & { + added: FakeAdd[]; + searched: FakeSearch[]; deleted: string[]; + records: MemoryRecord[]; } { - const added: Array<{ messages: readonly MemoryMessage[]; userId: string }> = []; - const searched: Array<{ query: string; userId: string }> = []; + const added: FakeAdd[] = []; + const searched: FakeSearch[] = []; const deleted: string[] = []; + const records: MemoryRecord[] = hits.map((hit) => ({ + id: hit.id, + userId: hit.userId ?? "scope_abc", + metadata: hit.metadata ?? {}, + })); return { added, searched, deleted, + records, async add(messages, input) { - added.push({ messages, userId: input.userId }); - return { eventId: "evt_1" }; + added.push({ + messages, + userId: input.userId, + infer: input.infer, + metadata: input.metadata, + }); + records.push({ + id: `mem_added_${added.length}`, + userId: input.userId, + metadata: input.metadata, + }); }, async search(query, input) { - searched.push({ query, userId: input.filters.user_id }); - return { results: hits }; + searched.push({ query, ...input }); + return { + results: hits.filter((hit) => (hit.userId ?? "scope_abc") === input.userId), + }; }, - async delete(memoryId) { + async get(memoryId) { + return records.find((record) => record.id === memoryId) ?? null; + }, + async listByMetadata({ userId, metadata }) { + return records.filter((record) => { + if (record.userId !== userId) { + return false; + } + return Object.entries(metadata).every( + ([key, value]) => record.metadata[key] === value, + ); + }); + }, + async delete(memoryId, userId) { + const record = records.find((item) => item.id === memoryId); + if (!record || record.userId !== userId) { + throw new Error("Memory not found"); + } deleted.push(memoryId); - return { message: "ok" }; }, }; } @@ -31,6 +88,14 @@ export function createFakeStore(hits: SearchHit[] = []): MemoryStore & { export function memoryContext(input?: { scopeKey?: string; operationId?: string; + aborted?: boolean; + turn?: + | { + id?: string; + sequence?: number; + input: Array<{ role: string; content: unknown }>; + } + | null; turnInput?: Array<{ role: string; content: unknown }>; messages?: Array<{ role: string; content: unknown }>; }): { @@ -46,10 +111,21 @@ export function memoryContext(input?: { id: string; sequence: number; input: Array<{ role: string; content: unknown }>; - }; + } | null; } { + const controller = new AbortController(); + if (input?.aborted) { + controller.abort(); + } + + const turnInput = + input?.turn === null + ? undefined + : (input?.turn?.input ?? + input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }]); + return { - abortSignal: new AbortController().signal, + abortSignal: controller.signal, operationId: input?.operationId ?? "op_1", session: { id: "sess_1" }, memory: { @@ -64,10 +140,13 @@ export function memoryContext(input?: { { 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" }], - }, + turn: + input?.turn === null + ? null + : { + id: input?.turn?.id ?? "turn_1", + sequence: input?.turn?.sequence ?? 1, + input: turnInput ?? [{ role: "user", content: "I am vegetarian" }], + }, }; } diff --git a/integrations/eve/tests/idempotency.test.ts b/integrations/eve/tests/idempotency.test.ts index 56bb74a0b..d92af9069 100644 --- a/integrations/eve/tests/idempotency.test.ts +++ b/integrations/eve/tests/idempotency.test.ts @@ -29,4 +29,19 @@ describe("createIdempotencyGate", () => { await once("op_1", work); expect(calls).toBe(2); }); + + it("evicts the oldest id after the cap", async () => { + const once = createIdempotencyGate(2); + let calls = 0; + const work = async () => { + calls += 1; + }; + + await once("op_1", work); + await once("op_2", work); + await once("op_3", work); + await once("op_1", work); + + expect(calls).toBe(4); + }); }); diff --git a/integrations/eve/tests/messages.test.ts b/integrations/eve/tests/messages.test.ts index 5f1de765a..4e8fca5e1 100644 --- a/integrations/eve/tests/messages.test.ts +++ b/integrations/eve/tests/messages.test.ts @@ -20,6 +20,10 @@ describe("extractText", () => { ).toBe("I like\ntea"); }); + it("reads a single text object", () => { + expect(extractText({ type: "text", text: " solo " })).toBe("solo"); + }); + it("ignores non-text parts", () => { expect(extractText([{ type: "image", url: "x" }])).toBe(""); }); diff --git a/integrations/eve/tests/options.test.ts b/integrations/eve/tests/options.test.ts index 6b67ff9b0..6b6ab80a9 100644 --- a/integrations/eve/tests/options.test.ts +++ b/integrations/eve/tests/options.test.ts @@ -4,7 +4,8 @@ import { createFakeStore } from "./helpers.js"; describe("resolveOptions", () => { it("applies defaults", () => { - expect(resolveOptions({ apiKey: "m0-test" })).toMatchObject({ + expect(resolveOptions({ apiKey: "m0-test" })).toEqual({ + apiKey: "m0-test", host: "https://api.mem0.ai", topK: 5, threshold: 0.1, @@ -12,6 +13,7 @@ describe("resolveOptions", () => { infer: true, autoSearch: { enabled: true }, capture: { enabled: true }, + metadata: {}, }); }); @@ -19,8 +21,56 @@ describe("resolveOptions", () => { expect(() => resolveOptions({ apiKey: " " })).toThrow(/empty/); }); + it("requires an api key when no store is provided", () => { + expect(() => resolveOptions({} as never)).toThrow(/apiKey is required/); + }); + + it("allows a store without an api key", () => { + const store = createFakeStore(); + expect(resolveOptions({ store }).store).toBe(store); + }); + + it("does not invoke a function api key at resolve time", () => { + let calls = 0; + const apiKey = () => { + calls += 1; + return ""; + }; + expect(() => resolveOptions({ apiKey })).not.toThrow(); + expect(calls).toBe(0); + }); + it("rejects an invalid topK", () => { expect(() => resolveOptions({ apiKey: "m0-test", topK: 0 })).toThrow(/topK/); + expect(() => resolveOptions({ apiKey: "m0-test", topK: 101 })).toThrow(/topK/); + expect(() => resolveOptions({ apiKey: "m0-test", topK: 1.5 })).toThrow(/topK/); + }); + + it("rejects an invalid threshold", () => { + expect(() => resolveOptions({ apiKey: "m0-test", threshold: -0.1 })).toThrow( + /threshold/, + ); + expect(() => resolveOptions({ apiKey: "m0-test", threshold: 1.1 })).toThrow( + /threshold/, + ); + expect(() => resolveOptions({ apiKey: "m0-test", threshold: Number.NaN })).toThrow( + /threshold/, + ); + }); + + it("rejects a non-http host", () => { + expect(() => resolveOptions({ apiKey: "m0-test", host: "localhost" })).toThrow( + /host/, + ); + expect(() => resolveOptions({ apiKey: "m0-test", host: "ftp://mem0.ai" })).toThrow( + /host/, + ); + }); + + it("treats a blank host as the default", () => { + expect(resolveOptions({ apiKey: "m0-test", host: " " }).host).toBe( + "https://api.mem0.ai", + ); }); it("keeps an injected store", () => { diff --git a/integrations/eve/tests/provider.test.ts b/integrations/eve/tests/provider.test.ts index a34313314..e6a30af9d 100644 --- a/integrations/eve/tests/provider.test.ts +++ b/integrations/eve/tests/provider.test.ts @@ -1,4 +1,5 @@ import type { + MemoryCompactionCompletedContext, MemoryToolsContext, MemoryTurnCompletedContext, MemoryTurnStartedContext, @@ -19,6 +20,15 @@ function completedContext( return memoryContext(input) as unknown as MemoryTurnCompletedContext; } +function compactionContext( + input?: Parameters[0], +): MemoryCompactionCompletedContext { + return memoryContext({ + ...input, + turn: input?.turn === undefined ? null : input.turn, + }) as unknown as MemoryCompactionCompletedContext; +} + function toolsContext( input?: Parameters[0], ): MemoryToolsContext { @@ -32,10 +42,20 @@ async function runTool( return tool.execute(input as never, {} as never); } +function requireCapture( + provider: ReturnType, +): NonNullable["turn.completed"]> { + const capture = provider.capture?.["turn.completed"]; + if (!capture) { + throw new Error("expected turn.completed"); + } + return capture; +} + 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 provider = mem0Provider({ store }); const result = await provider.recall["turn.started"]( startedContext({ turnInput: [{ role: "user", content: "What do I drink?" }], @@ -43,31 +63,110 @@ describe("mem0Provider", () => { ); expect(store.searched).toEqual([ - { query: "What do I drink?", userId: "scope_abc" }, + { + query: "What do I drink?", + userId: "scope_abc", + topK: 5, + threshold: 0.1, + rerank: false, + }, ]); expect(result).toEqual({ messages: [{ id: "mem_1", content: "User likes tea" }], }); }); - it("does not fail the turn when search throws", async () => { + it("forwards recall search options", async () => { + const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]); + const provider = mem0Provider({ + store, + topK: 3, + threshold: 0.5, + rerank: true, + }); + await provider.recall["turn.started"]( + startedContext({ + turnInput: [{ role: "user", content: "tea" }], + }), + ); + expect(store.searched[0]).toMatchObject({ + topK: 3, + threshold: 0.5, + rerank: true, + }); + }); + + it("skips search when autoSearch is disabled", async () => { + const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]); + const provider = mem0Provider({ store, autoSearch: { enabled: false } }); + await expect( + provider.recall["turn.started"](startedContext()), + ).resolves.toBeNull(); + expect(store.searched).toEqual([]); + }); + + it("recalls after compaction when turn is null", async () => { + const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]); + const provider = mem0Provider({ store }); + const recallAfterCompaction = provider.recall["compaction.completed"]; + if (!recallAfterCompaction) { + throw new Error("expected compaction.completed"); + } + const result = await recallAfterCompaction( + compactionContext({ + messages: [ + { role: "user", content: "What do I drink?" }, + { role: "assistant", content: "tea" }, + ], + }), + ); + + expect(store.searched).toEqual([ + { + query: "What do I drink?", + userId: "scope_abc", + topK: 5, + threshold: 0.1, + rerank: false, + }, + ]); + expect(result).toEqual({ + messages: [{ id: "mem_1", content: "User likes tea" }], + }); + }); + + it("fails 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 provider = mem0Provider({ store }); const error = vi.spyOn(console, "error").mockImplementation(() => {}); - await expect( - provider.recall["turn.started"](startedContext()), - ).resolves.toBeNull(); + try { + await expect( + provider.recall["turn.started"](startedContext()), + ).rejects.toThrow("mem0 down"); + } finally { + error.mockRestore(); + } + }); - error.mockRestore(); + it("rethrows when recall is aborted", async () => { + const store = createFakeStore(); + store.search = async () => { + throw new Error("cancelled"); + }; + const provider = mem0Provider({ store }); + + await expect( + provider.recall["turn.started"](startedContext({ aborted: true })), + ).rejects.toThrow("cancelled"); }); it("captures a completed turn once per operation id", async () => { const store = createFakeStore(); - const provider = mem0Provider({ apiKey: "m0-test", store }); + const provider = mem0Provider({ store }); const capture = provider.capture?.["turn.completed"]; expect(capture).toBeTypeOf("function"); @@ -76,12 +175,42 @@ describe("mem0Provider", () => { await capture!(context); expect(store.added).toHaveLength(1); - expect(store.added[0]?.userId).toBe("scope_abc"); + expect(store.added[0]).toMatchObject({ + userId: "scope_abc", + infer: true, + metadata: { + source: "eve", + operation_id: "op_replay", + session_id: "sess_1", + slot: "mem0", + }, + }); + }); + + it("does not recapture after process restart when operation metadata exists", async () => { + const store = createFakeStore(); + const first = requireCapture(mem0Provider({ store })); + await first(completedContext({ operationId: "op_replay" })); + + const restarted = requireCapture(mem0Provider({ store })); + await restarted(completedContext({ operationId: "op_replay" })); + + expect(store.added).toHaveLength(1); + }); + + it("surfaces capture store failures", async () => { + const store = createFakeStore(); + store.add = async () => { + throw new Error("write failed"); + }; + + await expect( + requireCapture(mem0Provider({ store }))(completedContext()), + ).rejects.toThrow("write failed"); }); it("omits capture when it is disabled", () => { const provider = mem0Provider({ - apiKey: "m0-test", store: createFakeStore(), capture: { enabled: false }, }); @@ -90,7 +219,13 @@ describe("mem0Provider", () => { 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 provider = mem0Provider({ + store, + topK: 3, + threshold: 0.4, + rerank: true, + infer: false, + }); const tools = await provider.tools!(toolsContext()); if (!tools) { throw new Error("expected tools"); @@ -114,8 +249,52 @@ describe("mem0Provider", () => { }); 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.searched[0]).toMatchObject({ + userId: "scope_abc", + topK: 3, + threshold: 0.4, + rerank: true, + }); + expect(store.added[0]).toMatchObject({ + userId: "scope_abc", + infer: false, + metadata: { source: "eve-tool" }, + messages: [{ role: "user", content: "Allergic to peanuts" }], + }); expect(store.deleted).toEqual(["mem_1"]); }); + + it("refuses to forget a memory from another scope", async () => { + const store = createFakeStore([ + { id: "mem_other", memory: "secret", userId: "scope_other" }, + ]); + const provider = mem0Provider({ store }); + const tools = await provider.tools!(toolsContext()); + const forgetTool = tools?.forget; + if (!forgetTool) { + throw new Error("expected forget tool"); + } + + await expect(runTool(forgetTool, { id: "mem_other" })).rejects.toThrow( + /Memory not found/, + ); + expect(store.deleted).toEqual([]); + }); + + it("surfaces tool store failures", async () => { + const store = createFakeStore(); + store.add = async () => { + throw new Error("add failed"); + }; + const provider = mem0Provider({ store }); + const tools = await provider.tools!(toolsContext()); + const rememberTool = tools?.remember; + if (!rememberTool) { + throw new Error("expected remember tool"); + } + + await expect( + runTool(rememberTool, { text: "remember this" }), + ).rejects.toThrow("add failed"); + }); }); diff --git a/integrations/eve/tests/recall.test.ts b/integrations/eve/tests/recall.test.ts index da95f26a3..7d55eb647 100644 --- a/integrations/eve/tests/recall.test.ts +++ b/integrations/eve/tests/recall.test.ts @@ -3,11 +3,12 @@ import { formatRecallMessages, recallMemories } from "../src/recall.js"; import { createFakeStore } from "./helpers.js"; describe("formatRecallMessages", () => { - it("drops empty memories", () => { + it("drops empty memories and empty ids", () => { expect( formatRecallMessages([ { id: "1", memory: " likes tea " }, { id: "2", memory: " " }, + { id: " ", memory: "kept text" }, ]), ).toEqual([{ id: "1", content: "likes tea" }]); }); @@ -20,13 +21,19 @@ describe("recallMemories", () => { store, scopeKey: "scope_abc", query: "What does the user drink?", - topK: 5, - threshold: 0.1, - rerank: false, + topK: 3, + threshold: 0.5, + rerank: true, }); expect(store.searched).toEqual([ - { query: "What does the user drink?", userId: "scope_abc" }, + { + query: "What does the user drink?", + userId: "scope_abc", + topK: 3, + threshold: 0.5, + rerank: true, + }, ]); expect(result).toEqual({ messages: [{ id: "mem_1", content: "User likes tea" }], @@ -47,4 +54,18 @@ describe("recallMemories", () => { ).resolves.toBeNull(); expect(store.searched).toEqual([]); }); + + it("returns null when search has no usable hits", async () => { + const store = createFakeStore([{ id: " ", memory: " " }]); + await expect( + recallMemories({ + store, + scopeKey: "scope_abc", + query: "tea", + topK: 5, + threshold: 0.1, + rerank: false, + }), + ).resolves.toBeNull(); + }); }); diff --git a/integrations/eve/tests/store.test.ts b/integrations/eve/tests/store.test.ts new file mode 100644 index 000000000..1ed3ccd15 --- /dev/null +++ b/integrations/eve/tests/store.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { MemoryClient, add, search, get, getAll, del } = vi.hoisted(() => { + const add = vi.fn(); + const search = vi.fn(); + const get = vi.fn(); + const getAll = vi.fn(); + const del = vi.fn(); + const MemoryClient = vi.fn( + class { + add = add; + search = search; + get = get; + getAll = getAll; + delete = del; + }, + ); + return { MemoryClient, add, search, get, getAll, del }; +}); + +vi.mock("mem0ai", () => ({ + default: MemoryClient, +})); + +import { + createLazyStore, + createMem0Store, + parseMemoryRecord, + parseSearchHit, + resolveApiKey, +} from "../src/store.js"; + +describe("parseSearchHit", () => { + it("accepts id, memory_id, nested text, and numeric ids", () => { + expect(parseSearchHit({ id: 12, memory: "tea" })).toEqual({ + id: "12", + memory: "tea", + }); + expect( + parseSearchHit({ memory_id: "m1", data: { memory: "nested" } }), + ).toEqual({ id: "m1", memory: "nested" }); + expect(parseSearchHit({ id: "m2", text: "plain" })).toEqual({ + id: "m2", + memory: "plain", + }); + expect(parseSearchHit({ id: "", memory: "x" })).toBeNull(); + }); +}); + +describe("parseMemoryRecord", () => { + it("requires id and userId", () => { + expect( + parseMemoryRecord({ + id: "m1", + user_id: "scope_abc", + metadata: { source: "eve" }, + }), + ).toEqual({ + id: "m1", + userId: "scope_abc", + metadata: { source: "eve" }, + }); + expect(parseMemoryRecord({ id: "m1" })).toBeNull(); + }); +}); + +describe("resolveApiKey", () => { + it("trims string keys and rejects empty values", async () => { + await expect(resolveApiKey(" token ")).resolves.toBe("token"); + await expect(resolveApiKey(" ")).rejects.toThrow(/empty/); + }); + + it("resolves async getters", async () => { + await expect(resolveApiKey(async () => " secret ")).resolves.toBe("secret"); + await expect(resolveApiKey(async () => "")).rejects.toThrow(/empty/); + }); +}); + +describe("createLazyStore", () => { + it("retries after a failed factory call", async () => { + let calls = 0; + const load = createLazyStore(async () => { + calls += 1; + if (calls === 1) { + throw new Error("unavailable"); + } + return { name: "store" } as never; + }); + + await expect(load()).rejects.toThrow("unavailable"); + await expect(load()).resolves.toEqual({ name: "store" }); + expect(calls).toBe(2); + }); +}); + +describe("createMem0Store", () => { + beforeEach(() => { + add.mockReset(); + search.mockReset(); + get.mockReset(); + getAll.mockReset(); + del.mockReset(); + MemoryClient.mockClear(); + }); + + it("constructs the client with a resolved key and host", async () => { + const store = await createMem0Store({ + apiKey: async () => " m0-live ", + host: "https://api.mem0.ai", + }); + expect(store).toBeTruthy(); + expect(MemoryClient).toHaveBeenCalledWith({ + apiKey: "m0-live", + host: "https://api.mem0.ai", + }); + }); + + it("forwards add, search, and metadata filters", async () => { + search.mockResolvedValue({ + results: [{ id: "m1", memory: "likes tea" }, { id: 2, memory: "" }], + }); + getAll.mockResolvedValue({ + results: [{ id: "m9", userId: "scope_abc", metadata: { operation_id: "op_1" } }], + }); + add.mockResolvedValue({ eventId: "evt_1" }); + + const store = await createMem0Store({ + apiKey: "m0-test", + host: "https://api.mem0.ai", + }); + + await store.add([{ role: "user", content: "I like tea" }], { + userId: "scope_abc", + infer: false, + metadata: { source: "eve", operation_id: "op_1" }, + }); + const found = await store.search("tea", { + userId: "scope_abc", + topK: 3, + threshold: 0.5, + rerank: true, + }); + const existing = await store.listByMetadata({ + userId: "scope_abc", + metadata: { operation_id: "op_1" }, + }); + + expect(add).toHaveBeenCalledWith( + [{ role: "user", content: "I like tea" }], + { + userId: "scope_abc", + infer: false, + metadata: { source: "eve", operation_id: "op_1" }, + }, + ); + expect(search).toHaveBeenCalledWith("tea", { + filters: { user_id: "scope_abc" }, + topK: 3, + threshold: 0.5, + rerank: true, + }); + expect(found.results).toEqual([{ id: "m1", memory: "likes tea" }]); + expect(existing).toEqual([ + { + id: "m9", + userId: "scope_abc", + metadata: { operation_id: "op_1" }, + }, + ]); + }); + + it("accepts a bare search array and rejects unknown envelopes", async () => { + const store = await createMem0Store({ + apiKey: "m0-test", + host: "https://api.mem0.ai", + }); + search.mockResolvedValueOnce([{ id: "m1", memory: "tea" }]); + await expect( + store.search("tea", { + userId: "scope_abc", + topK: 5, + threshold: 0.1, + rerank: false, + }), + ).resolves.toEqual({ results: [{ id: "m1", memory: "tea" }] }); + + search.mockResolvedValueOnce({ memories: "nope" }); + await expect( + store.search("tea", { + userId: "scope_abc", + topK: 5, + threshold: 0.1, + rerank: false, + }), + ).rejects.toThrow(/unexpected response shape/); + }); + + it("deletes only when the memory belongs to the scope", async () => { + const store = await createMem0Store({ + apiKey: "m0-test", + host: "https://api.mem0.ai", + }); + + get.mockResolvedValueOnce({ id: "m1", userId: "scope_abc" }); + await store.delete("m1", "scope_abc"); + expect(del).toHaveBeenCalledWith("m1"); + + get.mockResolvedValueOnce({ id: "m2", user_id: "other" }); + await expect(store.delete("m2", "scope_abc")).rejects.toThrow(/Memory not found/); + expect(del).toHaveBeenCalledTimes(1); + + get.mockRejectedValueOnce(new Error("Memory not found")); + await expect(store.delete("missing", "scope_abc")).rejects.toThrow( + /Memory not found/, + ); + }); +});