fix(eve): scope forget, persist capture idempotency, and fail closed
Review findings: delete was not partitioned by scope.key, capture replay after restart could double-write, and Mem0 errors were swallowed as empty recall. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1d83bb0835
commit
10d6717068
@@ -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.
|
[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
|
## Install
|
||||||
|
|
||||||
@@ -31,19 +31,15 @@ export default defineMemory({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
After Eve lists Mem0 in the official registry, the same slot is created by:
|
`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.
|
||||||
|
|
||||||
```bash
|
|
||||||
eve add memory/mem0
|
|
||||||
```
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **Recall.** Before each turn, Mem0 searches memories for the locked Eve scope and returns them as attributed user-role messages.
|
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 and assistant messages and extracts durable facts.
|
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`.
|
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
|
## Options
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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).
|
- [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.
|
- [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.
|
- [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.
|
- [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.
|
- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore.
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
|||||||
|
|
||||||
## What each one is
|
## 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`.
|
- **`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`).
|
- **`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.
|
- **`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.
|
||||||
|
|||||||
+17
-14
@@ -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).
|
`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
|
The live MCP path today is `eve add connection/mem0`. That is a different integration: tools the model may call, not automatic recall/capture.
|
||||||
eve add memory/mem0
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
| Eve hook | Mem0 behavior |
|
| Eve hook | Mem0 behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `recall["turn.started"]` | Semantic search over memories for the locked scope |
|
| `recall["turn.started"]` | Semantic search over memories for the locked scope |
|
||||||
| `recall["compaction.completed"]` | Same search after Eve compacting history |
|
| `recall["compaction.completed"]` | Same search after Eve compacting history (`turn` may be null) |
|
||||||
| `capture["turn.completed"]` | Add the completed user/assistant turn |
|
| `capture["turn.completed"]` | Add the completed user turn and, when present, the latest assistant reply |
|
||||||
| `tools()` | `search`, `remember`, `forget` |
|
| `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`.
|
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
|
## Options
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -63,15 +65,16 @@ mem0Provider({
|
|||||||
|
|
||||||
| Option | Default | Purpose |
|
| Option | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `apiKey` | required | String or async getter |
|
| `apiKey` | required without `store` | String or async getter. Function keys are validated on first use. |
|
||||||
| `host` | `https://api.mem0.ai` | Mem0 Platform host |
|
| `host` | `https://api.mem0.ai` | Mem0 Platform host (`http:` or `https:` URL) |
|
||||||
| `topK` | `5` | Memories recalled per turn |
|
| `topK` | `5` | Result count for automatic recall and the `search` tool |
|
||||||
| `threshold` | `0.1` | Minimum similarity |
|
| `threshold` | `0.1` | Minimum similarity for automatic recall and the `search` tool |
|
||||||
| `rerank` | `false` | Mem0 reranking |
|
| `rerank` | `false` | Mem0 reranking for automatic recall and the `search` tool |
|
||||||
| `infer` | `true` | Extract facts on capture |
|
| `infer` | `true` | Extract facts on capture and on `remember` |
|
||||||
| `autoSearch.enabled` | `true` | Recall before each turn |
|
| `autoSearch.enabled` | `true` | Recall on `turn.started` and `compaction.completed` |
|
||||||
| `capture.enabled` | `true` | Write after each successful turn |
|
| `capture.enabled` | `true` | Write after each successful turn |
|
||||||
| `metadata` | `{}` | Extra metadata on captured turns |
|
| `metadata` | `{}` | Extra metadata on captured turns |
|
||||||
|
| `store` | unset | Test / advanced injection. Replaces the Mem0 client and must honor `userId`. |
|
||||||
|
|
||||||
## Official registry
|
## Official registry
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## What the Eve PR should do
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { completedTurnMessages, type ConversationMessage } from "./messages.js";
|
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: {
|
export async function captureCompletedTurn(input: {
|
||||||
store: MemoryStore;
|
store: MemoryStore;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
|
operationId: string;
|
||||||
infer: boolean;
|
infer: boolean;
|
||||||
metadata: Readonly<Record<string, unknown>>;
|
metadata: StoreMetadata;
|
||||||
messages: readonly ConversationMessage[];
|
messages: readonly ConversationMessage[];
|
||||||
turnInput: readonly ConversationMessage[];
|
turnInput: readonly ConversationMessage[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
|
const existing = await input.store.listByMetadata({
|
||||||
|
userId: input.scopeKey,
|
||||||
|
metadata: { operation_id: input.operationId },
|
||||||
|
});
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const messages = completedTurnMessages({
|
const messages = completedTurnMessages({
|
||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
turnInput: input.turnInput,
|
turnInput: input.turnInput,
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
export function errorMessage(error: unknown): string {
|
export function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : "Unknown Mem0 error";
|
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<Record<string, unknown>>,
|
||||||
|
): void {
|
||||||
|
console.error(`[@mem0/eve] ${scope}`, {
|
||||||
|
error,
|
||||||
|
name: error instanceof Error ? error.name : undefined,
|
||||||
|
message: errorMessage(error),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const DEFAULT_LIMIT = 256;
|
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) {
|
export function createIdempotencyGate(limit = DEFAULT_LIMIT) {
|
||||||
const seen = new Map<string, Promise<void>>();
|
const seen = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
export { mem0Provider, mem0Provider as default } from "./provider.js";
|
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";
|
export type { MemoryStore, MemoryMessage, SearchHit } from "./store.js";
|
||||||
|
|||||||
@@ -5,27 +5,29 @@ export interface ConversationMessage {
|
|||||||
readonly content: unknown;
|
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 {
|
export function extractText(content: unknown): string {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return content.trim();
|
return content.trim();
|
||||||
}
|
}
|
||||||
|
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||||
|
return textFromPart(content).trim();
|
||||||
|
}
|
||||||
if (!Array.isArray(content)) {
|
if (!Array.isArray(content)) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return content
|
return content.map(textFromPart).join("\n").trim();
|
||||||
.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 {
|
export function lastUserText(messages: readonly ConversationMessage[]): string {
|
||||||
@@ -70,6 +72,7 @@ export function completedTurnMessages(input: {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pair this turn's users with the latest assistant in the settled history.
|
||||||
const history = conversationMessages(input.messages);
|
const history = conversationMessages(input.messages);
|
||||||
const lastAssistant = [...history]
|
const lastAssistant = [...history]
|
||||||
.reverse()
|
.reverse()
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import type { MemoryStore } from "./store.js";
|
|||||||
|
|
||||||
export type Mem0ApiKey = string | (() => string | Promise<string>);
|
export type Mem0ApiKey = string | (() => string | Promise<string>);
|
||||||
|
|
||||||
export interface Mem0ProviderOptions {
|
export interface Mem0SharedProviderOptions {
|
||||||
readonly apiKey: Mem0ApiKey;
|
|
||||||
readonly host?: string;
|
|
||||||
readonly topK?: number;
|
readonly topK?: number;
|
||||||
readonly threshold?: number;
|
readonly threshold?: number;
|
||||||
readonly rerank?: boolean;
|
readonly rerank?: boolean;
|
||||||
@@ -16,11 +14,23 @@ export interface Mem0ProviderOptions {
|
|||||||
readonly enabled?: boolean;
|
readonly enabled?: boolean;
|
||||||
};
|
};
|
||||||
readonly metadata?: Readonly<Record<string, string | number | boolean>>;
|
readonly metadata?: Readonly<Record<string, string | number | boolean>>;
|
||||||
readonly store?: MemoryStore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMem0ProviderOptions {
|
export interface Mem0RemoteProviderOptions extends Mem0SharedProviderOptions {
|
||||||
readonly apiKey: Mem0ApiKey;
|
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 host: string;
|
||||||
readonly topK: number;
|
readonly topK: number;
|
||||||
readonly threshold: number;
|
readonly threshold: number;
|
||||||
@@ -36,13 +46,38 @@ export interface ResolvedMem0ProviderOptions {
|
|||||||
readonly store?: MemoryStore;
|
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 {
|
export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0ProviderOptions {
|
||||||
|
const store = "store" in options ? options.store : undefined;
|
||||||
const apiKey = options.apiKey;
|
const apiKey = options.apiKey;
|
||||||
if (typeof apiKey === "string") {
|
if (!store && apiKey === undefined) {
|
||||||
const trimmed = apiKey.trim();
|
throw new Error("apiKey is required unless a store is provided");
|
||||||
if (trimmed.length === 0) {
|
}
|
||||||
throw new Error("Mem0 API key cannot be empty");
|
if (apiKey !== undefined) {
|
||||||
}
|
validateApiKey(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topK = options.topK ?? 5;
|
const topK = options.topK ?? 5;
|
||||||
@@ -56,8 +91,8 @@ export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0Provid
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey,
|
...(apiKey !== undefined ? { apiKey } : {}),
|
||||||
host: options.host ?? "https://api.mem0.ai",
|
host: resolveHost(options.host),
|
||||||
topK,
|
topK,
|
||||||
threshold,
|
threshold,
|
||||||
rerank: options.rerank ?? false,
|
rerank: options.rerank ?? false,
|
||||||
@@ -69,6 +104,6 @@ export function resolveOptions(options: Mem0ProviderOptions): ResolvedMem0Provid
|
|||||||
enabled: options.capture?.enabled ?? true,
|
enabled: options.capture?.enabled ?? true,
|
||||||
},
|
},
|
||||||
metadata: options.metadata ?? {},
|
metadata: options.metadata ?? {},
|
||||||
...(options.store ? { store: options.store } : {}),
|
...(store ? { store } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { defineMemoryProvider, type MemoryProvider } from "eve/memory";
|
import { defineMemoryProvider, type MemoryProvider } from "eve/memory";
|
||||||
import { captureCompletedTurn } from "./capture.js";
|
import { captureCompletedTurn } from "./capture.js";
|
||||||
import { errorMessage } from "./errors.js";
|
import { isSetupError, logProviderError } from "./errors.js";
|
||||||
import { createIdempotencyGate } from "./idempotency.js";
|
import { createIdempotencyGate } from "./idempotency.js";
|
||||||
import { lastUserText, type ConversationMessage } from "./messages.js";
|
import { lastUserText, type ConversationMessage } from "./messages.js";
|
||||||
import { resolveOptions, type Mem0ProviderOptions } from "./options.js";
|
import { resolveOptions, type Mem0ProviderOptions } from "./options.js";
|
||||||
import { recallMemories } from "./recall.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";
|
import { createMem0Tools } from "./tools.js";
|
||||||
|
|
||||||
function asConversation(messages: readonly unknown[]): ConversationMessage[] {
|
function asConversation(messages: readonly unknown[]): ConversationMessage[] {
|
||||||
@@ -14,7 +14,9 @@ function asConversation(messages: readonly unknown[]): ConversationMessage[] {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
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 {
|
export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
|
||||||
const config = resolveOptions(options);
|
const config = resolveOptions(options);
|
||||||
const once = createIdempotencyGate();
|
const once = createIdempotencyGate();
|
||||||
let storePromise: Promise<MemoryStore> | 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<MemoryStore> => {
|
const getStore = async (): Promise<MemoryStore> => {
|
||||||
if (config.store) {
|
if (config.store) {
|
||||||
return config.store;
|
return config.store;
|
||||||
}
|
}
|
||||||
storePromise ??= createMem0Store({
|
return loadRemoteStore();
|
||||||
apiKey: config.apiKey,
|
|
||||||
host: config.host,
|
|
||||||
});
|
|
||||||
return storePromise;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const recall = async (context: {
|
const recall = async (context: {
|
||||||
@@ -47,7 +53,13 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const querySource = context.turn?.input ?? context.messages;
|
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 {
|
try {
|
||||||
const store = await getStore();
|
const store = await getStore();
|
||||||
@@ -60,14 +72,13 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
|
|||||||
rerank: config.rerank,
|
rerank: config.rerank,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (context.abortSignal.aborted) {
|
if (context.abortSignal.aborted || isSetupError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error("[@mem0/eve] recall failed", {
|
logProviderError("recall failed", error, {
|
||||||
error: errorMessage(error),
|
|
||||||
sessionId: context.session.id,
|
sessionId: context.session.id,
|
||||||
});
|
});
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,41 +91,36 @@ export function mem0Provider(options: Mem0ProviderOptions): MemoryProvider {
|
|||||||
? {
|
? {
|
||||||
capture: {
|
capture: {
|
||||||
async "turn.completed"(context) {
|
async "turn.completed"(context) {
|
||||||
try {
|
const store = await getStore();
|
||||||
const store = await getStore();
|
await once(context.operationId, async () => {
|
||||||
await once(context.operationId, async () => {
|
await captureCompletedTurn({
|
||||||
await captureCompletedTurn({
|
store,
|
||||||
store,
|
scopeKey: context.memory.scope.key,
|
||||||
scopeKey: context.memory.scope.key,
|
operationId: context.operationId,
|
||||||
infer: config.infer,
|
infer: config.infer,
|
||||||
metadata: {
|
metadata: {
|
||||||
...config.metadata,
|
...config.metadata,
|
||||||
source: "eve",
|
source: "eve",
|
||||||
operation_id: context.operationId,
|
operation_id: context.operationId,
|
||||||
session_id: context.session.id,
|
session_id: context.session.id,
|
||||||
slot: context.memory.slot,
|
slot: context.memory.slot,
|
||||||
},
|
},
|
||||||
messages: asConversation(context.messages),
|
messages: asConversation(context.messages),
|
||||||
turnInput: asConversation(context.turn.input),
|
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) {
|
async tools(context) {
|
||||||
const store = await getStore();
|
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,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ export interface RecallMessage {
|
|||||||
|
|
||||||
export function formatRecallMessages(hits: readonly SearchHit[]): RecallMessage[] {
|
export function formatRecallMessages(hits: readonly SearchHit[]): RecallMessage[] {
|
||||||
return hits.flatMap((hit) => {
|
return hits.flatMap((hit) => {
|
||||||
|
const id = hit.id.trim();
|
||||||
const content = hit.memory.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, {
|
const { results } = await input.store.search(query, {
|
||||||
filters: { user_id: input.scopeKey },
|
userId: input.scopeKey,
|
||||||
topK: input.topK,
|
topK: input.topK,
|
||||||
threshold: input.threshold,
|
threshold: input.threshold,
|
||||||
rerank: input.rerank,
|
rerank: input.rerank,
|
||||||
|
|||||||
+126
-17
@@ -1,4 +1,5 @@
|
|||||||
import type { Mem0ApiKey } from "./options.js";
|
import type { Mem0ApiKey } from "./options.js";
|
||||||
|
import { isNotFoundError } from "./errors.js";
|
||||||
|
|
||||||
export type MemoryRole = "user" | "assistant";
|
export type MemoryRole = "user" | "assistant";
|
||||||
|
|
||||||
@@ -12,25 +13,94 @@ export interface SearchHit {
|
|||||||
readonly memory: string;
|
readonly memory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddMemoryInput {
|
export interface MemoryRecord {
|
||||||
|
readonly id: string;
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
readonly infer: boolean;
|
|
||||||
readonly metadata: Readonly<Record<string, unknown>>;
|
readonly metadata: Readonly<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StoreMetadata = Readonly<Record<string, string | number | boolean>>;
|
||||||
|
|
||||||
|
export interface AddMemoryInput {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly infer: boolean;
|
||||||
|
readonly metadata: StoreMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchMemoryInput {
|
export interface SearchMemoryInput {
|
||||||
readonly filters: {
|
readonly userId: string;
|
||||||
readonly user_id: string;
|
|
||||||
};
|
|
||||||
readonly topK: number;
|
readonly topK: number;
|
||||||
readonly threshold: number;
|
readonly threshold: number;
|
||||||
readonly rerank: boolean;
|
readonly rerank: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryStore {
|
export interface MemoryStore {
|
||||||
add(messages: readonly MemoryMessage[], input: AddMemoryInput): Promise<unknown>;
|
add(messages: readonly MemoryMessage[], input: AddMemoryInput): Promise<void>;
|
||||||
search(query: string, input: SearchMemoryInput): Promise<{ results: readonly SearchHit[] }>;
|
search(query: string, input: SearchMemoryInput): Promise<{ results: readonly SearchHit[] }>;
|
||||||
delete(memoryId: string): Promise<unknown>;
|
get(memoryId: string): Promise<MemoryRecord | null>;
|
||||||
|
listByMetadata(input: {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly metadata: Readonly<Record<string, string>>;
|
||||||
|
}): Promise<readonly MemoryRecord[]>;
|
||||||
|
delete(memoryId: string, userId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchHit(item: unknown): SearchHit | null {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const nested =
|
||||||
|
record.data && typeof record.data === "object"
|
||||||
|
? (record.data as Record<string, unknown>)
|
||||||
|
: 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<string, unknown>;
|
||||||
|
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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string> {
|
export async function resolveApiKey(apiKey: Mem0ApiKey): Promise<string> {
|
||||||
@@ -42,6 +112,20 @@ export async function resolveApiKey(apiKey: Mem0ApiKey): Promise<string> {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createLazyStore(factory: () => Promise<MemoryStore>) {
|
||||||
|
let pending: Promise<MemoryStore> | undefined;
|
||||||
|
|
||||||
|
return async (): Promise<MemoryStore> => {
|
||||||
|
if (!pending) {
|
||||||
|
pending = factory().catch((error: unknown) => {
|
||||||
|
pending = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMem0Store(input: {
|
export async function createMem0Store(input: {
|
||||||
apiKey: Mem0ApiKey;
|
apiKey: Mem0ApiKey;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -52,9 +136,9 @@ export async function createMem0Store(input: {
|
|||||||
host: input.host,
|
host: input.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const store: MemoryStore = {
|
||||||
async add(messages, options) {
|
async add(messages, options) {
|
||||||
return client.add([...messages], {
|
await client.add([...messages], {
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
infer: options.infer,
|
infer: options.infer,
|
||||||
metadata: { ...options.metadata },
|
metadata: { ...options.metadata },
|
||||||
@@ -62,22 +146,47 @@ export async function createMem0Store(input: {
|
|||||||
},
|
},
|
||||||
async search(query, options) {
|
async search(query, options) {
|
||||||
const response = await client.search(query, {
|
const response = await client.search(query, {
|
||||||
filters: options.filters,
|
filters: { user_id: options.userId },
|
||||||
topK: options.topK,
|
topK: options.topK,
|
||||||
threshold: options.threshold,
|
threshold: options.threshold,
|
||||||
rerank: options.rerank,
|
rerank: options.rerank,
|
||||||
});
|
});
|
||||||
const results = Array.isArray(response?.results) ? response.results : [];
|
|
||||||
return {
|
return {
|
||||||
results: results.flatMap((item) => {
|
results: asItemList(response, "search").flatMap((item) => {
|
||||||
const id = typeof item.id === "string" ? item.id : "";
|
const hit = parseSearchHit(item);
|
||||||
const memory = typeof item.memory === "string" ? item.memory : "";
|
return hit ? [hit] : [];
|
||||||
return id && memory ? [{ id, memory }] : [];
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async delete(memoryId) {
|
async get(memoryId) {
|
||||||
return client.delete(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ import { defineTool } from "eve/tools";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { MemoryStore } from "./store.js";
|
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 {
|
return {
|
||||||
search: defineTool({
|
search: defineTool({
|
||||||
description: "Search long-term memories for the current caller.",
|
description: "Search long-term memories for the current caller.",
|
||||||
@@ -11,10 +22,10 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) {
|
|||||||
}),
|
}),
|
||||||
async execute({ query }) {
|
async execute({ query }) {
|
||||||
const { results } = await store.search(query, {
|
const { results } = await store.search(query, {
|
||||||
filters: { user_id: scopeKey },
|
userId: scopeKey,
|
||||||
topK: 8,
|
topK: options.topK,
|
||||||
threshold: 0.1,
|
threshold: options.threshold,
|
||||||
rerank: false,
|
rerank: options.rerank,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
memories: results.map((hit) => ({
|
memories: results.map((hit) => ({
|
||||||
@@ -33,7 +44,7 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) {
|
|||||||
async execute({ text }) {
|
async execute({ text }) {
|
||||||
await store.add([{ role: "user", content: text }], {
|
await store.add([{ role: "user", content: text }], {
|
||||||
userId: scopeKey,
|
userId: scopeKey,
|
||||||
infer: true,
|
infer: options.infer,
|
||||||
metadata: { source: "eve-tool" },
|
metadata: { source: "eve-tool" },
|
||||||
});
|
});
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
@@ -45,7 +56,7 @@ export function createMem0Tools(store: MemoryStore, scopeKey: string) {
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
async execute({ id }) {
|
async execute({ id }) {
|
||||||
await store.delete(id);
|
await store.delete(id, scopeKey);
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ describe("captureCompletedTurn", () => {
|
|||||||
const captured = await captureCompletedTurn({
|
const captured = await captureCompletedTurn({
|
||||||
store,
|
store,
|
||||||
scopeKey: "scope_abc",
|
scopeKey: "scope_abc",
|
||||||
infer: true,
|
operationId: "op_1",
|
||||||
metadata: { source: "eve" },
|
infer: false,
|
||||||
|
metadata: { source: "eve", operation_id: "op_1" },
|
||||||
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
turnInput: [{ role: "user", content: "I am vegetarian" }],
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: "I am vegetarian" },
|
{ role: "user", content: "I am vegetarian" },
|
||||||
@@ -21,6 +22,8 @@ describe("captureCompletedTurn", () => {
|
|||||||
expect(store.added).toEqual([
|
expect(store.added).toEqual([
|
||||||
{
|
{
|
||||||
userId: "scope_abc",
|
userId: "scope_abc",
|
||||||
|
infer: false,
|
||||||
|
metadata: { source: "eve", operation_id: "op_1" },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: "I am vegetarian" },
|
{ role: "user", content: "I am vegetarian" },
|
||||||
{ role: "assistant", content: "Got it." },
|
{ role: "assistant", content: "Got it." },
|
||||||
@@ -34,12 +37,38 @@ describe("captureCompletedTurn", () => {
|
|||||||
const captured = await captureCompletedTurn({
|
const captured = await captureCompletedTurn({
|
||||||
store,
|
store,
|
||||||
scopeKey: "scope_abc",
|
scopeKey: "scope_abc",
|
||||||
|
operationId: "op_1",
|
||||||
infer: true,
|
infer: true,
|
||||||
metadata: {},
|
metadata: { operation_id: "op_1" },
|
||||||
turnInput: [],
|
turnInput: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
expect(captured).toBe(false);
|
expect(captured).toBe(false);
|
||||||
expect(store.added).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 & {
|
export type FakeHit = SearchHit & {
|
||||||
added: Array<{ messages: readonly MemoryMessage[]; userId: string }>;
|
readonly userId?: string;
|
||||||
searched: Array<{ query: string; userId: string }>;
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FakeAdd = {
|
||||||
|
messages: readonly MemoryMessage[];
|
||||||
|
userId: string;
|
||||||
|
infer: boolean;
|
||||||
|
metadata: Readonly<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FakeSearch = SearchMemoryInput & {
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createFakeStore(hits: FakeHit[] = []): MemoryStore & {
|
||||||
|
added: FakeAdd[];
|
||||||
|
searched: FakeSearch[];
|
||||||
deleted: string[];
|
deleted: string[];
|
||||||
|
records: MemoryRecord[];
|
||||||
} {
|
} {
|
||||||
const added: Array<{ messages: readonly MemoryMessage[]; userId: string }> = [];
|
const added: FakeAdd[] = [];
|
||||||
const searched: Array<{ query: string; userId: string }> = [];
|
const searched: FakeSearch[] = [];
|
||||||
const deleted: string[] = [];
|
const deleted: string[] = [];
|
||||||
|
const records: MemoryRecord[] = hits.map((hit) => ({
|
||||||
|
id: hit.id,
|
||||||
|
userId: hit.userId ?? "scope_abc",
|
||||||
|
metadata: hit.metadata ?? {},
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
added,
|
added,
|
||||||
searched,
|
searched,
|
||||||
deleted,
|
deleted,
|
||||||
|
records,
|
||||||
async add(messages, input) {
|
async add(messages, input) {
|
||||||
added.push({ messages, userId: input.userId });
|
added.push({
|
||||||
return { eventId: "evt_1" };
|
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) {
|
async search(query, input) {
|
||||||
searched.push({ query, userId: input.filters.user_id });
|
searched.push({ query, ...input });
|
||||||
return { results: hits };
|
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);
|
deleted.push(memoryId);
|
||||||
return { message: "ok" };
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -31,6 +88,14 @@ export function createFakeStore(hits: SearchHit[] = []): MemoryStore & {
|
|||||||
export function memoryContext(input?: {
|
export function memoryContext(input?: {
|
||||||
scopeKey?: string;
|
scopeKey?: string;
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
|
aborted?: boolean;
|
||||||
|
turn?:
|
||||||
|
| {
|
||||||
|
id?: string;
|
||||||
|
sequence?: number;
|
||||||
|
input: Array<{ role: string; content: unknown }>;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
turnInput?: Array<{ role: string; content: unknown }>;
|
turnInput?: Array<{ role: string; content: unknown }>;
|
||||||
messages?: Array<{ role: string; content: unknown }>;
|
messages?: Array<{ role: string; content: unknown }>;
|
||||||
}): {
|
}): {
|
||||||
@@ -46,10 +111,21 @@ export function memoryContext(input?: {
|
|||||||
id: string;
|
id: string;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
input: Array<{ role: string; content: unknown }>;
|
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 {
|
return {
|
||||||
abortSignal: new AbortController().signal,
|
abortSignal: controller.signal,
|
||||||
operationId: input?.operationId ?? "op_1",
|
operationId: input?.operationId ?? "op_1",
|
||||||
session: { id: "sess_1" },
|
session: { id: "sess_1" },
|
||||||
memory: {
|
memory: {
|
||||||
@@ -64,10 +140,13 @@ export function memoryContext(input?: {
|
|||||||
{ role: "user", content: "I am vegetarian" },
|
{ role: "user", content: "I am vegetarian" },
|
||||||
{ role: "assistant", content: "I will remember that." },
|
{ role: "assistant", content: "I will remember that." },
|
||||||
],
|
],
|
||||||
turn: {
|
turn:
|
||||||
id: "turn_1",
|
input?.turn === null
|
||||||
sequence: 1,
|
? null
|
||||||
input: input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
: {
|
||||||
},
|
id: input?.turn?.id ?? "turn_1",
|
||||||
|
sequence: input?.turn?.sequence ?? 1,
|
||||||
|
input: turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,19 @@ describe("createIdempotencyGate", () => {
|
|||||||
await once("op_1", work);
|
await once("op_1", work);
|
||||||
expect(calls).toBe(2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ describe("extractText", () => {
|
|||||||
).toBe("I like\ntea");
|
).toBe("I like\ntea");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads a single text object", () => {
|
||||||
|
expect(extractText({ type: "text", text: " solo " })).toBe("solo");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores non-text parts", () => {
|
it("ignores non-text parts", () => {
|
||||||
expect(extractText([{ type: "image", url: "x" }])).toBe("");
|
expect(extractText([{ type: "image", url: "x" }])).toBe("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { createFakeStore } from "./helpers.js";
|
|||||||
|
|
||||||
describe("resolveOptions", () => {
|
describe("resolveOptions", () => {
|
||||||
it("applies defaults", () => {
|
it("applies defaults", () => {
|
||||||
expect(resolveOptions({ apiKey: "m0-test" })).toMatchObject({
|
expect(resolveOptions({ apiKey: "m0-test" })).toEqual({
|
||||||
|
apiKey: "m0-test",
|
||||||
host: "https://api.mem0.ai",
|
host: "https://api.mem0.ai",
|
||||||
topK: 5,
|
topK: 5,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
@@ -12,6 +13,7 @@ describe("resolveOptions", () => {
|
|||||||
infer: true,
|
infer: true,
|
||||||
autoSearch: { enabled: true },
|
autoSearch: { enabled: true },
|
||||||
capture: { enabled: true },
|
capture: { enabled: true },
|
||||||
|
metadata: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,8 +21,56 @@ describe("resolveOptions", () => {
|
|||||||
expect(() => resolveOptions({ apiKey: " " })).toThrow(/empty/);
|
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", () => {
|
it("rejects an invalid topK", () => {
|
||||||
expect(() => resolveOptions({ apiKey: "m0-test", topK: 0 })).toThrow(/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", () => {
|
it("keeps an injected store", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
MemoryCompactionCompletedContext,
|
||||||
MemoryToolsContext,
|
MemoryToolsContext,
|
||||||
MemoryTurnCompletedContext,
|
MemoryTurnCompletedContext,
|
||||||
MemoryTurnStartedContext,
|
MemoryTurnStartedContext,
|
||||||
@@ -19,6 +20,15 @@ function completedContext(
|
|||||||
return memoryContext(input) as unknown as MemoryTurnCompletedContext;
|
return memoryContext(input) as unknown as MemoryTurnCompletedContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactionContext(
|
||||||
|
input?: Parameters<typeof memoryContext>[0],
|
||||||
|
): MemoryCompactionCompletedContext {
|
||||||
|
return memoryContext({
|
||||||
|
...input,
|
||||||
|
turn: input?.turn === undefined ? null : input.turn,
|
||||||
|
}) as unknown as MemoryCompactionCompletedContext;
|
||||||
|
}
|
||||||
|
|
||||||
function toolsContext(
|
function toolsContext(
|
||||||
input?: Parameters<typeof memoryContext>[0],
|
input?: Parameters<typeof memoryContext>[0],
|
||||||
): MemoryToolsContext {
|
): MemoryToolsContext {
|
||||||
@@ -32,10 +42,20 @@ async function runTool(
|
|||||||
return tool.execute(input as never, {} as never);
|
return tool.execute(input as never, {} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireCapture(
|
||||||
|
provider: ReturnType<typeof mem0Provider>,
|
||||||
|
): NonNullable<NonNullable<typeof provider.capture>["turn.completed"]> {
|
||||||
|
const capture = provider.capture?.["turn.completed"];
|
||||||
|
if (!capture) {
|
||||||
|
throw new Error("expected turn.completed");
|
||||||
|
}
|
||||||
|
return capture;
|
||||||
|
}
|
||||||
|
|
||||||
describe("mem0Provider", () => {
|
describe("mem0Provider", () => {
|
||||||
it("recalls memories for the locked scope on turn start", async () => {
|
it("recalls memories for the locked scope on turn start", async () => {
|
||||||
const store = createFakeStore([{ id: "mem_1", memory: "User likes tea" }]);
|
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"](
|
const result = await provider.recall["turn.started"](
|
||||||
startedContext({
|
startedContext({
|
||||||
turnInput: [{ role: "user", content: "What do I drink?" }],
|
turnInput: [{ role: "user", content: "What do I drink?" }],
|
||||||
@@ -43,31 +63,110 @@ describe("mem0Provider", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(store.searched).toEqual([
|
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({
|
expect(result).toEqual({
|
||||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
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();
|
const store = createFakeStore();
|
||||||
store.search = async () => {
|
store.search = async () => {
|
||||||
throw new Error("mem0 down");
|
throw new Error("mem0 down");
|
||||||
};
|
};
|
||||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
const provider = mem0Provider({ store });
|
||||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
await expect(
|
try {
|
||||||
provider.recall["turn.started"](startedContext()),
|
await expect(
|
||||||
).resolves.toBeNull();
|
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 () => {
|
it("captures a completed turn once per operation id", async () => {
|
||||||
const store = createFakeStore();
|
const store = createFakeStore();
|
||||||
const provider = mem0Provider({ apiKey: "m0-test", store });
|
const provider = mem0Provider({ store });
|
||||||
const capture = provider.capture?.["turn.completed"];
|
const capture = provider.capture?.["turn.completed"];
|
||||||
expect(capture).toBeTypeOf("function");
|
expect(capture).toBeTypeOf("function");
|
||||||
|
|
||||||
@@ -76,12 +175,42 @@ describe("mem0Provider", () => {
|
|||||||
await capture!(context);
|
await capture!(context);
|
||||||
|
|
||||||
expect(store.added).toHaveLength(1);
|
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", () => {
|
it("omits capture when it is disabled", () => {
|
||||||
const provider = mem0Provider({
|
const provider = mem0Provider({
|
||||||
apiKey: "m0-test",
|
|
||||||
store: createFakeStore(),
|
store: createFakeStore(),
|
||||||
capture: { enabled: false },
|
capture: { enabled: false },
|
||||||
});
|
});
|
||||||
@@ -90,7 +219,13 @@ describe("mem0Provider", () => {
|
|||||||
|
|
||||||
it("binds tools to the locked scope", async () => {
|
it("binds tools to the locked scope", async () => {
|
||||||
const store = createFakeStore([{ id: "mem_1", memory: "likes tea" }]);
|
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());
|
const tools = await provider.tools!(toolsContext());
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
throw new Error("expected tools");
|
throw new Error("expected tools");
|
||||||
@@ -114,8 +249,52 @@ describe("mem0Provider", () => {
|
|||||||
});
|
});
|
||||||
expect(remember).toEqual({ saved: true });
|
expect(remember).toEqual({ saved: true });
|
||||||
expect(forget).toEqual({ deleted: true });
|
expect(forget).toEqual({ deleted: true });
|
||||||
expect(store.searched[0]?.userId).toBe("scope_abc");
|
expect(store.searched[0]).toMatchObject({
|
||||||
expect(store.added[0]?.userId).toBe("scope_abc");
|
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"]);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { formatRecallMessages, recallMemories } from "../src/recall.js";
|
|||||||
import { createFakeStore } from "./helpers.js";
|
import { createFakeStore } from "./helpers.js";
|
||||||
|
|
||||||
describe("formatRecallMessages", () => {
|
describe("formatRecallMessages", () => {
|
||||||
it("drops empty memories", () => {
|
it("drops empty memories and empty ids", () => {
|
||||||
expect(
|
expect(
|
||||||
formatRecallMessages([
|
formatRecallMessages([
|
||||||
{ id: "1", memory: " likes tea " },
|
{ id: "1", memory: " likes tea " },
|
||||||
{ id: "2", memory: " " },
|
{ id: "2", memory: " " },
|
||||||
|
{ id: " ", memory: "kept text" },
|
||||||
]),
|
]),
|
||||||
).toEqual([{ id: "1", content: "likes tea" }]);
|
).toEqual([{ id: "1", content: "likes tea" }]);
|
||||||
});
|
});
|
||||||
@@ -20,13 +21,19 @@ describe("recallMemories", () => {
|
|||||||
store,
|
store,
|
||||||
scopeKey: "scope_abc",
|
scopeKey: "scope_abc",
|
||||||
query: "What does the user drink?",
|
query: "What does the user drink?",
|
||||||
topK: 5,
|
topK: 3,
|
||||||
threshold: 0.1,
|
threshold: 0.5,
|
||||||
rerank: false,
|
rerank: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.searched).toEqual([
|
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({
|
expect(result).toEqual({
|
||||||
messages: [{ id: "mem_1", content: "User likes tea" }],
|
messages: [{ id: "mem_1", content: "User likes tea" }],
|
||||||
@@ -47,4 +54,18 @@ describe("recallMemories", () => {
|
|||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
expect(store.searched).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user