Compare commits

..

6 Commits

Author SHA1 Message Date
Mgeeeek d50121cdf6 Merge remote-tracking branch 'origin/main' into fix/plugin-hook-cleanup 2026-05-09 19:09:20 +05:30
Mgeeeek 8b36f7c50e fix(plugin): expire stale state captures + document plugin-update restart
Memory expiration + recency
  Hook-side captures (session_state in on_pre_compact.py and
  compact_summary in capture_compact_summary.py) now set
  expiration_date = today + 90 days. These types describe a single
  moment of project state and become stale clutter after a quarter.
  Durable types written by the agent (decision, anti_pattern,
  convention, user_preference, task_learning, environmental) stay
  unexpired by design -- a decision from a year ago is still a
  decision.

  Skill updated with two new sections:
    - "Expiration: high-churn vs durable" -- table mapping each
      metadata.type to whether it should carry an expiration_date.
    - "Recency filter on recall" -- agent should layer a
      created_at >= <90 days ago> filter on recall when the user
      asks about *current* state, and skip it when looking for
      durable facts.

Plugin-update / MCP reconnect
  When the plugin updates, the MCP server connection in the running
  client goes stale -- there's no in-process reconnection path from
  inside the plugin. Documenting the workaround is the realistic
  fix: README now has an "Updating the plugin" section explaining
  /restart (Claude Code), quit-and-relaunch (Cursor), restart
  (Codex). The auth header re-reads MEM0_API_KEY from the new
  shell, so users don't have to re-enter the key.
2026-05-08 21:39:28 +05:30
Mgeeeek 2946fc9da5 feat(plugin): coding-focused custom-category setup script
mem0 auto-tags every memory with one or more `categories` from a
project-level list. The default list is consumer-oriented (food,
hobbies, music, ...), which produces meaningless tags for code
workflows.

Per-request `custom_categories` is not supported on the managed API,
so the fix has to be a one-time project-level configuration. New
script `setup_coding_categories.py` does this:

  - Dry-run by default: prints current vs proposed taxonomy, exits.
  - `--apply` flag actually calls `project.update(custom_categories=...)`.
  - Falls back gracefully when mem0ai SDK isn't installed or
    MEM0_API_KEY is missing/invalid (friendly error, no stack trace).

Recommended taxonomy:
  architecture_decisions, anti_patterns, task_learnings,
  tooling_setup, bug_fixes, coding_conventions, user_preferences

Skill clarification: `metadata.type` (agent-applied explicit tag,
used in filters) and `categories` (platform-applied auto-tag, used
in dashboards) are complementary; agent should keep using
`metadata.type` and not try to set `categories` per-request.

README: section under Step 2 explaining how to run the script.
2026-05-08 21:30:42 +05:30
Mgeeeek 0fd7cb9c5a fix(plugin): preserve structure on PreCompact + capture compact summary
Two changes to the compaction flow:

PreCompact: agent stores its summary with infer=False
  on_pre_compact.sh now instructs the agent to call add_memory with
  infer=False. The agent has full conversation context and writes a
  structured summary; without infer=False the platform runs a second
  LLM extraction pass over that summary, losing structure and
  producing fragmented facts. With it, the structured text is
  preserved verbatim. Same guidance added to mem0-mcp/SKILL.md so
  the agent applies it to other already-extracted writes (decisions,
  anti-patterns, conventions).

SessionStart-compact: capture the platform-generated summary
  PreCompact fires BEFORE Claude Code generates its compact summary,
  so the summary is unreachable from that hook. SessionStart-compact
  fires AFTER, so a new background helper (capture_compact_summary.py)
  reads the transcript at that point, finds the isCompactSummary=true
  entry, and stores its content as a separate memory tagged
  metadata.type=compact_summary. The post-compaction bootstrap text
  now tells the agent to layer its recall queries across
  session_state (its own pre-compact summary), compact_summary (the
  platform's condensed view), and topical types like decision /
  anti_pattern.

Net effect: three complementary memory types from one session
boundary, none of which used to be filterable or even captured
correctly.
2026-05-08 21:13:00 +05:30
Mgeeeek ed15fc1386 fix(plugin): deterministic mem0 user_id resolution
Hooks previously fell back to $USER when MEM0_USER_ID wasn't set,
producing a different user_id on every machine for the same person.
Result: a single account with memories scattered across many user
buckets, none of which can see each other.

Resolution priority (same in bash and python):
  1. MEM0_USER_ID env var (explicit override)
  2. ~/.mem0/identity.json cache (pinned to MEM0_API_KEY fingerprint)
  3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
  4. Fallback: $USER, else "default"

Same MEM0_API_KEY across machines now yields the same user_id without
the user having to set MEM0_USER_ID by hand on every laptop.

Resolver shipped as two tiny files instead of a shared module:
  _identity.sh -- sourced by bash hooks, exports MEM0_RESOLVED_USER_ID
  _identity.py -- imported by on_pre_compact.py, exposes resolve_user_id()

Hook integration:
  on_user_prompt.sh -- sources resolver, USER_ID interpolated into rubric
  on_session_start.sh -- emits an "Active user_id: <X>" header before the
    bootstrap text, so the agent's MCP search_memories/add_memory calls
    use the same bucket the hooks write to (closes the agent-side half
    of the symptom)
  on_pre_compact.py -- replaces inline env lookup with resolve_user_id()

Existing memories under previous $USER values are not auto-migrated.
The cache file regenerates on key rotation (fingerprint mismatch).
2026-05-08 21:02:24 +05:30
Mgeeeek 8cb4f09ded fix(plugin): mem0-plugin hook cleanup
Six independent fixes that make hook behaviour deterministic, debuggable,
and free of silent footguns.

Persist session_id in stored memories
  on_pre_compact.py now extracts session_id from the hook input and writes
  it under metadata.session_id, enabling search-by-session.

Skip SessionStart bootstrap when MEM0_API_KEY is missing
  on_session_start.sh exits 0 early instead of emitting bootstrap text that
  instructs the agent to call mem0 MCP tools that would all fail without
  a key.

Tighten block_memory_write.sh regex
  The old `*/memory/*.md` pattern blocked legitimate paths like
  docs/memory/architecture.md. New pattern blocks `*/MEMORY.md` and
  `*/.claude/memory/*` only -- the actual surfaces we want to protect.

Opt-in debug logging via MEM0_DEBUG=1
  Each hook (bash + python) redirects stderr / adds a FileHandler to
  ~/.mem0/hooks.log when MEM0_DEBUG is set. No helper module, no
  rotation -- KISS. Default behaviour is unchanged.

Drop the duplicate PreCompact registration
  hooks.json and cursor-hooks.json had on_pre_compact.sh AND
  on_pre_compact.py registered on PreCompact, producing duplicate
  session-state writes. Drop the python entry. The python script stays
  alive -- on_stop.sh still spawns it as a session-end safety net.

CI nudge
  Adds a single comment line to pyproject.toml because the repo's
  ci.yml path filters exclude mem0-plugin/-only PRs but the
  build_mem0/build_embedchain status checks are required by branch
  protection. Track the proper fix (path-conditional CI) as a
  follow-up.
2026-05-07 21:53:49 +05:30
55 changed files with 42 additions and 2635 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.2"
"version": "0.1.1"
}
]
}
+2 -5
View File
@@ -47,7 +47,7 @@
| Benchmark | Old | New | Tokens | Latency p50 |
| --- | --- | --- | --- | --- |
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
| **LongMemEval** | 67.8 | **94.8** | 6.8K | 1.09s |
| **LongMemEval** | 67.8 | **93.4** | 6.8K | 1.09s |
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
@@ -58,13 +58,12 @@ 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
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
- **93.4 on LongMemEval** -- +26 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)
@@ -86,8 +85,6 @@ 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 |
+2 -4
View File
@@ -503,7 +503,7 @@
},
{
"name": "init",
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"usage": "mem0 init [OPTIONS]",
"needsBackend": false,
"needsConfig": false,
@@ -516,9 +516,7 @@
{ "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": "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)." }
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
]
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/cli",
"version": "0.2.5",
"version": "0.2.4",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
-32
View File
@@ -1,32 +0,0 @@
/**
* 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;
}
+2 -34
View File
@@ -3,7 +3,7 @@
*/
import type { PlatformConfig } from "../config.js";
import { captureNotice, isAgentMode } from "../state.js";
import { isAgentMode } from "../state.js";
import { CLI_VERSION } from "../version.js";
import {
APIError,
@@ -90,39 +90,7 @@ export class PlatformBackend implements Backend {
if (resp.status === 204) {
return {};
}
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;
return resp.json();
}
async add(
-285
View File
@@ -1,285 +0,0 @@
/**
* 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
@@ -1,75 +0,0 @@
/**
* 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}.`);
}
+1 -167
View File
@@ -21,8 +21,6 @@ import {
redactKey,
saveConfig,
} from "../config.js";
import { formatJsonEnvelope } from "../output.js";
import { isAgentMode } from "../state.js";
const { brand, dim } = colors;
@@ -35,65 +33,6 @@ 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,
@@ -257,7 +196,6 @@ 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> {
@@ -311,35 +249,14 @@ 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) {
@@ -351,84 +268,6 @@ 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 &&
@@ -485,7 +324,6 @@ 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";
@@ -501,15 +339,13 @@ 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>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
"Usage: mem0 init --api-key <key> [--user-id <id>]",
);
process.exit(1);
}
@@ -520,7 +356,6 @@ 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);
@@ -568,7 +403,6 @@ 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,12 +21,6 @@ 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 {
@@ -60,11 +54,6 @@ export function createDefaultConfig(): Mem0Config {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
userEmail: "",
agentMode: false,
createdVia: "",
agentCaller: "",
claimedAt: "",
defaultUserId: "",
},
telemetry: {
anonymousId: "",
@@ -90,11 +79,6 @@ 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 ?? "";
@@ -134,11 +118,6 @@ 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,
@@ -147,20 +126,6 @@ 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 {
+4 -70
View File
@@ -13,12 +13,7 @@ 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 {
isAgentMode,
setAgentMode,
setCurrentCommand,
takeNotice,
} from "./state.js";
import { setAgentMode } from "./state.js";
import { captureEvent } from "./telemetry.js";
import { CLI_VERSION } from "./version.js";
@@ -146,11 +141,6 @@ 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}`);
@@ -159,7 +149,7 @@ program
.option("--json", "Output as JSON for agent/programmatic use.")
.option(
"--agent",
"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.",
"Output as JSON for agent/programmatic use. (alias: --json)",
)
.usage("<command> [options]")
.helpOption("--help", "Show this message and exit.")
@@ -176,14 +166,6 @@ 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}`,
@@ -211,32 +193,11 @@ 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\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",
"\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",
)
.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,
@@ -244,24 +205,9 @@ 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
@@ -823,16 +769,4 @@ program
// ── Entrypoint ────────────────────────────────────────────────────────────
// 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();
});
program.parse();
-16
View File
@@ -5,7 +5,6 @@
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;
@@ -245,15 +244,6 @@ 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));
}
@@ -366,12 +356,6 @@ 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
@@ -1,120 +0,0 @@
/**
* 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,7 +5,6 @@
let _agentMode = false;
let _currentCommand = "";
let _pendingNotice = "";
export function isAgentMode(): boolean {
return _agentMode;
@@ -22,19 +21,3 @@ 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,9 +115,6 @@ 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,
@@ -126,7 +123,6 @@ 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
@@ -1,141 +0,0 @@
/**
* 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
@@ -1,168 +0,0 @@
/**
* 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.5"
version = "0.2.4"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
-36
View File
@@ -1,36 +0,0 @@
"""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
+5 -71
View File
@@ -237,14 +237,6 @@ 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)
@@ -859,19 +851,6 @@ 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.
@@ -880,38 +859,10 @@ 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,
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)
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
# (entity_app registered at module level, below sub-group definitions)
@@ -1247,28 +1198,11 @@ def main() -> None:
import sys
# Allow --json/--agent anywhere in the command line (not just before subcommand).
# 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):
_json_flags = {"--json", "--agent"}
if any(a in _json_flags for a in sys.argv[1:]):
from mem0_cli.state import set_agent_mode
set_agent_mode(True)
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
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")
app()
+2 -21
View File
@@ -30,7 +30,7 @@ class PlatformBackend(Backend):
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
from mem0_cli.state import capture_notice, is_agent_mode
from mem0_cli.state import 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,26 +48,7 @@ class PlatformBackend(Backend):
resp.raise_for_status()
if resp.status_code == 204:
return {}
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
return resp.json()
def add(
self,
+2 -4
View File
@@ -87,12 +87,10 @@ 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:[/] {escape(str(message))}")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
if hint:
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
console.print(f" [{DIM_COLOR}]{hint}[/]")
def print_warning(console: Console, message: str) -> None:
@@ -1,239 +0,0 @@
"""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()
@@ -1,75 +0,0 @@
"""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}.")
+1 -149
View File
@@ -103,25 +103,6 @@ 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,
@@ -201,143 +182,21 @@ 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()
@@ -383,7 +242,6 @@ 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"
)
@@ -400,8 +258,6 @@ 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():
@@ -409,7 +265,7 @@ def run_init(
print_error(
err_console,
"Non-interactive terminal detected and --api-key is required.",
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
)
raise typer.Exit(1)
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
@@ -417,7 +273,6 @@ 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)
@@ -458,7 +313,6 @@ 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"
)
@@ -477,7 +331,6 @@ 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)
@@ -517,7 +370,6 @@ 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,14 +28,6 @@ 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
@@ -91,11 +83,6 @@ 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", "")
@@ -149,11 +136,6 @@ 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,
@@ -165,19 +147,6 @@ 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,16 +229,6 @@ 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))
@@ -333,15 +323,6 @@ 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
@@ -1,119 +0,0 @@
"""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,7 +4,6 @@ from __future__ import annotations
_agent_mode: bool = False
_current_command: str = ""
_pending_notice: str = ""
def is_agent_mode() -> bool:
@@ -23,23 +22,3 @@ 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
+2 -4
View File
@@ -87,6 +87,7 @@ 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()
@@ -106,9 +107,6 @@ 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,
@@ -117,7 +115,7 @@ def capture_event(
"source": "CLI",
"language": "python",
"cli_version": __version__,
"agent_mode": bool(config.platform.agent_mode),
"agent_mode": is_agent_mode(),
"python_version": sys.version,
"os": sys.platform,
"os_version": platform.version(),
-157
View File
@@ -1,157 +0,0 @@
"""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
@@ -1,206 +0,0 @@
"""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,5 +5,3 @@ 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,3 +83,4 @@ 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,3 +64,4 @@ 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,7 +49,6 @@ 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"
},
+1 -16
View File
@@ -4,21 +4,6 @@ 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**
@@ -127,4 +112,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>
+1 -11
View File
@@ -4,17 +4,6 @@ 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:**
@@ -312,3 +301,4 @@ mode: "wide"
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
</Update>
+1 -18
View File
@@ -55,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, 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()` `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()` 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))
@@ -1323,23 +1323,6 @@ 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 <5s 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,10 +156,6 @@ 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:
@@ -255,4 +251,4 @@ For the full list of filter logic, comparison operators, and optional search par
icon="rocket"
href="/cookbooks/operations/support-inbox"
/>
</CardGroup>
</CardGroup>
+2 -3
View File
@@ -72,8 +72,7 @@
"platform/features/entity-scoped-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
"platform/features/custom-categories",
"platform/features/temporal-reasoning"
"platform/features/custom-categories"
]
},
{
@@ -1145,4 +1144,4 @@
"destination": "/introduction"
}
]
}
}
-7
View File
@@ -11,12 +11,6 @@
- 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`
@@ -191,7 +185,6 @@ 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.
+2 -3
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, entities that appear across multiple memories, and time-aware queries (via Temporal Reasoning). The response shape is unchanged:
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:
```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. Temporal signals are applied internally during ranking and are not returned as extra client-facing fields.
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.
## API Changes
@@ -289,7 +289,6 @@ 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
+2 -17
View File
@@ -419,7 +419,7 @@
},
"results": {
"type": "array",
"description": "Array of results produced by the event. For add events, this confirms the write completed; temporal reasoning enrichment runs asynchronously by default."
"description": "Array of results produced by the event."
},
"created_at": {
"type": "string",
@@ -2071,7 +2071,7 @@
"memories"
],
"summary": "Search memories (V3)",
"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.",
"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.",
"operationId": "memories_search_v3",
"requestBody": {
"required": true,
@@ -2112,21 +2112,6 @@
"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."
}
}
},
@@ -1,145 +0,0 @@
---
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" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.1.2",
"version": "0.1.1",
"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",
-12
View File
@@ -2,18 +2,6 @@
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.**
-2
View File
@@ -46,8 +46,6 @@ 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:**
-2
View File
@@ -4,5 +4,3 @@ __version__ = importlib.metadata.version("mem0ai")
from mem0.client.main import AsyncMemoryClient, MemoryClient # noqa
from mem0.memory.main import AsyncMemory, Memory # noqa
-12
View File
@@ -19,18 +19,6 @@ 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)
-1
View File
@@ -155,4 +155,3 @@ 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).
+1 -17
View File
@@ -38,23 +38,7 @@ Both packages install a `mem0` binary with identical commands, options, and outp
## Setup
**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):**
**Interactive wizard:**
```bash
mem0 init
```
@@ -35,16 +35,12 @@ 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:**
@@ -54,30 +50,6 @@ 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
```
---
+2 -2
View File
@@ -248,8 +248,8 @@ Bake the choice into the goal doc in step 5. Do not re-decide later.
| 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.
If missing → **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`)
exits 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.
-2
View File
@@ -46,8 +46,6 @@ 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:**