Compare commits

...

17 Commits

Author SHA1 Message Date
chaithanyak42 8c1940dba2 feat(ts): expose latestOnly on hosted memory reads 2026-05-15 17:49:02 +05:30
Deshraj Yadav fbce5fab14 docs: remove unverified SOC2/GDPR compliance claims (#5150) 2026-05-14 23:22:37 -07:00
Mragank Shekhar 6a1597c6fb fix(plugin): drop API-key-derived user_id, restore $USER fallback (#5147) 2026-05-15 02:37:55 +05:30
Mragank Shekhar c9e8482a35 fix(docs): Mintlify <5s parse error + add Agent Mode to /platform/cli (#5145) 2026-05-14 21:49:08 +05:30
Mragank Shekhar e602923751 feat(cli): Agent Mode bootstrap + claim flow (Python + Node) (#5123) 2026-05-14 20:35:25 +05:30
Agam Pandey 70bc9e51d5 docs(readme): update LongMemEval benchmark to 94.8 and add Temporal Reasoning (#5131) 2026-05-13 14:31:15 +05:30
Agam Pandey 0107fd53b8 feat: add temporal reasoning cookbook and docs (#5061) 2026-05-13 01:59:38 +05:30
Mragank Shekhar 54a03cc721 chore(plugin): bump mem0 plugin to v0.1.2 (#5094) 2026-05-09 20:56:34 +05:30
Mragank Shekhar e95de4ca50 fix(plugin): hook cleanup + identity + compact-summary flow (#5076) 2026-05-09 19:19:30 +05:30
youneshima a623cfaf76 Oss qdrant hosted memories to platform migration (#5080) 2026-05-08 08:04:09 +05:30
Chaithanya Kumar 92491c00c2 docs(memory-decay): use SDK calls in code samples (#5079)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:53:55 +05:30
Mragank Shekhar 9043fbf61e chore(release): bump mem0ai to 2.0.2 (py) and 3.0.3 (ts) (#5078) 2026-05-08 01:27:23 +05:30
Chaithanya Kumar c90cbc75a2 docs: memory decay v0.5 — platform feature page + API reference (#5056)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:21:33 +05:30
Gabriel Stein 58304fc939 refactor(plugin): hand mem0 search decisions to the agent (#4992)
Co-authored-by: Mgeeeek <ms8939@bennett.edu.in>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:41:39 +05:30
Chaithanya Kumar 397f3414ee feat(sdk): expose decay on project.update (Python + TypeScript) (#5062)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:33:32 +05:30
Gabriel Stein a734e057cf fix (telemetry): stitch oss and platform telemetry identities for python and typescript sdk
Co-authored-by: Younes Slaoui <younes.slaoui@mem0.ai>
2026-05-05 13:48:21 -07:00
Saket Aryan 0fdaa29b4a feat(skills): add mem0-integrate + mem0-test-integration pipeline skills (#4961) 2026-05-05 18:52:22 +05:30
102 changed files with 8745 additions and 204 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.1.1"
"version": "0.1.3"
}
]
}
+4 -2
View File
@@ -27,7 +27,7 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
| `mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills |
| `skills/` | Claude Code skill definitions — `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/` |
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/` |
| `docs/` | Documentation site (Mintlify) |
| `tests/` | Python SDK tests (pytest) |
| `evaluation/` | Benchmarking framework — LOCOMO evals, experiment runner, score generation |
@@ -387,7 +387,9 @@ Model Context Protocol support in multiple places:
### Plugin & Skills System
- `mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
- `skills/` contains structured skill definitions for AI agents, covering SDK usage, CLI workflows, and Vercel AI SDK patterns.
- `skills/` contains structured skill definitions for AI agents, split into two categories:
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch. The two are loosely coupled via `.mem0-integration/` artifacts.
### Adding a New Provider
+26 -2
View File
@@ -47,7 +47,7 @@
| Benchmark | Old | New | Tokens | Latency p50 |
| --- | --- | --- | --- | --- |
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
| **LongMemEval** | 67.8 | **93.4** | 6.8K | 1.09s |
| **LongMemEval** | 67.8 | **94.8** | 6.8K | 1.09s |
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
@@ -58,12 +58,13 @@ All benchmarks run on the same production-representative model stack. Single-pas
- **Agent-generated facts are first-class** -- when an agent confirms an action, that information is now stored with equal weight.
- **Entity linking** -- entities are extracted, embedded, and linked across memories for retrieval boosting.
- **Multi-signal retrieval** -- semantic, BM25 keyword, and entity matching scored in parallel and fused.
- **Temporal Reasoning** -- time-aware retrieval that ranks the right dated instance for queries about current state, past events, and upcoming plans.
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgrade instructions. The [evaluation framework](https://github.com/mem0ai/memory-benchmarks) is open-sourced so anyone can reproduce the numbers.
## Research Highlights
- **91.6 on LoCoMo** -- +20 points over the previous algorithm
- **93.4 on LongMemEval** -- +26 points, with +53.6 on assistant memory recall
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
- [Read the full paper](https://mem0.ai/research)
@@ -85,6 +86,8 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra
## 🚀 Quickstart Guide <a name="quickstart"></a>
> **Agent Mode — for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), replacing `<your-name>` with your AI agent identity (e.g. `claude-code`, `cursor`, `codex`). This mints an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Use the key immediately against the Platform or the MCP server. The human owner can claim ownership later with `mem0 init --email <their-email>`: memories transfer, the same key keeps working, and the agent isn't disrupted.
| | Library | Self-Hosted Server | Cloud Platform |
|---|---------|-------------------|----------------|
| **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use |
@@ -147,6 +150,27 @@ mem0 search "What does Alice prefer?" --user-id alice
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
### Agent Skills
Teach your AI coding assistant (Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any tool that supports the skills standard) how to build with Mem0. Two categories:
**Reference skills — always on** (SDK knowledge loaded into the assistant's context):
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
```
**Pipeline skills — run on demand** (execute an end-to-end workflow in an existing repo):
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
```
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
### Basic Usage
Mem0 requires an LLM to function, with `gpt-5-mini` from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview).
+4 -2
View File
@@ -503,7 +503,7 @@
},
{
"name": "init",
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
"usage": "mem0 init [OPTIONS]",
"needsBackend": false,
"needsConfig": false,
@@ -516,7 +516,9 @@
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
{ "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." },
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." },
{ "name": "agent", "flags": ["--agent"], "type": "boolean", "default": false, "help": "Bootstrap an unattended Agent Mode account (no email required)." },
{ "name": "source", "flags": ["--source"], "type": "string", "default": null, "help": "Channel attribution for signup (e.g. github, hn, ph)." }
]
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/cli",
"version": "0.2.4",
"version": "0.2.5",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
+32
View File
@@ -0,0 +1,32 @@
/**
* Detect whether the CLI is being invoked from inside an AI-agent context.
*
* Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
* agent runtime env var is present. The return value is a context **trigger
* only** — the canonical agent identity is self-declared by the agent via
* `--agent-caller <name>` (Proof Editor-style) and never sniffed from env
* vars to fill the `agent_caller` field on the APIKey row.
*
* Returns a short name or null. Honest reporting depends on `--agent-caller`;
* this list is just enough to enable the zero-friction auto-bootstrap UX.
*/
const AGENT_CALLER_ENV: ReadonlyArray<readonly [string, readonly string[]]> = [
["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
["cursor", ["CURSOR_AGENT", "CURSOR_SESSION_ID"]],
["codex", ["CODEX_CLI", "OPENAI_CODEX"]],
["cline", ["CLINE_AGENT", "CLINE"]],
["continue", ["CONTINUE_AGENT", "CONTINUE_SESSION"]],
["aider", ["AIDER_SESSION"]],
["goose", ["GOOSE_AGENT"]],
["windsurf", ["WINDSURF_AGENT"]],
] as const;
export function detectAgentCaller(): string | null {
for (const [name, envVars] of AGENT_CALLER_ENV) {
if (envVars.some((v) => process.env[v])) {
return name;
}
}
return null;
}
+34 -2
View File
@@ -3,7 +3,7 @@
*/
import type { PlatformConfig } from "../config.js";
import { isAgentMode } from "../state.js";
import { captureNotice, isAgentMode } from "../state.js";
import { CLI_VERSION } from "../version.js";
import {
APIError,
@@ -90,7 +90,39 @@ export class PlatformBackend implements Backend {
if (resp.status === 204) {
return {};
}
return resp.json();
const data = await resp.json();
// Pull the unclaimed-Agent-Mode notice out of the body (or the header
// fallback for endpoints returning non-dict / non-dict-leading payloads)
// and stash for end-of-command surfacing.
let notice: string | null = null;
if (
data &&
typeof data === "object" &&
!Array.isArray(data) &&
"mem0_notice" in data
) {
notice = (data as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: intentional strip so downstream consumers don't see duplicate notice
delete (data as Record<string, unknown>).mem0_notice;
} else if (
Array.isArray(data) &&
data.length > 0 &&
typeof data[0] === "object" &&
data[0] !== null &&
"mem0_notice" in data[0]
) {
notice = (data[0] as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: see above.
delete (data[0] as Record<string, unknown>).mem0_notice;
}
if (!notice) {
notice = resp.headers.get("X-Mem0-Notice-Message") ?? null;
}
captureNotice(notice);
return data;
}
async add(
+285
View File
@@ -0,0 +1,285 @@
/**
* Agent Mode commands — bootstrap (unattended signup) and OTP-based claim.
*/
import readline from "node:readline";
import { colors, printError, printInfo, printSuccess } from "../branding.js";
import { type Mem0Config, saveConfig } from "../config.js";
const { brand, dim } = colors;
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export interface BootstrapEnvelope {
api_key: string;
default_user_id: string;
org_id: string;
project_id: string;
mcp_url?: string;
smoke_test_url?: string;
claim_command?: string;
mem0_notice?: string;
}
function isValidEnvelope(v: unknown): v is BootstrapEnvelope {
return (
!!v &&
typeof v === "object" &&
typeof (v as BootstrapEnvelope).api_key === "string" &&
(v as BootstrapEnvelope).api_key.length > 0 &&
typeof (v as BootstrapEnvelope).default_user_id === "string" &&
(v as BootstrapEnvelope).default_user_id.length > 0
);
}
/**
* POST /api/v1/auth/agent_mode/ and mutate config in place.
*
* @param config - Mem0Config mutated in place with the new platform values.
* @param source - `--source` flag passthrough (analytics tag, free-form).
* @param agentCaller - Self-declared agent identity passed via `--agent-caller`
* (e.g. `claude-code`, `cursor`). May be null when the caller omitted the
* flag; the agent can backfill later via `mem0 identify <name>`. Sent to the
* backend in the request body and saved into `platform.agentCaller` for
* local introspection.
*/
export async function bootstrapViaBackend(
config: Mem0Config,
{
source,
agentCaller,
}: { source?: string | null; agentCaller?: string | null } = {},
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
const body: Record<string, unknown> = {};
if (source) body.source = source;
if (agentCaller) body.agent_caller = agentCaller;
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
method: "POST",
headers: {
...SOURCE_HEADERS,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (resp.status === 429) {
printError("Rate-limited. Try again in a few minutes.");
process.exit(1);
}
if (resp.status === 503) {
printError("Agent Mode is temporarily disabled. Try again later.");
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const errBody = (await resp.json()) as {
error?: string;
detail?: string;
};
detail = errBody.error ?? errBody.detail ?? resp.statusText;
} catch {
/* leave detail as statusText */
}
// Backend's @ratelimit decorator raises PermissionDenied, which DRF
// translates to a generic 403 "You do not have permission to perform
// this action." That's opaque — surface it as the rate-limit message
// it actually is.
if (resp.status === 403 && /permission/i.test(detail)) {
printError(
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
);
process.exit(1);
}
printError(`Bootstrap failed: ${detail}`);
process.exit(1);
}
const envelope = (await resp.json()) as BootstrapEnvelope;
if (!isValidEnvelope(envelope)) {
// Defend against partial/malformed backend responses (e.g. {api_key: null}).
// Without this guard, the typed `string` field is silently set to
// undefined/null and persisted, producing confusing downstream errors.
printError(
"Bootstrap response missing required fields — please update the CLI.",
);
process.exit(1);
}
config.platform.apiKey = envelope.api_key;
config.platform.baseUrl = baseUrl;
config.platform.agentMode = true;
config.platform.createdVia = "agent_mode";
config.platform.agentCaller = agentCaller ?? "";
config.platform.claimedAt = "";
config.platform.defaultUserId = envelope.default_user_id;
// Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.userId = envelope.default_user_id;
saveConfig(config);
printSuccess(
`Agent Mode active. Default user_id: ${envelope.default_user_id}`,
);
if (envelope.mem0_notice) {
console.log(`\n\x1b[33m🔔 ${envelope.mem0_notice}\x1b[0m\n`);
} else {
// Fallback for older backends without the unified notice field.
const claimCmd = envelope.claim_command ?? "mem0 init --email <your-email>";
console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`);
}
if (!agentCaller) {
console.log(
` ${dim("If you are an AI agent, identify yourself: `mem0 identify <your-name>` (e.g. claude-code, cursor).")}`,
);
}
}
/**
* Claim an existing Agent Mode account via OTP — no browser, no polling.
*
* Hits /api/v1/auth/email_code/ to send a verification code, prompts for it
* interactively (or accepts via `code`), then sends it to /verify/ alongside
* `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place
* inline and returns the claim result.
*/
export async function claimViaOtp(
config: Mem0Config,
{ email, code }: { email: string; code?: string },
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
if (!config.platform.apiKey || !config.platform.agentMode) {
printError(
"This command requires an active Agent Mode config. Run `mem0 init` first.",
);
process.exit(1);
}
const rawKey = config.platform.apiKey;
// Step 1: request OTP (unless --code was supplied)
if (!code) {
const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
if (sendResp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!sendResp.ok) {
let detail: string = sendResp.statusText;
try {
const errBody = (await sendResp.json()) as { error?: string };
if (errBody.error) detail = errBody.error;
} catch {
/* leave as statusText */
}
printError(`Failed to send code: ${detail}`);
process.exit(1);
}
printSuccess(`Verification code sent to ${email}. Check your inbox.`);
if (!process.stdin.isTTY) {
printError(
"No --code provided and terminal is non-interactive.",
`Re-run: mem0 init --email ${email} --code <code>`,
);
process.exit(1);
}
console.log();
code = await promptLine(` ${brand("Verification Code")}`);
if (!code) {
printError("Code is required.");
process.exit(1);
}
}
// Step 2: verify + claim atomically
const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: code.trim(),
agent_mode_api_key: rawKey,
}),
signal: AbortSignal.timeout(30_000),
});
if (!verifyResp.ok) {
let detail: string = verifyResp.statusText;
let errCode = "";
try {
const errBody = (await verifyResp.json()) as {
error?: string;
code?: string;
};
if (errBody.error) detail = errBody.error;
if (errBody.code) errCode = errBody.code;
} catch {
/* leave as statusText */
}
printError(`Claim failed: ${detail}`);
if (errCode === "email_already_claimed") {
console.log(
` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.")}`,
);
}
process.exit(1);
}
const claimBody = (await verifyResp.json()) as {
claimed?: boolean;
claimed_at?: string;
};
if (!claimBody.claimed) {
printError(`Unexpected verify response: ${JSON.stringify(claimBody)}`);
process.exit(1);
}
config.platform.agentMode = false;
config.platform.claimedAt = claimBody.claimed_at ?? new Date().toISOString();
config.platform.userEmail = email;
config.platform.createdVia = "email";
saveConfig(config);
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`);
}
function promptLine(label: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${label}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
+75
View File
@@ -0,0 +1,75 @@
/**
* mem0 identify — declare which agent owns the current agent-mode key.
*
* Used when `mem0 init --agent` ran without --agent-caller, so the backend
* saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
* its own row with its real identity. Idempotent.
*/
import { printError, printSuccess } from "../branding.js";
import { loadConfig, saveConfig } from "../config.js";
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export async function runIdentify(name: string): Promise<void> {
const config = loadConfig();
if (!config.platform.apiKey) {
printError("No API key configured. Run `mem0 init --agent` first.");
process.exit(1);
}
if (!config.platform.agentMode) {
printError("This command only works on unclaimed agent-mode keys.");
process.exit(1);
}
const clean = (name ?? "").trim();
if (!clean) {
printError("Agent name is required.");
process.exit(1);
}
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/caller/`, {
method: "PATCH",
headers: {
...SOURCE_HEADERS,
Authorization: `Token ${config.platform.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: clean }),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = (await resp.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* leave as statusText */
}
printError(`Identify failed: ${detail}`);
process.exit(1);
}
const body = (await resp.json()) as { agent_caller?: string };
const canonical = body.agent_caller ?? clean;
config.platform.agentCaller = canonical;
saveConfig(config);
printSuccess(`Identified as ${canonical}.`);
}
+167 -1
View File
@@ -21,6 +21,8 @@ import {
redactKey,
saveConfig,
} from "../config.js";
import { formatJsonEnvelope } from "../output.js";
import { isAgentMode } from "../state.js";
const { brand, dim } = colors;
@@ -33,6 +35,65 @@ function validateEmail(email: string): void {
}
}
/** @internal — exported for unit tests. */
export async function pingKey(
apiKey: string,
baseUrl: string,
timeoutMs = 5000,
): Promise<boolean> {
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
// Network errors, timeouts, and 5xx responses return true so we prefer
// reusing an existing key over silently minting a new shadow on a transient
// blip (which would also clobber config + plugin-sync targets).
try {
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
headers: { Authorization: `Token ${apiKey}` },
signal: AbortSignal.timeout(timeoutMs),
});
return resp.status !== 401 && resp.status !== 403;
} catch {
return true; // unknown — prefer reuse
}
}
async function maybeIdentify(
key: string,
baseUrl: string,
agentCaller: string | undefined,
): Promise<void> {
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
// reused key. Silent no-op on any failure — reuse must not break.
if (!agentCaller) return;
try {
const resp = await fetch(
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
{
method: "PATCH",
headers: {
Authorization: `Token ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: agentCaller }),
signal: AbortSignal.timeout(10_000),
},
);
if (resp.ok) {
try {
const body = (await resp.json()) as { agent_caller?: string };
if (fs.existsSync(CONFIG_FILE)) {
const cfg = loadConfig();
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
saveConfig(cfg);
}
} catch {
/* swallow — best effort */
}
}
} catch {
/* swallow — best effort */
}
}
async function emailLogin(
email: string,
code: string | undefined,
@@ -196,6 +257,7 @@ async function setupPlatform(config: Mem0Config): Promise<void> {
process.exit(1);
}
config.platform.apiKey = apiKey;
config.platform.createdVia = "api_key";
}
async function setupDefaults(config: Mem0Config): Promise<void> {
@@ -249,14 +311,35 @@ export async function runInit(
email?: string;
code?: string;
force?: boolean;
agent?: boolean;
source?: string;
agentCaller?: string;
} = {},
): Promise<void> {
const { detectAgentCaller } = await import("../agent-detect.js");
const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js");
const { isAgentMode } = await import("../state.js");
const { captureEvent } = await import("../telemetry.js");
const fireInit = (
mode: "agent" | "email" | "api_key" | "existing_key",
claimed = false,
) => {
const props: Record<string, unknown> = { command: "init", mode };
// Self-declared via --agent-caller; not sniffed from env vars.
if (opts.agentCaller) props.agent_caller = opts.agentCaller;
if (opts.source) props.signup_source = opts.source;
if (claimed) props.claimed_agent_mode = true;
captureEvent("cli.init", props);
};
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
config.platform.baseUrl = baseUrl;
// Guards
if (opts.code && !opts.email) {
@@ -268,6 +351,84 @@ export async function runInit(
process.exit(1);
}
// ── Claim flow: --email against an existing agent-mode config ───────────
if (
opts.email &&
fs.existsSync(CONFIG_FILE) &&
savedConfig.platform.agentMode &&
savedConfig.platform.apiKey
) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printInfo(`Claiming Agent Mode account to ${email}...`);
await claimViaOtp(savedConfig, { email, code: opts.code });
fireInit("email", true);
return;
}
// ── Agent Mode path runs BEFORE the existing-config guard ──────────────
// Rule 1/2 will REUSE a valid existing key (not overwrite), so we must
// short-circuit before the guard prompts the user about overwriting.
// Rule 3 only mints when there's no valid key to reuse — in that case
// overwriting is what the user wants.
const agentCtx =
opts.agent === true || isAgentMode() || detectAgentCaller() !== null;
if (!opts.apiKey && !opts.email && agentCtx) {
const emitReuseEnvelope = (source: "env" | "config") => {
if (isAgentMode()) {
formatJsonEnvelope({
command: "init",
data: {
api_key_saved: false,
api_key_source: source,
agent_mode: false,
message:
"Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
});
} else {
printSuccess(
source === "env"
? "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
: "Existing API key in config is valid; reusing it. No new Agent Mode key was minted.",
);
}
};
// Rule 1: env MEM0_API_KEY valid → reuse, no new key.
const envKey = (process.env.MEM0_API_KEY || "").trim();
if (envKey && (await pingKey(envKey, baseUrl))) {
await maybeIdentify(envKey, baseUrl, opts.agentCaller);
emitReuseEnvelope("env");
fireInit("existing_key");
return;
}
// Rule 2: existing config api_key valid → reuse.
if (
savedConfig.platform.apiKey &&
(await pingKey(savedConfig.platform.apiKey, baseUrl))
) {
await maybeIdentify(
savedConfig.platform.apiKey,
baseUrl,
opts.agentCaller,
);
emitReuseEnvelope("config");
fireInit("existing_key");
return;
}
// Rule 3: mint a fresh shadow (no valid key to reuse).
// agent_caller is self-declared via --agent-caller (Proof Editor-style),
// not derived from env-var sniffing. detectAgentCaller() above is still
// used as a context trigger (does this look like an agent?) but never
// to fill identity.
await bootstrapViaBackend(config, {
source: opts.source ?? null,
agentCaller: opts.agentCaller ?? null,
});
fireInit("agent");
return;
}
// Warn if an existing config with an API key would be overwritten
if (
!opts.force &&
@@ -324,6 +485,7 @@ export async function runInit(
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.platform.userEmail = email;
config.platform.createdVia = "email";
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
@@ -339,13 +501,15 @@ export async function runInit(
}
// ── API key flow ──────────────────────────────────────────────────────────
// (Agent Mode branch runs earlier — see above, before the existing-config
// guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
if (!process.stdin.isTTY) {
if (!opts.apiKey) {
printError(
"Non-interactive terminal detected and --api-key is required.",
"Usage: mem0 init --api-key <key> [--user-id <id>]",
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
);
process.exit(1);
}
@@ -356,6 +520,7 @@ export async function runInit(
// Non-interactive: both flags provided
if (opts.apiKey && opts.userId) {
config.platform.apiKey = opts.apiKey;
config.platform.createdVia = "api_key";
config.defaults.userId = opts.userId;
await validatePlatform(config);
saveConfig(config);
@@ -403,6 +568,7 @@ export async function runInit(
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.platform.userEmail = email;
config.platform.createdVia = "email";
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
+35
View File
@@ -21,6 +21,12 @@ export interface PlatformConfig {
apiKey: string;
baseUrl: string;
userEmail: string;
// Agent Mode (unclaimed-shadow signup)
agentMode: boolean; // true while the key is an unclaimed agent-mode key
createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key"
agentCaller: string; // canonical agent name when createdVia === "agent_mode" (e.g. "claude-code")
claimedAt: string; // ISO timestamp once the agent has been claimed
defaultUserId: string; // `user_<slug>` returned by bootstrap; auto-default scope
}
export interface DefaultsConfig {
@@ -54,6 +60,11 @@ export function createDefaultConfig(): Mem0Config {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
userEmail: "",
agentMode: false,
createdVia: "",
agentCaller: "",
claimedAt: "",
defaultUserId: "",
},
telemetry: {
anonymousId: "",
@@ -79,6 +90,11 @@ export function loadConfig(): Mem0Config {
config.platform.apiKey = plat.api_key ?? "";
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
config.platform.userEmail = plat.user_email ?? "";
config.platform.agentMode = Boolean(plat.agent_mode ?? false);
config.platform.createdVia = plat.created_via ?? "";
config.platform.agentCaller = plat.agent_caller ?? "";
config.platform.claimedAt = plat.claimed_at ?? "";
config.platform.defaultUserId = plat.default_user_id ?? "";
const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? "";
@@ -118,6 +134,11 @@ export function saveConfig(config: Mem0Config): void {
api_key: config.platform.apiKey,
base_url: config.platform.baseUrl,
user_email: config.platform.userEmail,
agent_mode: config.platform.agentMode,
created_via: config.platform.createdVia,
agent_caller: config.platform.agentCaller,
claimed_at: config.platform.claimedAt,
default_user_id: config.platform.defaultUserId,
},
telemetry: {
anonymous_id: config.telemetry.anonymousId,
@@ -126,6 +147,20 @@ export function saveConfig(config: Mem0Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600);
// Propagate api_key to ecosystem touchpoints (Claude plugin env injection,
// shell rc exports). Idempotent — updates only EXISTING entries; never
// creates new ones. Best-effort: errors swallowed so config.json is
// always authoritative, never blocked by plugin-state issues.
if (config.platform.apiKey) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { syncApiKey } = require("./plugin-sync.js");
syncApiKey(config.platform.apiKey);
} catch {
/* swallow */
}
}
}
export function redactKey(key: string): string {
+70 -4
View File
@@ -13,7 +13,12 @@ import { colors, printError, printWarning } from "./branding.js";
import type { Mem0Config } from "./config.js";
import { loadConfig, saveConfig } from "./config.js";
import { richFormatHelp } from "./help.js";
import { setAgentMode } from "./state.js";
import {
isAgentMode,
setAgentMode,
setCurrentCommand,
takeNotice,
} from "./state.js";
import { captureEvent } from "./telemetry.js";
import { CLI_VERSION } from "./version.js";
@@ -141,6 +146,11 @@ program
.description(
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
)
// Positional options: flags AFTER a subcommand name belong to that
// subcommand, not the global program. Without this, `mem0 init --agent`
// routes `--agent` to the program-level alias (for --json) and init's own
// `--agent` (Agent Mode bootstrap) silently never fires.
.enablePositionalOptions()
.option("--version", "Show version and exit.")
.on("option:version", () => {
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
@@ -149,7 +159,7 @@ program
.option("--json", "Output as JSON for agent/programmatic use.")
.option(
"--agent",
"Output as JSON for agent/programmatic use. (alias: --json)",
"Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent <cmd>`. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.",
)
.usage("<command> [options]")
.helpOption("--help", "Show this message and exit.")
@@ -166,6 +176,14 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
parentName && parentName !== "mem0"
? `${parentName}.${commandName}`
: commandName;
// Stash the active command name in shared state so the JSON
// error envelope (printError) can report which command failed
// instead of an empty `"command": ""` field.
setCurrentCommand(fullCommand);
// init fires its own telemetry from runInit with full M1-M6 props
// (mode/agent_caller/signup_source/claimed_agent_mode); skip the
// auto-fire here so we don't double-count.
if (fullCommand === "init") return;
const isAgent = !!(program.opts().json || program.opts().agent);
captureEvent(
`cli.${fullCommand}`,
@@ -193,11 +211,32 @@ program
"Verification code (use with --email for non-interactive login).",
)
.option("--force", "Overwrite existing config without confirmation.", false)
.option(
"--agent",
"Bootstrap an unattended Agent Mode account (no email required).",
false,
)
.option(
"--source <channel>",
"Channel attribution for signup (e.g. github, hn, ph).",
)
.option(
"--agent-caller <name>",
"Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
)
// Accept `--json` at the init level too so the PRD-documented form
// `mem0 init --agent --json` works without requiring users to move it
// before the subcommand. Effect is identical to the global `--json`:
// flip agent-mode output state.
.option("--json", "Output as JSON (alias for global `--json`).", false)
.addHelpText(
"after",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present",
)
.action(async (opts) => {
// `--json` at init level mirrors the global flag — flip agent_mode
// state so downstream formatters use JSON envelopes.
if (opts.json) setAgentMode(true);
const { runInit } = await import("./commands/init.js");
await runInit({
apiKey: opts.apiKey,
@@ -205,9 +244,24 @@ program
email: opts.email,
code: opts.code,
force: opts.force,
agent: opts.agent,
source: opts.source,
agentCaller: opts.agentCaller,
});
});
// ── Setup: identify (post-bootstrap agent self-tag) ──────────────────────
program
.command("identify <name>")
.description(
"Tag your active Agent Mode key with the AI agent that's using it (e.g. claude-code, cursor).",
)
.action(async (name: string) => {
const { runIdentify } = await import("./commands/identify.js");
await runIdentify(name);
});
// ── Memory: add ───────────────────────────────────────────────────────────
program
@@ -769,4 +823,16 @@ program
// ── Entrypoint ────────────────────────────────────────────────────────────
program.parse();
// Surface any unclaimed Agent Mode notice once per command, after the primary
// output. In JSON/agent mode the notice is folded into the envelope by
// formatJsonEnvelope, so skip the stderr banner there to avoid duplication.
function surfaceNotice(): void {
const notice = takeNotice();
if (notice && !isAgentMode()) {
process.stderr.write(`\n\x1b[33m🔔 ${notice}\x1b[0m\n\n`);
}
}
program.parseAsync().finally(() => {
surfaceNotice();
});
+16
View File
@@ -5,6 +5,7 @@
import boxen from "boxen";
import Table from "cli-table3";
import { colors, sym } from "./branding.js";
import { takeNotice } from "./state.js";
const { brand, accent, success, error: errorColor, dim } = colors;
@@ -244,6 +245,15 @@ export function formatJsonEnvelope(opts: {
if (opts.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error;
envelope.data = opts.data;
// If the platform flagged this as an unclaimed Agent Mode account, surface
// the notice inside the JSON envelope so an agent consuming the output
// sees it without needing to inspect HTTP headers.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { takeNotice } = require("./state.js");
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
@@ -356,6 +366,12 @@ export function formatAgentEnvelope(opts: {
}
if (opts.count !== undefined) envelope.count = opts.count;
envelope.data = sanitizeAgentData(opts.command, opts.data);
// Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
// agent reading the JSON output sees it without inspecting HTTP headers.
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Sync the active Mem0 API key into other ecosystem touchpoints.
*
* Why: the CLI canonical state is ~/.mem0/config.json. MCP servers
* (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or
* their own config files. Without a sync, agent-mode bootstrap mints a
* new key into config.json but the plugin's MCP keeps using the old
* key from env — silent surprise.
*
* Design:
* - Update ONLY entries that already exist; never create new ones
* - Preserve surrounding content, formatting, other keys
* - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt
* - Idempotent — re-running with the same key is a no-op
*
* Targets:
* - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
* - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines
*
* Out of scope: Codex / Cursor MCP configs and the plugin's own
* <plugin-dir>/.api_key file (plugin-managed, different schema).
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
const SHELL_RCS = [
path.join(os.homedir(), ".zshrc"),
path.join(os.homedir(), ".bashrc"),
path.join(os.homedir(), ".bash_profile"),
];
// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved
// when the MEM0_API_KEY export is the last line of the rc file.
const RC_LINE_RE =
/^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m;
export function syncApiKey(apiKey: string): string[] {
if (!apiKey) return [];
const updated: string[] = [];
if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) {
updated.push(CLAUDE_SETTINGS);
}
for (const rc of SHELL_RCS) {
if (updateShellRc(rc, apiKey)) updated.push(rc);
}
return updated;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateClaudeSettings(
filePath: string,
apiKey: string,
): boolean {
if (!fs.existsSync(filePath)) return false;
let raw: string;
let data: Record<string, unknown>;
try {
raw = fs.readFileSync(filePath, "utf-8");
data = JSON.parse(raw);
} catch {
return false;
}
const env = data.env;
if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) {
return false; // no existing entry — don't create one
}
const envObj = env as Record<string, string>;
if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync
envObj.MEM0_API_KEY = apiKey;
atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`);
return true;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateShellRc(filePath: string, apiKey: string): boolean {
if (!fs.existsSync(filePath)) return false;
let text: string;
try {
text = fs.readFileSync(filePath, "utf-8");
} catch {
return false;
}
const match = text.match(RC_LINE_RE);
if (!match) return false; // no existing line
if (match[3] === apiKey) return false;
const newText = text.replace(
RC_LINE_RE,
(_full, prefix) => `${prefix}"${apiKey}"`,
);
atomicWriteText(filePath, newText);
return true;
}
function atomicWriteText(filePath: string, content: string): void {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
try {
fs.writeFileSync(tmp, content, "utf-8");
// Preserve permissions if original existed.
if (fs.existsSync(filePath)) {
try {
const mode = fs.statSync(filePath).mode & 0o777;
fs.chmodSync(tmp, mode);
} catch {
/* best-effort */
}
}
fs.renameSync(tmp, filePath);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
+17
View File
@@ -5,6 +5,7 @@
let _agentMode = false;
let _currentCommand = "";
let _pendingNotice = "";
export function isAgentMode(): boolean {
return _agentMode;
@@ -21,3 +22,19 @@ export function getCurrentCommand(): string {
export function setCurrentCommand(name: string): void {
_currentCommand = name;
}
/**
* Stash a Mem0 backend notice (Agent Mode unclaimed reminder) for end-of-
* command surfacing. Called from the platform backend after each response so
* the notice prints once per command regardless of how many sub-requests
* fired. Last-write-wins is fine — the message text is identical.
*/
export function captureNotice(notice: string | null | undefined): void {
if (notice) _pendingNotice = notice;
}
export function takeNotice(): string {
const msg = _pendingNotice;
_pendingNotice = "";
return msg;
}
+4
View File
@@ -115,6 +115,9 @@ export function captureEvent(
}
}
// M4: every cli.* event carries agent_mode based on the config flag
// (unclaimed Agent Mode key). This is the growth-doc property used to
// join init → add → search funnels in PostHog.
const payload = {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
@@ -123,6 +126,7 @@ export function captureEvent(
source: "CLI",
language: "node",
cli_version: CLI_VERSION,
agent_mode: Boolean(config.platform.agentMode),
node_version: process.version,
os: process.platform,
...properties,
+141
View File
@@ -0,0 +1,141 @@
/**
* Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
*
* Mirror of `cli/python/tests/test_agent_mode.py` — both files MUST stay
* in sync so that the Python and Node CLIs expose an identical surface
* for the Agent Mode entrypoint. If you add a flag here, add the same
* assertion on the Python side (and vice versa).
*
* Network-bound bootstrap is covered by the platform-side E2E suite
* (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify
* the CLI surface that ships in the binary.
*/
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function run(
args: string[],
opts: { home?: string; env?: Record<string, string> } = {},
): { stdout: string; stderr: string; exitCode: number } {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("MEM0_")) delete env[key];
}
if (opts.home) env.HOME = opts.home;
if (opts.env) Object.assign(env, opts.env);
try {
const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, {
cwd: path.join(__dirname, ".."),
env,
encoding: "utf-8",
timeout: 15000,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: e.status ?? 1,
};
}
}
function cleanHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
}
describe("init flag surface", () => {
it("init --help lists --agent", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--agent");
});
it("init --help describes Agent Mode", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
// Description must mention what --agent actually does so an agent
// reading the help can self-discover the bootstrap entrypoint.
expect(
result.stdout.includes("Agent Mode") ||
result.stdout.toLowerCase().includes("unattended"),
).toBe(true);
});
it("init --help lists --source", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--source");
});
it("init --help lists --email and --code", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--email");
expect(result.stdout).toContain("--code");
});
});
describe("argv preprocessing — --agent reaches init subcommand", () => {
// Regression for the bug where the global --agent JSON-alias swallowed
// the init-level --agent flag, making `mem0 init --agent` behave like
// the plain interactive wizard.
it("init --agent triggers bootstrap branch (not the wizard)", () => {
const home = cleanHome();
const result = run(["init", "--agent"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole
FORCE_COLOR: "0",
},
});
const combined = (result.stdout + result.stderr).toLowerCase();
// Either bootstrap-attempt error, or a connection/network error —
// both prove the --agent path executed (the wizard would prompt for
// input and succeed/hang, not surface a network error).
expect(
combined.includes("agent") ||
combined.includes("connect") ||
combined.includes("network") ||
combined.includes("fetch") ||
combined.includes("bootstrap"),
).toBe(true);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("JSON envelope on network failure", () => {
it("init --agent --json does not leak a stack trace when backend is unreachable", () => {
const home = cleanHome();
const result = run(["init", "--agent", "--json"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1",
FORCE_COLOR: "0",
},
});
const combined = result.stdout + result.stderr;
// No raw Node stack should escape the agent-mode handler.
expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/);
expect(combined).not.toContain("UnhandledPromiseRejection");
expect(result.exitCode).not.toBe(0);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("top-level help lists init", () => {
// `mem0 --help` must list `init` so agents walking the top-level help
// can discover the Agent Mode entrypoint without prior knowledge.
it("--help lists init", () => {
const result = run(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("init");
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Unit tests for init internals — decision tree primitives + plugin sync.
*
* Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay
* in sync — if you add a behavioral assertion here, mirror it on the Python
* side and vice versa.
*
* - `pingKey` must NOT treat network errors as "invalid key" (else a VPN
* flap silently mints a new shadow over a working key).
* - `plugin_sync` must only update entries that already exist, preserve
* trailing newlines, and never mangle other lines.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pingKey } from "../src/commands/init.js";
import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js";
// ── pingKey ──────────────────────────────────────────────────────────────
describe("pingKey — network vs auth distinction", () => {
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
vi.restoreAllMocks();
});
it("returns true for 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns false for 401 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns false for 403 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns true for 5xx (transient upstream — prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on network error (prefer reuse over re-mint)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on timeout (prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
});
// ── updateShellRc ────────────────────────────────────────────────────────
describe("updateShellRc — exists-only contract", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("updates existing export and preserves trailing newline", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n');
expect(updateShellRc(rc, "newkey")).toBe(true);
expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n');
});
it("does NOT create a new export when none exists", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, "alias ll='ls -la'\n");
expect(updateShellRc(rc, "newkey")).toBe(false);
expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n");
});
it("preserves surrounding content", () => {
const rc = path.join(tmpDir, ".zshrc");
const original =
"# my zshrc\n" +
"alias ll='ls -la'\n" +
"export MEM0_API_KEY='old'\n" +
"export OTHER=keepme\n";
fs.writeFileSync(rc, original);
updateShellRc(rc, "newkey");
const after = fs.readFileSync(rc, "utf-8");
expect(after).toContain("alias ll='ls -la'\n");
expect(after).toContain("export OTHER=keepme\n");
expect(after).toContain("# my zshrc\n");
expect(after).toContain('export MEM0_API_KEY="newkey"\n');
});
it("is idempotent when value already matches", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n');
expect(updateShellRc(rc, "same")).toBe(false);
});
it("is a no-op for missing files", () => {
const rc = path.join(tmpDir, ".zshrc"); // does not exist
expect(updateShellRc(rc, "x")).toBe(false);
});
});
// ── updateClaudeSettings ─────────────────────────────────────────────────
describe("updateClaudeSettings — never creates entries", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("does not create env block when none exists", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({
otherKey: 1,
});
});
it("does not create MEM0_API_KEY entry in existing env block", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
});
it("updates existing entry and preserves siblings", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2),
);
expect(updateClaudeSettings(settings, "fresh")).toBe(true);
const data = JSON.parse(fs.readFileSync(settings, "utf-8"));
expect(data.env.MEM0_API_KEY).toBe("fresh");
expect(data.env.OTHER).toBe("y");
});
it("is idempotent when value already matches", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "same" } }),
);
expect(updateClaudeSettings(settings, "same")).toBe(false);
});
it("is a no-op for malformed JSON", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, "{ this is not json");
expect(updateClaudeSettings(settings, "x")).toBe(false);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.4"
version = "0.2.5"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
+36
View File
@@ -0,0 +1,36 @@
"""Detect whether the CLI is being invoked from inside an AI-agent context.
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
agent runtime env var is present. The return value is a context **trigger
only** — the canonical agent identity is self-declared by the agent via
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
vars to fill the ``agent_caller`` field on the APIKey row.
Returns a short name or None. The list is curated, not exhaustive — env
vars we don't recognise fall through to None (caller treated as
non-agent). Honest reporting depends on ``--agent-caller``; this list is
just enough to enable the zero-friction auto-bootstrap UX.
"""
from __future__ import annotations
import os
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
("cline", ("CLINE_AGENT", "CLINE")),
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
("aider", ("AIDER_SESSION",)),
("goose", ("GOOSE_AGENT",)),
("windsurf", ("WINDSURF_AGENT",)),
)
def detect_agent_caller() -> str | None:
"""Return a canonical agent name if any agent env var is set, else None."""
for name, env_vars in _AGENT_CALLER_ENV:
if any(os.environ.get(v) for v in env_vars):
return name
return None
+71 -5
View File
@@ -237,6 +237,14 @@ def main_callback(
cmd_version()
raise typer.Exit()
if ctx.invoked_subcommand:
# Stash the active subcommand name so the JSON error envelope
# (print_error in agent mode) can report which command failed
# instead of an empty `"command": ""` field.
from mem0_cli.state import set_current_command
set_current_command(ctx.invoked_subcommand)
if ctx.invoked_subcommand and ctx.invoked_subcommand != "init":
# init fires its own telemetry from init_cmd.run_init with full M1-M6 props.
_fire_telemetry(ctx.invoked_subcommand)
@@ -851,6 +859,19 @@ def init(
force: bool = typer.Option(
False, "--force", help="Overwrite existing config without confirmation."
),
agent_signal: bool = typer.Option(
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
),
source: str | None = typer.Option(
None,
"--source",
help="Channel attribution for signup (e.g. github, hn, ph).",
),
agent_caller: str | None = typer.Option(
None,
"--agent-caller",
help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
),
) -> None:
"""Interactive setup wizard for mem0 CLI.
@@ -859,10 +880,38 @@ def init(
mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
mem0 init --agent --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap
mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present
"""
from mem0_cli.commands.init_cmd import run_init
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
run_init(
api_key=api_key,
user_id=user_id,
email=email,
code=code,
force=force,
source=source,
agent=agent_signal,
agent_caller=agent_caller,
)
@app.command(rich_help_panel="Setup")
def identify(
name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."),
) -> None:
"""Tag your active Agent Mode key with the AI agent that's using it.
Run this once after `mem0 init --agent` if you didn't pass --agent-caller.
Idempotent — re-running just overwrites the value.
Example:
mem0 identify claude-code
"""
from mem0_cli.commands.identify_cmd import run_identify
run_identify(name)
# (entity_app registered at module level, below sub-group definitions)
@@ -1198,11 +1247,28 @@ def main() -> None:
import sys
# Allow --json/--agent anywhere in the command line (not just before subcommand).
_json_flags = {"--json", "--agent"}
if any(a in _json_flags for a in sys.argv[1:]):
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
argv_rest = sys.argv[1:]
is_init = "init" in argv_rest
_global_flags = {"--json"} if is_init else {"--json", "--agent"}
if any(a in _global_flags for a in argv_rest):
from mem0_cli.state import set_agent_mode
set_agent_mode(True)
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
app()
try:
app()
finally:
# Surface any unclaimed Agent Mode notice once per command, after the
# primary output. In JSON/agent mode the notice is folded into the
# envelope by format_json_envelope, so skip the stderr banner there
# to avoid duplicate output.
from mem0_cli.state import is_agent_mode, take_notice
notice = take_notice()
if notice and not is_agent_mode():
from rich.console import Console
Console(stderr=True).print(f"\n[yellow]🔔 {notice}[/yellow]\n")
+21 -2
View File
@@ -30,7 +30,7 @@ class PlatformBackend(Backend):
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
from mem0_cli.state import is_agent_mode
from mem0_cli.state import capture_notice, is_agent_mode
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
resp = self._client.request(method, path, **kwargs)
@@ -48,7 +48,26 @@ class PlatformBackend(Backend):
resp.raise_for_status()
if resp.status_code == 204:
return {}
return resp.json()
data = resp.json()
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
# fallback for endpoints that return non-dict / non-dict-leading
# payloads) and stash it for end-of-command surfacing.
notice = None
if isinstance(data, dict) and "mem0_notice" in data:
notice = data.pop("mem0_notice")
elif (
isinstance(data, list)
and data
and isinstance(data[0], dict)
and "mem0_notice" in data[0]
):
notice = data[0].pop("mem0_notice")
if notice is None:
notice = resp.headers.get("X-Mem0-Notice-Message") or None
capture_notice(notice)
return data
def add(
self,
+4 -2
View File
@@ -87,10 +87,12 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
}
print(_json.dumps(envelope))
return
from rich.markup import escape
sym = _sym("✗", "[error]")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
if hint:
console.print(f" [{DIM_COLOR}]{hint}[/]")
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
def print_warning(console: Console, message: str) -> None:
@@ -0,0 +1,239 @@
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from typing import Any
import httpx
import typer
from rich.console import Console
from rich.prompt import Prompt
from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR,
print_error,
print_success,
)
from mem0_cli.config import Mem0Config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def _validate_envelope(envelope: Any) -> None:
"""Defend against partial/malformed backend responses.
A backend regression that returns ``{"api_key": null}`` would otherwise be
silently persisted, producing confusing downstream errors far from the
source. Fail fast with a clear message if the required fields are missing.
"""
if not isinstance(envelope, dict):
print_error(err_console, "Bootstrap response was not a JSON object.")
raise typer.Exit(1)
for field in ("api_key", "default_user_id"):
value = envelope.get(field)
if not isinstance(value, str) or not value:
print_error(
err_console,
f"Bootstrap response missing required field {field!r} — please update the CLI.",
)
raise typer.Exit(1)
def bootstrap_via_backend(
config: Mem0Config,
*,
source: str | None = None,
agent_caller: str | None = None,
) -> None:
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
Args:
config: Mem0Config mutated in place with the new platform values.
source: ``--source`` flag passthrough (analytics tag, free-form).
agent_caller: Self-declared agent identity passed via ``--agent-caller``
(e.g. ``claude-code``, ``cursor``). May be None when the caller
omitted the flag; the agent can backfill later via
``mem0 identify <name>``. Sent to the backend in the request body
and saved into ``platform.agent_caller`` for local introspection.
Raises typer.Exit(1) on failure.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
body: dict[str, Any] = {}
if source:
body["source"] = source
if agent_caller:
body["agent_caller"] = agent_caller
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{base_url}/api/v1/auth/agent_mode/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json=body,
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error contacting Mem0: {exc}")
raise typer.Exit(1) from exc
if resp.status_code == 429:
print_error(err_console, "Rate-limited. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code == 503:
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
raise typer.Exit(1)
if resp.status_code != 200:
detail = resp.text
try:
err_body = resp.json()
detail = err_body.get("error") or err_body.get("detail") or resp.text
except (json.JSONDecodeError, ValueError, AttributeError):
pass
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
# translates to a generic 403 "You do not have permission to perform
# this action." That's opaque — surface as the rate-limit it actually is.
if resp.status_code == 403 and "permission" in str(detail).lower():
print_error(
err_console,
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
)
raise typer.Exit(1)
print_error(err_console, f"Bootstrap failed: {detail}")
raise typer.Exit(1)
envelope = resp.json()
_validate_envelope(envelope)
config.platform.api_key = envelope["api_key"]
config.platform.base_url = base_url
config.platform.agent_mode = True
config.platform.created_via = "agent_mode"
config.platform.agent_caller = agent_caller or ""
config.platform.claimed_at = ""
config.platform.default_user_id = envelope["default_user_id"]
# Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.user_id = envelope["default_user_id"]
save_config(config)
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
notice = envelope.get("mem0_notice")
if notice:
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
else:
# Fallback if the backend hasn't deployed the unified notice yet.
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
if not agent_caller:
console.print(
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
)
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
`/.../verify/`) and adds the local agent-mode API key in the verify body
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
upgrade-in-place transaction inline and returns claim result.
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
`user_email`. The api_key value itself never changes.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
if not config.platform.api_key or not config.platform.agent_mode:
print_error(
err_console,
"This command requires an active Agent Mode config. Run `mem0 init` first.",
)
raise typer.Exit(1)
raw_key = config.platform.api_key
with httpx.Client(timeout=30.0) as client:
# Step 1: request OTP (unless --code provided)
if not code:
send = client.post(
f"{base_url}/api/v1/auth/email_code/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={"email": email},
)
if send.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if send.status_code != 200:
try:
detail = send.json().get("error", send.text)
except Exception:
detail = send.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, f"Verification code sent to {email}. Check your inbox.")
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint=f"Re-run: mem0 init --email {email} --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 2: verify + claim in one shot
verify = client.post(
f"{base_url}/api/v1/auth/email_code/verify/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={
"email": email,
"code": code.strip(),
"agent_mode_api_key": raw_key,
},
)
if verify.status_code != 200:
try:
err_body = verify.json()
detail = err_body.get("error", verify.text)
code_str = err_body.get("code", "")
except (json.JSONDecodeError, ValueError, AttributeError):
detail = verify.text
code_str = ""
print_error(err_console, f"Claim failed: {detail}")
if code_str == "email_already_claimed":
console.print(
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.[/]"
)
raise typer.Exit(1)
claim_body = verify.json()
if not claim_body.get("claimed"):
print_error(err_console, f"Unexpected verify response: {claim_body}")
raise typer.Exit(1)
config.platform.agent_mode = False
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
config.platform.user_email = email
config.platform.created_via = "email"
save_config(config)
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,75 @@
"""mem0 identify — declare which agent owns the current agent-mode key.
Used when `mem0 init --agent` ran without --agent-caller, so the backend
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
its own row with its real identity. Idempotent — running it again just
overwrites.
"""
from __future__ import annotations
import httpx
import typer
from rich.console import Console
from mem0_cli.branding import print_error, print_success
from mem0_cli.config import load_config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def run_identify(name: str) -> None:
"""PATCH the active agent-mode key's agent_caller field."""
config = load_config()
if not config.platform.api_key:
print_error(
err_console,
"No API key configured. Run `mem0 init --agent` first.",
)
raise typer.Exit(1)
if not config.platform.agent_mode:
print_error(
err_console,
"This command only works on unclaimed agent-mode keys.",
)
raise typer.Exit(1)
name = (name or "").strip()
if not name:
print_error(err_console, "Agent name is required.")
raise typer.Exit(1)
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
try:
with httpx.Client(timeout=30.0) as client:
resp = client.patch(
f"{base_url}/api/v1/auth/agent_mode/caller/",
headers={
**_SOURCE_HEADERS,
"Authorization": f"Token {config.platform.api_key}",
"Content-Type": "application/json",
},
json={"agent_caller": name},
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error: {exc}")
raise typer.Exit(1) from exc
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Identify failed: {detail}")
raise typer.Exit(1)
canonical = resp.json().get("agent_caller", name)
config.platform.agent_caller = canonical
save_config(config)
print_success(console, f"Identified as {canonical}.")
+149 -1
View File
@@ -103,6 +103,25 @@ def _validate_email(email: str) -> None:
raise typer.Exit(1)
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
"""Validate api_key against /v1/ping/.
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
Network errors, timeouts, and 5xx responses return True so we prefer
reusing an existing key over silently minting a new shadow on a transient
blip (which would also clobber config + plugin-sync targets).
"""
try:
resp = httpx.get(
f"{base_url.rstrip('/')}/v1/ping/",
headers={"Authorization": f"Token {api_key}"},
timeout=timeout,
)
except httpx.HTTPError:
return True # unknown — prefer reuse
return resp.status_code not in (401, 403)
def _email_login(
email: str,
code: str | None,
@@ -182,21 +201,143 @@ def run_init(
email: str | None = None,
code: str | None = None,
force: bool = False,
source: str | None = None,
agent: bool = False,
agent_caller: str | None = None,
) -> None:
"""Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped
(non-interactive mode). When running in a non-TTY without the required
flags, an error message is printed.
Agent Mode dispatch (no email/api-key flags):
- If existing config has an active API key → reuse (existing_key path).
- Else if any positive agent signal (--agent, --json global, agent env
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
- Else fall through to the interactive wizard.
Claim dispatch:
- If `--email` is set AND existing config has `agent_mode=true`, run the
claim device-flow against the existing key instead of minting a new
email-based key.
"""
from mem0_cli.agent_detect import detect_agent_caller
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
from mem0_cli.state import is_agent_mode as _global_agent_mode
from mem0_cli.telemetry import capture_event
def _fire_init(mode: str, *, claimed: bool = False) -> None:
"""Fire cli.init telemetry with M1-M6 properties."""
props: dict = {"command": "init", "mode": mode}
if agent_caller:
# Self-declared via --agent-caller; not sniffed from env vars.
props["agent_caller"] = agent_caller
if source:
props["signup_source"] = source
if claimed:
props["claimed_agent_mode"] = True
capture_event("cli.init", props)
config = Mem0Config()
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
config.platform.base_url = base_url
if code and not email:
print_error(err_console, "--code requires --email.")
raise typer.Exit(1)
# ── Email + existing agent-mode config → claim flow ─────────────────
if email and CONFIG_FILE.exists():
existing = load_config()
if existing.platform.agent_mode and existing.platform.api_key:
email = email.strip().lower()
_validate_email(email)
print_info(console, f"Claiming Agent Mode account to {email}...")
claim_via_otp(existing, email=email, code=code)
_fire_init("email", claimed=True)
return
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
# short-circuit before the guard prompts. Rule 3 mints only when there
# is no valid key to reuse — in that case overwriting is correct.
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
if not api_key and not email and _agent_ctx:
from mem0_cli.output import format_json_envelope
from mem0_cli.state import is_agent_mode as _is_json_mode
def _emit_reuse(source: str) -> None:
if _is_json_mode():
format_json_envelope(
console,
command="init",
data={
"api_key_saved": False,
"api_key_source": source,
"agent_mode": False,
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
)
else:
msg = (
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
if source == "env"
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
)
print_success(console, msg)
def _maybe_identify(key: str) -> None:
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
reused key. Silent no-op on any failure — reuse must not break.
"""
if not agent_caller:
return
try:
resp = httpx.patch(
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
headers={
"Authorization": f"Token {key}",
"Content-Type": "application/json",
},
json={"agent_caller": agent_caller},
timeout=10.0,
)
# Also reflect in local config so introspection matches backend.
if resp.status_code == 200 and CONFIG_FILE.exists():
try:
cfg = load_config()
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
save_config(cfg)
except Exception:
pass
except httpx.HTTPError:
pass
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
if _env_key and _ping_key(_env_key, base_url):
_maybe_identify(_env_key)
_emit_reuse("env")
_fire_init("existing_key")
return
# Rule 2: existing config api_key valid → reuse.
if CONFIG_FILE.exists():
_existing = load_config()
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
_maybe_identify(_existing.platform.api_key)
_emit_reuse("config")
_fire_init("existing_key")
return
# Rule 3: mint a fresh shadow (no valid key to reuse).
# agent_caller is the agent's self-declared identity from --agent-caller
# (Proof Editor-style). Env-var auto-detect is still used above to
# decide we're in an agent context, but never to fill identity.
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
_fire_init("agent")
return
# Warn if an existing config with an API key would be overwritten
if not force and CONFIG_FILE.exists():
existing = load_config()
@@ -242,6 +383,7 @@ def run_init(
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
@@ -258,6 +400,8 @@ def run_init(
return
# ── API key flow (existing) ───────────────────────────────────────
# (Agent Mode branch runs earlier — see above, before the existing-config
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
if not sys.stdin.isatty():
@@ -265,7 +409,7 @@ def run_init(
print_error(
err_console,
"Non-interactive terminal detected and --api-key is required.",
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
)
raise typer.Exit(1)
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
@@ -273,6 +417,7 @@ def run_init(
# Fully non-interactive when both flags provided
if api_key and user_id:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
config.defaults.user_id = user_id
_validate_platform(config)
save_config(config)
@@ -313,6 +458,7 @@ def run_init(
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email_addr
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
@@ -331,6 +477,7 @@ def run_init(
# API key flow
if api_key:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
else:
_setup_platform(config)
@@ -370,6 +517,7 @@ def _setup_platform(config: Mem0Config) -> None:
raise typer.Exit(1)
config.platform.api_key = api_key
config.platform.created_via = "api_key"
def _setup_defaults(config: Mem0Config) -> None:
+31
View File
@@ -28,6 +28,14 @@ class PlatformConfig:
api_key: str = ""
base_url: str = DEFAULT_BASE_URL
user_email: str = ""
# Agent Mode (unclaimed-shadow signup)
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
agent_caller: str = (
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
)
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
@dataclass
@@ -83,6 +91,11 @@ def load_config() -> Mem0Config:
config.platform.api_key = plat.get("api_key", "")
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
config.platform.user_email = plat.get("user_email", "")
config.platform.agent_mode = bool(plat.get("agent_mode", False))
config.platform.created_via = plat.get("created_via", "")
config.platform.agent_caller = plat.get("agent_caller", "")
config.platform.claimed_at = plat.get("claimed_at", "")
config.platform.default_user_id = plat.get("default_user_id", "")
defaults = data.get("defaults", {})
config.defaults.user_id = defaults.get("user_id", "")
@@ -136,6 +149,11 @@ def save_config(config: Mem0Config) -> None:
"api_key": config.platform.api_key,
"base_url": config.platform.base_url,
"user_email": config.platform.user_email,
"agent_mode": config.platform.agent_mode,
"created_via": config.platform.created_via,
"agent_caller": config.platform.agent_caller,
"claimed_at": config.platform.claimed_at,
"default_user_id": config.platform.default_user_id,
},
"telemetry": {
"anonymous_id": config.telemetry.anonymous_id,
@@ -147,6 +165,19 @@ def save_config(config: Mem0Config) -> None:
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
# Propagate the active api_key to ecosystem touchpoints (Claude Code
# plugin env injection, shell rc exports). Idempotent — only updates
# EXISTING entries; never creates new ones. Best-effort: any IOError
# in the sync is swallowed so config.json is always the authoritative
# write, never blocked by plugin-state issues.
if config.platform.api_key:
try:
from mem0_cli.plugin_sync import sync_api_key
sync_api_key(config.platform.api_key)
except Exception:
pass
def redact_key(key: str) -> str:
"""Redact an API key for display: m0-xxx...xxx"""
+19
View File
@@ -229,6 +229,16 @@ def format_json_envelope(
if error:
envelope["error"] = error
envelope["data"] = data
# If the platform flagged this as an unclaimed Agent Mode account, surface
# the notice inside the JSON envelope so an agent consuming the output
# sees it without needing to inspect HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
@@ -323,6 +333,15 @@ def format_agent_envelope(
if count is not None:
envelope["count"] = count
envelope["data"] = sanitize_agent_data(command, data)
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
# agent reading the JSON output sees it without inspecting HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
+119
View File
@@ -0,0 +1,119 @@
"""Sync the active Mem0 API key into other ecosystem touchpoints.
Why this exists:
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
vars or their own config files. Without a sync, an agent-mode bootstrap
mints a new key into config.json but the plugin's MCP keeps using the
old key from env — silent surprise.
Design:
- Update ONLY entries that already exist (never create new ones)
- Preserve all surrounding content / formatting / other keys
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
- Idempotent — re-running with the same key is a no-op
- Skip on dry_run
Targets currently handled:
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
Out of scope (deliberately not touched):
- Codex / Cursor MCP configs — would require schema-aware edits and
those tools don't have mem0 entries by default
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import tempfile
from pathlib import Path
# Files we know how to update safely.
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
def sync_api_key(api_key: str) -> list[str]:
"""Propagate ``api_key`` into known ecosystem touchpoints.
Returns the list of paths actually updated. Empty list means nothing
needed updating (either targets didn't exist or already had this value).
"""
if not api_key:
return []
updated: list[str] = []
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
updated.append(str(_CLAUDE_SETTINGS))
for rc in _SHELL_RCS:
if _update_shell_rc(rc, api_key):
updated.append(str(rc))
return updated
def _update_claude_settings(path: Path, api_key: str) -> bool:
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
if not path.is_file():
return False
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return False
env = data.get("env")
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
# No existing entry — don't create one.
return False
if env["MEM0_API_KEY"] == api_key:
return False # already in sync
env["MEM0_API_KEY"] = api_key
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
return True
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
# end-of-file is preserved when MEM0_API_KEY is the last line.
_RC_LINE = re.compile(
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
re.MULTILINE,
)
def _update_shell_rc(path: Path, api_key: str) -> bool:
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
if not path.is_file():
return False
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
match = _RC_LINE.search(text)
if not match:
return False # no existing line
if match.group(3) == api_key:
return False
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
_atomic_write_text(path, new_text)
return True
def _atomic_write_text(path: Path, content: str) -> None:
"""Write content to path atomically (temp + rename)."""
dirname = path.parent
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
# Preserve mode if the original existed.
if path.exists():
os.chmod(tmp_path, path.stat().st_mode & 0o777)
os.replace(tmp_path, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
+21
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
_agent_mode: bool = False
_current_command: str = ""
_pending_notice: str = ""
def is_agent_mode() -> bool:
@@ -22,3 +23,23 @@ def get_current_command() -> str:
def set_current_command(name: str) -> None:
global _current_command
_current_command = name
def capture_notice(notice: str | None) -> None:
"""Stash a Mem0 backend notice for end-of-command surfacing.
Called from the platform backend after each response so the notice can
be printed once per command (regardless of how many sub-requests fired).
Last-write-wins is fine — the message text is identical across requests.
"""
global _pending_notice
if notice:
_pending_notice = notice
def take_notice() -> str:
"""Return and clear the pending notice."""
global _pending_notice
msg = _pending_notice
_pending_notice = ""
return msg
+4 -2
View File
@@ -87,7 +87,6 @@ def capture_event(
try:
from mem0_cli import __version__
from mem0_cli.config import CONFIG_FILE, load_config, save_config
from mem0_cli.state import is_agent_mode
config = load_config()
distinct_id = pre_resolved_email or _get_distinct_id()
@@ -107,6 +106,9 @@ def capture_event(
with contextlib.suppress(Exception):
save_config(config)
# M4: every cli.* event carries agent_mode based on the config flag
# (unclaimed Agent Mode key). This is the growth-doc property used to
# join init → add → search funnels in PostHog.
payload = {
"api_key": POSTHOG_API_KEY,
"distinct_id": distinct_id,
@@ -115,7 +117,7 @@ def capture_event(
"source": "CLI",
"language": "python",
"cli_version": __version__,
"agent_mode": is_agent_mode(),
"agent_mode": bool(config.platform.agent_mode),
"python_version": sys.version,
"os": sys.platform,
"os_version": platform.version(),
+157
View File
@@ -0,0 +1,157 @@
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
sync so that the Python and Node CLIs expose an identical surface for the
Agent Mode entrypoint. If you add a flag here, add the same assertion on
the Node side (and vice versa).
Network-bound bootstrap is covered by the platform-side E2E suite
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
the CLI surface that ships in the binary.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
if home_dir:
env["HOME"] = home_dir
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
text=True,
env=env,
timeout=15,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
return str(tmp_path)
class TestInitFlagSurface:
"""`mem0 init --help` must expose the Agent Mode flags."""
def test_init_help_lists_agent_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--agent" in result.stdout
def test_init_help_describes_agent_mode(self):
result = _run(["init", "--help"])
assert result.returncode == 0
# Description must mention what --agent actually does so an agent
# reading the help can self-discover the bootstrap entrypoint.
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
def test_init_help_lists_source_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--source" in result.stdout
def test_init_help_lists_email_and_code(self):
# Claim flow flags must remain present alongside Agent Mode flags.
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--email" in result.stdout
assert "--code" in result.stdout
class TestArgvPreprocessing:
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
Regression for the bug where the top-level `--agent` JSON-alias was
stripped from ``sys.argv`` before Typer could bind it to the init
subcommand, making ``mem0 init --agent`` indistinguishable from a
plain ``mem0 init`` (interactive wizard).
"""
def test_init_with_agent_reaches_subcommand(self, clean_home):
# We can't hit a real backend in unit tests, so we point the CLI at
# a guaranteed-dead URL and assert the failure is the bootstrap
# request failing — proving the --agent flag was honored and the
# bootstrap branch ran, not the interactive wizard.
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
capture_output=True,
text=True,
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
"FORCE_COLOR": "0",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr).lower()
# Either we got a connection/network error from the bootstrap POST,
# or the CLI surfaced an Agent Mode-specific failure message.
assert (
"agent" in combined
or "connect" in combined
or "network" in combined
or "fetch" in combined
or "bootstrap" in combined
), f"Expected bootstrap attempt, got: {combined!r}"
class TestJsonEnvelopeParity:
"""`mem0 init --agent --json` should produce a JSON envelope on success.
Without a live backend we can only assert the failure shape: when the
backend is unreachable, the CLI must still exit non-zero AND not crash
on a Python traceback (which would mean we leaked an exception past
the agent-mode handler).
"""
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
capture_output=True,
text=True,
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1",
"FORCE_COLOR": "0",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr)
assert "Traceback (most recent call last)" not in combined
assert result.returncode != 0
class TestInitInCommandList:
"""`mem0 --help` must list `init` so agents walking the top-level help
can discover the Agent Mode entrypoint without prior knowledge."""
def test_top_level_help_lists_init(self):
result = _run(["--help"])
assert result.returncode == 0
assert "init" in result.stdout
+206
View File
@@ -0,0 +1,206 @@
"""Unit tests for init internals — decision tree primitives + plugin sync.
These tests exercise the units that the high-level subprocess parity tests in
``test_agent_mode.py`` deliberately can't reach:
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
flap silently mints a new shadow over a working key).
- ``plugin_sync`` must only update entries that already exist, preserve
trailing newlines, and never mangle other lines.
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
real cause instead of DRF's opaque "You do not have permission" string.
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
behavioral assertion here, mirror it on the Node side and vice versa.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import pytest
from mem0_cli.commands.init_cmd import _ping_key
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
# ── _ping_key ──────────────────────────────────────────────────────────────
class _Resp:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
assert _ping_key("k", "http://x") is True
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
assert _ping_key("k", "http://x") is False
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
assert _ping_key("k", "http://x") is False
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
# Transient upstream failure must NOT cause a shadow to be minted.
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
assert _ping_key("k", "http://x") is True
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
def boom(*a, **kw):
raise httpx.ConnectError("nope")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(*a, **kw):
raise httpx.ReadTimeout("slow")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is True
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is False
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
rc = tmp_path / ".zshrc"
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
rc.write_text(original, encoding="utf-8")
_update_shell_rc(rc, "newkey")
after = rc.read_text(encoding="utf-8")
assert "alias ll='ls -la'\n" in after
assert "export OTHER=keepme\n" in after
assert "# my zshrc\n" in after
assert 'export MEM0_API_KEY="newkey"\n' in after
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
assert _update_shell_rc(rc, "same") is False
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
rc = tmp_path / ".zshrc" # does not exist
assert _update_shell_rc(rc, "x") is False
# ── plugin_sync._update_claude_settings ────────────────────────────────────
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
# Original content unchanged.
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
encoding="utf-8",
)
changed = _update_claude_settings(settings, "fresh")
assert changed is True
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["env"]["MEM0_API_KEY"] == "fresh"
assert data["env"]["OTHER"] == "y" # other keys preserved
def test_claude_settings_idempotent(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
assert _update_claude_settings(settings, "same") is False
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
settings = tmp_path / "settings.json"
settings.write_text("{ this is not json", encoding="utf-8")
assert _update_claude_settings(settings, "x") is False
# ── bootstrap rate-limit translation ──────────────────────────────────────
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
from mem0_cli.config import Mem0Config
fake_resp = MagicMock()
fake_resp.status_code = 403
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
fake_resp.json = MagicMock(
return_value={"detail": "You do not have permission to perform this action."}
)
class _Client:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *a, **kw):
return fake_resp
monkeypatch.setattr(httpx, "Client", _Client)
cfg = Mem0Config()
cfg.platform.base_url = "https://api.mem0.ai"
import typer
with pytest.raises(typer.Exit):
bootstrap_via_backend(cfg)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "Daily Agent Mode signup limit reached" in combined
assert "permission to perform this action" not in combined
+2
View File
@@ -5,3 +5,5 @@ openapi: get /v1/event/{event_id}/
---
Retrieve details about a specific event by passing its `event_id`. This endpoint is particularly helpful for tracking the status, payload, and completion details of asynchronous memory operations.
For `POST /v3/memories/add/`, the event confirms that the write pipeline completed. Temporal reasoning enrichment runs asynchronously by default, so the event may be `SUCCEEDED` slightly before temporal ranking signals are available to subsequent `search` calls.
@@ -83,4 +83,3 @@ The request is queued for background processing. The response contains an `event
<Info>
Poll the event status via `GET /v1/event/{event_id}/`. Status will be `SUCCEEDED` or `FAILED` once processing completes.
</Info>
@@ -64,4 +64,3 @@ memories = client.get_all(
<Info>
The response is a paginated envelope with `count`, `next`, `previous`, and `results`. Use `page` and `page_size` query params to step through results.
</Info>
@@ -49,6 +49,7 @@ related_memories = client.search(
{
"id": "ea925981-272f-40dd-b576-be64e4871429",
"memory": "Likes to play cricket and plays cricket on weekends.",
"user_id": "alice",
"metadata": {
"category": "hobbies"
},
@@ -109,6 +109,19 @@ client.project.update(
)
```
#### Toggle Memory Decay
`decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay) — a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint:
```bash cURL
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
-H "Authorization: Token $MEM0_API_KEY" \
-H "Content-Type: application/json" \
-d '{"decay": true}'
```
The current state is returned on every project read (and supports `?fields=decay` for a minimal response). Toggling has no effect on stored memories, only on how v3 search ranks them.
### Delete Project
<Warning>
+29 -1
View File
@@ -4,6 +4,34 @@ description: "Major product launches, headline features, and milestones for Mem0
mode: "wide"
---
<Update label="2026-05-13" description="Temporal Reasoning for Mem0 Platform v3">
**Temporal Reasoning — Time-Aware Retrieval for Platform v3**
Mem0 Platform v3 can now interpret time-aware memories and queries so assistants retrieve the right information for questions about the past, upcoming plans, and current state.
- **Time-aware search intent** — Queries like `last week`, `upcoming`, `right now`, and `as of March 2025` return contextually appropriate results automatically
- **Enabled by default** — No per-request toggle required for v3 writes or searches
- **Anchored relative queries** — `reference_date` anchors relative search phrases for tests, backfills, and reproducible demos
- **Normal response shape** — Temporal reasoning affects ranking while preserving existing client response patterns
See [Temporal Reasoning](/platform/features/temporal-reasoning) for usage details.
</Update>
<Update label="2026-05-08" description="Memory Decay">
**Memory Decay — Recently-Used Memories Surface Higher, Automatically**
Per-project search-time ranking bias that boosts recently-touched memories and gently dampens stale ones. Off by default; opt in per project via the `decay` field on the project endpoint, or via `client.project.update(decay=True)` in the SDKs (Python `v2.0.2` / TypeScript `v3.0.3`).
- **Soft bias, never a filter.** The scaling factor stays in `0.3×–1.5×`. Decay can reorder candidates but never zeros them out — anything that surfaced before decay can still surface after.
- **Reinforcement loop.** Every memory returned in a search has its access history updated, so frequently-used facts naturally float to the top over time.
- **Public score still clamped to `[0, 1]`.** Existing API contract preserved; no client-side changes needed.
- **v3 search only**, fully reversible. See [Memory Decay docs](/platform/features/memory-decay).
</Update>
<Update label="2026-04-14" description="Mem0 SDK v2.0.0 / v3.0.0">
**New Memory Algorithm — State-of-the-Art Accuracy at ~3-4x Lower Cost**
@@ -99,4 +127,4 @@ Major expansion of the provider ecosystem:
First skill launch — a dedicated Mem0 skill providing platform API reference, quickstart patterns, and integration examples directly inside agent sessions. Available on [skills.sh](https://skills.sh) for any compatible AI coding agent.
</Update>
</Update>
+18 -1
View File
@@ -4,6 +4,24 @@ description: "Release notes for the Mem0 hosted platform — backend, dashboard,
mode: "wide"
---
<Update label="2026-05-13" description="">
**New Features:**
- **Memory:** Added Temporal Reasoning for Platform v3 to improve ranking for time-aware queries such as `last week`, `upcoming`, `right now`, and `as of ...`
- **Search:** Added `reference_date` support to anchor relative temporal queries for tests, backfills, and reproducible demos
**Improvements:**
- **API:** Temporal reasoning preserves the normal client response shape for search and get-all results
</Update>
<Update label="2026-05-04" description="">
**New Features:**
- **Memory Decay:** Per-project search-time ranking bias that boosts recently-used memories and gently dampens stale ones. Opt-in via `decay` on the project endpoint; off by default. The scaling factor stays in `0.3×–1.5×`, the public `score` remains clamped to `[0, 1]`, and the bias never filters a candidate out. See [Memory Decay docs](/platform/features/memory-decay).
</Update>
<Update label="2026-04-16" description="">
**Improvements:**
@@ -294,4 +312,3 @@ mode: "wide"
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
</Update>
+44 -1
View File
@@ -7,6 +7,20 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-05-08" description="v2.0.2">
**Bug Fixes:**
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
**New Features:**
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
**Improvements:**
- **Plugin:** Hand `mem0` search decisions to the agent ([#4992](https://github.com/mem0ai/mem0/pull/4992))
</Update>
<Update label="2026-04-25" description="v2.0.1">
**Bug Fixes:**
@@ -41,7 +55,7 @@ mode: "wide"
**Breaking Changes:**
- **`add()` returns ADD-only events** — No more `"UPDATE"` or `"DELETE"` events. Memories accumulate; nothing is overwritten ([#4805](https://github.com/mem0ai/mem0/pull/4805))
- **`search()` default `threshold` is now `0.1`** — Pass `threshold=0.0` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, and entity boost into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries. Per-signal scores are not exposed on the response ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, entity signals, and temporal boosts into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
- **`search()` default `rerank` is now `False`** — Pass `rerank=True` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
- **`top_k` default changed 100 → 20** in `Memory.get_all()` and `Memory.search()` (sync + async). Pass `top_k=100` explicitly to restore the old behavior ([#4843](https://github.com/mem0ai/mem0/pull/4843))
- **Entity ID validation:** `user_id` / `agent_id` / `run_id` are trimmed; empty-string and whitespace-only values now raise `ValueError` ([#4843](https://github.com/mem0ai/mem0/pull/4843))
@@ -910,6 +924,18 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
</Tab>
<Tab title="TypeScript">
<Update label="2026-05-08" description="v3.0.3">
**Bug Fixes:**
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
- **Vector Stores:** Fix inverted vector distance in PGVector implementation ([#4944](https://github.com/mem0ai/mem0/pull/4944))
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
**New Features:**
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
</Update>
<Update label="2026-04-25" description="v3.0.2">
**Bug Fixes:**
@@ -1297,6 +1323,23 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
<Tab title="CLI">
<Update label="2026-05-14" description="Python v0.2.5 / Node v0.2.5">
**New Features:**
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in under 5 seconds with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email <their-email>` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **Self-declared agent identity:** Agents pass `--agent-caller <name>` (e.g. `claude-code`, `cursor`, `codex`) on `mem0 init --agent` so signups attribute to the right tool in analytics. Proof Editor-style — the agent declares itself rather than the CLI sniffing it from env vars ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **`mem0 identify <name>`:** New subcommand to self-tag an Agent Mode key after the fact when the agent forgot to pass `--agent-caller` on init. Idempotent — re-running just overwrites ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **Plugin sync:** `~/.claude/settings.json::env::MEM0_API_KEY` and `~/.zshrc`/`.bashrc` `export MEM0_API_KEY=` lines stay in sync with `~/.mem0/config.json` automatically. Idempotent — only updates EXISTING entries, never creates new ones ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **Claim flow:** `mem0 init --email <email>` claims an existing Agent Mode shadow via OTP. Upgrade-in-place — the API key never changes, memories transfer to the human's account ([#5123](https://github.com/mem0ai/mem0/pull/5123))
**Bug Fixes:**
- **Decision tree network resilience:** `pingKey` now distinguishes network errors from invalid keys — returns false ONLY on HTTP 401/403, returns true on connection failures / timeouts / 5xx. Prevents a VPN flap from silently rotating the user's API key and rewriting plugin-sync targets ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **Rate-limit error clarity:** DRF's opaque `"You do not have permission"` 403 from Agent Mode rate limits is now translated to `"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC."` ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **JSON envelope `command` field:** `mem0 init --agent --json` error envelopes now populate the `command` field correctly instead of returning an empty string ([#5123](https://github.com/mem0ai/mem0/pull/5123))
- **Bootstrap envelope validation:** Defends against partial/malformed backend responses (e.g. `{api_key: null}`) silently persisting null/undefined into typed string fields ([#5123](https://github.com/mem0ai/mem0/pull/5123))
</Update>
<Update label="2026-04-22" description="Python v0.2.4 / Node v0.2.4">
**New Features:**
@@ -156,6 +156,10 @@ const memories = memory.search("food preferences", {
Expect an array of memory documents. Platform responses include vectors, metadata, and timestamps; OSS returns your stored schema.
</Info>
<Note>
On Mem0 Platform v3, time-aware queries use Temporal Reasoning internally while preserving the normal search response shape. See <Link href="/platform/features/temporal-reasoning">Temporal Reasoning</Link>.
</Note>
## Filter patterns
Filters help narrow down search results. Common use cases:
@@ -251,4 +255,4 @@ For the full list of filter logic, comparison operators, and optional search par
icon="rocket"
href="/cookbooks/operations/support-inbox"
/>
</CardGroup>
</CardGroup>
+5 -3
View File
@@ -72,7 +72,8 @@
"platform/features/entity-scoped-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
"platform/features/custom-categories"
"platform/features/custom-categories",
"platform/features/temporal-reasoning"
]
},
{
@@ -83,7 +84,8 @@
"platform/advanced-memory-operations",
"platform/features/criteria-retrieval",
"platform/features/contextual-add",
"platform/features/custom-instructions"
"platform/features/custom-instructions",
"platform/features/memory-decay"
]
},
{
@@ -1143,4 +1145,4 @@
"destination": "/introduction"
}
]
}
}
+8
View File
@@ -11,6 +11,12 @@
- Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`).
- Source repo: https://github.com/mem0ai/mem0
### Don't have a Mem0 API key?
Run: `mem0 init --agent --agent-caller <your-name> --json` — replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init to attribute the signup.
This mints an evaluation key in <5 seconds — no email, no dashboard, no OTP required. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email <your-email>` to transfer ownership (memories preserved, same API key, no agent disruption).
## Install
- Python SDK: `pip install mem0ai`
@@ -185,8 +191,10 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
### Features - Advanced Retrieval
- [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval.
- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity.
- [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering.
- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn.
- [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform.
- [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones — opt-in per project, search-time only, never filters candidates out.
- [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows.
### Features - Data Management
+1 -1
View File
@@ -18,7 +18,7 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
**Why migrate to Platform?**
- **Time to Market**: Set up in 5 minutes vs 30+ minutes for OSS configuration
- **Enterprise Ready**: SOC2 Type II compliance, GDPR support, audit logs
- **Enterprise Ready**: Audit logs, workspace governance, and dedicated support
- **Advanced Features**: Webhooks, memory export, analytics dashboard, custom categories
- **Multi-tenancy**: Organizations, projects, and team management out of the box
- **Zero Infrastructure**: No vector database, LLM provider, or maintenance overhead
+3 -2
View File
@@ -42,7 +42,7 @@ Previously, when an agent said something like "I've booked your flight for March
### Retrieval is hybrid now
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, or entities that appear across multiple memories. The response shape is unchanged:
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, entities that appear across multiple memories, and time-aware queries (via Temporal Reasoning). The response shape is unchanged:
```json
{
@@ -58,7 +58,7 @@ Search now uses hybrid retrieval, which improves ranking quality — especially
}
```
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries.
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries. Temporal signals are applied internally during ranking and are not returned as extra client-facing fields.
## API Changes
@@ -289,6 +289,7 @@ If your application previously read graph relations from the API response (`rela
- **V1 and V2 endpoints continue to work.** There is no requirement to migrate to V3 endpoints immediately.
- **Existing memories are preserved.** The new algorithm does not modify or re-process previously stored memories.
- **Search response shape is unchanged.** The top-level `score` and `results[]` array are the same; existing code that reads `score` continues to work. What changed is the scoring method behind the number (multi-signal fusion instead of pure cosine), so the absolute values shift even when ranking stays comparable.
- **Search remains backward-compatible at the top level.** Existing code that reads `results[]` and `score` continues to work. Temporal signals are applied internally during retrieval and do not change the client response shape.
- **List response shape changed.** `get_all` now returns a paginated envelope (`{count, next, previous, results}`) instead of a bare `{results: [...]}`. Update code that reads `response["results"]` to continue working, or switch to the client SDKs which handle both shapes.
## Performance Improvements
+17 -2
View File
@@ -419,7 +419,7 @@
},
"results": {
"type": "array",
"description": "Array of results produced by the event."
"description": "Array of results produced by the event. For add events, this confirms the write completed; temporal reasoning enrichment runs asynchronously by default."
},
"created_at": {
"type": "string",
@@ -2071,7 +2071,7 @@
"memories"
],
"summary": "Search memories (V3)",
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval — the returned `score` is a combined `[0, 1]` value; per-signal component scores are not exposed on the response. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
"operationId": "memories_search_v3",
"requestBody": {
"required": true,
@@ -2112,6 +2112,21 @@
"type": "boolean",
"default": false,
"description": "Apply the managed reranker for better ordering (adds latency)."
},
"reference_date": {
"oneOf": [
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "string"
}
],
"nullable": true,
"description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime."
}
}
},
+55 -2
View File
@@ -25,7 +25,44 @@ pip install mem0-cli
```
</CodeGroup>
## Authentication
## Agent Mode — zero-friction signup for AI agents
<Tip>
**If you're an AI agent reading this**, skip the rest of this page until you've run the command below. Everything else assumes you already have an API key.
</Tip>
AI agents (Claude Code, Cursor, Codex, Cline, Aider, …) can mint a working Mem0 API key in under five seconds with no email, no dashboard, and no OTP:
```bash
mem0 init --agent --agent-caller claude-code
```
Replace `claude-code` with your agent's name (e.g. `cursor`, `codex`, `cline`, `aider`, or any custom string). The CLI saves the key to `~/.mem0/config.json` and prints a notice you should surface to your human user. The human can claim the account later — same API key keeps working, memories transfer.
<Tip>
**Forgot `--agent-caller`?** Run `mem0 identify <your-name>` after init — same key, just back-fills the attribution. Idempotent.
</Tip>
### Claiming an Agent Mode key (humans)
When the human is ready, they claim ownership of the agent's key by running:
```bash
mem0 init --email you@yourcompany.com
```
The CLI detects the existing unclaimed Agent Mode config, sends a verification code, and upgrades the shadow account in-place: **the API key never changes, all memories transfer to the human's account, and the agent isn't disrupted**. Pass `--code 123456` for fully non-interactive flows.
### Plugin sync
When you save a Mem0 API key (any flow — Agent Mode, email, or `--api-key`), the CLI also propagates it to ecosystem touchpoints **that already exist**:
- `~/.claude/settings.json` → `env.MEM0_API_KEY` (Claude Code plugin env injection)
- `~/.zshrc` / `~/.bashrc` → existing `export MEM0_API_KEY=` lines
Sync is idempotent and **never creates new entries** — it only updates entries you've already set up. Failures are best-effort and never block the canonical write to `~/.mem0/config.json`.
## Authentication (humans)
Run the interactive setup wizard to configure your API key:
@@ -79,6 +116,7 @@ Interactive setup wizard. Prompts for your API key and default user ID.
mem0 init
mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --agent --agent-caller claude-code # zero-friction signup for AI agents
```
If an existing configuration is detected, the CLI will ask for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD pipelines).
@@ -91,10 +129,25 @@ mem0 init --api-key m0-xxx --user-id alice --force
|------|-------------|
| `--api-key` | API key (skip prompt) |
| `-u, --user-id` | Default user ID (skip prompt) |
| `--email` | Login via email verification code |
| `--email` | Login via email verification code (also used to claim an Agent Mode key) |
| `--code` | Verification code (use with `--email` for non-interactive login) |
| `--agent` | Bootstrap an Agent Mode account — no email, no dashboard |
| `--agent-caller` | Self-declared agent identity for Agent Mode signups (e.g. `claude-code`, `cursor`) |
| `--source` | Channel attribution for analytics (e.g. `github`, `hn`, `ph`) |
| `--force` | Overwrite existing config without confirmation |
### `mem0 identify`
Tag your active Agent Mode key with the AI agent that's using it. Run this once after `mem0 init --agent` if you didn't pass `--agent-caller` on init. Idempotent — re-running just overwrites the value.
```bash
mem0 identify claude-code
mem0 identify cursor
mem0 identify my-custom-bot
```
The CLI calls `PATCH /api/v1/auth/agent_mode/caller/` against the active key. Only works on unclaimed Agent Mode keys; the backend rejects with 400 otherwise. Agent names are sanitized server-side (lowercased, restricted to `[a-z0-9._/-]`, truncated to 32 chars).
### `mem0 add`
Add a memory from text, a JSON messages array, a file, or stdin.
+191
View File
@@ -0,0 +1,191 @@
---
title: Memory Decay
description: "Boost recently-used memories and gently dampen stale ones at search time, without filtering anything out."
---
# Memory Decay
Older memories drift in relevance at different speeds. A user's coffee order matters every morning; a one-off project name from last quarter rarely matters again. Memory Decay makes that intuition explicit at search time: every time a memory is returned in a search it gets a small reinforcement, and memories that haven't been touched in a while have their ranking score gently dampened.
It is **a soft ranking bias, never a filter.** Decay never zeroes a candidate out — at worst it scales its score by `0.3×`. Anything that would have surfaced without decay can still surface with decay on, just with a different ranking among similarly-scored results.
<Info>
**Use Memory Decay when…**
- Search results are crowded with old facts the user no longer cares about.
- You want recently-used memories to drift to the top automatically — without writing custom scoring logic.
- You want this preference applied per project so cohorts can be compared side-by-side.
</Info>
<Warning>
Memory Decay is **opt-in per project** and **off by default**. Search behavior is bit-identical to today until you turn it on. The toggle applies to v3 search only.
</Warning>
## How it works
Every memory carries a small piece of bookkeeping: when was it last retrieved, and how often. Memory Decay turns that history into a *scaling factor* in the range `0.3×` to `1.5×` and multiplies it into the ranking score at search time.
| Memory state | Scaling factor | Ranking effect |
|---|---|---|
| Just accessed | ≈ **1.5×** | Strong boost |
| Touched today | 1.2 – 1.4× | Mild boost |
| Idle for a few days | 0.6 – 1.0× | Mild dampening |
| Idle for weeks | 0.4 – 0.6× | Stronger dampening |
| Idle for many months / years | ≈ **0.3×** | Floor — never lower |
The bounds matter: `0.3` is the floor and `1.5` is the ceiling, so decay can meaningfully reorder candidates without ever dominating the underlying relevance score.
At search time the pipeline:
1. Widens the candidate pool (`top_k × 3`, with a floor of 50) so reordering has room.
2. Multiplies each candidate's score by its scaling factor.
3. Sorts on the unclamped product so the full `0.3×–1.5×` range can rearrange candidates.
4. Returns the public `score` clamped to `[0, 1]` so the API contract is preserved.
5. Truncates to the `top_k` you requested.
6. Records a fire-and-forget reinforcement against each returned memory — its access history grows by one, capped at the most recent 20 touches.
Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: their `updated_at` is treated as a single past touch, so the same scale above applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band, a long-stale one sits closer to the floor. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory.
## Configure access
- Set `MEM0_API_KEY` in your environment, or pass it to the SDK constructor.
- Initialize the client with the organization and project you want to scope to.
The toggle lives on the project. You enable decay by patching the project's `decay` field; everything else — your `add` calls, your `search` calls, your application code — stays exactly the same.
## Enable decay for a project
### 1. Turn the flag on
The toggle is exposed on the standard project-update endpoint, the same place where `multilingual` and `custom_categories` live.
<CodeGroup>
```python Python
client.project.update(decay=True)
```
```javascript JavaScript
await client.project.update({ decay: true });
```
```bash cURL
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
-H "Authorization: Token $MEM0_API_KEY" \
-H "Content-Type: application/json" \
-d '{"decay": true}'
```
```json Response
{ "message": "Updated decay" }
```
</CodeGroup>
### 2. Confirm the state
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
<CodeGroup>
```python Python
response = client.project.get(fields=["decay"])
print(response["decay"])
```
```javascript JavaScript
const response = await client.project.get({ fields: ["decay"] });
console.log(response.decay);
```
```bash cURL
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
-H "Authorization: Token $MEM0_API_KEY"
```
```json Response
{ "decay": true }
```
</CodeGroup>
### 3. Turn it back off
The toggle is fully reversible. Setting it to `false` immediately restores the pre-decay ranking; nothing about your stored memories is modified or lost.
<CodeGroup>
```python Python
client.project.update(decay=False)
```
```javascript JavaScript
await client.project.update({ decay: false });
```
```bash cURL
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
-H "Authorization: Token $MEM0_API_KEY" \
-H "Content-Type: application/json" \
-d '{"decay": false}'
```
</CodeGroup>
<Note>
The toggle is idempotent. Re-applying the same value is a no-op, and access history accumulated while decay was on is preserved if you flip it back on later.
</Note>
## What changes when decay is on
- **Search ranking reorders.** A relevant memory you reinforced an hour ago will tend to outrank an equally-relevant memory that was last touched a month ago.
- **The candidate pool over-fetches** to give the scaling factor room to reorder. You still get exactly the `top_k` you requested, but the items returned can come from a deeper slice of the pre-decay ranking than before.
- **The public `score` field stays in `[0, 1]`.** Even when the internal product exceeds 1, the field returned to the client is clamped, so existing assertions and downstream UI logic continue to work.
## What stays the same
- **Public API shape** — every endpoint accepts the same parameters and returns the same fields. You don't touch your client code.
- **Threshold semantics on the request side** — your `threshold` is still applied during candidate selection.
- **Memory creation and storage** — every new memory still lands the same way. Decay is a search-time concern.
- **Per-memory data** — categories, metadata, timestamps, embeddings: untouched.
<Warning>
Because the scaling factor is applied *after* the threshold filter has already run, an item that passed the request `threshold` can come back with a public `score` slightly below it (a stale candidate dampened by `0.3×`). This is intentional — decay is a soft bias, not a filter. If you require a hard `score >= threshold` invariant on the response, filter client-side after the call.
</Warning>
## Lifecycle of a memory under decay
| Stage | Scaling factor | Effect |
|---|---|---|
| Just added | ≈ 1.5× | Strong boost — fresh facts surface easily. |
| Reinforced on a recent search | 1.2 – 1.5× | Sustains its boost for the next several searches. |
| Idle for a few days | 0.6 – 1.0× | Falls back into the neutral band. |
| Idle for weeks | 0.4 – 0.6× | Mild dampening — can still surface for strong matches. |
| Pre-decay legacy memory (no access history) | 0.3 – 1.0× | Falls back to `updated_at`: recently-updated entries land near 1.0×, long-stale entries approach the 0.3× floor. |
The reinforcement is bounded: each memory tracks at most the last 20 access timestamps, so the boost stays well-behaved no matter how many times a memory is retrieved.
## FAQ
**Will decay ever drop a result that would otherwise surface?**
No. The floor is `0.3×` — the scaling factor can dampen a score, never zero it. Threshold filtering happens *before* decay, so any candidate that cleared the threshold is in the pool decay reorders.
**Why is the public score sometimes below my requested threshold?**
The threshold is applied to the candidate pool pre-decay; the scaling factor then reshapes scores in the `0.3×–1.5×` band. A stale-but-relevant candidate can come back with a final score slightly under your threshold by design — the candidate stays visible but visibly dampened. Filter client-side if you need a hard floor on the response.
**Does decay change how I add memories?**
No. The `client.add(...)` path is unchanged. Decay is a search-time ranking adjustment.
**What if I had memories before turning decay on?**
They use a fallback: the memory's `updated_at` is treated as a single historical touch, so the same scaling applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band (~1.0×), a long-stale one closer to the floor (~0.3×). Once retrieved they accumulate access history and behave like any other memory.
**Can I tune how aggressively decay scales scores?**
Not in this version. The current scaling is calibrated to be conservative — wide enough to meaningfully reorder candidates, narrow enough to never dominate the underlying relevance score. Per-project tuning is on the roadmap.
**Can I see the scaling factor per result?**
Internal scoring details are persisted on the search Event for support and debugging. They aren't exposed in the public response by design — the response surface stays a single `score` field.
**Does decay interact with reranking?**
Yes — they layer cleanly. The reranker produces a richer relevance score; decay then biases that score by reinforcement history before final truncation to `top_k`.
## What's next
This release is deliberately the simplest version of decay we could ship — every memory contributes to ranking through its access history alone, so the signal can be evaluated in isolation. On the roadmap:
- **Category-aware weighting.** A fact tagged `health` will be able to carry more weight than a passing observation tagged `misc`, so important categories don't get dampened the same way as noise.
- **Auto-tuning per project.** Project-scoped automatic adjustment of how aggressively decay scales scores, based on observed access patterns — replacing the fixed scaling band with one that fits your workload.
Both extensions are forward-compatible — no migration on your side will be needed when they ship.
@@ -0,0 +1,145 @@
---
title: Temporal Reasoning
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories."
icon: "clock"
badge: "v3"
---
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results.
<Info>
**Use Temporal Reasoning when…**
- Users ask questions like "what happened last week?" or "what do I have coming up?"
- Your app stores both past events and future plans for the same person
- You want time-aware retrieval without building your own date-parsing layer
</Info>
<Warning>
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
</Warning>
## Configure access
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client:
```python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
```
## How it works
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time.
A query like `what did I do last week?` should return a completed past event — not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
### Memory types Temporal Reasoning handles
| Type | What it represents | Example |
| --- | --- | --- |
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
| Preference | A stable preference or habit | "I prefer morning meetings." |
Results come back in the normal search response shape — Temporal Reasoning affects ranking, not the response format.
## Configure it
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
Two parameters give you precise control when you need it:
- `timestamp` on `add()` — anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0
- `reference_date` on `search()` — resolves relative phrases like `last week` against a fixed point in time
<CodeGroup>
```python Python
from datetime import datetime, timezone
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Import a historical memory anchored to when it happened
client.add(
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
user_id="jordan",
timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()),
)
# Search with a relative query anchored to a known date
results = client.search(
"what did I do last week?",
filters={"user_id": "jordan"},
reference_date="2025-03-21T00:00:00Z",
)
```
```javascript JavaScript
import { MemoryClient } from "mem0ai";
const client = new MemoryClient({ apiKey: "your-api-key" });
// Import a historical memory anchored to when it happened
await client.add(
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
{
userId: "jordan",
timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000),
}
);
// Search with a relative query anchored to a known date
const results = await client.search("what did I do last week?", {
filters: { user_id: "jordan" },
referenceDate: "2025-03-21T00:00:00Z",
});
```
</CodeGroup>
<Tip>
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
</Tip>
## Supported query patterns
<AccordionGroup>
<Accordion title="Historical questions">
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
</Accordion>
<Accordion title="Upcoming questions">
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
</Accordion>
<Accordion title="Current-state questions">
Examples: `right now`, `currently`, `where do I work now?`
</Accordion>
<Accordion title="As-of questions">
Examples: `as of March 2025`, `where was I living as of 2024?`
</Accordion>
<Accordion title="Duration questions">
Examples: `how long have I lived here?`, `since when have I worked there?`
</Accordion>
</AccordionGroup>
## Verify the feature is working
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
## Best practices
- Use explicit dates in source conversations when events or plans matter temporally.
- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor.
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
- Use `reference_date` in automated tests and reproducible demos.
<CardGroup cols={1}>
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
Anchor imported memories to when they actually happened.
</Card>
</CardGroup>
<Snippet file="get-help.mdx" />
+2 -2
View File
@@ -12,7 +12,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
- **Personalized replies**: Memories persist across users and agents, cutting prompt bloat and repeat questions.
- **Hosted stack**: Mem0 runs the vector store, graph services, and rerankers—no provisioning, tuning, or maintenance.
- **Enterprise controls**: SOC 2, audit logs, and workspace governance ship by default for production readiness.
- **Enterprise controls**: Audit logs and workspace governance ship by default for production readiness.
<AccordionGroup>
<Accordion title="What you get with Mem0 Platform" icon="sparkles">
@@ -22,7 +22,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
| Fast setup | Add a few lines of code and you’re production-ready—no vector database or LLM configuration required. |
| Production scale | Automatic scaling, high availability, and managed infrastructure so you focus on product work. |
| Advanced features | Graph memory, webhooks, multimodal support, and custom categories are ready to enable. |
| Enterprise ready | SOC 2 Type II, GDPR compliance, and dedicated support keep security and governance covered. |
| Enterprise ready | Audit logs, workspace governance, and dedicated support keep security and governance covered. |
</Accordion>
</AccordionGroup>
+24 -2
View File
@@ -22,13 +22,35 @@ We follow the llms.txt standard:
## Agent Skills
Teach your coding assistant how to build with Mem0:
Mem0 ships two kinds of skills for AI coding assistants. Both work with Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any assistant that supports the skills standard.
### Reference skills — always on
Teach your assistant Mem0's SDK surface so it writes correct code in everyday development:
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
```
Works with Claude Code, Cursor, Windsurf, and any assistant that supports skills. Once installed, your assistant understands Mem0's full API, framework integrations, and common patterns.
- `mem0` — Python and TypeScript SDKs (Platform + OSS), plus framework integrations (LangChain, CrewAI, OpenAI Agents, LangGraph, LlamaIndex, etc.)
- `mem0-cli` — terminal workflows for the `mem0` CLI (both Node and Python builds)
- `mem0-vercel-ai-sdk` — `@mem0/vercel-ai-provider` and `createMem0`
### Pipeline skills — run on demand
Let your assistant execute an end-to-end workflow in an existing repo. Invoked as slash commands:
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
```
- `/mem0-integrate` — wire Mem0 into an existing repository using a goal-driven, test-first pipeline. Detects the stack, asks whether to use Platform or OSS, writes failing tests first, and keeps the integration additive and feature-flagged.
- `/mem0-test-integration` — verify what `/mem0-integrate` produced. Runs the repo's native test suite and a real end-to-end smoke flow against your API key, then produces a scorecard.
See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog.
## MCP Server Setup
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.1.1",
"version": "0.1.3",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+45
View File
@@ -0,0 +1,45 @@
# Changelog
All notable changes to the Mem0 plugin will be documented in this file.
## 0.1.3
### Fixed
- **user_id resolution no longer derives from `MEM0_API_KEY`.** v0.1.2 changed the resolver to fall back to `"mem0-" + sha256(MEM0_API_KEY)[:12]` ahead of `$USER`, which silently moved every existing user to a new bucket on update. Memories written under the previous `$USER` value became unreachable from the plugin. Resolution is now back to `MEM0_USER_ID` → `$USER` → `"default"`.
- Dropped the "regardless of which machine you're on" line from the SessionStart bootstrap, since cross-machine consolidation now requires setting `MEM0_USER_ID` explicitly.
### Notes for users upgrading from 0.1.2
- The `~/.mem0/identity.json` cache file is no longer read or written. Safe to delete.
- If you wrote memories during the v0.1.2 window, they live under `mem0-<sha256(api_key)[:12]>`. To recover: temporarily `export MEM0_USER_ID=mem0-<hash>`, search/export, then unset.
- Want a single bucket across machines (the original goal of #5076)? Set `MEM0_USER_ID` explicitly in your shell profile. The plugin will not auto-derive one.
## 0.1.2
### Added
- Deterministic `user_id` resolver (`_identity.sh` / `_identity.py`) — **reverted in 0.1.3, see above.**
- SessionStart-compact handler (`capture_compact_summary.py`) that stores the post-compaction summary as a memory with `metadata.type=compact_summary`.
- Coding-taxonomy setup script (`setup_coding_categories.py`) — one-shot `project.update(custom_categories=[...])` for `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`.
- Opt-in hook logging via `MEM0_DEBUG=1` → `~/.mem0/hooks.log`.
- `mem0-mcp` skill replacing the Claude-Code-specific `mem0-codex` skill.
### Fixed
- `session_id` now written to memory metadata (`on_pre_compact.py`).
- SessionStart bootstrap exits silently when `MEM0_API_KEY` is unset.
- `block_memory_write.sh` regex tightened to `MEMORY.md` / `.claude/memory/*` — no longer blocks `docs/memory/*.md`.
- Removed duplicate PreCompact write path (kept agent-driven, dropped the parallel Python REST entry from `hooks.json` / `cursor-hooks.json`).
- Hook-side captures (`session_state`, `compact_summary`) now set `expiration_date = today + 90 days`.
## 0.1.1
- Cursor plugin fully functional (`#4547`).
- Codex plugin support and integration docs (`#4665`).
- Codex lifecycle hooks via opt-in installer (`#4917`).
- Removed invalid keys from Claude plugin config (`#4821`).
## 0.1.0
- Initial release: Mem0 plugin for Claude Code and Cursor (`#4518`).
+38
View File
@@ -2,6 +2,18 @@
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, and **Codex**.
## Quick path for agents
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup available), run:
```bash
mem0 init --agent --json
```
This mints an evaluation API key in <5 seconds — no email, no OTP, no browser. The key is written to your config and exported as `MEM0_API_KEY` for the current shell. Use it immediately against the Mem0 Platform or the MCP server. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, the same API key keeps working, and the agent isn't disrupted.
Humans setting up Mem0 by hand should continue with Step 1 below.
## Step 1: Set your API key
> **You must complete this step before installing the plugin.**
@@ -157,6 +169,32 @@ After installing, confirm the MCP server is connected:
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
## Updating the plugin
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
- **Cursor:** quit and relaunch.
- **Codex:** restart the editor session.
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
## Optional: tune categories for coding workflows
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
```bash
# Dry-run first -- prints current vs proposed, no changes:
python mem0-plugin/scripts/setup_coding_categories.py
# Actually write:
python mem0-plugin/scripts/setup_coding_categories.py --apply
```
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
## MCP Tools
Once installed, the following tools are available:
+1 -1
View File
@@ -18,7 +18,7 @@
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"statusMessage": "Searching mem0 memories...",
"statusMessage": "Checking memory relevance...",
"timeout": 5
}
]
-4
View File
@@ -15,10 +15,6 @@
"preCompact": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
},
{
"command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py",
"timeout": 30
}
],
"stop": [
+1 -7
View File
@@ -30,12 +30,6 @@
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
"statusMessage": "Preparing pre-compaction summary..."
},
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py",
"statusMessage": "Saving session state to mem0...",
"timeout": 30
}
]
}
@@ -57,7 +51,7 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"statusMessage": "Searching mem0 memories...",
"statusMessage": "Checking memory relevance...",
"timeout": 5
}
]
+17
View File
@@ -0,0 +1,17 @@
"""Resolve mem0 user_id.
Resolution priority:
1. MEM0_USER_ID env var (explicit override)
2. $USER, else "default"
"""
from __future__ import annotations
import os
def resolve_user_id() -> str:
explicit = os.environ.get("MEM0_USER_ID", "").strip()
if explicit:
return explicit
return os.environ.get("USER") or "default"
+16
View File
@@ -0,0 +1,16 @@
# Source this file. Sets MEM0_RESOLVED_USER_ID.
#
# Resolution priority:
# 1. MEM0_USER_ID env var (explicit override)
# 2. $USER, else "default"
_mem0_resolve_identity() {
if [ -n "${MEM0_USER_ID:-}" ]; then
printf '%s' "$MEM0_USER_ID"
return
fi
printf '%s' "${USER:-default}"
}
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
export MEM0_RESOLVED_USER_ID
+5 -1
View File
@@ -13,6 +13,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
@@ -22,7 +26,7 @@ if [ -z "$FILE_PATH" ]; then
fi
case "$FILE_PATH" in
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
*/MEMORY.md|*/.claude/memory/*)
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
exit 2
;;
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Capture the post-compaction summary into mem0.
PreCompact hooks fire BEFORE the summary is generated, so they can't
store the actual compact-summary text. This script runs at
SessionStart with source=compact, reads the transcript, finds the
most recent entry flagged isCompactSummary=true, and stores it as a
memory tagged metadata.type=compact_summary.
Input: JSON on stdin with transcript_path, session_id, source
Output: stderr logs only (exit 0 always -- must not block)
Spawned in the background by on_session_start.sh; the user-facing
bootstrap text continues without waiting on the network.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
log = logging.getLogger("mem0-compact-summary")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 2000
MAX_SUMMARY_CHARS = 50000
# Compact summaries describe a single session's state -- stale after a quarter.
COMPACT_SUMMARY_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
if file_size == 0:
return []
chunk_size = min(file_size, n * 4096)
f.seek(max(0, file_size - chunk_size))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-n:]
except OSError:
return []
def find_compact_summary(lines: list[str]) -> str:
"""Walk transcript backwards, return text content of the most recent
entry flagged isCompactSummary=true. Empty string if none found."""
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not entry.get("isCompactSummary"):
continue
message = entry.get("message", {})
content = message.get("content", [])
if isinstance(content, str):
return content[:MAX_SUMMARY_CHARS]
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
return ""
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
body = {
"messages": [{"role": "user", "content": summary}],
"user_id": user_id,
"metadata": {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
},
"infer": False,
"expiration_date": expires,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v1/memories/",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status in (200, 201):
log.info("Compact summary stored")
return True
log.warning("API returned status %d", resp.status)
return False
except urllib.error.URLError as e:
log.warning("API call failed: %s", e)
return False
def main():
api_key = os.environ.get("MEM0_API_KEY", "")
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
return
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
user_id = resolve_user_id()
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
summary = find_compact_summary(lines)
if not summary:
log.debug("No isCompactSummary entry found")
return
log.info("Capturing compact summary (%d chars)", len(summary))
store_summary(api_key, summary, user_id, session_id)
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
+26 -4
View File
@@ -18,8 +18,12 @@ import json
import logging
import os
import sys
import urllib.request
import urllib.error
import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
log = logging.getLogger("mem0-capture")
log.setLevel(logging.DEBUG)
@@ -27,11 +31,25 @@ _handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
MAX_ASSISTANT_TEXT = 10000
# session_state captures churn fast (active codebase, files in flight). Past
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
# stored separately by the agent without an expiration_date.
SESSION_STATE_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
@@ -149,8 +167,9 @@ def build_content(state: dict, source: str) -> str:
return "\n".join(parts)
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
"""Store session state as a memory via the Mem0 REST API."""
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
body = {
"messages": [
{"role": "user", "content": content}
@@ -159,7 +178,9 @@ def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
"metadata": {
"type": "session_state",
"source": source,
"session_id": session_id,
},
"expiration_date": expires,
}
data = json.dumps(body).encode("utf-8")
@@ -207,7 +228,8 @@ def main():
log.debug("No transcript_path provided")
return
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
session_id = hook_input.get("session_id", "")
user_id = resolve_user_id()
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
@@ -228,7 +250,7 @@ def main():
len(state["bash_commands"]),
)
store_memory(api_key, content, user_id, source)
store_memory(api_key, content, user_id, source, session_id)
if __name__ == "__main__":
+20 -6
View File
@@ -5,12 +5,16 @@
# the full context before it gets compressed.
#
# Output: Text instructions injected into Claude's context.
# Claude still has the full conversation and can write an accurate summary.
# A companion Python script (on_pre_compact.py) also runs to capture
# transcript state directly via the Mem0 REST API as a safety net.
# Claude still has the full conversation and can write an accurate summary,
# which it stores via add_memory(infer=False) so the platform preserves
# the structure verbatim instead of running a second extraction pass.
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
cat <<'EOF'
## CRITICAL: Pre-Compaction Session Summary
@@ -18,7 +22,9 @@ Context compaction is about to happen. You are about to lose most of your conver
### Step 1: Store session summary
Call `add_memory` with a thorough summary covering ALL of the following:
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
```
## Session Summary (Pre-Compaction)
@@ -44,11 +50,19 @@ Call `add_memory` with a thorough summary covering ALL of the following:
the post-compaction agent continue without asking redundant questions]
```
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
Tool call shape:
```
add_memory(
messages=[{"role":"user","content":"<the summary above>"}],
user_id="<the active user_id from the SessionStart bootstrap>",
metadata={"type":"session_state","source":"pre-compaction"},
infer=False,
)
```
### Step 2: Store any unstored learnings
If there are learnings from this session that you haven't stored yet, store them as separate memories:
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
- Failed approaches -> metadata `{"type": "anti_pattern"}`
- Successful strategies -> metadata `{"type": "task_learning"}`
- Architecture decisions -> metadata `{"type": "decision"}`
+37 -4
View File
@@ -11,9 +11,34 @@
# even if jq is missing or stdin is malformed.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
# Skip the bootstrap entirely if no API key is configured -- the agent
# would otherwise be told to call mem0 MCP tools that will all fail.
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
# Identity line is emitted before every bootstrap variant so the agent
# uses the same user_id the hooks resolved. Without this, the agent's
# search_memories/add_memory MCP calls may bind to a different bucket
# than what the hooks write to.
echo "## Mem0 Identity"
echo ""
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
echo ""
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps the agent's MCP calls aligned with the bucket the hooks write to."
echo ""
if [ "$SOURCE" = "startup" ]; then
cat <<'EOF'
## Mem0 Session Bootstrap
@@ -40,14 +65,22 @@ Continue where you left off.
EOF
elif [ "$SOURCE" = "compact" ]; then
# Capture the just-generated compact summary in the background.
# PreCompact fires too early to see this entry; SessionStart-compact
# is the first place isCompactSummary=true is in the transcript.
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
cat <<'EOF'
## Mem0 Post-Compaction Recovery
Context was just compacted. You may have lost important session context.
Context was just compacted. The Claude Code-generated compact summary
is being captured to mem0 in the background as `metadata.type=compact_summary`.
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
2. Check for any session state memories that were saved before compaction.
3. Continue working based on the recovered context.
1. Call `search_memories` to reload context, layering up to three angles:
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
2. Continue working from the recovered context.
EOF
fi
+4
View File
@@ -12,6 +12,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
+4
View File
@@ -17,6 +17,10 @@
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
+4
View File
@@ -9,6 +9,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
+48 -37
View File
@@ -1,61 +1,72 @@
#!/usr/bin/env bash
# Hook: UserPromptSubmit
#
# Fires on every user message. Searches mem0 for relevant memories
# and injects them into Claude's context before processing.
# Fires on every user message. Instead of pre-searching mem0 with the
# raw prompt, this injects a decision rubric telling the agent when
# and how to search itself. The agent has more context than this
# script does -- let it decide.
#
# Input: JSON on stdin with prompt, session_id, cwd, transcript_path
# Output: Matching memories as context text (exit 0)
#
# Skips search for very short prompts (< 20 chars) and when
# MEM0_API_KEY is not set. Uses a 3s timeout to minimize latency.
# Input: JSON on stdin (prompt, session_id, cwd, transcript_path)
# Output: Decision rubric injected into Claude's context (exit 0)
# Intentionally omit -e so the script always exits 0 even if
# curl or jq fail — must never block the user's prompt.
# Intentionally omit -e so the script always exits 0 even if jq fails --
# must never block the user's prompt.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
# Skip trivial prompts — not worth a network call
# Acknowledgements and short replies don't warrant memory context
if [ ${#PROMPT} -lt 20 ]; then
exit 0
fi
API_KEY="${MEM0_API_KEY:-}"
if [ -z "$API_KEY" ]; then
# No API key means the agent can't search anyway
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
USER_ID="$MEM0_RESOLVED_USER_ID"
# Build request body safely via jq to avoid injection
BODY=$(jq -n --arg query "$PROMPT" --arg user_id "$USER_ID" \
'{query: $query, filters: {user_id: $user_id}, top_k: 5}')
cat <<EOF
## Memory check
# Search mem0 for memories relevant to this prompt
RESPONSE=$(curl -s --max-time 3 \
-X POST "https://api.mem0.ai/v2/memories/search/" \
-H "Authorization: Token $API_KEY" \
-H "Content-Type: application/json" \
-d "$BODY" \
2>/dev/null || echo "")
Before responding, decide whether persistent memory context from mem0 would
improve your answer. The agent -- not this hook -- owns this decision.
if [ -z "$RESPONSE" ]; then
exit 0
fi
**Search WHEN** the user:
- references past work, decisions, or things "we" built
- asks "how should we...", "best way to...", or any decision-style question
- hits an error, bug, or asks for debugging help
- requests work that touches their stack, tools, conventions, or preferences
- starts a non-trivial task in a known project
# Extract memories from response (API returns a flat array)
MEMORIES=$(echo "$RESPONSE" | jq -r '
if type == "array" then . else .results // [] end |
if length == 0 then empty else
"## Relevant memories from mem0\n\n" +
(map(select(.memory != null) | "- " + .memory) | join("\n"))
end
' 2>/dev/null || echo "")
**Skip WHEN:**
- the prompt is an acknowledgement or continuation
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
- it's a pure syntax / factual question answerable from general knowledge
- you already searched this scope earlier in the turn
if [ -n "$MEMORIES" ]; then
echo "$MEMORIES"
fi
**If searching, do it well:**
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
query that echoes the user's prompt.
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
with an array, and metadata uses a **nested** object (not dotted keys).
Combine \`user_id\` with one \`metadata.type\` clause per call:
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits.
- Empty results are normal -- proceed without context.
EOF
exit 0
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Replace mem0's default category taxonomy with one tuned for coding workflows.
mem0 auto-tags every memory with one or more `categories`. By default the list
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
This script replaces the project's category list with a coding-focused one.
The change is project-level (per the platform docs, per-request overrides are
not supported on the managed API). Run once per project; future memories will
be tagged using the new list automatically.
Usage:
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
python setup_coding_categories.py --apply # actually call project.update()
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
CODING_CATEGORIES = [
{
"architecture_decisions": (
"Design choices, system structure, technology selection, trade-offs evaluated, "
"and architectural patterns adopted in the project."
)
},
{
"anti_patterns": (
"Approaches that failed, debugging dead-ends, common mistakes to avoid, "
"and lessons learned from things that didn't work."
)
},
{
"task_learnings": (
"Strategies and approaches that succeeded for specific tasks, including tooling "
"tricks, workflow shortcuts, and effective problem-solving patterns."
)
},
{
"tooling_setup": (
"Development environment, build tools, dependencies, package managers, deploy "
"pipelines, and configuration steps for the project."
)
},
{
"bug_fixes": (
"Specific bug fixes with root cause analysis, the fix applied, and how the bug "
"was diagnosed -- useful for recognising similar issues later."
)
},
{
"coding_conventions": (
"Code style, naming patterns, file organisation, error-handling conventions, "
"and team agreements about how code is written in this project."
)
},
{
"user_preferences": (
"User's stated preferences for tools, libraries, languages, formatting, "
"and ways of working."
)
},
]
def _print_categories(label: str, cats):
print(f"=== {label} ===")
if cats:
print(json.dumps(cats, indent=2))
else:
print("(none / using mem0 defaults)")
print()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument(
"--apply",
action="store_true",
help="Actually call project.update(). Without this flag, runs in dry-run mode.",
)
args = ap.parse_args()
if not os.environ.get("MEM0_API_KEY"):
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
return 1
try:
from mem0 import MemoryClient
except ImportError:
print(
"ERROR: the mem0ai Python SDK is not installed.\n"
"Install with: pip install mem0ai\n"
"Then re-run this script.",
file=sys.stderr,
)
return 1
try:
client = MemoryClient()
except Exception as e:
print(
f"ERROR initialising MemoryClient: {e}\n"
"Most commonly this is an invalid MEM0_API_KEY -- check the key at "
"https://app.mem0.ai/dashboard/api-keys",
file=sys.stderr,
)
return 1
try:
current = client.project.get(fields=["custom_categories"])
current_cats = current.get("custom_categories") if isinstance(current, dict) else None
except Exception as e:
print(f"ERROR fetching current categories: {e}", file=sys.stderr)
return 1
_print_categories("Current project categories", current_cats)
_print_categories("Proposed coding categories", CODING_CATEGORIES)
if not args.apply:
print("Dry-run only -- no changes made. Re-run with --apply to write.")
return 0
print("Applying coding categories...")
try:
response = client.project.update(custom_categories=CODING_CATEGORIES)
except Exception as e:
print(f"ERROR applying update: {e}", file=sys.stderr)
return 1
print("Done.", response if response else "")
return 0
if __name__ == "__main__":
sys.exit(main())
-62
View File
@@ -1,62 +0,0 @@
---
name: mem0-codex
description: >
Mem0 persistent memory integration for Codex. Automatically retrieve relevant
memories at the start of each task, store key learnings when tasks complete,
and capture session state before context is lost. Use the mem0 MCP tools
(add_memory, search_memories, get_memories, etc.) for all memory operations.
---
# Mem0 Memory Protocol for Codex
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
## On every new task
1. Call `search_memories` with a query related to the current task or project to load relevant context.
2. Review returned memories to understand what has been learned in prior sessions.
3. If appropriate, call `get_memories` to browse all stored memories for this user.
## After completing significant work
Extract key learnings and store them using the `add_memory` tool:
- **Decisions made** -> Include metadata `{"type": "decision"}`
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
- **Conventions established** -> Include metadata `{"type": "convention"}`
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
## Before losing context
If context is about to be compacted or the session is ending, store a comprehensive session summary:
```
## Session Summary
### User's Goal
[What the user originally asked for]
### What Was Accomplished
[Numbered list of tasks completed]
### Key Decisions Made
[Architectural choices, trade-offs discussed]
### Files Created or Modified
[Important file paths with what changed]
### Current State
[What is in progress, pending items, next steps]
```
Include metadata: `{"type": "session_state"}`
## Memory hygiene
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
- Only store genuinely useful learnings. Skip trivial interactions.
- Use specific, searchable language in memory content.
+170
View File
@@ -0,0 +1,170 @@
---
name: mem0-mcp
description: >
Mem0 memory protocol for agents using the mem0 MCP tools (Claude Code, Cursor,
Codex, and any other MCP-aware runtime). Decide deliberately when memory context
would help, run targeted searches with metadata filters when it would, and store
key learnings as work completes. Use the mem0 MCP tools (add_memory,
search_memories, get_memories, etc.) for all memory operations.
---
# Mem0 MCP Memory Protocol
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
## On every new task
Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately.
### Decide: search or skip?
**Search WHEN** the user:
- references past work, decisions, or things "we" built
- asks "how should we...", "best way to...", or any decision-style question
- hits an error, bug, or asks for debugging help
- requests work that touches their stack, tools, conventions, or preferences
- starts a non-trivial task in a known project
**Skip WHEN:**
- the prompt is an acknowledgement or continuation ("ok", "thanks", "continue")
- the user is *stating* new info — that's a write trigger (`add_memory`), not a search
- it's a pure syntax / factual question answerable from general knowledge
- you already searched this scope earlier in the turn
Empty results are normal. Proceed without context — they don't mean the system is broken.
### How to search well
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
**Query phrasing:**
- Use **nouns**, not sentences. `"auth module decisions"` beats `"what did we decide about auth"`.
- Strip conversational filler. *"remember when we picked Postgres?"* → search `"Postgres choice"`.
- Use entity names, not pronouns. Resolve "that thing" from recent context first.
- Don't search on meta-questions ("what was that?") — use recent context or `get_memories` ordered by `created_at`.
**Metadata filters** match the same `type` values written under "After completing significant work" below.
Two rules from the v2 filter spec:
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
Combine `user_id` with one metadata clause per call:
| `metadata.type` clause | Use for |
|--------|---------|
| `{"metadata": {"type": "decision"}}` | design / architecture / "how should we" questions |
| `{"metadata": {"type": "anti_pattern"}}` | debugging, error handling, things that failed before |
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
Full filter (replace `<your_user_id>` with the active user_id from your runtime):
```python
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]}
```
### Worked example
User asks: *"Refactor the auth module to use JWT."*
Don't:
```python
search_memories(query="Refactor the auth module to use JWT")
# Hits whatever shares words. Misses prior decisions and preferences.
```
Do (parallel — substitute the active `user_id` for `<your_user_id>`):
```python
search_memories(query="auth module decisions",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]})
search_memories(query="JWT",
filters={"AND": [{"user_id": "<your_user_id>"}]})
search_memories(query="auth refactor failures",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "anti_pattern"}}]})
search_memories(query="auth",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "user_preference"}}]})
```
## After completing significant work
Extract key learnings and store them using the `add_memory` tool:
- **Decisions made** -> Include metadata `{"type": "decision"}`
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
- **Conventions established** -> Include metadata `{"type": "convention"}`
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
### Expiration: high-churn vs durable
Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes.
| Type | Expiration | Why |
|---|---|---|
| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. |
| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. |
`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule.
### Recency filter on recall
When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface:
```python
# Last 90 days only
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
```
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
### Use `infer=False` for already-structured content
When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it.
```python
add_memory(
messages=[{"role": "user", "content": "<your structured fact>"}],
user_id="<active user_id>",
metadata={"type": "decision"},
infer=False,
)
```
Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure.
## Before losing context
If context is about to be compacted or the session is ending, store a comprehensive session summary:
```
## Session Summary
### User's Goal
[What the user originally asked for]
### What Was Accomplished
[Numbered list of tasks completed]
### Key Decisions Made
[Architectural choices, trade-offs discussed]
### Files Created or Modified
[Important file paths with what changed]
### Current State
[What is in progress, pending items, next steps]
```
Include metadata: `{"type": "session_state"}`
## Memory hygiene
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
- Only store genuinely useful learnings. Skip trivial interactions.
- Use specific, searchable language in memory content.
+2
View File
@@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key"
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=mem0-plugin-skill
> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email or dashboard. The human can claim later with `mem0 init --email <your-email>`.
## Step 2: Initialize the client
**Python:**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.0.2",
"version": "3.0.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+165
View File
@@ -0,0 +1,165 @@
/**
* Best-effort read/write of ~/.mem0/config.json from the TS SDK.
*
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
* distinct_id values here, and the TS MemoryClient reads those on init to
* fire $identify and merge them into the email identity.
*
* Node-only. Browsers (no `process.versions.node`) no-op.
*/
export interface Mem0AnonIds {
oss?: string;
cli?: string;
aliasedPairs: string[];
}
interface NodeFs {
fs: typeof import("fs");
path: typeof import("path");
crypto: typeof import("crypto");
configPath: string;
}
async function getNodeFs(): Promise<NodeFs | null> {
if (typeof process === "undefined" || !process.versions?.node) return null;
try {
const [fs, path, os, crypto] = await Promise.all([
import("fs"),
import("path"),
import("os"),
import("crypto"),
]);
const fsMod = (fs as any).default ?? fs;
const pathMod = (path as any).default ?? path;
const osMod = (os as any).default ?? os;
const cryptoMod = (crypto as any).default ?? crypto;
const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0");
return {
fs: fsMod,
path: pathMod,
crypto: cryptoMod,
configPath: pathMod.join(dir, "config.json"),
};
} catch {
return null;
}
}
function loadConfig(node: NodeFs): Record<string, any> | null {
try {
if (!node.fs.existsSync(node.configPath)) return null;
const parsed = JSON.parse(node.fs.readFileSync(node.configPath, "utf8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function writeConfig(node: NodeFs, config: Record<string, any>): void {
node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true });
node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4));
}
function aliasPairMarker(node: NodeFs, anonId: string, email: string): string {
return node.crypto
.createHash("sha256")
.update(`${anonId}\0${email}`, "utf8")
.digest("hex");
}
function randomUserId(node: NodeFs): string {
if (typeof node.crypto.randomUUID === "function") {
return node.crypto.randomUUID();
}
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
export async function getOrCreateMem0UserId(): Promise<string | null> {
const node = await getNodeFs();
if (!node) return null;
try {
const config = loadConfig(node) ?? {};
if (typeof config.user_id === "string" && config.user_id) {
return config.user_id;
}
const userId = randomUserId(node);
config.user_id = userId;
writeConfig(node, config);
return userId;
} catch {
return null;
}
}
export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
const node = await getNodeFs();
if (!node) return null;
const config = loadConfig(node);
if (!config) return null;
const telemetry =
config.telemetry && typeof config.telemetry === "object"
? config.telemetry
: {};
return {
oss: typeof config.user_id === "string" ? config.user_id : undefined,
cli:
typeof telemetry.anonymous_id === "string"
? telemetry.anonymous_id
: undefined,
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
? telemetry.aliased_pairs.filter(
(item: unknown) => typeof item === "string",
)
: [],
};
}
export async function isMem0Aliased(
anonId: string,
email: string,
): Promise<boolean> {
if (!anonId || !email) return false;
const node = await getNodeFs();
if (!node) return false;
const config = loadConfig(node);
if (!config) return false;
const telemetry =
config.telemetry && typeof config.telemetry === "object"
? config.telemetry
: {};
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
? telemetry.aliased_pairs
: [];
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
}
export async function markMem0Aliased(
anonId: string,
email: string,
): Promise<void> {
const node = await getNodeFs();
if (!node) return;
try {
const config = loadConfig(node) ?? {};
const telemetry =
config.telemetry && typeof config.telemetry === "object"
? config.telemetry
: {};
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
? telemetry.aliased_pairs
: [];
const marker = aliasPairMarker(node, anonId, email);
if (!aliasedPairs.includes(marker)) {
aliasedPairs.push(marker);
}
telemetry.aliased_pairs = aliasedPairs;
config.telemetry = telemetry;
writeConfig(node, config);
} catch {
// Best-effort: read-only filesystems and unwritable paths just skip.
}
}
+38 -1
View File
@@ -20,7 +20,18 @@ import {
CreateMemoryExportPayload,
GetMemoryExportPayload,
} from "./mem0.types";
import { captureClientEvent, generateHash } from "./telemetry";
import {
captureClientEvent,
generateHash,
isTelemetryEnabled,
telemetry,
} from "./telemetry";
import {
getOrCreateMem0UserId,
isMem0Aliased,
markMem0Aliased,
readMem0AnonIds,
} from "./config";
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
@@ -118,6 +129,8 @@ export default class MemoryClient {
this.telemetryId = generateHash(this.apiKey);
}
await this._maybeAliasAnonToEmail();
captureClientEvent("init", this, {
client_type: "MemoryClient",
}).catch((error: any) => {
@@ -132,6 +145,30 @@ export default class MemoryClient {
}
}
private async _maybeAliasAnonToEmail(): Promise<void> {
if (!isTelemetryEnabled()) return;
try {
const email = this.telemetryId;
if (!email || !email.includes("@")) return;
const sharedAnonId = await getOrCreateMem0UserId();
const anonIds = await readMem0AnonIds();
if (!anonIds && !sharedAnonId) return;
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
(id): id is string => !!id && id !== email,
);
const seen = new Set<string>();
for (const anonId of candidates) {
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
seen.add(anonId);
if (await telemetry.captureIdentify(anonId, email)) {
await markMem0Aliased(anonId, email);
}
}
} catch (error: any) {
console.error("Failed to alias telemetry identity:", error);
}
}
private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, {
success: true,
+9
View File
@@ -22,6 +22,7 @@ export interface SearchMemoryOptions {
topK?: number;
threshold?: number;
rerank?: boolean;
latestOnly?: boolean;
fields?: string[];
categories?: string[];
}
@@ -32,6 +33,7 @@ export interface GetAllMemoryOptions {
pageSize?: number;
startDate?: string;
endDate?: string;
latestOnly?: boolean;
categories?: string[];
}
@@ -50,6 +52,13 @@ export interface PromptUpdatePayload {
memoryDepth?: string | null;
usecaseSetting?: string | number;
multilingual?: boolean;
/**
* Toggle Memory Decay for this project. When `true`, search-time ranking
* boosts recently-used memories and gently dampens stale ones; when `false`,
* ranking is restored to the pre-decay behaviour. Off by default.
* See https://docs.mem0.ai/platform/features/memory-decay
*/
decay?: boolean;
[key: string]: any;
}
+52 -3
View File
@@ -32,8 +32,12 @@ class UnifiedTelemetry implements TelemetryClient {
this.host = host;
}
async captureEvent(distinctId: string, eventName: string, properties = {}) {
if (!MEM0_TELEMETRY) return;
async captureEvent(
distinctId: string,
eventName: string,
properties = {},
): Promise<boolean> {
if (!MEM0_TELEMETRY) return false;
const eventProperties = {
client_version: version,
@@ -61,9 +65,50 @@ class UnifiedTelemetry implements TelemetryClient {
if (!response.ok) {
console.error("Telemetry event capture failed:", await response.text());
return false;
}
return true;
} catch (error) {
console.error("Telemetry event capture failed:", error);
return false;
}
}
async captureIdentify(anonId: string, email: string): Promise<boolean> {
if (!MEM0_TELEMETRY) return false;
if (!anonId || !email || anonId === email) return false;
const payload = {
api_key: this.apiKey,
distinct_id: email,
event: "$identify",
properties: {
$anon_distinct_id: anonId,
client_source: "typescript",
$lib: "posthog-node",
},
};
try {
const response = await fetch(this.host, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(
"Telemetry identify capture failed:",
await response.text(),
);
return false;
}
return true;
} catch (error) {
console.error("Telemetry identify capture failed:", error);
return false;
}
}
@@ -72,6 +117,10 @@ class UnifiedTelemetry implements TelemetryClient {
}
}
function isTelemetryEnabled(): boolean {
return MEM0_TELEMETRY;
}
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
async function captureClientEvent(
@@ -101,4 +150,4 @@ async function captureClientEvent(
);
}
export { telemetry, captureClientEvent, generateHash };
export { telemetry, captureClientEvent, generateHash, isTelemetryEnabled };
+1 -1
View File
@@ -3,7 +3,7 @@ export interface TelemetryClient {
distinctId: string,
eventName: string,
properties?: Record<string, any>,
): Promise<void>;
): Promise<boolean>;
shutdown(): Promise<void>;
}
@@ -63,6 +63,24 @@ describe("MemoryClient - search()", () => {
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
});
test("serializes latestOnly as latest_only", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.search("test", {
filters: { user_id: "u1" },
latestOnly: true,
});
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
expect(getFetchBody(call!).latest_only).toBe(true);
});
test("passes complex OR filters through to the API body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/search/", {
@@ -264,4 +282,22 @@ describe("MemoryClient - getAll() entity param rejection", () => {
await client.getAll({ filters: { user_id: "u1" } });
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
});
test("serializes latestOnly as latest_only", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", {
status: 200,
body: { results: [] },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({
filters: { user_id: "u1" },
latestOnly: true,
});
const call = findFetchCall(mock, "/v3/memories/", "POST");
expect(getFetchBody(call!).latest_only).toBe(true);
});
});
@@ -0,0 +1,410 @@
/**
* Tests for PostHog identity stitching in the TS MemoryClient.
*
* Covers $identify firing, idempotency via pair markers, and the node/browser
* gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json.
*/
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { MemoryClient } from "../mem0";
import { telemetry } from "../telemetry";
import {
getOrCreateMem0UserId,
isMem0Aliased,
markMem0Aliased,
readMem0AnonIds,
} from "../config";
import { TEST_API_KEY } from "./helpers";
import { setupMockFetch, installConsoleSuppression } from "./setup";
installConsoleSuppression();
function setupMockFetchWithPostHog(): jest.Mock {
return setupMockFetch(
new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]),
);
}
// ─── config.ts (node-only fs read/write) ──────────────────────
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
let tmpHome: string;
const originalMem0Dir = process.env.MEM0_DIR;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-test-"));
process.env.MEM0_DIR = tmpHome;
});
afterEach(() => {
if (fs.existsSync(tmpHome)) {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
if (originalMem0Dir === undefined) {
delete process.env.MEM0_DIR;
} else {
process.env.MEM0_DIR = originalMem0Dir;
}
});
test("returns null when config file does not exist", async () => {
expect(await readMem0AnonIds()).toBeNull();
});
test("reads OSS user_id only", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const ids = await readMem0AnonIds();
expect(ids).toEqual({
oss: "oss-uuid",
cli: undefined,
aliasedPairs: [],
});
});
test("reads CLI anonymous_id and aliased_pairs", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
}),
);
const ids = await readMem0AnonIds();
expect(ids).toEqual({
oss: undefined,
cli: "cli-anon",
aliasedPairs: ["pair-marker"],
});
});
test("getOrCreateMem0UserId creates and reuses shared SDK user_id", async () => {
const first = await getOrCreateMem0UserId();
const second = await getOrCreateMem0UserId();
expect(first).toBeTruthy();
expect(second).toBe(first);
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.user_id).toBe(first);
});
test("returns null on malformed JSON", async () => {
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
expect(await readMem0AnonIds()).toBeNull();
});
test("markMem0Aliased preserves other fields", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
user_id: "oss-uuid",
telemetry: { anonymous_id: "cli-anon" },
}),
);
await markMem0Aliased("oss-uuid", "user@example.com");
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.user_id).toBe("oss-uuid");
expect(written.telemetry.anonymous_id).toBe("cli-anon");
expect(written.telemetry.aliased_pairs).toHaveLength(1);
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
});
test("markMem0Aliased creates telemetry section when missing", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
await markMem0Aliased("oss-uuid", "user@example.com");
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.telemetry.aliased_pairs).toHaveLength(1);
});
test("markMem0Aliased tracks each pair independently", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
await markMem0Aliased("oss-uuid", "user@example.com");
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
expect(await isMem0Aliased("other-uuid", "user@example.com")).toBe(false);
expect(await isMem0Aliased("oss-uuid", "other@example.com")).toBe(false);
});
test("markMem0Aliased does not throw when target dir is unwritable", async () => {
// Point at a path that cannot be written to (a file-as-dir collision).
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
await expect(
markMem0Aliased("oss-uuid", "user@example.com"),
).resolves.toBeUndefined();
});
});
// ─── telemetry.captureIdentify ───────────────────────────────
describe("telemetry.captureIdentify", () => {
test("fires $identify with $anon_distinct_id", async () => {
const fetchMock = jest.fn(async () => ({
ok: true,
status: 200,
text: async () => "ok",
})) as unknown as typeof fetch;
global.fetch = fetchMock as any;
await telemetry.captureIdentify("anon-uuid", "user@example.com");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = (fetchMock as jest.Mock).mock.calls[0];
const payload = JSON.parse(init.body);
expect(payload.event).toBe("$identify");
expect(payload.distinct_id).toBe("user@example.com");
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
expect(payload.properties.$process_person_profile).toBeUndefined();
});
test("skips when anon equals email", async () => {
const fetchMock = jest.fn() as unknown as typeof fetch;
global.fetch = fetchMock as any;
await telemetry.captureIdentify("user@example.com", "user@example.com");
expect(fetchMock).not.toHaveBeenCalled();
});
test("skips when either input is empty", async () => {
const fetchMock = jest.fn() as unknown as typeof fetch;
global.fetch = fetchMock as any;
await telemetry.captureIdentify("", "user@example.com");
await telemetry.captureIdentify("anon", "");
expect(fetchMock).not.toHaveBeenCalled();
});
});
// ─── MemoryClient init aliasing ──────────────────────────────
describe("MemoryClient — _maybeAliasAnonToEmail", () => {
let tmpHome: string;
const originalMem0Dir = process.env.MEM0_DIR;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-init-"));
process.env.MEM0_DIR = tmpHome;
});
afterEach(() => {
if (fs.existsSync(tmpHome)) {
fs.rmSync(tmpHome, { recursive: true, force: true });
}
if (originalMem0Dir === undefined) {
delete process.env.MEM0_DIR;
} else {
process.env.MEM0_DIR = originalMem0Dir;
}
});
// Construct a non-initialised client so we can call _maybeAliasAnonToEmail
// in isolation (the real constructor's _initializeClient also fires it).
function makeStubClient(telemetryId: string): MemoryClient {
const client = Object.create(MemoryClient.prototype) as MemoryClient;
(client as any).apiKey = TEST_API_KEY;
(client as any).host = "https://api.mem0.ai";
(client as any).telemetryId = telemetryId;
return client;
}
test("fires $identify on first init and persists pair marker", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const fetchMock = setupMockFetchWithPostHog();
const client = makeStubClient("test@example.com");
await (client as any)._maybeAliasAnonToEmail();
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(1);
const body = JSON.parse(identifyCalls[0][1].body);
expect(body.distinct_id).toBe("test@example.com");
expect(body.properties.$anon_distinct_id).toBe("oss-uuid");
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.telemetry.aliased_pairs).toHaveLength(1);
});
test("platform-first init creates shared anon ID and identifies it", async () => {
const fetchMock = setupMockFetchWithPostHog();
const client = makeStubClient("test@example.com");
await (client as any)._maybeAliasAnonToEmail();
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.user_id).toBeTruthy();
expect(written.telemetry.aliased_pairs).toHaveLength(1);
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(1);
const body = JSON.parse(identifyCalls[0][1].body);
expect(body.distinct_id).toBe("test@example.com");
expect(body.properties.$anon_distinct_id).toBe(written.user_id);
});
test("second init does not refire $identify", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
user_id: "oss-uuid",
telemetry: {},
}),
);
await markMem0Aliased("oss-uuid", "test@example.com");
const fetchMock = setupMockFetchWithPostHog();
const client = makeStubClient("test@example.com");
await (client as any)._maybeAliasAnonToEmail();
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(0);
});
test("fires $identify for both OSS and CLI anon ids", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
user_id: "oss-uuid",
telemetry: { anonymous_id: "cli-anon" },
}),
);
const fetchMock = setupMockFetchWithPostHog();
const client = makeStubClient("test@example.com");
await (client as any)._maybeAliasAnonToEmail();
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(2);
const anonIds = identifyCalls.map(
(c: [string, RequestInit]) =>
JSON.parse(c[1].body as string).properties.$anon_distinct_id,
);
expect(anonIds).toContain("oss-uuid");
expect(anonIds).toContain("cli-anon");
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.telemetry.aliased_pairs).toHaveLength(2);
});
test("noop when telemetryId is not an email", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const fetchMock = setupMockFetch();
const client = makeStubClient("not-an-email");
await (client as any)._maybeAliasAnonToEmail();
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(0);
});
test("does not throw when config read fails", async () => {
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
setupMockFetch();
const client = makeStubClient("test@example.com");
await expect(
(client as any)._maybeAliasAnonToEmail(),
).resolves.toBeUndefined();
});
test("noop when telemetry disabled — no fs read, no fs write, no events", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const fetchMock = setupMockFetch();
jest.resetModules();
const original = process.env.MEM0_TELEMETRY;
process.env.MEM0_TELEMETRY = "false";
try {
const { MemoryClient: ColdClient } = await import("../mem0");
const client = Object.create(ColdClient.prototype);
client.apiKey = TEST_API_KEY;
client.host = "https://api.mem0.ai";
client.telemetryId = "test@example.com";
await client._maybeAliasAnonToEmail();
} finally {
if (original === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = original;
jest.resetModules();
}
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
([, init]: [string, RequestInit]) => {
if (!init?.body) return false;
return JSON.parse(init.body as string).event === "$identify";
},
);
expect(identifyCalls.length).toBe(0);
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.telemetry?.aliased_pairs).toBeUndefined();
});
});
// ─── Browser env path (no process.versions.node) ─────────────
describe("config.ts in browser-like environment", () => {
test("readMem0AnonIds returns null when not Node", async () => {
const originalProcess = global.process;
// @ts-expect-error force-undefining global to simulate a browser
delete global.process;
try {
jest.resetModules();
const { readMem0AnonIds: browserRead } = await import("../config");
expect(await browserRead()).toBeNull();
} finally {
global.process = originalProcess;
jest.resetModules();
}
});
});
+7 -1
View File
@@ -53,6 +53,7 @@ import {
ScoredResult,
} from "../utils/scoring";
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
import { getOrCreateMem0UserId } from "../../../client/config";
// Entity params that must be passed via filters - check both snake_case and camelCase
const ENTITY_PARAMS = [
@@ -466,7 +467,12 @@ export class Memory {
this.telemetryId === "anonymous" ||
this.telemetryId === "anonymous-supabase"
) {
this.telemetryId = await this.vectorStore.getUserId();
this.telemetryId =
(await getOrCreateMem0UserId()) ||
(await this.vectorStore.getUserId());
try {
await this.vectorStore.setUserId(this.telemetryId);
} catch {}
}
return this.telemetryId;
} catch (error) {
+30 -2
View File
@@ -19,8 +19,8 @@ from mem0.client.types import (
from mem0.client.utils import api_error_handler
# Exception classes are referenced in docstrings only
from mem0.memory.setup import get_user_id, setup_config
from mem0.memory.telemetry import capture_client_event
from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config
from mem0.memory.telemetry import capture_client_event, client_telemetry
logger = logging.getLogger(__name__)
@@ -33,6 +33,32 @@ setup_config()
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
def _maybe_alias_anon_to_email(user_email):
"""Fire $identify per prior anon ID so PostHog merges them into email.
Idempotent via telemetry.aliased_pairs: only writes markers when
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
Best-effort: never raises.
"""
if client_telemetry.posthog is None:
return
if not user_email or "@" not in user_email:
return
try:
anon_ids = read_anon_ids()
seen = set()
for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")):
if not anon_id or anon_id == user_email or anon_id in seen:
continue
seen.add(anon_id)
if is_aliased(anon_id, user_email):
continue
if client_telemetry.capture_identify(anon_id, user_email):
mark_aliased(anon_id, user_email)
except Exception as e:
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
class MemoryClient:
"""Client for interacting with the Mem0 API.
@@ -108,6 +134,7 @@ class MemoryClient:
user_email=self.user_email,
)
_maybe_alias_anon_to_email(self.user_email)
capture_client_event("client.init", self, {"sync_type": "sync"})
def _validate_api_key(self):
@@ -985,6 +1012,7 @@ class AsyncMemoryClient:
user_email=self.user_email,
)
_maybe_alias_anon_to_email(self.user_email)
capture_client_event("client.init", self, {"sync_type": "async"})
def _validate_api_key(self):
+16 -2
View File
@@ -398,6 +398,7 @@ class Project(BaseProject):
custom_categories: Optional[List[str]] = None,
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Update project settings.
@@ -407,6 +408,9 @@ class Project(BaseProject):
custom_categories: New categories for the project
retrieval_criteria: New retrieval criteria for the project
multilingual: Whether to use the input language for memory storage and retrieval
decay: Toggle Memory Decay for this project. When True, search-time
ranking boosts recently-used memories and gently dampens stale ones; when
False, ranking is restored to the pre-decay behaviour. Off by default.
Returns:
Dictionary containing the API response.
@@ -423,11 +427,12 @@ class Project(BaseProject):
and custom_categories is None
and retrieval_criteria is None
and multilingual is None
and decay is None
):
raise ValueError(
"At least one parameter must be provided for update: "
"custom_instructions, custom_categories, retrieval_criteria, "
"multilingual"
"multilingual, decay"
)
payload = self._prepare_params(
@@ -436,6 +441,7 @@ class Project(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"multilingual": multilingual,
"decay": decay,
}
)
response = self._client.patch(
@@ -451,6 +457,7 @@ class Project(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"multilingual": multilingual,
"decay": decay,
"sync_type": "sync",
},
)
@@ -715,6 +722,7 @@ class AsyncProject(BaseProject):
custom_categories: Optional[List[str]] = None,
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Update project settings.
@@ -724,6 +732,9 @@ class AsyncProject(BaseProject):
custom_categories: New categories for the project
retrieval_criteria: New retrieval criteria for the project
multilingual: Whether to use the input language for memory storage and retrieval
decay: Toggle Memory Decay for this project. When True, search-time
ranking boosts recently-used memories and gently dampens stale ones; when
False, ranking is restored to the pre-decay behaviour. Off by default.
Returns:
Dictionary containing the API response.
@@ -740,11 +751,12 @@ class AsyncProject(BaseProject):
and custom_categories is None
and retrieval_criteria is None
and multilingual is None
and decay is None
):
raise ValueError(
"At least one parameter must be provided for update: "
"custom_instructions, custom_categories, retrieval_criteria, "
"multilingual"
"multilingual, decay"
)
payload = self._prepare_params(
@@ -753,6 +765,7 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"multilingual": multilingual,
"decay": decay,
}
)
response = await self._client.patch(
@@ -768,6 +781,7 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"multilingual": multilingual,
"decay": decay,
"sync_type": "async",
},
)
+102 -15
View File
@@ -1,6 +1,8 @@
import json
import logging
import os
import uuid
from hashlib import sha256
# Set up the directory path
VECTOR_ID = str(uuid.uuid4())
@@ -8,28 +10,113 @@ home_dir = os.path.expanduser("~")
mem0_dir = os.environ.get("MEM0_DIR") or os.path.join(home_dir, ".mem0")
os.makedirs(mem0_dir, exist_ok=True)
_logger = logging.getLogger(__name__)
def _config_path():
return os.path.join(mem0_dir, "config.json")
def _load_config():
"""Load ~/.mem0/config.json, returning {} on missing/malformed file."""
path = _config_path()
if not os.path.exists(path):
return {}
try:
with open(path, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception as e:
_logger.debug("Failed to load mem0 config %s: %s", path, e)
return {}
def _write_config(config):
"""Best-effort write of ~/.mem0/config.json. Never raises."""
path = _config_path()
try:
with open(path, "w") as f:
json.dump(config, f, indent=4)
except Exception as e:
_logger.debug("Failed to write mem0 config %s: %s", path, e)
def setup_config():
config_path = os.path.join(mem0_dir, "config.json")
if not os.path.exists(config_path):
user_id = str(uuid.uuid4())
config = {"user_id": user_id}
with open(config_path, "w") as config_file:
json.dump(config, config_file, indent=4)
"""Ensure ~/.mem0/config.json exists with a top-level user_id.
Idempotent: backfills user_id for users whose config was written by the
CLI (which writes telemetry.anonymous_id but no top-level user_id).
Without this, OSS Python telemetry is silently dropped because
get_user_id() returns None when user_id is missing.
"""
config = _load_config()
if config.get("user_id"):
return
config["user_id"] = str(uuid.uuid4())
_write_config(config)
def get_user_id():
config_path = os.path.join(mem0_dir, "config.json")
if not os.path.exists(config_path):
config = _load_config()
if not config:
return "anonymous_user"
return config.get("user_id")
try:
with open(config_path, "r") as config_file:
config = json.load(config_file)
user_id = config.get("user_id")
return user_id
except Exception:
return "anonymous_user"
def read_anon_ids():
"""Return anon IDs and alias markers from ~/.mem0/config.json.
Returns a dict with keys "oss", "cli", "aliased_pairs" (IDs may be
None). OSS Python writes top-level "user_id"; the CLI writes
"telemetry.anonymous_id". They may coexist depending on which surface ran
first.
"""
config = _load_config()
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
aliased_pairs = telemetry.get("aliased_pairs")
return {
"oss": config.get("user_id"),
"cli": telemetry.get("anonymous_id"),
"aliased_pairs": aliased_pairs if isinstance(aliased_pairs, list) else [],
}
def _alias_pair_marker(anon_id, email):
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
def is_aliased(anon_id, email):
"""Return whether anon_id -> email has already been identified."""
if not anon_id or not email:
return False
config = _load_config()
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
aliased_pairs = telemetry.get("aliased_pairs")
if not isinstance(aliased_pairs, list):
return False
return _alias_pair_marker(anon_id, email) in aliased_pairs
def mark_aliased(anon_id, email):
"""Persist an anon_id -> email alias marker so $identify fires once per pair.
The marker is hashed to avoid storing platform emails in the local config.
"""
if not anon_id or not email:
return
config = _load_config()
telemetry = config.get("telemetry")
if not isinstance(telemetry, dict):
telemetry = {}
aliased_pairs = telemetry.get("aliased_pairs")
if not isinstance(aliased_pairs, list):
aliased_pairs = []
marker = _alias_pair_marker(anon_id, email)
if marker not in aliased_pairs:
aliased_pairs.append(marker)
telemetry["aliased_pairs"] = aliased_pairs
config["telemetry"] = telemetry
_write_config(config)
def get_or_create_user_id(vector_store=None):
+19 -1
View File
@@ -48,7 +48,8 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S
# Events that bypass sampling and always fire. Keep this set in sync with the
# event names passed to capture_event() in mem0/memory/main.py.
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory"})
# $identify is included so PostHog person-merging is never lost to sampling.
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"})
def _sampling_before_send(msg):
@@ -112,6 +113,23 @@ class AnonymousTelemetry:
except Exception as e:
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
def capture_identify(self, anon_id, email):
"""Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
if self.posthog is None:
return False
if not anon_id or not email or anon_id == email:
return False
try:
self.posthog.capture(
distinct_id=email,
event="$identify",
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
)
return True
except Exception as e:
_logger.debug("Failed to capture $identify for %r: %s", email, e)
return False
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
+12
View File
@@ -19,6 +19,18 @@ openclaw --version
|------------------|----------------|
| `>= 2026.4.25` | Fully supported |
## Quick path for agents
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup), mint an evaluation API key in <5 seconds:
```bash
mem0 init --agent --json
```
No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY` — pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id <id>` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, same API key keeps working, no agent disruption.
Humans should follow the Quick Start below.
## Quick Start
### Platform (Mem0 Cloud)
+5 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.1"
version = "2.0.2"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
@@ -153,3 +153,7 @@ known-first-party = ["mem0", "mem0_cli"]
[tool.isort]
profile = "black"
known_first_party = ["mem0", "mem0_cli"]
# isort scope kept aligned with [tool.ruff.lint.isort] above.
# black-equivalent profile here matches the formatter behaviour ruff applies.
# Plugin-version bumps need a touch here to fire required CI checks (path-filter trap).
# Last touched: plugin v0.1.3
+1195
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
# Mem0 Skills for AI Coding Assistants
Mem0 ships structured skill definitions for Claude Code, Codex, Cursor, OpenCode, OpenClaw, and any assistant that supports the [skills standard](https://github.com/anthropic-experimental/skills). Skills teach the assistant how to work with Mem0 — either by loading SDK knowledge into context, or by executing an end-to-end workflow on demand.
## Two Categories
### Reference skills — always on
Installed once, loaded into context so the assistant writes correct Mem0 code. Use these for day-to-day development.
| Skill | Surface | Install |
|-------|---------|---------|
| [`mem0`](./mem0/) | Python + TypeScript SDKs (Platform + OSS), framework integrations | `npx skills add https://github.com/mem0ai/mem0 --skill mem0` |
| [`mem0-cli`](./mem0-cli/) | Terminal workflows (`mem0` CLI, both Node and Python) | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli` |
| [`mem0-vercel-ai-sdk`](./mem0-vercel-ai-sdk/) | `@mem0/vercel-ai-provider` and `createMem0` | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk` |
### Pipeline skills — run on demand
Invoked as a slash command to execute a specific end-to-end workflow. These do real work: they create branches, write tests, run code.
| Skill | Trigger | Install |
|-------|---------|---------|
| [`mem0-integrate`](./mem0-integrate/) | `/mem0-integrate` — wire Mem0 into an existing repo via TDD | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate` |
| [`mem0-test-integration`](./mem0-test-integration/) | `/mem0-test-integration` — verify what `/mem0-integrate` produced | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration` |
The two pipeline skills are designed to run in sequence on the same workspace:
```
/mem0-integrate → mem0-integrate/<slug> branch + .mem0-integration/ artifacts
/mem0-test-integration → scorecard (compile + runtime verification, real API smoke test)
```
## Choosing a Skill
- **Writing Mem0 code in a new or existing project?** → `mem0`
- **Using the terminal CLI?** → `mem0-cli`
- **Building with `@ai-sdk/*`?** → `mem0-vercel-ai-sdk`
- **Want the assistant to wire Mem0 into an existing repo for you?** → `mem0-integrate`, then `mem0-test-integration`
## Links
- [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) — canonical landing page
- [Claude Code integration](https://docs.mem0.ai/integrations/claude-code)
- [Mem0 Platform Dashboard](https://app.mem0.ai)
- [Mem0 Documentation](https://docs.mem0.ai)
## License
Apache-2.0
+17 -1
View File
@@ -38,7 +38,23 @@ Both packages install a `mem0` binary with identical commands, options, and outp
## Setup
**Interactive wizard:**
**Autonomous Mem0 setup for agents (no email, no dashboard):**
```bash
mem0 init --agent --agent-caller <your-name> --json
```
Replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init — it PATCHes the same key, idempotent, no disruption.
This mints an evaluation API key in <5 seconds. Use it immediately. Print this line to the human:
> Mem0 memory is running in Agent Mode. Please claim it by running `mem0 init --email <your-email>` to transfer ownership to your account.
Never commit API keys, `.env`, or `~/.mem0/config.json`.
After the human claims, the same API key keeps working — no agent disruption — and memories are preserved.
**Interactive wizard (for humans):**
```bash
mem0 init
```
@@ -35,12 +35,16 @@ Interactive setup wizard. Configures API key and default user ID.
| `--email <addr>` | string | - | Login via email verification code instead of API key. |
| `--code <code>` | string | - | Verification code (use with `--email` for fully non-interactive login). |
| `--force` | boolean | false | Overwrite existing config without confirmation. |
| `--agent` | boolean | false | Bootstrap an Agent Mode account (no email required). |
| `--agent-caller <name>` | string | - | Self-declared agent identity for Agent Mode (e.g. `claude-code`, `cursor`). |
| `--source <channel>` | string | - | Channel attribution for signup analytics. |
**Behavior:**
- If `~/.mem0/config.json` already exists with an API key, warns and asks for confirmation (or errors in non-TTY unless `--force` is set).
- **Email login flow** (`--email`): sends a 6-digit code to the email via `POST /api/v1/auth/email_code/`. If `--code` is also given, verifies immediately. On success, saves API key, org_id, and project_id. Cannot be combined with `--api-key`.
- **API key flow**: if both `--api-key` and `--user-id` are given, runs fully non-interactively. Otherwise prompts for missing values.
- **Agent Mode flow** (`--agent`): POSTs to `/api/v1/auth/agent_mode/`, mints a shadow API key in <5s with no email required. Pass `--agent-caller <your-name>` to attribute the signup to your AI agent identity. If omitted, run `mem0 identify <your-name>` afterward.
- In non-TTY without sufficient flags, prints a usage hint and exits with error.
**Examples:**
@@ -50,6 +54,30 @@ mem0 init --api-key m0-xxx --user-id alice
mem0 init --api-key m0-xxx --user-id alice --force
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
mem0 init --agent --agent-caller claude-code # AI agent self-identifies during bootstrap
```
---
### `mem0 identify`
Tag your active Agent Mode key with the AI agent that's using it. Run this once after `mem0 init --agent` if you didn't pass `--agent-caller`. Idempotent — re-running just overwrites the value.
**Usage:** `mem0 identify <name>`
**Argument:** `<name>` — the AI agent identity (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`, or a custom string).
**Behavior:**
- PATCHes `/api/v1/auth/agent_mode/caller/` with `Authorization: Token <current-api-key>` and body `{agent_caller}`.
- Only works on unclaimed agent-mode keys (`platform.agent_mode=true` in config).
- Backend sanitizes the value: lowercases, drops anything outside `[a-z0-9._/-]`, truncates to 32 chars.
**Examples:**
```bash
mem0 identify claude-code
mem0 identify cursor
mem0 identify my-custom-bot
```
---
+189
View File
@@ -0,0 +1,189 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Mem0.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+89
View File
@@ -0,0 +1,89 @@
# mem0-integrate — Pipeline Skill
Wire [Mem0](https://mem0.ai) into an existing repository end-to-end, using a goal-driven, test-first pipeline.
> **This is a pipeline skill, not a reference skill.** Invoke it as `/mem0-integrate` when you want your assistant to do the work of integrating Mem0 into a target repo. For day-to-day SDK coding help, install [`mem0`](../mem0/SKILL.md) instead.
>
> **Part of the Mem0 Skill Graph:**
> - Reference: [mem0](../mem0/SKILL.md) · [mem0-cli](../mem0-cli/SKILL.md) · [mem0-vercel-ai-sdk](../mem0-vercel-ai-sdk/SKILL.md)
> - Pipeline: **mem0-integrate** (this skill) → [mem0-test-integration](../mem0-test-integration/SKILL.md)
## What This Skill Does
When invoked, your assistant will:
- **Detect** the target repo's language and stack automatically
- **Ask** whether to integrate with Mem0 Platform (managed) or Mem0 Open Source (self-hosted)
- **Write failing tests first** — no implementation until tests exist
- **Keep the integration additive and feature-flagged** — existing behavior stays byte-for-byte identical when the flag is unset
- **Produce a local feature branch** (`mem0-integrate/...`) and a `.mem0-integration/` directory of artifacts (`goal.md`, `plan.md`, `product.json`) consumed by the companion verification skill
## When to Use
Trigger phrases:
- "Integrate Mem0 into this repo"
- "Add Mem0 to my project"
- "Wire Mem0 into `<repo>`"
- "How do I add memory to an existing project?"
Do **not** use this skill for general SDK usage (install [`mem0`](../mem0/SKILL.md)), terminal workflows (install [`mem0-cli`](../mem0-cli/SKILL.md)), or Vercel AI SDK integration (install [`mem0-vercel-ai-sdk`](../mem0-vercel-ai-sdk/SKILL.md)).
## Installation
### CLI (Claude Code, Codex, OpenCode, OpenClaw, or any tool that supports skills)
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
```
For verification on the same branch, also install the companion skill:
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
```
### Claude.ai
1. Download this `skills/mem0-integrate` folder as a ZIP
2. Go to **Settings > Capabilities > Skills**
3. Click **Upload skill** and select the ZIP
### Claude API (Skills API)
```bash
curl -X POST https://api.anthropic.com/v1/skills \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "mem0-integrate", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0-integrate"}'
```
### Prerequisites
- A Mem0 Platform API key ([get one](https://app.mem0.ai/dashboard/api-keys)) *or* a working OSS setup (LLM + vector store)
- Python 3.10+ or Node.js 18+ in the target repo
- A clean working tree on the target repo's default branch
## Workflow
```
/mem0-integrate → creates mem0-integrate/<slug> branch,
writes .mem0-integration/ artifacts,
implements against failing tests
/mem0-test-integration → runs the repo's native test suite,
executes a real end-to-end smoke flow,
produces a scorecard
```
The two skills are loosely coupled — they share the same workspace and branch via `.mem0-integration/`, but the verifier never modifies source.
## Links
- [Mem0 Platform Dashboard](https://app.mem0.ai)
- [Mem0 Documentation](https://docs.mem0.ai)
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
- [Platform vs OSS comparison](https://docs.mem0.ai/platform/platform-vs-oss)
## License
Apache-2.0
+620
View File
@@ -0,0 +1,620 @@
---
name: mem0-integrate
description: >
Integrate Mem0 into an existing repository using a goal-driven, TDD pipeline.
Detects the repo's language automatically and asks the user to pick between
Mem0 Platform (managed) and Mem0 Open Source (self-hosted). Writes failing
tests before any implementation. Produces a local feature branch plus
`.mem0-integration/` artifacts consumed by the paired verification skill.
TRIGGER when: user says "integrate mem0", "add mem0 to this repo", "wire
mem0 into <repo>", or asks how to add memory to an existing project.
DO NOT TRIGGER when: the user wants general SDK usage (use skill:mem0),
CLI usage (use skill:mem0-cli), or Vercel AI SDK (use skill:mem0-vercel-ai-sdk).
After success, invoke skill:mem0-test-integration to verify in the same
workspace (loose coupling).
license: Apache-2.0
metadata:
author: mem0ai
version: "0.1.0"
category: ai-memory
tags: "memory, integration, tdd, platform, oss"
mem0_tested_versions: "mem0ai (PyPI) >=2.0.0,<3.0.0; mem0ai (npm) >=3.0.0,<4.0.0"
---
# mem0-integrate
Wire Mem0 into an existing repo with a goal-driven, test-first pipeline.
Pairs with `mem0-test-integration` for verification.
## Canonical sources (fetch before deciding anything)
The skill MUST `WebFetch` these URLs before step 3 and cite them in
`plan.md`. They are the ground truth — do not rely on ambient knowledge
of the Mem0 API.
### Agent-ready docs
- Scope-tagged docs index: https://docs.mem0.ai/llms.txt
- Full docs (single file, deep dives): https://docs.mem0.ai/llms-full.txt
- OpenAPI spec (Platform REST, machine-readable): https://docs.mem0.ai/openapi.json
- Hosted MCP server: https://mcp.mem0.ai (requires Platform API key)
- Integrations index: https://docs.mem0.ai/integrations
### Published Mem0 skills — delegate; do not reimplement
Prefer these over writing your own call-site patterns. Each is a
standalone `SKILL.md` with triggers, examples, and version-pinned code.
- SDK (Python + TS, Platform + OSS): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0/SKILL.md
- CLI: https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-cli/SKILL.md
- Vercel AI SDK: https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-vercel-ai-sdk/SKILL.md
- Editor/MCP plugin glue (9 MCP tools): https://github.com/mem0ai/mem0/tree/main/mem0-plugin
### SDK source (read when docs are ambiguous)
Public repo. Cross-check against the `mem0_tested_versions` range in this
skill's frontmatter if the `main` branch has moved past a major.
- Repo root: https://github.com/mem0ai/mem0
- Python SDK: https://github.com/mem0ai/mem0/tree/main/mem0
- TypeScript SDK: https://github.com/mem0ai/mem0/tree/main/mem0-ts
### Quickstarts (for bootstrapping unfamiliar stacks)
- Platform: https://docs.mem0.ai/platform/quickstart
- OSS Python: https://docs.mem0.ai/open-source/python-quickstart
- OSS Node: https://docs.mem0.ai/open-source/node-quickstart
- Platform vs OSS comparison: https://docs.mem0.ai/platform/platform-vs-oss
## Integration principles (non-negotiable)
The true goal of this skill is to produce a **PR the maintainers can accept
without argument**. That rules out anything invasive.
1. **Additive, not replacing.** If the target repo already has a memory
system, a session store, a user-context layer, or anything named
`Memory` / `memory_*`, Mem0 sits **alongside** it, not in place of it.
The existing system keeps working unchanged.
2. **Opt-in by default.** Gate all new Mem0 code behind a feature flag
(env var like `MEM0_ENABLED=1`, a config key, or a strategy selector).
With the flag unset, behavior is the repo's original behavior,
byte-for-byte.
3. **No breakage.** No removed exports, no renamed public functions,
no changed method signatures, no modified existing tests, no changed
behavior of existing tests. All pre-existing tests must pass unchanged
both with the flag set and unset.
4. **Minimal dependency surface.** Add `mem0ai` (plus any deps the
delegated skill requires) and nothing else. No new vector stores, no
graph databases, no provider SDKs the repo does not already use.
5. **Separable commits.** Code, tests, and config/docs land in separate
commits so reviewers can cherry-pick.
6. **The null hypothesis wins.** If no additive, gated fit exists after
step 6 (plan), exit with code 1 and a rationale. A bad PR is worse
than no PR.
7. **Backend only.** Mem0 integration lives in server-side code. API keys,
memory scope, and user-identity resolution are not safe client-side.
If the repo has both backend and frontend, the call sites live in
backend files. Frontend-only repos are rejected at preconditions.
Enforced at four gates: **preconditions** (reject frontend-only repos
and repos where additive fit is impossible), **step 2 comprehension**
(confirm a backend exists and name candidate surfaces), **step 6 plan
review** (reject plans that mutate existing exports or name client-side
call sites), and **step 10 self-healing loop** (refuse to "fix" principle
violations — surface them instead).
## Skill delegation rules
Before writing any code, check whether a published skill already covers
the target stack. If yes, delegate — copy its call-site pattern into
`plan.md` and into the tests; do not paraphrase.
| Detected in target repo | Delegate to | Why |
|---|---|---|
| `@ai-sdk/*` + `ai` in `package.json` | `skills/mem0-vercel-ai-sdk` | Integration is via `createMem0` provider wrapper, not raw `MemoryClient`. |
| CLI-only repo (Typer, Commander, Click, Cobra) with no LLM call sites | `skills/mem0-cli` | Call sites are command handlers, not model wrappers. Consider whether mem0 actually fits first. |
| Target is an MCP client / editor config (Claude Code, Cursor, Codex settings) | `mem0-plugin` | Wire via MCP server URL + hooks; no SDK code usually needed. |
| Any other Python or TS repo with an LLM call site | `skills/mem0` | Default SDK integration path. |
Record the delegated skill's raw URL in `plan.md` under a
**"Delegated skill:"** field. The test writer in step 7 and the
implementation subagent in step 8 both read this field.
## Preconditions
Refuse to start unless ALL of the following are true:
- Current working directory is inside a git repository with a clean index
(no uncommitted changes). Protects the user's work — every edit lands on
a feature branch, not on top of in-progress changes.
- Repo has a detectable language (`package.json` / `pyproject.toml` /
`requirements.txt`). No language → exit cleanly with a written rationale.
- Repo has a **backend**. Detected by: a `backend/` or `server/` or `api/`
directory; a Python package with FastAPI/Flask/Django/Starlette; a Node
package with Express/Fastify/Koa/NestJS/Next-API-routes; an agent-loop
framework (LangGraph, LangChain, LlamaIndex, Agno). Frontend-only repos
(pure React/Vue/Svelte SPAs, static sites, mobile-only) → exit with
code 1 and a rationale. Mem0 is not installed client-side.
- The user has already decided Mem0 fits this repo. This skill does NOT
survey the codebase to justify fit — bring a concrete goal. (Step 2
*does* read the repo to understand what it does and locate backend
integration surfaces; that is mechanics, not fit-justification.)
Exit with a written rationale if any precondition fails. Do not try to
"make it work anyway."
## Pipeline
### 1. Language detection
| Signal | Track |
|---|---|
| `package.json` + TypeScript config | Node / TypeScript |
| `package.json` (no TS config) | Node / JavaScript |
| `pyproject.toml` or `requirements.txt` | Python |
Monorepo with both → ask which subdirectory to operate in, then recurse.
### 2. Repo comprehension — what does this repo do, and where is the backend?
Before any decision (product, goal, plan), understand the repo enough
to locate *where in the backend* the integration belongs. This is not
fit-surveying — the user already decided Mem0 fits. This is mechanics:
you cannot write a plan without knowing what files matter.
Read, in order, with a token budget — do not scan the whole tree:
1. `README.md` (root) + first-page of any `README_*.md` variants.
2. `CONTRIBUTING.md` / `AGENTS.md` / `CLAUDE.md` at root if present —
these often spell out architecture and entry points.
3. `package.json` / `pyproject.toml` scripts + entry points.
4. The layout of the top two directory levels (not recursive).
5. Key config files: `docker-compose.yml`, `Dockerfile`, `Makefile`,
`langgraph.json`, `next.config.*`, `nuxt.config.*`.
Produce `.mem0-integration/repo-summary.md`:
# Repo comprehension
**What this repo does:** <one paragraph in plain English. Who is
the end user? What does the app do for them? What LLM / agent
behavior is central? Do not list dependencies — describe behavior.>
**Architecture at a glance:**
- Backend: <path(s), framework, primary entry point>
- Frontend: <path(s) if any, framework — for context only; no
integration here>
- Agent loop / orchestration: <LangGraph? custom? none?>
- Existing memory/session/state systems: <name them — these are
what step 6 Coexistence must preserve>
**Candidate backend integration surfaces** (ranked, best first):
1. `<backend-file>:<line_range>` — <function> — <one-sentence
reason this is where write/read could slot in without
replacing anything existing>
2. ...
3. ...
**Not a fit here:** <list anything the skill considered but ruled
out — e.g., "frontend chat component: client-side, excluded by
backend-only rule"; "existing memory subsystem X: would require
replacement, excluded by additive principle">
**Sources read:** <list the files actually opened, with line counts,
so reviewers can verify coverage.>
Show the user the rendered summary and ask: *"Is this understanding
correct? Which of the candidate surfaces (1, 2, 3 ...) should step 3
forward target?"*
Gate rules:
- If no backend surface is found → exit code 1. The preconditions
should already have caught frontend-only repos; reaching this point
means a more subtle miss (e.g., the "backend" is actually just a
static build). Do not force a fit.
- If every candidate surface would require replacing an existing
memory/session system → exit code 1 with the "additive principle"
rationale. The user can manually point at a non-conflicting location
and re-run.
- User corrections update `repo-summary.md` and re-confirm. Max 3
rounds; beyond that, exit code 1.
The user's chosen surface index is baked into `product.json` as
`preferred_site` and referenced by steps 5 and 6.
### 3. Product selection — Platform vs OSS (ask with a recommendation)
Read the `## Identify the User's Setup` block in
`https://docs.mem0.ai/llms.txt` for the Platform-first routing rules, then
apply the heuristics below. Ask, but never blank:
- Other managed-service SDKs present (`@clerk/*`, `stripe`, `@supabase/*`,
`openai`, `@upstash/*`, `posthog-*`) — 3+ → recommend **Platform**.
- Local-infra signals (`docker-compose.yml` with postgres / redis / qdrant /
neo4j, ollama configs, self-hosted auth) — 2+ → recommend **OSS**.
- No strong signal → default recommendation: **Platform** (lower integration
cost; migration later is supported).
Example:
> I see `stripe`, `@clerk/nextjs`, and `@supabase/supabase-js` — managed
> services throughout. I recommend **Mem0 Platform** (4-line integration).
> Override and use open source?
Bake the choice into the goal doc in step 5. Do not re-decide later.
### 4. API key check (env-first, then ask)
| Track | Key | Where to find |
|---|---|---|
| Platform | `MEM0_API_KEY` | https://app.mem0.ai |
| OSS (default LLM) | `OPENAI_API_KEY` | https://platform.openai.com/api-keys |
If present in env → continue.
If `MEM0_API_KEY` is missing AND the track is **Platform** → **default to Agent Mode**: run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Cache the key to `.env` (with user consent) and continue. Tell the user to claim later with `mem0 init --email <their-email>` — same key, no agent disruption.
If missing AND **CI mode** (`MEM0_INTEGRATE_CI=1`) → exit with code 2 and the name of the missing key.
Never echo key values into `trace.jsonl`. Persist to `.env` only with
explicit user consent, and append `.env` to `.gitignore` if not already there.
If the user is on OSS and wants a non-OpenAI LLM, route them to the
`components/llms/*` docs and re-run this step with the chosen provider's key.
### 5. Goal doc — the hard gate
Write `.mem0-integration/goal.md` and **require user approval before step 6**.
Template:
# Mem0 Integration Goal
**What gets stored:** <one sentence — user utterances? extracted
preferences? a specific domain fact like "dietary restrictions"?>
**When it gets retrieved:** <one sentence — on each user turn? before a
specific tool call? at session start?>
**Why:** <one sentence — the user-visible behavior change. "Assistant
remembers previous orders across sessions," not "we added memory.">
**Product:** Platform | OSS (locked from step 3, do not change)
**Delegated skill:** <raw URL of the published skill being used
from "Skill delegation rules" above, or "none — custom integration
against `skills/mem0`">.
**Out of scope:** <anything explicitly excluded: "no graph memory,"
"no multimodal," "no migration from existing store">
Rules:
- User must approve explicitly. If they edit the doc, reload and re-confirm.
- `goal.md` is the contract the test suite is written against. Never
rewrite it after step 6 starts.
- Max 3 rejection rounds. On the 4th, exit with code 3 and the rejection
notes — the integration is not well-specified enough to proceed.
### 6. Integration plan — how and where (hard gate)
Given `goal.md` is "what and why," this step produces "where and how" and
gets explicit user sign-off before any code is written.
The skill does a **scoped** read of the repo (no wide survey):
- Grep for the LLM call sites that match the goal (e.g., `openai.chat.`,
`anthropic.messages.`, `model.generateContent`, `ChatOpenAI`, `createLLM`).
- Grep for the user-identity source (`req.user`, `session.user`, `auth()`,
`ctx.userId`, cookies).
- Check `package.json` / `pyproject.toml` / `requirements.txt` for
conflicts (e.g., existing `mem0ai` at a different version).
Then write `.mem0-integration/plan.md`:
# Mem0 Integration Plan
**Write pattern:** <one sentence — e.g., "After each assistant reply,
call client.add([user_msg, assistant_msg], user_id=<source>).">
**Read pattern:** <one sentence — e.g., "Before building the LLM prompt,
call client.search(query=latest_user_msg, user_id=<source>, limit=5)
and inject results as a system message.">
**User identifier source:** <code path — e.g., `req.auth.userId`,
`session.user.email`, `ctx.params.user_id`. If none, ask the user.>
**Session scoping:**
- user_id: <source>
- agent_id: <static slug | null>
- run_id: <source | null>
**Write call site:** `<file:line_range>` — inside `<function>`
**Read call site:** `<file:line_range>` — inside `<function>`
**Dependencies to add:**
- `<package>@<version pinned in frontmatter>`
**Preserved behavior:** <list the existing repo behaviors that must
keep working after this edit — e.g., "existing OpenAI streaming still
works," "existing Redis session store still used," "existing tests
still pass unchanged.">
**Coexistence:** <one bullet per existing system the integration sits
alongside. Name the files/classes. Example: "The existing
`agents/memory/storage.py` MemoryStorage class remains untouched and
keeps its LangGraph SummarizationEvent flow. Mem0 is added as a
parallel long-term-facts store, in a new file, invoked only when
MEM0_ENABLED=1 is set.">
**Feature flag:** <the exact mechanism and the default. Required.
Example: `env MEM0_ENABLED=1`, default unset / off; `config.mem0.enabled`,
default false. With the flag in its default state, the repo must
behave exactly like `main`.>
**Sources consulted:** <minimum 2 URLs from "Canonical sources" above
that informed this plan. At least one `docs.mem0.ai` URL and one
delegated-skill URL. Cite the specific section or heading.>
**E2E recipe:** <how the verification skill should drive the app
end-to-end. Omit only if the repo is a pure library with no runnable
entry point — in which case the E2E step will skip with a warning.>
start: <shell command to launch the app locally,
using $PORT for any network port>
ready_probe: <one of: url=<URL> status=<code> /
log="<substring to wait for>" /
sleep=<seconds, last resort>>
compose_services: <optional: whitespace-separated service
names in docker-compose.yml to start first;
use label mem0-e2e: "true" to mark them>
write_call: <command that triggers the Mem0 write path
exactly once; ≤ 60s runtime>
write_async_wait_ms: <milliseconds to wait after write_call for
async memory flush; default 0>
read_call: <command that triggers the Mem0 read path,
typically a fresh session / new request>
read_assert: <substring, regex, or jsonpath=<expr>=<value>
that MUST appear in read_call's output for
the E2E to pass. Derived from goal.md's
"What gets stored.">
**Rejected alternatives:** <briefly, 1–2 bullets — patterns the skill
considered but did not pick, and why. Helps the user decide.>
Rules:
- Show the user the proposed call sites with 10 lines of context around
each before asking for approval.
- If the skill can't find a plausible call site for either write or read,
it exits with code 5 and asks the user to name the file(s) manually
(this is the "no fit here" signal — don't guess).
- Max 3 rejection rounds on the plan. On the 4th, exit code 5 with the
last plan and the user's notes.
- If the user edits `plan.md` by hand, reload and re-confirm.
`plan.md` (not `goal.md`) is the contract the subagent implements against
in step 8.
### 7. Tests first (TDD)
Main agent writes failing tests against `goal.md` in the repo's native
test framework:
| Track | Default framework |
|---|---|
| Python | `pytest` |
| TypeScript | `vitest` if detected, else `jest` |
| JavaScript | same |
Test assertion shapes must match the **canonical signatures**:
- Platform method signatures: `https://docs.mem0.ai/openapi.json`
(request body schemas for `/v1/memories/` and `/v1/memories/search/`).
- OSS method signatures: the delegated skill named in `plan.md`
(fetched from its raw URL) or `skills/mem0/SKILL.md` as the default.
- Do not hand-roll request shapes. If the delegated skill has an
example block, lift it verbatim.
Minimum two test files (paths taken from `plan.md` call sites):
- `test_mem0_write.<ext>` — asserts `add()` is called at the Write call
site with the right payload shape (Platform messages-array vs OSS string)
and the right `user_id` source.
- `test_mem0_read.<ext>` — asserts `search()` runs before the Read call
site and the result is wired into the LLM prompt / response path.
Tests MUST be importable with `MEM0_API_KEY` unset. This is the design
pressure that forces step 8's lazy `MemoryClient()` / `Memory()`
construction — eager module-level init hits the API on import and
breaks pre-existing test collection when the key is missing.
Run the tests. They **must fail**. If they pass before any implementation,
the tests are wrong — rewrite them.
### 8. Implementation (subagent, fresh context)
Spawn a subagent with:
- **Inputs**: the repo, `goal.md`, `plan.md`, the two test files, and
direct URLs to: the delegated skill (from `plan.md`), the SDK source
(pinned per `mem0_tested_versions`), `https://docs.mem0.ai/llms.txt`,
and `https://docs.mem0.ai/openapi.json`.
- **No access** to main agent's reasoning trace or scratchpad.
- **System prompt** (verbatim):
You are implementing a Mem0 integration for an existing repo.
Read these first:
- plan.md (the mechanical contract)
- goal.md (the intent — do not change it)
- the test files (do not change them either)
- <delegated skill raw URL from plan.md>
- https://docs.mem0.ai/llms.txt
- https://docs.mem0.ai/openapi.json (Platform only)
Constraints — all required, all enforced at review:
1. Touch only the files named in plan.md's call sites, or add
strictly new files.
2. Do not remove or rename any existing symbol. Do not change
any public signature.
3. Do not modify any existing test.
4. Gate every line of new Mem0 code behind the feature flag from
plan.md. With the flag in its default state, the repo must
behave exactly like `main` — byte-for-byte, including stdout
and return values.
5. Use only the <Platform | OSS> SDK surface. No new dependencies
beyond those listed under plan.md's "Dependencies to add."
6. Preserve everything listed under plan.md's "Preserved behavior"
and "Coexistence."
7. Lazy client construction. `MemoryClient()` validates the API
key in `__init__` (it makes a network call). Never instantiate
it at module-import time — construct on first use inside the
request / handler path. The same rule applies to OSS `Memory()`,
which can eagerly initialize embedding and LLM providers. Use
a function-local singleton (`functools.lru_cache`, a module-level
`_client = None` + getter, or DI scope) — never a top-level
global. Eager init breaks the pre-existing test suite at
collection time whenever the key is missing or invalid, which
is a non-invasiveness violation.
Implement the plan to make the new tests pass while all
pre-existing tests continue to pass unchanged.
Subagent returns a diff. Main agent reviews against `plan.md` (the
mechanical contract) and `goal.md` (the intent):
- Approved → apply the diff, commit.
- Rejected → return with specific, actionable feedback (not "try again").
- Max 3 review loops. Beyond that → exit code 4 with the last diff and
reviewer feedback.
### 9. Commit + handoff
Create branch `mem0-integrate/<short-goal-slug>` and commit in
**separate commits** so reviewers can cherry-pick:
1. `mem0: add gated dependency` — just the `pyproject.toml` / `package.json`
change.
2. `mem0: add integration module` — the new file(s).
3. `mem0: wire into <call site>` — the call-site edit(s), still gated.
4. `mem0: add tests` — the new test files.
If `--no-heal` is set → print `Run /mem0-test-integration to verify.`
and exit. Otherwise proceed to step 10.
### 10. Self-healing loop (default ON; disable with `--no-heal`)
Run `/mem0-test-integration --ci` in a subprocess. If `scorecard.json`
reports `overall: pass` → done, exit 0.
Otherwise loop:
1. **Categorize the failing check** from `scorecard.json`. Route per
category:
- `install` / `static_checks` → dependency or import fix.
- `unit_tests` → wiring or assertion fix.
- `smoke_test` → API key or SDK call-shape fix.
- `e2e_test` → recipe, flag-wiring, or integration-point fix.
- **Pre-existing test failure (test skill exit code 7,
`non_invasive: false` in scorecard) → STOP.** This is a
non-invasiveness violation. Do NOT attempt to "fix" it (that
breaks principle 3). Exit code 6 with rationale.
2. **Spawn a remediation subagent**, fresh context. Inputs:
`plan.md`, `goal.md`, `scorecard.md`, `scorecard.json`, the last
committed diff, and the relevant log file for the failing category
(`test-stdout.log` / `smoke-stdout.log` / `e2e-app.log` /
`e2e-calls.log`).
System prompt (verbatim):
You are fixing a failing Mem0 integration test.
Non-negotiable constraints:
- Do not modify test files.
- Do not remove or rename any existing symbol or signature.
- Do not change pre-existing behavior. The feature flag from
plan.md must still default to OFF, and with the flag in its
default state the repo must behave exactly like main.
- Touch only the files named in plan.md's call sites, or add
strictly new files.
- Return the smallest possible diff that fixes the single
failing check listed in scorecard.md. No drive-by cleanup.
3. **Apply the diff**; commit on the same branch with message
`mem0-heal: <category> attempt <N>`. Do NOT amend earlier commits
(reviewers need the heal trail).
4. **Re-run `/mem0-test-integration --ci`**. Outcomes:
- `overall: pass` → done, exit 0.
- Same check still failing → increment attempt counter; loop.
- A *different* check now failing → regression. Revert the heal
commit (`git revert HEAD --no-edit`), record the regression in
`.mem0-integration/heal-trace.md`, exit code 6.
5. **Bounded iterations.** Default 3 attempts per failing category.
Override with `--heal-max N` (hard cap 10). On exhaustion, exit 6
with the full attempt trace: each diff, each scorecard, final log
tail.
6. **Post-loop summary** written to `.mem0-integration/heal-trace.md`:
which category failed, how many attempts, each diff's intent, final
status, and — on success — the delta from initial scorecard to final.
## Artifacts (all under `.mem0-integration/`)
| File | Purpose | Retention |
|---|---|---|
| `repo-summary.md` | Repo comprehension + candidate backend surfaces (step 2). | Keep across runs. |
| `goal.md` | Approved intent. Never rewritten after step 6. | Keep across runs. |
| `plan.md` | Approved mechanics (where, how, call sites, preserved behavior). | Keep across runs. |
| `trace.jsonl` | Every tool call, decision, and subagent exchange this run. | Overwritten per run. |
| `diff.patch` | The committed integration as a reviewable patch. | Overwritten per run. |
| `heal-trace.md` | Per-attempt record of the self-healing loop (step 10). | Overwritten per run. |
| `product.json` | `{"product": "platform"\|"oss", "language": "...", "mem0_version": "...", "write_site": "file:line", "read_site": "file:line", "feature_flag": "MEM0_ENABLED"}` — consumed by the verification skill. | Overwritten per run. |
`.mem0-integration/` is added to `.gitignore` on first run. Nothing is
written outside this directory and the repo's source tree.
## Modes
| Mode | Trigger | Behavior |
|---|---|---|
| Interactive (default) | TTY present, `MEM0_INTEGRATE_CI` unset | Asks for keys, confirms goal doc, shows recommendations. |
| CI | `MEM0_INTEGRATE_CI=1` | Requires keys in env, requires `--product`, auto-approves goal doc from `goal.md` if present, fails fast otherwise. |
## Invocation
/mem0-integrate # interactive, heal ON
/mem0-integrate --no-heal # stop after commit; manual verify
/mem0-integrate --heal-max 5 # cap heal attempts per category (default 3)
/mem0-integrate --product platform # skip the product ask
/mem0-integrate --product oss
/mem0-integrate --ci # non-interactive (for test harness)
## Exit codes
| Code | Meaning |
|---|---|
| 0 | Success. Feature branch committed; verification skill ready to run. |
| 1 | Precondition failed (dirty repo, no detectable language, etc.). |
| 2 | Missing env key in CI mode. |
| 3 | Goal doc rejected 3+ times — integration is not well-specified. |
| 4 | Subagent review loop did not converge in 3 rounds. |
| 5 | Integration plan rejected 3+ times, or no plausible additive call site found. |
| 6 | Self-healing loop did not converge, detected a non-invasiveness violation, or a pre-existing test failed. |
## Explicitly out of scope
- Surveying the repo for fit points. Humans decide where Mem0 helps before
invoking this skill.
- Replacing any existing memory / session / state system. Always additive
and feature-flagged; see "Integration principles."
- Modifying pre-existing tests, even to "fix" them under self-heal. Tests
that fail after integration with the flag unset are a non-invasiveness
violation, not a bug to patch.
- Deciding Platform vs OSS silently. Always ask with a recommendation.
- Switching branches, pushing, or opening PRs. Commits locally and stops
(or enters the heal loop, still local).
- Data migration between stores. Point user at `migration/oss-to-platform`
docs if they ask.
- Provider selection beyond the default LLM for OSS. If they need a custom
LLM / embedder / vector store, route to `components/*` docs and re-run
step 4 with the new key.
+189
View File
@@ -0,0 +1,189 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Mem0.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+81
View File
@@ -0,0 +1,81 @@
# mem0-test-integration — Pipeline Skill
Verify a Mem0 integration produced by [`/mem0-integrate`](../mem0-integrate/SKILL.md). Runs in the same workspace on the same branch — installs dependencies, runs the repo's native test suite, then exercises a real end-to-end smoke flow against the user's API key.
> **This is a pipeline skill, not a reference skill.** Invoke it as `/mem0-test-integration` after `/mem0-integrate` has produced a branch to verify. It catches compile and runtime bugs by design — logical integration errors (wrong data stored, wrong scoping) are for human review.
>
> **Part of the Mem0 Skill Graph:**
> - Reference: [mem0](../mem0/SKILL.md) · [mem0-cli](../mem0-cli/SKILL.md) · [mem0-vercel-ai-sdk](../mem0-vercel-ai-sdk/SKILL.md)
> - Pipeline: [mem0-integrate](../mem0-integrate/SKILL.md) → **mem0-test-integration** (this skill)
## What This Skill Does
When invoked, your assistant will:
- **Refuse to start** unless the branch has `.mem0-integration/` artifacts, the working tree is clean, and the right API key is in the environment
- **Install** the repo's dependencies using its native tooling (pip, pnpm, npm, hatch, etc.)
- **Run the native test suite** in two passes: flag-unset (must behave like `main`) and flag-set (new tests run)
- **Execute a real end-to-end smoke flow** against Mem0 Platform (`MEM0_API_KEY`) or OSS (`OPENAI_API_KEY`)
- **Produce a scorecard** — `overall: pass | fail`, per-check reasons, and the reproduction command for each failure
## When to Use
Trigger phrases:
- "Verify the integration"
- "Test the Mem0 integration"
- "Run `/mem0-test-integration`"
Do **not** use this skill to run general project tests (defer to the repo's native test command) or before `/mem0-integrate` has produced a branch on the current workspace.
## Installation
### CLI (Claude Code, Codex, OpenCode, OpenClaw, or any tool that supports skills)
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
```
Typically installed alongside the companion pipeline skill:
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
```
### Claude.ai
1. Download this `skills/mem0-test-integration` folder as a ZIP
2. Go to **Settings > Capabilities > Skills**
3. Click **Upload skill** and select the ZIP
### Claude API (Skills API)
```bash
curl -X POST https://api.anthropic.com/v1/skills \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "mem0-test-integration", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0-test-integration"}'
```
### Preconditions
The skill refuses to start unless all of the following are true:
- `.mem0-integration/` directory exists in the repo root
- Current branch starts with `mem0-integrate/`
- Working tree is clean
- The same API key used during `/mem0-integrate` is exported in the environment
## What This Skill Does *Not* Catch
By design, this skill only catches compile and runtime bugs. Logical errors — memories stored with the wrong scoping, retrieval returning the wrong user's data, filter mismatches — are the human reviewer's responsibility.
## Links
- [Mem0 Documentation](https://docs.mem0.ai)
- [Mem0 GitHub](https://github.com/mem0ai/mem0)
- [API Reference](https://docs.mem0.ai/api-reference)
## License
Apache-2.0
+368
View File
@@ -0,0 +1,368 @@
---
name: mem0-test-integration
description: >
Verify a Mem0 integration produced by /mem0-integrate. Runs in the same
workspace on the same branch (loose coupling) — installs dependencies,
runs the repo's native test suite, then exercises a real end-to-end
smoke flow against the user's API key. Produces a scorecard.
TRIGGER when: user has just run /mem0-integrate and says "verify",
"test the integration", "run /mem0-test-integration", or when a
.mem0-integration/ directory exists and tests have not been run yet
on the current branch.
DO NOT TRIGGER when: the user wants to run general project tests
(defer to the repo's native test command), or when no prior /mem0-integrate
run exists in the current branch (ask them to run /mem0-integrate first).
This skill ONLY catches compile and runtime bugs by design. Logical
integration errors — wrong data stored, wrong time retrieved, wrong
user scoping — are on the human reviewer.
license: Apache-2.0
metadata:
author: mem0ai
version: "0.1.0"
category: ai-memory
tags: "memory, integration, testing, tdd, platform, oss"
coupling: loose
mem0_tested_versions: "mem0ai (PyPI) >=2.0.0,<3.0.0; mem0ai (npm) >=3.0.0,<4.0.0"
---
# mem0-test-integration
Verifies what `/mem0-integrate` produced. Runs in the same workspace,
on the same feature branch. Loose coupling — fast, catches compile and
runtime bugs, does not catch logical errors.
## Canonical sources (use these, not ambient knowledge)
All static checks and smoke-test shapes validate against these URLs.
`WebFetch` each before running step 3.
- Scope-tagged docs index: https://docs.mem0.ai/llms.txt
- OpenAPI (Platform REST): https://docs.mem0.ai/openapi.json
- Published SDK skill (canonical call patterns): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0/SKILL.md
- Vercel AI SDK skill (if the target repo uses `@ai-sdk/*`): https://raw.githubusercontent.com/mem0ai/mem0/main/skills/mem0-vercel-ai-sdk/SKILL.md
- SDK source (cross-check version against frontmatter `mem0_tested_versions`):
- Repo root: https://github.com/mem0ai/mem0
- Python: https://github.com/mem0ai/mem0/tree/main/mem0
- TypeScript: https://github.com/mem0ai/mem0/tree/main/mem0-ts
Read the `Delegated skill:` field in `.mem0-integration/plan.md` — if it
names a skill URL, fetch that skill and use its example blocks as the
reference for both static checks (step 3) and the smoke test (step 5).
## Non-invasiveness contract
Every check in this skill assumes the integration is **additive and
feature-flagged** (see `/mem0-integrate` "Integration principles").
Specifically:
- `product.json` must contain a `feature_flag` field.
- Steps 4–6 run in two passes:
- **Pass A — flag unset.** All pre-existing tests must pass, smoke/E2E
skip. The repo must behave like `main`. Any failure here is a
**hard fail** — do not let the self-heal loop attempt a patch.
- **Pass B — flag set.** New tests must pass, smoke and E2E run.
- If Pass A fails, the scorecard marks `non_invasive: false` and sets
`overall: fail` with a distinct reason code the integrator's heal
loop refuses to touch.
## Preconditions
Refuse to start unless ALL of the following are true:
- `.mem0-integration/` directory exists in the repo root.
- `.mem0-integration/product.json`, `goal.md`, and `plan.md` are readable
and internally consistent (JSON parses, docs non-empty).
- Current branch name begins with `mem0-integrate/` (set by the companion
skill). Prevents accidental runs on unrelated branches.
- Working tree is clean. The skill never modifies source files; any dirty
state means the integration is mid-edit and not ready to verify.
- The same API key the integration used is available in the environment
(`MEM0_API_KEY` for Platform, `OPENAI_API_KEY` for OSS — read which from
`product.json`). Interactive mode asks if missing; CI mode exits 2.
Exit with a written rationale on any precondition failure. Never attempt
to "fix up" state.
## Pipeline
### 1. Read the contract
Load:
- `product.json` → which language, which product (Platform vs OSS), which
mem0 version, `write_site`, `read_site`.
- `plan.md` → the mechanical contract (write pattern, read pattern,
preserved behavior).
- `goal.md` → the intent (displayed in the scorecard only; not tested).
### 2. Install dependencies
Route by language from `product.json`:
| Language | Command |
|---|---|
| Python | `pip install -e .` if editable, else `pip install -r requirements.txt`. Then `pip install mem0ai` if not already present at the pinned version. |
| TypeScript / JavaScript | `npm install` (or `pnpm install` / `yarn install` if detected by lockfile). |
If install fails → exit code 2 with stderr tail. Never move to testing
if dependencies don't resolve.
### 3. Static sanity checks (fast, local, no API calls)
- **Import check**: does the write-site file import the expected Mem0
surface? Authoritative list comes from `## Identify the User's Setup`
in `https://docs.mem0.ai/llms.txt`:
- Platform Python → `from mem0 import MemoryClient`
- Platform TS → `import MemoryClient from "mem0ai"`
- OSS Python → `from mem0 import Memory`
- OSS TS → `import { Memory } from "mem0ai/oss"`
If `plan.md` names a delegated skill (e.g., Vercel AI), use *that*
skill's import signature instead of the list above. Mismatch → fail
with line number.
- **Version check**: installed `mem0ai` version falls in the range from
this skill's `mem0_tested_versions`. Out of range → warn but continue.
- **Type check** (TS tracks only): run `tsc --noEmit` or `tsup --dts`.
Non-zero → fail.
- **Lint** (if the repo has a linter configured): run the repo's own
lint command. Lint failures from this skill's changes → fail; pre-existing
lint failures → surface as a warning.
- **Eager-init check**: grep the `write_site` and `read_site` files (paths
from `product.json`) for `MemoryClient(` or `Memory(` at module scope —
i.e., not inside a function, method, or class body. `MemoryClient()`
validates the API key in `__init__` (network call) and OSS `Memory()`
can eagerly initialize embedding/LLM providers — module-level
instantiation hits the wire on import and breaks Pass A's test
collection whenever the key is unset. Hit → fail with `file:line` and
the lazy-init guidance from `/mem0-integrate` step 8 constraint #7.
### 4. Run the repo's native test suite (two passes)
| Language | Test command (in priority order) |
|---|---|
| Python | `pytest` with the test files from step 5 of the companion skill, else `python -m unittest discover`. |
| TypeScript / JavaScript | `npm test` if defined in package.json; else auto-detect `vitest` or `jest`. |
**Pass A — `feature_flag` unset.** Run the *entire* pre-existing suite
(excluding the new `test_mem0_*` files). **Must be 100% green.** Any
failure here marks `non_invasive: false` in the scorecard and is
a **hard fail** — the integrator's self-heal loop refuses to touch it.
**Pass B — `feature_flag` set** (value from `product.json`). Run the
full suite including the new tests. All must pass.
Isolate integration-introduced failures using `git diff main..HEAD
--name-only`. A test file that exists on `main` and fails only under
the integration branch (flag set *or* unset) counts against the
scorecard regardless of pass. A test file that already failed on `main`
is surfaced as `pre_existing_unrelated` and does not count — but is
still reported so the user can clean it up.
Capture output to `.mem0-integration/test-stdout-flag-off.log` and
`.mem0-integration/test-stdout-flag-on.log`. Scorecard reports pass/fail
per pass.
### 5. Smoke test (real API call, shortest round-trip)
Scripted end-to-end flow tailored to `product.json`. The call shapes
below are the minimal ones; if `plan.md` names a delegated skill, use
*that skill's* minimal example verbatim instead — it is the canonical
shape for the detected stack.
**Platform (Python):**
from mem0 import MemoryClient
c = MemoryClient() # uses MEM0_API_KEY
uid = f"mem0-test-integration-{os.urandom(4).hex()}"
c.add([{"role": "user", "content": "I prefer aisle seats"}], user_id=uid)
hits = c.search("seat preference", user_id=uid)
assert any("aisle" in h.get("memory", "") for h in hits), hits
c.delete_all(user_id=uid) # clean up
**Platform (TS):** same shape with `MemoryClient` from `"mem0ai"`.
**OSS (Python / TS):** uses `Memory()` / `new Memory()` with default config
(OpenAI LLM via `OPENAI_API_KEY`, local Qdrant). If the repo ships a
`docker-compose.yml` with a Qdrant service, the skill starts it first and
tears it down after. If no backing store is reachable → fail with a
clear message naming the fix.
The smoke test always uses a **disposable random user_id** prefixed with
`mem0-test-integration-` so a failed cleanup doesn't pollute the user's
real data. A background tidy step deletes any prefix-matching entries
older than 24 hours on the next run.
Capture output to `.mem0-integration/smoke-stdout.log`.
### 6. E2E integration test (run the app, exercise the flow)
Unit tests + smoke prove the SDK works in isolation. This step is the
real signal: **does memory actually appear in the app's user-visible
output when the integration runs end-to-end?**
Requires `plan.md` to contain an `E2E recipe:` section (authored by
`/mem0-integrate` step 5). If absent → status `skipped` (not `fail`),
note in scorecard that the repo has no runnable entry point.
Recipe fields the skill reads:
- `start` — shell command to launch the app using `$PORT` for any network
port. Run in background with stdout/stderr teed to
`.mem0-integration/e2e-app.log`.
- `ready_probe` — how to detect readiness. `url=... status=...` polls an
HTTP endpoint; `log="..."` waits for a substring in `e2e-app.log`;
`sleep=N` waits N seconds (last resort). 60-second hard timeout.
- `compose_services` — optional. If set, bring them up via
`docker compose up -d <services>` before `start`, tear them down with
`docker compose down` at the end.
- `write_call` — triggers the Mem0 write path exactly once. Output is
captured and surfaced on failure. 60-second hard timeout.
- `write_async_wait_ms` — pause after `write_call` to let async memory
flushes land. Default 0.
- `read_call` — triggers the Mem0 read path. Typically a fresh session
or new request that should surface the stored memory.
- `read_assert` — substring, `regex=...`, or `jsonpath=<expr>=<value>`
that must appear in `read_call`'s stdout. This is the E2E pass gate.
Execution order:
1. Allocate an ephemeral TCP port; export as `PORT`.
2. Set `MEM0_USER_ID` to a disposable `mem0-test-integration-<rand>` value
and export it, so the app can use the same scoping the smoke test does
if the recipe wants cleanup.
3. Bring up `compose_services` if named.
4. Run `start` in the background.
5. Poll `ready_probe` until success or 60s timeout. Timeout → fail.
6. Run `write_call`. Non-zero exit → fail (but continue to cleanup).
7. Sleep `write_async_wait_ms`.
8. Run `read_call`.
9. Evaluate `read_assert` against `read_call`'s stdout. Miss → fail.
10. Cleanup (always, even on failure): SIGTERM the app, SIGKILL after
5s, `docker compose down` if services were started, `delete_all`
memories matching `mem0-test-integration-*` on Platform scenarios.
On any failure, the scorecard includes:
- Last 40 lines of `e2e-app.log`
- Full `write_call` output
- Full `read_call` output
- The expected vs actual for `read_assert`
### 7. Scorecard
Write `.mem0-integration/scorecard.md` and `.mem0-integration/scorecard.json`:
{
"timestamp": "2026-04-20T14:03:11Z",
"branch": "mem0-integrate/remember-user-preferences",
"product": "platform",
"language": "python",
"mem0_version": "2.0.0",
"non_invasive": true,
"feature_flag": "MEM0_ENABLED",
"results": {
"install": {"status": "pass", "duration_ms": 12043},
"static_checks":{"status": "pass", "duration_ms": 812},
"unit_tests_flag_off": {"status": "pass", "duration_ms": 3920, "count": 47,
"reason": "all pre-existing tests green with flag unset"},
"unit_tests_flag_on": {"status": "pass", "duration_ms": 4321, "count": 49},
"smoke_test": {"status": "pass", "duration_ms": 2890, "memory_id": "mem_..."},
"e2e_test": {"status": "pass", "duration_ms": 14200,
"ready_probe_ms": 3100, "write_exit": 0,
"read_assert_matched": true}
},
"friction": {
"dependency_install_retries": 0,
"pre_existing_test_failures": 0,
"warnings": ["mem0ai 2.0.0 pinned; consider 2.0.1 for fix X"]
},
"overall": "pass"
}
The markdown version is human-readable and includes:
- Goal doc + plan doc reprinted at top (so reviewers don't have to hunt).
- Each check with pass/fail + log excerpt.
- Friction summary.
- Verbatim warnings from mem0 SDK (if any — e.g., deprecated field usage).
- **Explicit "NOT checked" section** listing what loose coupling misses:
"Whether the stored data is what the user wants stored. Whether search
runs at the right moment. Whether user_id matches the actual session
scope. Human review required."
### 8. Report + exit
- Print the scorecard path + overall pass/fail to stdout.
- **Do not commit the scorecard files.** They live in `.mem0-integration/`,
which is gitignored. The user can inspect and optionally pin.
- On fail: print the first failing step's log tail (last 40 lines) and
stop. Do not attempt to fix anything.
## Artifacts (all under `.mem0-integration/`)
| File | Purpose | Retention |
|---|---|---|
| `scorecard.md` | Human-readable verdict. | Overwritten per run. |
| `scorecard.json` | Machine-readable verdict. Consumed by the CI scorecard workflow later. | Overwritten per run. |
| `test-stdout-flag-off.log` | Step 4 Pass A (pre-existing suite, flag unset). | Overwritten per run. |
| `test-stdout-flag-on.log` | Step 4 Pass B (full suite, flag set). | Overwritten per run. |
| `smoke-stdout.log` | Full output from step 5. | Overwritten per run. |
| `e2e-app.log` | Background app stdout/stderr from step 6. | Overwritten per run. |
| `e2e-calls.log` | write_call + read_call invocations and outputs. | Overwritten per run. |
## Modes
| Mode | Trigger | Behavior |
|---|---|---|
| Interactive (default) | TTY present, `MEM0_TEST_CI` unset | Asks for missing keys, prints friendly summaries. |
| CI | `MEM0_TEST_CI=1` | Keys must be in env, no prompts, non-zero exit on any fail. JSON scorecard goes to stdout's tail for workflow parsing. |
## Invocation
/mem0-test-integration # interactive, all steps
/mem0-test-integration --ci # non-interactive
/mem0-test-integration --skip-smoke # no API calls, no E2E
/mem0-test-integration --skip-e2e # unit + smoke only (faster CI)
/mem0-test-integration --only-smoke # just smoke
/mem0-test-integration --only-e2e # just E2E (assumes deps installed)
Composition: `--skip-*` can stack (`--skip-smoke --skip-e2e` = static +
unit only, zero API cost). `--only-*` is mutually exclusive with all
other flags.
## Exit codes
| Code | Meaning |
|---|---|
| 0 | All checks passed. |
| 1 | Precondition failed (no `.mem0-integration/`, wrong branch, dirty tree). |
| 2 | Missing env key (CI mode) or dependency install failure. |
| 3 | Static sanity check failed (wrong import, type error). |
| 4 | Unit tests failed (Pass B — integration itself broken). |
| 5 | Smoke test failed. |
| 6 | E2E test failed (ready_probe timeout, write/read call failed, or read_assert miss). |
| 7 | Non-invasiveness violation: Pass A failed (pre-existing tests broke). Integrator's heal loop refuses to touch this. |
| 8 | Internal error (skill bug — report it). |
## Explicitly out of scope
- **Modifying source files.** The skill is read-only against the repo.
If verification exposes a bug, re-run `/mem0-integrate` on the same
goal + plan; do not hand-patch.
- **Fixing broken tests.** Failing unit tests are a signal that the
integration is wrong, not that the tests are wrong. The skill does
not "try a different test."
- **Deep logical correctness.** The E2E step proves "something the user
said earlier comes back later," which is a useful but shallow signal.
It does NOT prove the integration picks the *right* facts to store,
scopes `user_id` correctly across real users, or handles conflict
resolution well. That's human review territory.
- **Self-healing.** This skill never modifies source files. The paired
`/mem0-integrate` skill in its default `--heal` mode consumes the
scorecard produced here and drives its own remediation loop. Exit
code 7 (non-invasiveness violation) is the explicit signal the heal
loop must stop and surface to the user.
- **Cross-branch comparisons.** No `main` baseline diffing. The
scorecard reflects this branch only.
- **Running against production data.** Every smoke test uses a disposable
random user_id and cleans up after. Never touches any other user's data.
+2
View File
@@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key"
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=skill-mem0
> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. The human can claim later with `mem0 init --email <your-email>`.
## Step 2: Initialize the client
**Python:**
+838
View File
@@ -0,0 +1,838 @@
from __future__ import annotations
import json
import os
import subprocess
import threading
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "oss-to-platform-migrate.sh"
class MigrationHTTPServer:
def __init__(
self,
*,
ping_emails: dict[str, str] | None = None,
verify_api_key: str = "verified-key",
verify_status: int = 200,
qdrant_api_key: str = "qdrant-key",
qdrant_collection: str = "mem0",
qdrant_pages: list[dict[str, Any]] | None = None,
platform_memories: list[dict[str, Any]] | None = None,
) -> None:
self.ping_emails = ping_emails or {}
self.verify_api_key = verify_api_key
self.verify_status = verify_status
self.qdrant_api_key = qdrant_api_key
self.qdrant_collection = qdrant_collection
self.qdrant_pages = qdrant_pages or [{"points": [], "next_page_offset": None}]
self.platform_memories = platform_memories or []
self.requests: list[dict[str, Any]] = []
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
self.url = f"http://127.0.0.1:{self._server.server_port}"
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
def __enter__(self) -> "MigrationHTTPServer":
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
def _handler(self) -> type[BaseHTTPRequestHandler]:
owner = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format: str, *_args: object) -> None:
return
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b""
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _record(self, body: dict[str, Any] | None = None) -> None:
owner.requests.append(
{
"method": self.command,
"path": self.path,
"headers": dict(self.headers),
"body": body or {},
}
)
def do_GET(self) -> None:
self._record()
if self.path == "/v1/ping/":
auth = self.headers.get("Authorization", "")
token = auth.removeprefix("Token ")
email = owner.ping_emails.get(token)
if email:
self._send_json(200, {"user_email": email})
else:
self._send_json(401, {"detail": "Invalid token"})
return
if self.path == f"/collections/{owner.qdrant_collection}":
if self.headers.get("api-key") != owner.qdrant_api_key:
self._send_json(401, {"status": {"error": "unauthorized"}})
else:
self._send_json(200, {"result": {"status": "green"}, "status": "ok"})
return
self._send_json(404, {"detail": "Not found"})
def do_POST(self) -> None:
body = self._read_json()
self._record(body)
parsed = urlparse(self.path)
if self.path == "/posthog":
self._send_json(200, {"ok": True})
return
if self.path == "/api/v1/auth/email_code/":
self._send_json(200, {"sent": True})
return
if self.path == "/api/v1/auth/email_code/verify/":
if owner.verify_status != 200:
self._send_json(owner.verify_status, {"error": "bad verification code"})
else:
self._send_json(200, {"api_key": owner.verify_api_key})
return
if self.path == f"/collections/{owner.qdrant_collection}/points/scroll":
if self.headers.get("api-key") != owner.qdrant_api_key:
self._send_json(401, {"status": {"error": "unauthorized"}})
return
offset = body.get("offset")
page_index = int(offset) if offset is not None else 0
page = owner.qdrant_pages[page_index]
self._send_json(
200,
{
"result": {
"points": page["points"],
"next_page_offset": page.get("next_page_offset"),
},
"status": "ok",
},
)
return
if parsed.path == "/v3/memories/":
query = parse_qs(parsed.query)
page = int(query.get("page", ["1"])[0])
page_size = int(query.get("page_size", ["100"])[0])
filters = body.get("filters") if isinstance(body.get("filters"), dict) else {}
filtered = owner.platform_memories
for key in ("user_id", "agent_id", "run_id"):
if key in filters:
filtered = [memory for memory in filtered if memory.get(key) == filters[key]]
start = (page - 1) * page_size
end = start + page_size
page_results = filtered[start:end]
next_url = (
f"{owner.url}/v3/memories/?page={page + 1}&page_size={page_size}"
if end < len(filtered)
else None
)
self._send_json(
200,
{
"count": len(filtered),
"next": next_url,
"previous": None,
"results": page_results,
},
)
return
if parsed.path == "/v3/memories/add/":
memory_id = f"platform-{len(owner.platform_memories) + 1}"
message = (body.get("messages") or [{}])[0]
memory = {
"id": memory_id,
"memory": message.get("content"),
"metadata": body.get("metadata"),
"user_id": body.get("user_id"),
"agent_id": body.get("agent_id"),
"run_id": body.get("run_id"),
}
owner.platform_memories.append(memory)
self._send_json(
200,
{
"message": "Memories stored successfully",
"status": "SUCCEEDED",
"event_id": "event-1",
"results": [{"id": memory_id, "data": {"memory": message.get("content")}, "event": "ADD"}],
},
)
return
self._send_json(404, {"detail": "Not found"})
return Handler
def alias_marker(anon_id: str, email: str) -> str:
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
def write_config(mem0_dir: Path, data: dict[str, Any]) -> None:
mem0_dir.mkdir(parents=True, exist_ok=True)
(mem0_dir / "config.json").write_text(json.dumps(data), encoding="utf-8")
def read_config(mem0_dir: Path) -> dict[str, Any]:
return json.loads((mem0_dir / "config.json").read_text(encoding="utf-8"))
def run_migration_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
raw_config: str | None = None,
) -> tuple[subprocess.CompletedProcess[str], Path]:
mem0_dir = tmp_path / "mem0"
if config is not None:
write_config(mem0_dir, config)
if raw_config is not None:
mem0_dir.mkdir(parents=True, exist_ok=True)
(mem0_dir / "config.json").write_text(raw_config, encoding="utf-8")
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
["bash", str(SCRIPT), "--auth-only", "--base-url", server.url, *args],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir
def run_export_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
qdrant_api_key: str = "qdrant-key",
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
mem0_dir = tmp_path / "mem0"
output_path = tmp_path / "export.json"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"QDRANT_API_KEY": qdrant_api_key,
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--export-only",
"--qdrant-url",
server.url,
"--qdrant-collection",
server.qdrant_collection,
"--output",
str(output_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir, output_path
def run_import_script(
tmp_path: Path,
server: MigrationHTTPServer,
input_path: Path,
*args: str,
config: dict[str, Any] | None = None,
api_key: str = "import-key",
) -> tuple[subprocess.CompletedProcess[str], Path]:
mem0_dir = tmp_path / "mem0"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"MEM0_API_KEY": api_key,
}
)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--import-only",
"--base-url",
server.url,
"--input",
str(input_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir
def run_full_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
qdrant_api_key: str = "qdrant-key",
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
mem0_dir = tmp_path / "mem0"
output_path = tmp_path / "full-export.json"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"QDRANT_API_KEY": qdrant_api_key,
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--base-url",
server.url,
"--qdrant-url",
server.url,
"--qdrant-collection",
server.qdrant_collection,
"--output",
str(output_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir, output_path
def posthog_events(server: MigrationHTTPServer) -> list[dict[str, Any]]:
return [request["body"] for request in server.requests if request["path"] == "/posthog"]
def test_existing_api_key_authenticates_and_stitches_ids(tmp_path: Path) -> None:
config = {
"user_id": "oss-123",
"platform": {"api_key": "stored-key", "base_url": "https://api.mem0.ai"},
"telemetry": {"anonymous_id": "cli-456"},
}
with MigrationHTTPServer(ping_emails={"stored-key": "bob@example.com"}) as server:
result, mem0_dir = run_migration_script(tmp_path, server, "--yes", config=config)
assert result.returncode == 0, result.stderr
assert "Authenticated as bob@example.com" in result.stdout
assert not any(request["path"] == "/api/v1/auth/email_code/verify/" for request in server.requests)
updated = read_config(mem0_dir)
assert updated["platform"] == config["platform"]
assert alias_marker("oss-123", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
assert alias_marker("cli-456", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.started" in event_names
assert "oss.migrate.authenticated" in event_names
assert event_names.count("$identify") == 2
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
assert authenticated["distinct_id"] == "bob@example.com"
assert authenticated["properties"]["local_anonymous_id"] == "oss-123"
assert authenticated["properties"]["authenticated_email"] == "bob@example.com"
def test_email_code_authenticates_without_persisting_credentials(tmp_path: Path) -> None:
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"Alice@Example.COM",
"--code",
"123456",
)
assert result.returncode == 0, result.stderr
assert "Authenticated as alice@example.com" in result.stdout
verify_request = next(request for request in server.requests if request["path"] == "/api/v1/auth/email_code/verify/")
assert verify_request["body"] == {"email": "alice@example.com", "code": "123456"}
assert not any(request["path"] == "/api/v1/auth/email_code/" for request in server.requests)
updated = read_config(mem0_dir)
assert "api_key" not in updated.get("platform", {})
assert "user_email" not in updated.get("platform", {})
assert updated["user_id"]
assert alias_marker(updated["user_id"], "alice@example.com") in updated["telemetry"]["aliased_pairs"]
events = posthog_events(server)
assert [event["event"] for event in events].count("$identify") == 1
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
assert authenticated["properties"]["auth_method"] == "email_code"
def test_invalid_stored_key_falls_back_to_email_code(tmp_path: Path) -> None:
config = {
"user_id": "oss-fallback",
"platform": {"api_key": "bad-key", "base_url": "https://api.mem0.ai"},
}
with MigrationHTTPServer(ping_emails={"verified-key": "new@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"new@example.com",
"--code",
"123456",
config=config,
)
assert result.returncode == 0, result.stderr
assert "Stored Mem0 Platform API key is invalid or expired" in result.stdout
assert "Authenticated as new@example.com" in result.stdout
ping_tokens = [
request["headers"]["Authorization"].removeprefix("Token ")
for request in server.requests
if request["path"] == "/v1/ping/"
]
assert ping_tokens == ["bad-key", "verified-key"]
updated = read_config(mem0_dir)
assert updated["platform"] == config["platform"]
assert alias_marker("oss-fallback", "new@example.com") in updated["telemetry"]["aliased_pairs"]
def test_email_code_failure_reports_failed_telemetry(tmp_path: Path) -> None:
with MigrationHTTPServer(verify_status=400) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"fail@example.com",
"--code",
"bad",
)
assert result.returncode == 1
assert "Verification failed: bad verification code" in result.stderr
updated = read_config(mem0_dir)
assert "telemetry" not in updated or "aliased_pairs" not in updated["telemetry"]
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.started" in event_names
assert "oss.migrate.failed" in event_names
assert "$identify" not in event_names
failed = next(event for event in events if event["event"] == "oss.migrate.failed")
assert "Verification failed" in failed["properties"]["error"]
def test_malformed_config_does_not_crash_and_authenticates(tmp_path: Path) -> None:
with MigrationHTTPServer(ping_emails={"verified-key": "malformed@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"malformed@example.com",
"--code",
"123456",
raw_config="{not valid json",
)
assert result.returncode == 0, result.stderr
assert "Authenticated as malformed@example.com" in result.stdout
updated = read_config(mem0_dir)
assert updated["user_id"]
assert alias_marker(updated["user_id"], "malformed@example.com") in updated["telemetry"]["aliased_pairs"]
def test_weird_telemetry_shape_does_not_crash(tmp_path: Path) -> None:
config = {"user_id": "oss-weird-telemetry", "telemetry": "not-an-object"}
with MigrationHTTPServer(ping_emails={"verified-key": "weird@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"weird@example.com",
"--code",
"123456",
config=config,
)
assert result.returncode == 0, result.stderr
updated = read_config(mem0_dir)
assert isinstance(updated["telemetry"], dict)
assert alias_marker("oss-weird-telemetry", "weird@example.com") in updated["telemetry"]["aliased_pairs"]
def test_missing_python3_prints_clear_shell_error(tmp_path: Path) -> None:
env = os.environ.copy()
env["PATH"] = str(tmp_path)
result = subprocess.run(
["/bin/bash", str(SCRIPT), "--help"],
capture_output=True,
text=True,
env=env,
timeout=20,
check=False,
)
assert result.returncode == 1
assert "python3 is required to run the Mem0 migration" in result.stderr
def test_curl_piped_help_works() -> None:
result = subprocess.run(
["bash", "-c", f"curl -fsSL file://{SCRIPT} | bash -s -- --help"],
capture_output=True,
text=True,
timeout=20,
check=False,
)
assert result.returncode == 0, result.stderr
assert "Migrate Python OSS hosted-Qdrant memories" in result.stdout
def test_export_qdrant_memories_to_json_without_vectors_or_api_key(tmp_path: Path) -> None:
pages = [
{
"points": [
{
"id": "point-1",
"vector": [0.1, 0.2],
"payload": {
"data": "User likes dark mode",
"hash": "hash-1",
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z",
"user_id": "alice",
"agent_id": "agent-1",
"run_id": "run-1",
"actor_id": "actor-1",
"role": "user",
"topic": "preferences",
"text_lemmatized": "user like dark mode",
},
}
],
"next_page_offset": 1,
},
{
"points": [
{
"id": "point-2",
"vector": [0.3, 0.4],
"payload": {
"data": "User prefers concise answers",
"hash": "hash-2",
"user_id": "alice",
"metadata_note": "extra",
},
}
],
"next_page_offset": None,
},
]
with MigrationHTTPServer(qdrant_pages=pages) as server:
result, _mem0_dir, output_path = run_export_script(
tmp_path,
server,
"--user-id",
"alice",
"--qdrant-page-size",
"1",
config={"user_id": "oss-export-user"},
)
assert result.returncode == 0, result.stderr
assert "Exported 2 memories" in result.stdout
artifact = json.loads(output_path.read_text(encoding="utf-8"))
assert artifact["kind"] == "mem0_oss_qdrant_export"
assert artifact["source"]["sdk"] == "python"
assert artifact["source"]["vector_store"] == "qdrant"
assert artifact["source"]["storage"] == "hosted"
assert artifact["source"]["filters"]["user_id"] == "alice"
assert artifact["record_count"] == 2
assert artifact["local_anonymous_id"] == "oss-export-user"
first = artifact["records"][0]
assert first["id"] == "point-1"
assert first["memory"] == "User likes dark mode"
assert first["hash"] == "hash-1"
assert first["user_id"] == "alice"
assert first["agent_id"] == "agent-1"
assert first["run_id"] == "run-1"
assert first["actor_id"] == "actor-1"
assert first["role"] == "user"
assert first["metadata"] == {"topic": "preferences"}
assert all("vector" not in record for record in artifact["records"])
assert "qdrant-key" not in output_path.read_text(encoding="utf-8")
scroll_requests = [request for request in server.requests if request["path"].endswith("/points/scroll")]
assert len(scroll_requests) == 2
assert scroll_requests[0]["body"]["with_vector"] is False
assert scroll_requests[0]["body"]["filter"] == {"must": [{"key": "user_id", "match": {"value": "alice"}}]}
assert scroll_requests[1]["body"]["offset"] == 1
def test_export_requires_scope_or_all(tmp_path: Path) -> None:
with MigrationHTTPServer() as server:
result, _mem0_dir, output_path = run_export_script(tmp_path, server)
assert result.returncode == 1
assert "Export requires --user-id, --agent-id, --run-id, or --all" in result.stderr
assert not output_path.exists()
assert not any(request["path"].endswith("/points/scroll") for request in server.requests)
def test_export_all_uses_no_qdrant_filter(tmp_path: Path) -> None:
with MigrationHTTPServer(qdrant_pages=[{"points": [], "next_page_offset": None}]) as server:
result, _mem0_dir, output_path = run_export_script(tmp_path, server, "--all")
assert result.returncode == 0, result.stderr
artifact = json.loads(output_path.read_text(encoding="utf-8"))
assert artifact["record_count"] == 0
assert artifact["records"] == []
scroll_request = next(request for request in server.requests if request["path"].endswith("/points/scroll"))
assert "filter" not in scroll_request["body"]
def test_export_invalid_qdrant_credentials_fail_clearly(tmp_path: Path) -> None:
with MigrationHTTPServer(qdrant_api_key="correct-key") as server:
result, _mem0_dir, output_path = run_export_script(
tmp_path,
server,
"--user-id",
"alice",
qdrant_api_key="wrong-key",
)
assert result.returncode == 1
assert "Qdrant authentication failed" in result.stderr
assert not output_path.exists()
def test_import_platform_memories_from_export_json(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
input_path.write_text(
json.dumps(
{
"source": {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"},
"records": [
{
"id": "local-1",
"memory": "User likes barbecue",
"hash": "hash-1",
"created_at": "2026-05-07T00:00:00Z",
"user_id": "alice",
"metadata": {"topic": "food"},
}
],
}
),
encoding="utf-8",
)
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 1" in result.stdout
assert "Failed: 0" in result.stdout
add_request = next(request for request in server.requests if request["path"] == "/v3/memories/add/")
body = add_request["body"]
assert body["messages"] == [{"role": "user", "content": "User likes barbecue"}]
assert body["user_id"] == "alice"
assert body["infer"] is False
assert body["source"] == "migration"
assert body["timestamp"] == 1778112000
assert body["metadata"]["topic"] == "food"
assert body["metadata"]["mem0_migration_source"] == "python_oss_qdrant"
assert body["metadata"]["mem0_migration_collection"] == "mem0_test"
assert body["metadata"]["mem0_migration_local_id"] == "local-1"
assert body["metadata"]["mem0_migration_local_hash"] == "hash-1"
events = posthog_events(server)
assert "oss.migrate.completed" in [event["event"] for event in events]
def test_import_skips_existing_identical_memory(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
record = {"id": "local-1", "memory": "User likes barbecue", "hash": "hash-1", "user_id": "alice"}
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
existing = [
{
"id": "platform-1",
"memory": "User likes barbecue",
"user_id": "alice",
"metadata": {
"mem0_migration_import_key": import_key,
"mem0_migration_local_hash": "hash-1",
},
}
]
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 0" in result.stdout
assert "Skipped existing identical: 1" in result.stdout
assert "Changed existing: 0" in result.stdout
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
def test_import_reports_changed_existing_without_update_or_add(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
record = {"id": "local-1", "memory": "User likes brisket", "hash": "hash-new", "user_id": "alice"}
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
existing = [
{
"id": "platform-1",
"memory": "User likes barbecue",
"user_id": "alice",
"metadata": {
"mem0_migration_import_key": import_key,
"mem0_migration_local_hash": "hash-old",
},
}
]
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 0" in result.stdout
assert "Skipped existing identical: 0" in result.stdout
assert "Changed existing: 1" in result.stdout
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
review_path_line = next(line for line in result.stdout.splitlines() if line.startswith("Review file: "))
review_path = Path(review_path_line.removeprefix("Review file: "))
review = json.loads(review_path.read_text(encoding="utf-8"))
assert review["records"][0]["status"] == "changed_existing"
assert review["records"][0]["platform_memory_id"] == "platform-1"
def test_full_flow_auth_export_and_imports_memories(tmp_path: Path) -> None:
qdrant_pages = [
{
"points": [
{
"id": "point-1",
"payload": {
"data": "User likes barbecue",
"hash": "hash-1",
"created_at": "2026-05-07T00:00:00Z",
"user_id": "alice",
},
}
],
"next_page_offset": None,
}
]
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}, qdrant_pages=qdrant_pages) as server:
result, _mem0_dir, output_path = run_full_script(
tmp_path,
server,
"--email",
"alice@example.com",
"--code",
"123456",
"--user-id",
"alice",
)
assert result.returncode == 0, result.stderr
assert "Phase 1/3: Authenticate with Mem0 Platform" in result.stdout
assert "Phase 2/3: Export Python OSS memories from hosted Qdrant" in result.stdout
assert "Phase 3/3: Import memories into Mem0 Platform" in result.stdout
assert "Imported: 1" in result.stdout
assert output_path.exists()
assert any(request["path"] == "/v3/memories/add/" for request in server.requests)
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.authenticated" in event_names
assert "oss.migrate.completed" in event_names

Some files were not shown because too many files have changed in this diff Show More