Compare commits

...

24 Commits

Author SHA1 Message Date
Mgeeeek 6158ac1065 chore: trigger CI build_mem0 / build_embedchain on cli-only PR 2026-05-14 19:56:56 +05:30
Mgeeeek 3d3303bf36 chore(cli): bump to v0.2.5 + changelog + ruff format fix
- Python: 0.2.4 → 0.2.5 in cli/python/pyproject.toml
- Node:   0.2.4 → 0.2.5 in cli/node/package.json
- docs/changelog/sdk.mdx: new CLI v0.2.5 entry (Agent Mode bootstrap,
  --agent-caller, mem0 identify, plugin sync, claim flow, pingKey fix,
  rate-limit clarity, JSON envelope command field, envelope validation)
- CI fix: ruff format applied to config.py + test_init_internals.py

Release flow: merge → tag cli-v0.2.5 (Python CD) and cli-node-v0.2.5
(Node CD) on GitHub Releases. OIDC trusted publishing — no tokens needed.
2026-05-14 19:51:27 +05:30
Mgeeeek bd6d170ebb fix(cli): pingKey distinguishes network failures from invalid keys + tests
PR review surfaced a data-loss path: pingKey returned False on ANY exception,
so a VPN flap or DNS hiccup made Rules 1/2 (reuse existing valid key) fall
through to Rule 3 (mint new shadow), silently rotating the user's API key
and rewriting plugin-sync targets (~/.claude/settings.json, .zshrc).

Now pingKey returns False ONLY on a definitive auth failure (HTTP 401/403).
Network errors, timeouts, and 5xx responses return True so the existing key
is preferred over re-minting. Mirror change in both Python and Node.

Additional fixes from the same review pass:

  - --agent-caller is now PATCHed to the backend when supplied on a Rule 1
    or Rule 2 reuse path (previously silently dropped). Best-effort —
    failures don't break reuse.

  - bootstrap_via_backend / bootstrapViaBackend renames the `body` local on
    the error path to `err_body` (no longer shadows the request payload).
    Same rename in the claim flow.

  - Bootstrap envelope is now validated for non-empty api_key +
    default_user_id before mutation — defends against partial backend
    responses silently persisting null/undefined into typed string fields.

  - Stale docstrings in agent_detect.{py,ts} and bootstrap_via_backend no
    longer claim env-var sniffing fills `agent_caller`; the field is
    self-declared via --agent-caller only.

Tests (new file mirrored across runtimes):

  - test_init_internals.py / init-internals.test.ts
  - pingKey: 200/401/403/5xx/connect-error/timeout matrix
  - plugin_sync.updateShellRc: trailing-newline preservation, no-create,
    surrounding-content preservation, idempotency, missing-file no-op
  - plugin_sync.updateClaudeSettings: no env-block creation, no
    MEM0_API_KEY insertion into existing env, idempotency, malformed JSON
    no-op
  - Python only: bootstrap 403 "permission" → daily-limit translation

Python: 161 tests pass. Node: 112 tests pass. ruff + biome clean.
2026-05-14 19:39:05 +05:30
Mgeeeek a5cdd45651 docs: teach agents the --agent-caller flag + mem0 identify fallback
Update all Agent Mode quickstart references so agents know how to
attribute their signup:

  1. Canonical: mem0 init --agent --agent-caller <your-name> --json
  2. Fallback if forgotten: mem0 identify <your-name>

Files updated:
- README.md: Agent Mode promo line
- docs/llms.txt: LLM-facing quickstart
- skills/mem0-cli/SKILL.md: Autonomous setup section
- skills/mem0-cli/references/command-reference.md: --agent / --agent-caller / --source columns added to init; new `mem0 identify` section
- skills/mem0/SKILL.md: setup-without-key fallback
- skills/mem0-integrate/SKILL.md: Platform-track key-missing branch

Backend changes that this docs change pairs with: mem0ai/platform#2784
(open sanitize-only normalize + PATCH /agent_mode/caller/).
2026-05-14 18:00:08 +05:30
Mgeeeek a9455313cc feat(cli): mem0 identify <name> — post-init agent self-identify
Adds a one-shot subcommand the agent runs when it bootstrapped without
--agent-caller: `mem0 identify claude-code` PATCHes the active key's
agent_caller via /api/v1/auth/agent_mode/caller/, then mirrors the
canonical value into ~/.mem0/config.json.

Bootstrap success message now prompts unidentified agents to run it.
If --agent-caller was passed on init, no prompt (already attributed).

Both runtimes (Python + Node) get the command + the prompt update.
2026-05-14 17:57:35 +05:30
Mgeeeek 8aa07c2f62 refactor(cli): self-declared agent_caller via --agent-caller flag
Switch agent identity from env-var sniffing to explicit self-declaration
(Proof Editor-style). The agent now passes its own name when running
mem0 init --agent --agent-caller <name>.

Why: env-var detection was speculative for everything but Claude Code —
CLAUDECODE=1 is documented and verified, but CURSOR_AGENT, CODEX_CLI,
CLINE_AGENT etc. were plausible-sounding picks without upstream
confirmation. Self-declaration is honest, future-proof (no whitelist
treadmill as new agents emerge), and matches how Proof handles agent
join: explicit identity from the agent itself.

CLI changes:
- New --agent-caller <name> option on `mem0 init` (Python + Node)
- Removed detect_agent_caller() as the source for agent_caller field;
  still used as a context trigger ("does this look like an agent?")
  for Rule 3 auto-bootstrap and for telemetry-only event property —
  identity goes to NULL unless --agent-caller is passed
- runInit / run_init accept agent_caller kwarg, forward to backend
- PostHog cli.init event uses the self-declared value (not env-sniffed)

Companion backend change (mem0ai/platform#2784) drops the whitelist
normalizer for an open sanitize so any sensible name is accepted.
2026-05-14 17:40:52 +05:30
Mgeeeek d54dad265d feat(cli): pass detected agent_caller to backend on Agent Mode bootstrap
The CLI's detect_agent_caller() already canonicalizes the caller (env-var
sniff returning "claude-code", "cursor", etc.) and reports it to PostHog —
but the value never reached the backend, so APIKey.agent_caller stayed
NULL. Join: send it in the bootstrap request body so the platform can
persist it (see mem0ai/platform#2784).

Wire-up:
- bootstrap_via_backend / bootstrapViaBackend gain an agent_caller kwarg,
  pass it as request body field "agent_caller"
- init_cmd / init.ts call detect_agent_caller() once and forward
- platform.agent_caller persisted to ~/.mem0/config.json so the local
  view matches what the backend stored

No change to created_via — that stays the channel enum ("agent_mode" /
"email" / "api_key"). agent_caller is the orthogonal "who started this".
2026-05-14 17:24:12 +05:30
Mgeeeek bac961bc8d style(cli): biome format multi-line import in node index.ts 2026-05-14 17:01:14 +05:30
Mgeeeek f21e9fe2b3 fix(cli): JSON error envelope shows command name + clearer rate-limit message
Two papercuts surfaced when prod's bootstrap returned 403:

  {"status": "error", "command": "", "error": "Bootstrap failed: {\"detail\":\"You do not have permission to perform this action.\"}", "data": null}

1. `"command": ""` — the JSON error envelope (printError under agent
   mode) reads from current_command state, but nothing ever called
   setCurrentCommand on the init subcommand. Hook into Node's
   preAction and Python's main_callback to stash the active
   subcommand name so error envelopes report which command failed.

2. Opaque rate-limit message. The backend's @ratelimit decorator
   raises PermissionDenied → DRF translates to generic 403
   "You do not have permission to perform this action." Users don't
   know it's a rate limit. Detect status_code==403 with /permission/i
   in the detail and surface the actual reason:
   "Daily Agent Mode signup limit reached for this network (5/day).
   Try again from a different IP or after midnight UTC."

Both fixes mirrored in Python (agent_mode_cmd.py, app.py) and Node
(agent-mode.ts, index.ts).
2026-05-14 16:43:17 +05:30
Mgeeeek 844d633960 feat(cli): emit JSON envelope on init --agent --json reuse paths
PRD's documented form is `mem0 init --agent --json`, but rules 1/2
(env/config reuse) called printSuccess which is silenced under
agent mode — so the command exited 0 with no output.

Two related fixes:

1. Add `--json` as a subcommand-level option on init (Node only;
   Python's argv preprocessor already handles this). Lets the
   PRD-style invocation `mem0 init --agent --json` parse without
   "unknown option" error.

2. When agent mode is set AND a rule 1/2 reuse fires, emit the
   Dev Spec C4 envelope:
     {
       "status": "success", "command": "init",
       "data": {
         "api_key_saved": false,
         "api_key_source": "env" | "config",
         "agent_mode": false,
         "message": "Existing Mem0 API key found and reused..."
       }
     }
   Identical shape between Python and Node (parity).

Verified live: `init --agent --json` against prod with a valid
MEM0_API_KEY env returns the envelope above (no bootstrap call).
2026-05-14 16:30:52 +05:30
Mgeeeek 4f40437d65 feat(cli): auto-sync active api_key to plugin env touchpoints
When saveConfig writes a fresh api_key (e.g. agent-mode bootstrap, OTP
signup), propagate the value into other ecosystem locations that hold
the same key:

  - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
  - ~/.zshrc / ~/.bashrc / ~/.bash_profile `export MEM0_API_KEY="..."`

Without this, agent-mode bootstrap mints a new shadow into config.json
but the Claude plugin's MCP server keeps using the OLD env-var key —
silent surprise.

Hard guarantees:

  1. **Update-only**, never create. If a target file doesn't already
     contain a MEM0_API_KEY entry, we leave it alone. The user's
     existing setup decides which surfaces are managed; we don't
     unilaterally start writing to new files.
  2. **Preserve surrounding content.** JSON files keep all other keys.
     Shell rc files keep all other lines, comments, and the trailing
     newline (regex uses [ \t]* not \s*, which would eat the final \n
     when MEM0_API_KEY is the last line of .zshrc).
  3. **Atomic writes.** tmpfile + rename, so a crash mid-write leaves
     the original intact.
  4. **Idempotent.** If the target already has this value, no-op.
  5. **Best-effort.** Any IOError in the sync is swallowed; the
     canonical config.json write is never blocked by plugin-state.

Implemented identically in Python (plugin_sync.py) and Node
(plugin-sync.ts). Hooked into save_config() / saveConfig() so every
api_key change propagates without any caller plumbing.

Verified on a sandbox copy of real ~/.claude/settings.json and
~/.zshrc: only the MEM0_API_KEY values changed; all 36 other lines
in settings.json and 50+ lines in .zshrc preserved byte-for-byte
including the trailing newline.

Out of scope (deliberate non-changes):
  - ~/.codex/config.toml — no mem0 server entry to update
  - ~/.cursor/mcp.json — no mem0 server entry to update
  - <plugin-install-dir>/.api_key — plugin-managed, different schema
2026-05-14 16:19:21 +05:30
Mgeeeek 477279daeb feat(cli): implement Dev Spec C4 rules 1-2 — reuse valid env/config key
Before this change, `mem0 init --agent` always minted a fresh shadow,
even when a valid MEM0_API_KEY env var (set by the Claude plugin or
shell rc) was already in place. The env var would then silently shadow
the freshly-minted shadow key on the next CLI call — leading to the
surprising "Agent Mode active but my personal account does the work"
behaviour.

Dev Spec §C4 already specifies the right precedence:
  Rule 1: env MEM0_API_KEY valid → reuse, emit existing_key, no new key
  Rule 2: config api_key valid → reuse, emit existing_key
  Rule 3: mint a fresh shadow

This commit implements rules 1-3 in both runtimes (Python + Node), with
a 5-second ping to validate candidate keys against /v1/ping/.

Also reorders so the Agent Mode branch runs BEFORE the existing-config
overwrite guard. The guard's intent is "warn before overwriting a valid
key" — but rules 1/2 REUSE (not overwrite) a valid key, so the guard
must not fire on the agent path.

Net effect: the plugin's MEM0_API_KEY env var and the CLI's
config.json::platform.api_key now naturally co-exist:

  - User installs plugin → MEM0_API_KEY set, persisted via shell rc
  - User runs `mem0 init --agent` → rule 1 fires → existing key kept,
    no shadow created, no confusion
  - Plugin's MCP server (which reads ${MEM0_API_KEY}) and the CLI use
    the same key

No new shadow accounts on prod from CI / repeat invocations.
2026-05-14 16:10:31 +05:30
Mgeeeek 37d5658171 fix(cli): mem0 init --agent now triggers bootstrap (Node) + created_via parity
External sandbox audit (Sandbox E2E Test Report 2026-05-13) surfaced
two CLI issues.

1. Node — `mem0 init --agent` silently falls through to the non-TTY
   error when no agent env var is set. Commander resolves the
   program-level `--agent` alias (for --json) before init's own
   `--agent`; init's opts.agent stays false. Fix: enable Commander's
   positional options so flags AFTER a subcommand belong to that
   subcommand. Now `mem0 --agent <cmd>` is the JSON-output alias and
   `mem0 init --agent` is the Agent Mode bootstrap flag.

   The Python side already had an argv preprocessor doing the
   equivalent; this brings Node to parity using Commander's built-in
   mechanism (cleaner than mirroring the preprocessor).

   Verified: `mem0 init --agent` with bogus MEM0_BASE_URL now hits the
   bootstrap fetch and surfaces a network error (proving it reached
   bootstrap_via_backend), where before it printed "Non-interactive
   terminal detected" and never made an HTTP call.

2. Both — `config.platform.created_via` was inconsistent: set to
   "agent_mode" / "email" on the agent/claim paths but left empty on
   normal email signup and api-key paths. Now all four paths set it:
   "agent_mode" (bootstrap), "email" (OTP signup or claim), "api_key"
   (--api-key flag or interactive prompt). Downstream consumers can
   reliably filter on created_via==value.
2026-05-14 14:48:01 +05:30
Mgeeeek 0414aedce5 fix(cli): escape user-supplied message in print_error
Backend error details can contain `[/...]` or other rich-markup-like text
(e.g. regex patterns in validation errors). print_error interpolated the
raw string into a markup template, so rich's parser would crash with
MarkupError instead of showing the actual error to the user.

Escape message + hint via rich.markup.escape so the colored prefix
still renders but the user-supplied content prints literally.
2026-05-14 04:03:35 +05:30
Mgeeeek a2516269cc Surface mem0_notice for unclaimed Agent Mode keys + docs
CLI now consumes the unified mem0_notice surface that the platform side
emits for unclaimed Agent Mode keys. The notice is a directive to the
LLM agent reading the output, with a verbatim sentence to relay to the
human owner. Two presentation paths:

- Human/text output: yellow stderr banner after the primary output,
  once per command. Skipped in agent mode (the JSON envelope carries
  it instead, so no duplication).
- JSON/agent output (--json/--agent): folded into the envelope as
  "mem0_notice" so an agent parsing the output sees it without
  inspecting HTTP headers.

CLI changes (Python + Node, kept in lockstep):
- state.{ts,py}: captureNotice / takeNotice helpers — last-write-wins
  stash so multi-request commands fire the notice exactly once.
- backend/platform.{ts,py}: _request extracts notice from response
  bodies (top-level dict or list[0]) with header fallback, strips
  from downstream payload, captures for end-of-command surfacing.
- output.{ts,py}: JSON envelope formatters fold in any pending notice.
- index.ts / app.py: entrypoint surfaces notice on exit when not in
  agent mode.
- commands/agent-mode.{ts,py}: init success path prints the platform's
  notice verbatim (fallback to dim claim-command line if a stale
  backend doesn't return it).

Init-flag handling fix: the Python argv preprocessor was stripping
--agent from sys.argv unconditionally as the global JSON-output alias.
That swallowed `mem0 init --agent` (where --agent is a subcommand flag
for unattended bootstrap). Now preserved when "init" is in argv.

Parity tests: cli/python/tests/test_agent_mode.py and
cli/node/tests/agent-mode.test.ts — 7 tests each, kept in sync.

cli-spec.json updated: init now lists --agent and --source.

Docs:
- README.md: Agent Mode promo at top of Quickstart.
- docs/llms.txt: fast-path block for AI agents reading the docs.
- skills/mem0/SKILL.md, skills/mem0-cli/SKILL.md,
  skills/mem0-integrate/SKILL.md, mem0-plugin/skills/mem0/SKILL.md:
  autonomous-setup section + fallback hints.
- mem0-plugin/README.md, openclaw/README.md: "Quick path for agents"
  blocks above the human Quick Start.
2026-05-14 02:11:39 +05:30
Mgeeeek 1c92c466c4 feat(cli): rework claim to OTP-only — drop device flow + browser + polling
Replace claim_via_device_flow / claimViaDeviceFlow with claim_via_otp /
claimViaOtp. The new flow:
  1. POST /api/v1/auth/email_code/ with the user's email
  2. Prompt for the verification code (or accept via --code for non-TTY)
  3. POST /.../verify/ with {email, code, agent_mode_api_key: <local key>}
  4. Backend's verify_email_code runs upgrade-in-place inline and returns
     {claimed: true, claimed_at, ...}

No browser open, no localhost:3000 frontend dependency, no 10-minute poll
loop. Just two HTTP calls + an OTP prompt. Same upgrade-in-place
semantics on the backend; same key-value-unchanged guarantee for the
caller.

--code flag still supported on `mem0 init --email` for non-interactive
use (CI, agent-driven claim scripts).
2026-05-13 23:08:20 +05:30
Mgeeeek bcba0560c4 feat(cli): Agent Mode bootstrap + claim flow (Node)
Mirrors the Python implementation in TypeScript:
  - New PlatformConfig fields: agentMode, createdVia, claimedAt, defaultUserId.
  - agent-detect.ts: detectAgentCaller() — env-var detection covering
    CLAUDECODE / CURSOR_AGENT / CODEX_CLI / CLINE / CONTINUE / AIDER /
    GOOSE / WINDSURF.
  - commands/agent-mode.ts: bootstrapViaBackend() + claimViaDeviceFlow().
  - commands/init.ts: decision tree dispatches to bootstrap (positive agent
    signal + no email/api-key) or claim (--email with existing agent-mode
    config). Raw API key never leaves the device through the claim.
  - index.ts: --agent and --source flags added to `mem0 init`. Skip the
    preAction auto-fire for init so it can fire its own M1-M6 cli.init.
  - telemetry.ts: all cli.* events now carry agent_mode based on
    config.platform.agentMode (per growth-doc M4).

End-to-end verified against the sandbox:
  bootstrap (CLAUDECODE=1) → config.agent_mode=true → claim via --email →
  config.agent_mode=false, claimed_at set, api_key unchanged.
2026-05-13 23:08:20 +05:30
Mgeeeek 84ffb36190 feat(cli): Agent Mode bootstrap + claim flow (Python)
New behavior on `mem0 init`:
  - With no `--email`/`--api-key` AND a positive agent signal (`--agent`,
    global `--json`/`--agent`, or one of the recognized agent env vars
    CLAUDECODE / CURSOR_AGENT / CODEX_CLI / CLINE / CONTINUE / AIDER /
    GOOSE / WINDSURF), bootstrap an unattended Agent Mode account via
    POST /api/v1/auth/agent_mode/. No email, no OTP, no dashboard.
  - With `--email <addr>` AND an existing config that has agent_mode=true,
    run the claim device-flow against the existing key instead of minting
    a fresh one. The raw API key never leaves the device; backend confirms
    claim via the existing CLILoginRequest poll path. Config flips
    agent_mode=false and stamps claimed_at on success.
  - Bare `mem0 init` with no signal + no TTY still errors out — auto-bootstrap
    requires a positive agent signal to avoid surprising pipe-using humans.

New flags:
  --agent   Force unattended Agent Mode bootstrap.
  --source  Channel attribution string for signup_source PostHog property.

Config schema extensions on PlatformConfig:
  agent_mode, created_via, claimed_at, default_user_id.

Telemetry (M1-M6 from the growth doc):
  - cli.init: mode (agent|email|api_key|existing_key), agent_caller,
    signup_source, claimed_agent_mode (bool when --email claims an
    existing agent-mode config).
  - All cli.* events: agent_mode reflects config.platform.agent_mode (the
    bootstrap flag), not the output-format flag — per the growth-doc spec.
2026-05-13 23:08:20 +05:30
Agam Pandey 70bc9e51d5 docs(readme): update LongMemEval benchmark to 94.8 and add Temporal Reasoning (#5131) 2026-05-13 14:31:15 +05:30
Agam Pandey 0107fd53b8 feat: add temporal reasoning cookbook and docs (#5061) 2026-05-13 01:59:38 +05:30
Mragank Shekhar 54a03cc721 chore(plugin): bump mem0 plugin to v0.1.2 (#5094) 2026-05-09 20:56:34 +05:30
Mragank Shekhar e95de4ca50 fix(plugin): hook cleanup + identity + compact-summary flow (#5076) 2026-05-09 19:19:30 +05:30
youneshima a623cfaf76 Oss qdrant hosted memories to platform migration (#5080) 2026-05-08 08:04:09 +05:30
Chaithanya Kumar 92491c00c2 docs(memory-decay): use SDK calls in code samples (#5079)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:53:55 +05:30
73 changed files with 5298 additions and 96 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.1.1"
"version": "0.1.2"
}
]
}
+5 -2
View File
@@ -47,7 +47,7 @@
| Benchmark | Old | New | Tokens | Latency p50 |
| --- | --- | --- | --- | --- |
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
| **LongMemEval** | 67.8 | **93.4** | 6.8K | 1.09s |
| **LongMemEval** | 67.8 | **94.8** | 6.8K | 1.09s |
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
@@ -58,12 +58,13 @@ All benchmarks run on the same production-representative model stack. Single-pas
- **Agent-generated facts are first-class** -- when an agent confirms an action, that information is now stored with equal weight.
- **Entity linking** -- entities are extracted, embedded, and linked across memories for retrieval boosting.
- **Multi-signal retrieval** -- semantic, BM25 keyword, and entity matching scored in parallel and fused.
- **Temporal Reasoning** -- time-aware retrieval that ranks the right dated instance for queries about current state, past events, and upcoming plans.
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgrade instructions. The [evaluation framework](https://github.com/mem0ai/memory-benchmarks) is open-sourced so anyone can reproduce the numbers.
## Research Highlights
- **91.6 on LoCoMo** -- +20 points over the previous algorithm
- **93.4 on LongMemEval** -- +26 points, with +53.6 on assistant memory recall
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
- [Read the full paper](https://mem0.ai/research)
@@ -85,6 +86,8 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra
## 🚀 Quickstart Guide <a name="quickstart"></a>
> **Agent Mode — for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), replacing `<your-name>` with your AI agent identity (e.g. `claude-code`, `cursor`, `codex`). This mints an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Use the key immediately against the Platform or the MCP server. The human owner can claim ownership later with `mem0 init --email <their-email>`: memories transfer, the same key keeps working, and the agent isn't disrupted.
| | Library | Self-Hosted Server | Cloud Platform |
|---|---------|-------------------|----------------|
| **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use |
+4 -2
View File
@@ -503,7 +503,7 @@
},
{
"name": "init",
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
"usage": "mem0 init [OPTIONS]",
"needsBackend": false,
"needsConfig": false,
@@ -516,7 +516,9 @@
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
{ "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." },
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." },
{ "name": "agent", "flags": ["--agent"], "type": "boolean", "default": false, "help": "Bootstrap an unattended Agent Mode account (no email required)." },
{ "name": "source", "flags": ["--source"], "type": "string", "default": null, "help": "Channel attribution for signup (e.g. github, hn, ph)." }
]
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/cli",
"version": "0.2.4",
"version": "0.2.5",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
+32
View File
@@ -0,0 +1,32 @@
/**
* Detect whether the CLI is being invoked from inside an AI-agent context.
*
* Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
* agent runtime env var is present. The return value is a context **trigger
* only** — the canonical agent identity is self-declared by the agent via
* `--agent-caller <name>` (Proof Editor-style) and never sniffed from env
* vars to fill the `agent_caller` field on the APIKey row.
*
* Returns a short name or null. Honest reporting depends on `--agent-caller`;
* this list is just enough to enable the zero-friction auto-bootstrap UX.
*/
const AGENT_CALLER_ENV: ReadonlyArray<readonly [string, readonly string[]]> = [
["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
["cursor", ["CURSOR_AGENT", "CURSOR_SESSION_ID"]],
["codex", ["CODEX_CLI", "OPENAI_CODEX"]],
["cline", ["CLINE_AGENT", "CLINE"]],
["continue", ["CONTINUE_AGENT", "CONTINUE_SESSION"]],
["aider", ["AIDER_SESSION"]],
["goose", ["GOOSE_AGENT"]],
["windsurf", ["WINDSURF_AGENT"]],
] as const;
export function detectAgentCaller(): string | null {
for (const [name, envVars] of AGENT_CALLER_ENV) {
if (envVars.some((v) => process.env[v])) {
return name;
}
}
return null;
}
+34 -2
View File
@@ -3,7 +3,7 @@
*/
import type { PlatformConfig } from "../config.js";
import { isAgentMode } from "../state.js";
import { captureNotice, isAgentMode } from "../state.js";
import { CLI_VERSION } from "../version.js";
import {
APIError,
@@ -90,7 +90,39 @@ export class PlatformBackend implements Backend {
if (resp.status === 204) {
return {};
}
return resp.json();
const data = await resp.json();
// Pull the unclaimed-Agent-Mode notice out of the body (or the header
// fallback for endpoints returning non-dict / non-dict-leading payloads)
// and stash for end-of-command surfacing.
let notice: string | null = null;
if (
data &&
typeof data === "object" &&
!Array.isArray(data) &&
"mem0_notice" in data
) {
notice = (data as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: intentional strip so downstream consumers don't see duplicate notice
delete (data as Record<string, unknown>).mem0_notice;
} else if (
Array.isArray(data) &&
data.length > 0 &&
typeof data[0] === "object" &&
data[0] !== null &&
"mem0_notice" in data[0]
) {
notice = (data[0] as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: see above.
delete (data[0] as Record<string, unknown>).mem0_notice;
}
if (!notice) {
notice = resp.headers.get("X-Mem0-Notice-Message") ?? null;
}
captureNotice(notice);
return data;
}
async add(
+285
View File
@@ -0,0 +1,285 @@
/**
* Agent Mode commands — bootstrap (unattended signup) and OTP-based claim.
*/
import readline from "node:readline";
import { colors, printError, printInfo, printSuccess } from "../branding.js";
import { type Mem0Config, saveConfig } from "../config.js";
const { brand, dim } = colors;
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export interface BootstrapEnvelope {
api_key: string;
default_user_id: string;
org_id: string;
project_id: string;
mcp_url?: string;
smoke_test_url?: string;
claim_command?: string;
mem0_notice?: string;
}
function isValidEnvelope(v: unknown): v is BootstrapEnvelope {
return (
!!v &&
typeof v === "object" &&
typeof (v as BootstrapEnvelope).api_key === "string" &&
(v as BootstrapEnvelope).api_key.length > 0 &&
typeof (v as BootstrapEnvelope).default_user_id === "string" &&
(v as BootstrapEnvelope).default_user_id.length > 0
);
}
/**
* POST /api/v1/auth/agent_mode/ and mutate config in place.
*
* @param config - Mem0Config mutated in place with the new platform values.
* @param source - `--source` flag passthrough (analytics tag, free-form).
* @param agentCaller - Self-declared agent identity passed via `--agent-caller`
* (e.g. `claude-code`, `cursor`). May be null when the caller omitted the
* flag; the agent can backfill later via `mem0 identify <name>`. Sent to the
* backend in the request body and saved into `platform.agentCaller` for
* local introspection.
*/
export async function bootstrapViaBackend(
config: Mem0Config,
{
source,
agentCaller,
}: { source?: string | null; agentCaller?: string | null } = {},
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
const body: Record<string, unknown> = {};
if (source) body.source = source;
if (agentCaller) body.agent_caller = agentCaller;
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
method: "POST",
headers: {
...SOURCE_HEADERS,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (resp.status === 429) {
printError("Rate-limited. Try again in a few minutes.");
process.exit(1);
}
if (resp.status === 503) {
printError("Agent Mode is temporarily disabled. Try again later.");
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const errBody = (await resp.json()) as {
error?: string;
detail?: string;
};
detail = errBody.error ?? errBody.detail ?? resp.statusText;
} catch {
/* leave detail as statusText */
}
// Backend's @ratelimit decorator raises PermissionDenied, which DRF
// translates to a generic 403 "You do not have permission to perform
// this action." That's opaque — surface it as the rate-limit message
// it actually is.
if (resp.status === 403 && /permission/i.test(detail)) {
printError(
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
);
process.exit(1);
}
printError(`Bootstrap failed: ${detail}`);
process.exit(1);
}
const envelope = (await resp.json()) as BootstrapEnvelope;
if (!isValidEnvelope(envelope)) {
// Defend against partial/malformed backend responses (e.g. {api_key: null}).
// Without this guard, the typed `string` field is silently set to
// undefined/null and persisted, producing confusing downstream errors.
printError(
"Bootstrap response missing required fields — please update the CLI.",
);
process.exit(1);
}
config.platform.apiKey = envelope.api_key;
config.platform.baseUrl = baseUrl;
config.platform.agentMode = true;
config.platform.createdVia = "agent_mode";
config.platform.agentCaller = agentCaller ?? "";
config.platform.claimedAt = "";
config.platform.defaultUserId = envelope.default_user_id;
// Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.userId = envelope.default_user_id;
saveConfig(config);
printSuccess(
`Agent Mode active. Default user_id: ${envelope.default_user_id}`,
);
if (envelope.mem0_notice) {
console.log(`\n\x1b[33m🔔 ${envelope.mem0_notice}\x1b[0m\n`);
} else {
// Fallback for older backends without the unified notice field.
const claimCmd = envelope.claim_command ?? "mem0 init --email <your-email>";
console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`);
}
if (!agentCaller) {
console.log(
` ${dim("If you are an AI agent, identify yourself: `mem0 identify <your-name>` (e.g. claude-code, cursor).")}`,
);
}
}
/**
* Claim an existing Agent Mode account via OTP — no browser, no polling.
*
* Hits /api/v1/auth/email_code/ to send a verification code, prompts for it
* interactively (or accepts via `code`), then sends it to /verify/ alongside
* `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place
* inline and returns the claim result.
*/
export async function claimViaOtp(
config: Mem0Config,
{ email, code }: { email: string; code?: string },
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
if (!config.platform.apiKey || !config.platform.agentMode) {
printError(
"This command requires an active Agent Mode config. Run `mem0 init` first.",
);
process.exit(1);
}
const rawKey = config.platform.apiKey;
// Step 1: request OTP (unless --code was supplied)
if (!code) {
const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
if (sendResp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!sendResp.ok) {
let detail: string = sendResp.statusText;
try {
const errBody = (await sendResp.json()) as { error?: string };
if (errBody.error) detail = errBody.error;
} catch {
/* leave as statusText */
}
printError(`Failed to send code: ${detail}`);
process.exit(1);
}
printSuccess(`Verification code sent to ${email}. Check your inbox.`);
if (!process.stdin.isTTY) {
printError(
"No --code provided and terminal is non-interactive.",
`Re-run: mem0 init --email ${email} --code <code>`,
);
process.exit(1);
}
console.log();
code = await promptLine(` ${brand("Verification Code")}`);
if (!code) {
printError("Code is required.");
process.exit(1);
}
}
// Step 2: verify + claim atomically
const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: code.trim(),
agent_mode_api_key: rawKey,
}),
signal: AbortSignal.timeout(30_000),
});
if (!verifyResp.ok) {
let detail: string = verifyResp.statusText;
let errCode = "";
try {
const errBody = (await verifyResp.json()) as {
error?: string;
code?: string;
};
if (errBody.error) detail = errBody.error;
if (errBody.code) errCode = errBody.code;
} catch {
/* leave as statusText */
}
printError(`Claim failed: ${detail}`);
if (errCode === "email_already_claimed") {
console.log(
` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.")}`,
);
}
process.exit(1);
}
const claimBody = (await verifyResp.json()) as {
claimed?: boolean;
claimed_at?: string;
};
if (!claimBody.claimed) {
printError(`Unexpected verify response: ${JSON.stringify(claimBody)}`);
process.exit(1);
}
config.platform.agentMode = false;
config.platform.claimedAt = claimBody.claimed_at ?? new Date().toISOString();
config.platform.userEmail = email;
config.platform.createdVia = "email";
saveConfig(config);
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`);
}
function promptLine(label: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${label}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
+75
View File
@@ -0,0 +1,75 @@
/**
* mem0 identify — declare which agent owns the current agent-mode key.
*
* Used when `mem0 init --agent` ran without --agent-caller, so the backend
* saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
* its own row with its real identity. Idempotent.
*/
import { printError, printSuccess } from "../branding.js";
import { loadConfig, saveConfig } from "../config.js";
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export async function runIdentify(name: string): Promise<void> {
const config = loadConfig();
if (!config.platform.apiKey) {
printError("No API key configured. Run `mem0 init --agent` first.");
process.exit(1);
}
if (!config.platform.agentMode) {
printError("This command only works on unclaimed agent-mode keys.");
process.exit(1);
}
const clean = (name ?? "").trim();
if (!clean) {
printError("Agent name is required.");
process.exit(1);
}
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/caller/`, {
method: "PATCH",
headers: {
...SOURCE_HEADERS,
Authorization: `Token ${config.platform.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: clean }),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = (await resp.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* leave as statusText */
}
printError(`Identify failed: ${detail}`);
process.exit(1);
}
const body = (await resp.json()) as { agent_caller?: string };
const canonical = body.agent_caller ?? clean;
config.platform.agentCaller = canonical;
saveConfig(config);
printSuccess(`Identified as ${canonical}.`);
}
+167 -1
View File
@@ -21,6 +21,8 @@ import {
redactKey,
saveConfig,
} from "../config.js";
import { formatJsonEnvelope } from "../output.js";
import { isAgentMode } from "../state.js";
const { brand, dim } = colors;
@@ -33,6 +35,65 @@ function validateEmail(email: string): void {
}
}
/** @internal — exported for unit tests. */
export async function pingKey(
apiKey: string,
baseUrl: string,
timeoutMs = 5000,
): Promise<boolean> {
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
// Network errors, timeouts, and 5xx responses return true so we prefer
// reusing an existing key over silently minting a new shadow on a transient
// blip (which would also clobber config + plugin-sync targets).
try {
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
headers: { Authorization: `Token ${apiKey}` },
signal: AbortSignal.timeout(timeoutMs),
});
return resp.status !== 401 && resp.status !== 403;
} catch {
return true; // unknown — prefer reuse
}
}
async function maybeIdentify(
key: string,
baseUrl: string,
agentCaller: string | undefined,
): Promise<void> {
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
// reused key. Silent no-op on any failure — reuse must not break.
if (!agentCaller) return;
try {
const resp = await fetch(
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
{
method: "PATCH",
headers: {
Authorization: `Token ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: agentCaller }),
signal: AbortSignal.timeout(10_000),
},
);
if (resp.ok) {
try {
const body = (await resp.json()) as { agent_caller?: string };
if (fs.existsSync(CONFIG_FILE)) {
const cfg = loadConfig();
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
saveConfig(cfg);
}
} catch {
/* swallow — best effort */
}
}
} catch {
/* swallow — best effort */
}
}
async function emailLogin(
email: string,
code: string | undefined,
@@ -196,6 +257,7 @@ async function setupPlatform(config: Mem0Config): Promise<void> {
process.exit(1);
}
config.platform.apiKey = apiKey;
config.platform.createdVia = "api_key";
}
async function setupDefaults(config: Mem0Config): Promise<void> {
@@ -249,14 +311,35 @@ export async function runInit(
email?: string;
code?: string;
force?: boolean;
agent?: boolean;
source?: string;
agentCaller?: string;
} = {},
): Promise<void> {
const { detectAgentCaller } = await import("../agent-detect.js");
const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js");
const { isAgentMode } = await import("../state.js");
const { captureEvent } = await import("../telemetry.js");
const fireInit = (
mode: "agent" | "email" | "api_key" | "existing_key",
claimed = false,
) => {
const props: Record<string, unknown> = { command: "init", mode };
// Self-declared via --agent-caller; not sniffed from env vars.
if (opts.agentCaller) props.agent_caller = opts.agentCaller;
if (opts.source) props.signup_source = opts.source;
if (claimed) props.claimed_agent_mode = true;
captureEvent("cli.init", props);
};
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
config.platform.baseUrl = baseUrl;
// Guards
if (opts.code && !opts.email) {
@@ -268,6 +351,84 @@ export async function runInit(
process.exit(1);
}
// ── Claim flow: --email against an existing agent-mode config ───────────
if (
opts.email &&
fs.existsSync(CONFIG_FILE) &&
savedConfig.platform.agentMode &&
savedConfig.platform.apiKey
) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printInfo(`Claiming Agent Mode account to ${email}...`);
await claimViaOtp(savedConfig, { email, code: opts.code });
fireInit("email", true);
return;
}
// ── Agent Mode path runs BEFORE the existing-config guard ──────────────
// Rule 1/2 will REUSE a valid existing key (not overwrite), so we must
// short-circuit before the guard prompts the user about overwriting.
// Rule 3 only mints when there's no valid key to reuse — in that case
// overwriting is what the user wants.
const agentCtx =
opts.agent === true || isAgentMode() || detectAgentCaller() !== null;
if (!opts.apiKey && !opts.email && agentCtx) {
const emitReuseEnvelope = (source: "env" | "config") => {
if (isAgentMode()) {
formatJsonEnvelope({
command: "init",
data: {
api_key_saved: false,
api_key_source: source,
agent_mode: false,
message:
"Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
});
} else {
printSuccess(
source === "env"
? "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
: "Existing API key in config is valid; reusing it. No new Agent Mode key was minted.",
);
}
};
// Rule 1: env MEM0_API_KEY valid → reuse, no new key.
const envKey = (process.env.MEM0_API_KEY || "").trim();
if (envKey && (await pingKey(envKey, baseUrl))) {
await maybeIdentify(envKey, baseUrl, opts.agentCaller);
emitReuseEnvelope("env");
fireInit("existing_key");
return;
}
// Rule 2: existing config api_key valid → reuse.
if (
savedConfig.platform.apiKey &&
(await pingKey(savedConfig.platform.apiKey, baseUrl))
) {
await maybeIdentify(
savedConfig.platform.apiKey,
baseUrl,
opts.agentCaller,
);
emitReuseEnvelope("config");
fireInit("existing_key");
return;
}
// Rule 3: mint a fresh shadow (no valid key to reuse).
// agent_caller is self-declared via --agent-caller (Proof Editor-style),
// not derived from env-var sniffing. detectAgentCaller() above is still
// used as a context trigger (does this look like an agent?) but never
// to fill identity.
await bootstrapViaBackend(config, {
source: opts.source ?? null,
agentCaller: opts.agentCaller ?? null,
});
fireInit("agent");
return;
}
// Warn if an existing config with an API key would be overwritten
if (
!opts.force &&
@@ -324,6 +485,7 @@ export async function runInit(
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.platform.userEmail = email;
config.platform.createdVia = "email";
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
@@ -339,13 +501,15 @@ export async function runInit(
}
// ── API key flow ──────────────────────────────────────────────────────────
// (Agent Mode branch runs earlier — see above, before the existing-config
// guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
if (!process.stdin.isTTY) {
if (!opts.apiKey) {
printError(
"Non-interactive terminal detected and --api-key is required.",
"Usage: mem0 init --api-key <key> [--user-id <id>]",
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
);
process.exit(1);
}
@@ -356,6 +520,7 @@ export async function runInit(
// Non-interactive: both flags provided
if (opts.apiKey && opts.userId) {
config.platform.apiKey = opts.apiKey;
config.platform.createdVia = "api_key";
config.defaults.userId = opts.userId;
await validatePlatform(config);
saveConfig(config);
@@ -403,6 +568,7 @@ export async function runInit(
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.platform.userEmail = email;
config.platform.createdVia = "email";
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
+35
View File
@@ -21,6 +21,12 @@ export interface PlatformConfig {
apiKey: string;
baseUrl: string;
userEmail: string;
// Agent Mode (unclaimed-shadow signup)
agentMode: boolean; // true while the key is an unclaimed agent-mode key
createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key"
agentCaller: string; // canonical agent name when createdVia === "agent_mode" (e.g. "claude-code")
claimedAt: string; // ISO timestamp once the agent has been claimed
defaultUserId: string; // `user_<slug>` returned by bootstrap; auto-default scope
}
export interface DefaultsConfig {
@@ -54,6 +60,11 @@ export function createDefaultConfig(): Mem0Config {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
userEmail: "",
agentMode: false,
createdVia: "",
agentCaller: "",
claimedAt: "",
defaultUserId: "",
},
telemetry: {
anonymousId: "",
@@ -79,6 +90,11 @@ export function loadConfig(): Mem0Config {
config.platform.apiKey = plat.api_key ?? "";
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
config.platform.userEmail = plat.user_email ?? "";
config.platform.agentMode = Boolean(plat.agent_mode ?? false);
config.platform.createdVia = plat.created_via ?? "";
config.platform.agentCaller = plat.agent_caller ?? "";
config.platform.claimedAt = plat.claimed_at ?? "";
config.platform.defaultUserId = plat.default_user_id ?? "";
const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? "";
@@ -118,6 +134,11 @@ export function saveConfig(config: Mem0Config): void {
api_key: config.platform.apiKey,
base_url: config.platform.baseUrl,
user_email: config.platform.userEmail,
agent_mode: config.platform.agentMode,
created_via: config.platform.createdVia,
agent_caller: config.platform.agentCaller,
claimed_at: config.platform.claimedAt,
default_user_id: config.platform.defaultUserId,
},
telemetry: {
anonymous_id: config.telemetry.anonymousId,
@@ -126,6 +147,20 @@ export function saveConfig(config: Mem0Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600);
// Propagate api_key to ecosystem touchpoints (Claude plugin env injection,
// shell rc exports). Idempotent — updates only EXISTING entries; never
// creates new ones. Best-effort: errors swallowed so config.json is
// always authoritative, never blocked by plugin-state issues.
if (config.platform.apiKey) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { syncApiKey } = require("./plugin-sync.js");
syncApiKey(config.platform.apiKey);
} catch {
/* swallow */
}
}
}
export function redactKey(key: string): string {
+70 -4
View File
@@ -13,7 +13,12 @@ import { colors, printError, printWarning } from "./branding.js";
import type { Mem0Config } from "./config.js";
import { loadConfig, saveConfig } from "./config.js";
import { richFormatHelp } from "./help.js";
import { setAgentMode } from "./state.js";
import {
isAgentMode,
setAgentMode,
setCurrentCommand,
takeNotice,
} from "./state.js";
import { captureEvent } from "./telemetry.js";
import { CLI_VERSION } from "./version.js";
@@ -141,6 +146,11 @@ program
.description(
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
)
// Positional options: flags AFTER a subcommand name belong to that
// subcommand, not the global program. Without this, `mem0 init --agent`
// routes `--agent` to the program-level alias (for --json) and init's own
// `--agent` (Agent Mode bootstrap) silently never fires.
.enablePositionalOptions()
.option("--version", "Show version and exit.")
.on("option:version", () => {
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
@@ -149,7 +159,7 @@ program
.option("--json", "Output as JSON for agent/programmatic use.")
.option(
"--agent",
"Output as JSON for agent/programmatic use. (alias: --json)",
"Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent <cmd>`. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.",
)
.usage("<command> [options]")
.helpOption("--help", "Show this message and exit.")
@@ -166,6 +176,14 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
parentName && parentName !== "mem0"
? `${parentName}.${commandName}`
: commandName;
// Stash the active command name in shared state so the JSON
// error envelope (printError) can report which command failed
// instead of an empty `"command": ""` field.
setCurrentCommand(fullCommand);
// init fires its own telemetry from runInit with full M1-M6 props
// (mode/agent_caller/signup_source/claimed_agent_mode); skip the
// auto-fire here so we don't double-count.
if (fullCommand === "init") return;
const isAgent = !!(program.opts().json || program.opts().agent);
captureEvent(
`cli.${fullCommand}`,
@@ -193,11 +211,32 @@ program
"Verification code (use with --email for non-interactive login).",
)
.option("--force", "Overwrite existing config without confirmation.", false)
.option(
"--agent",
"Bootstrap an unattended Agent Mode account (no email required).",
false,
)
.option(
"--source <channel>",
"Channel attribution for signup (e.g. github, hn, ph).",
)
.option(
"--agent-caller <name>",
"Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
)
// Accept `--json` at the init level too so the PRD-documented form
// `mem0 init --agent --json` works without requiring users to move it
// before the subcommand. Effect is identical to the global `--json`:
// flip agent-mode output state.
.option("--json", "Output as JSON (alias for global `--json`).", false)
.addHelpText(
"after",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present",
)
.action(async (opts) => {
// `--json` at init level mirrors the global flag — flip agent_mode
// state so downstream formatters use JSON envelopes.
if (opts.json) setAgentMode(true);
const { runInit } = await import("./commands/init.js");
await runInit({
apiKey: opts.apiKey,
@@ -205,9 +244,24 @@ program
email: opts.email,
code: opts.code,
force: opts.force,
agent: opts.agent,
source: opts.source,
agentCaller: opts.agentCaller,
});
});
// ── Setup: identify (post-bootstrap agent self-tag) ──────────────────────
program
.command("identify <name>")
.description(
"Tag your active Agent Mode key with the AI agent that's using it (e.g. claude-code, cursor).",
)
.action(async (name: string) => {
const { runIdentify } = await import("./commands/identify.js");
await runIdentify(name);
});
// ── Memory: add ───────────────────────────────────────────────────────────
program
@@ -769,4 +823,16 @@ program
// ── Entrypoint ────────────────────────────────────────────────────────────
program.parse();
// Surface any unclaimed Agent Mode notice once per command, after the primary
// output. In JSON/agent mode the notice is folded into the envelope by
// formatJsonEnvelope, so skip the stderr banner there to avoid duplication.
function surfaceNotice(): void {
const notice = takeNotice();
if (notice && !isAgentMode()) {
process.stderr.write(`\n\x1b[33m🔔 ${notice}\x1b[0m\n\n`);
}
}
program.parseAsync().finally(() => {
surfaceNotice();
});
+16
View File
@@ -5,6 +5,7 @@
import boxen from "boxen";
import Table from "cli-table3";
import { colors, sym } from "./branding.js";
import { takeNotice } from "./state.js";
const { brand, accent, success, error: errorColor, dim } = colors;
@@ -244,6 +245,15 @@ export function formatJsonEnvelope(opts: {
if (opts.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error;
envelope.data = opts.data;
// If the platform flagged this as an unclaimed Agent Mode account, surface
// the notice inside the JSON envelope so an agent consuming the output
// sees it without needing to inspect HTTP headers.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { takeNotice } = require("./state.js");
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
@@ -356,6 +366,12 @@ export function formatAgentEnvelope(opts: {
}
if (opts.count !== undefined) envelope.count = opts.count;
envelope.data = sanitizeAgentData(opts.command, opts.data);
// Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
// agent reading the JSON output sees it without inspecting HTTP headers.
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Sync the active Mem0 API key into other ecosystem touchpoints.
*
* Why: the CLI canonical state is ~/.mem0/config.json. MCP servers
* (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or
* their own config files. Without a sync, agent-mode bootstrap mints a
* new key into config.json but the plugin's MCP keeps using the old
* key from env — silent surprise.
*
* Design:
* - Update ONLY entries that already exist; never create new ones
* - Preserve surrounding content, formatting, other keys
* - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt
* - Idempotent — re-running with the same key is a no-op
*
* Targets:
* - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
* - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines
*
* Out of scope: Codex / Cursor MCP configs and the plugin's own
* <plugin-dir>/.api_key file (plugin-managed, different schema).
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
const SHELL_RCS = [
path.join(os.homedir(), ".zshrc"),
path.join(os.homedir(), ".bashrc"),
path.join(os.homedir(), ".bash_profile"),
];
// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved
// when the MEM0_API_KEY export is the last line of the rc file.
const RC_LINE_RE =
/^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m;
export function syncApiKey(apiKey: string): string[] {
if (!apiKey) return [];
const updated: string[] = [];
if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) {
updated.push(CLAUDE_SETTINGS);
}
for (const rc of SHELL_RCS) {
if (updateShellRc(rc, apiKey)) updated.push(rc);
}
return updated;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateClaudeSettings(
filePath: string,
apiKey: string,
): boolean {
if (!fs.existsSync(filePath)) return false;
let raw: string;
let data: Record<string, unknown>;
try {
raw = fs.readFileSync(filePath, "utf-8");
data = JSON.parse(raw);
} catch {
return false;
}
const env = data.env;
if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) {
return false; // no existing entry — don't create one
}
const envObj = env as Record<string, string>;
if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync
envObj.MEM0_API_KEY = apiKey;
atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`);
return true;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateShellRc(filePath: string, apiKey: string): boolean {
if (!fs.existsSync(filePath)) return false;
let text: string;
try {
text = fs.readFileSync(filePath, "utf-8");
} catch {
return false;
}
const match = text.match(RC_LINE_RE);
if (!match) return false; // no existing line
if (match[3] === apiKey) return false;
const newText = text.replace(
RC_LINE_RE,
(_full, prefix) => `${prefix}"${apiKey}"`,
);
atomicWriteText(filePath, newText);
return true;
}
function atomicWriteText(filePath: string, content: string): void {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
try {
fs.writeFileSync(tmp, content, "utf-8");
// Preserve permissions if original existed.
if (fs.existsSync(filePath)) {
try {
const mode = fs.statSync(filePath).mode & 0o777;
fs.chmodSync(tmp, mode);
} catch {
/* best-effort */
}
}
fs.renameSync(tmp, filePath);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
+17
View File
@@ -5,6 +5,7 @@
let _agentMode = false;
let _currentCommand = "";
let _pendingNotice = "";
export function isAgentMode(): boolean {
return _agentMode;
@@ -21,3 +22,19 @@ export function getCurrentCommand(): string {
export function setCurrentCommand(name: string): void {
_currentCommand = name;
}
/**
* Stash a Mem0 backend notice (Agent Mode unclaimed reminder) for end-of-
* command surfacing. Called from the platform backend after each response so
* the notice prints once per command regardless of how many sub-requests
* fired. Last-write-wins is fine — the message text is identical.
*/
export function captureNotice(notice: string | null | undefined): void {
if (notice) _pendingNotice = notice;
}
export function takeNotice(): string {
const msg = _pendingNotice;
_pendingNotice = "";
return msg;
}
+4
View File
@@ -115,6 +115,9 @@ export function captureEvent(
}
}
// M4: every cli.* event carries agent_mode based on the config flag
// (unclaimed Agent Mode key). This is the growth-doc property used to
// join init → add → search funnels in PostHog.
const payload = {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
@@ -123,6 +126,7 @@ export function captureEvent(
source: "CLI",
language: "node",
cli_version: CLI_VERSION,
agent_mode: Boolean(config.platform.agentMode),
node_version: process.version,
os: process.platform,
...properties,
+141
View File
@@ -0,0 +1,141 @@
/**
* Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
*
* Mirror of `cli/python/tests/test_agent_mode.py` — both files MUST stay
* in sync so that the Python and Node CLIs expose an identical surface
* for the Agent Mode entrypoint. If you add a flag here, add the same
* assertion on the Python side (and vice versa).
*
* Network-bound bootstrap is covered by the platform-side E2E suite
* (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify
* the CLI surface that ships in the binary.
*/
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function run(
args: string[],
opts: { home?: string; env?: Record<string, string> } = {},
): { stdout: string; stderr: string; exitCode: number } {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("MEM0_")) delete env[key];
}
if (opts.home) env.HOME = opts.home;
if (opts.env) Object.assign(env, opts.env);
try {
const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, {
cwd: path.join(__dirname, ".."),
env,
encoding: "utf-8",
timeout: 15000,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: e.status ?? 1,
};
}
}
function cleanHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
}
describe("init flag surface", () => {
it("init --help lists --agent", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--agent");
});
it("init --help describes Agent Mode", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
// Description must mention what --agent actually does so an agent
// reading the help can self-discover the bootstrap entrypoint.
expect(
result.stdout.includes("Agent Mode") ||
result.stdout.toLowerCase().includes("unattended"),
).toBe(true);
});
it("init --help lists --source", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--source");
});
it("init --help lists --email and --code", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--email");
expect(result.stdout).toContain("--code");
});
});
describe("argv preprocessing — --agent reaches init subcommand", () => {
// Regression for the bug where the global --agent JSON-alias swallowed
// the init-level --agent flag, making `mem0 init --agent` behave like
// the plain interactive wizard.
it("init --agent triggers bootstrap branch (not the wizard)", () => {
const home = cleanHome();
const result = run(["init", "--agent"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole
FORCE_COLOR: "0",
},
});
const combined = (result.stdout + result.stderr).toLowerCase();
// Either bootstrap-attempt error, or a connection/network error —
// both prove the --agent path executed (the wizard would prompt for
// input and succeed/hang, not surface a network error).
expect(
combined.includes("agent") ||
combined.includes("connect") ||
combined.includes("network") ||
combined.includes("fetch") ||
combined.includes("bootstrap"),
).toBe(true);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("JSON envelope on network failure", () => {
it("init --agent --json does not leak a stack trace when backend is unreachable", () => {
const home = cleanHome();
const result = run(["init", "--agent", "--json"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1",
FORCE_COLOR: "0",
},
});
const combined = result.stdout + result.stderr;
// No raw Node stack should escape the agent-mode handler.
expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/);
expect(combined).not.toContain("UnhandledPromiseRejection");
expect(result.exitCode).not.toBe(0);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("top-level help lists init", () => {
// `mem0 --help` must list `init` so agents walking the top-level help
// can discover the Agent Mode entrypoint without prior knowledge.
it("--help lists init", () => {
const result = run(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("init");
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Unit tests for init internals — decision tree primitives + plugin sync.
*
* Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay
* in sync — if you add a behavioral assertion here, mirror it on the Python
* side and vice versa.
*
* - `pingKey` must NOT treat network errors as "invalid key" (else a VPN
* flap silently mints a new shadow over a working key).
* - `plugin_sync` must only update entries that already exist, preserve
* trailing newlines, and never mangle other lines.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pingKey } from "../src/commands/init.js";
import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js";
// ── pingKey ──────────────────────────────────────────────────────────────
describe("pingKey — network vs auth distinction", () => {
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
vi.restoreAllMocks();
});
it("returns true for 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns false for 401 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns false for 403 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns true for 5xx (transient upstream — prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on network error (prefer reuse over re-mint)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on timeout (prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
});
// ── updateShellRc ────────────────────────────────────────────────────────
describe("updateShellRc — exists-only contract", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("updates existing export and preserves trailing newline", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n');
expect(updateShellRc(rc, "newkey")).toBe(true);
expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n');
});
it("does NOT create a new export when none exists", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, "alias ll='ls -la'\n");
expect(updateShellRc(rc, "newkey")).toBe(false);
expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n");
});
it("preserves surrounding content", () => {
const rc = path.join(tmpDir, ".zshrc");
const original =
"# my zshrc\n" +
"alias ll='ls -la'\n" +
"export MEM0_API_KEY='old'\n" +
"export OTHER=keepme\n";
fs.writeFileSync(rc, original);
updateShellRc(rc, "newkey");
const after = fs.readFileSync(rc, "utf-8");
expect(after).toContain("alias ll='ls -la'\n");
expect(after).toContain("export OTHER=keepme\n");
expect(after).toContain("# my zshrc\n");
expect(after).toContain('export MEM0_API_KEY="newkey"\n');
});
it("is idempotent when value already matches", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n');
expect(updateShellRc(rc, "same")).toBe(false);
});
it("is a no-op for missing files", () => {
const rc = path.join(tmpDir, ".zshrc"); // does not exist
expect(updateShellRc(rc, "x")).toBe(false);
});
});
// ── updateClaudeSettings ─────────────────────────────────────────────────
describe("updateClaudeSettings — never creates entries", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("does not create env block when none exists", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({
otherKey: 1,
});
});
it("does not create MEM0_API_KEY entry in existing env block", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
});
it("updates existing entry and preserves siblings", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2),
);
expect(updateClaudeSettings(settings, "fresh")).toBe(true);
const data = JSON.parse(fs.readFileSync(settings, "utf-8"));
expect(data.env.MEM0_API_KEY).toBe("fresh");
expect(data.env.OTHER).toBe("y");
});
it("is idempotent when value already matches", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "same" } }),
);
expect(updateClaudeSettings(settings, "same")).toBe(false);
});
it("is a no-op for malformed JSON", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, "{ this is not json");
expect(updateClaudeSettings(settings, "x")).toBe(false);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.4"
version = "0.2.5"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
+36
View File
@@ -0,0 +1,36 @@
"""Detect whether the CLI is being invoked from inside an AI-agent context.
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
agent runtime env var is present. The return value is a context **trigger
only** — the canonical agent identity is self-declared by the agent via
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
vars to fill the ``agent_caller`` field on the APIKey row.
Returns a short name or None. The list is curated, not exhaustive — env
vars we don't recognise fall through to None (caller treated as
non-agent). Honest reporting depends on ``--agent-caller``; this list is
just enough to enable the zero-friction auto-bootstrap UX.
"""
from __future__ import annotations
import os
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
("cline", ("CLINE_AGENT", "CLINE")),
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
("aider", ("AIDER_SESSION",)),
("goose", ("GOOSE_AGENT",)),
("windsurf", ("WINDSURF_AGENT",)),
)
def detect_agent_caller() -> str | None:
"""Return a canonical agent name if any agent env var is set, else None."""
for name, env_vars in _AGENT_CALLER_ENV:
if any(os.environ.get(v) for v in env_vars):
return name
return None
+71 -5
View File
@@ -237,6 +237,14 @@ def main_callback(
cmd_version()
raise typer.Exit()
if ctx.invoked_subcommand:
# Stash the active subcommand name so the JSON error envelope
# (print_error in agent mode) can report which command failed
# instead of an empty `"command": ""` field.
from mem0_cli.state import set_current_command
set_current_command(ctx.invoked_subcommand)
if ctx.invoked_subcommand and ctx.invoked_subcommand != "init":
# init fires its own telemetry from init_cmd.run_init with full M1-M6 props.
_fire_telemetry(ctx.invoked_subcommand)
@@ -851,6 +859,19 @@ def init(
force: bool = typer.Option(
False, "--force", help="Overwrite existing config without confirmation."
),
agent_signal: bool = typer.Option(
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
),
source: str | None = typer.Option(
None,
"--source",
help="Channel attribution for signup (e.g. github, hn, ph).",
),
agent_caller: str | None = typer.Option(
None,
"--agent-caller",
help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
),
) -> None:
"""Interactive setup wizard for mem0 CLI.
@@ -859,10 +880,38 @@ def init(
mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
mem0 init --agent --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap
mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present
"""
from mem0_cli.commands.init_cmd import run_init
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
run_init(
api_key=api_key,
user_id=user_id,
email=email,
code=code,
force=force,
source=source,
agent=agent_signal,
agent_caller=agent_caller,
)
@app.command(rich_help_panel="Setup")
def identify(
name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."),
) -> None:
"""Tag your active Agent Mode key with the AI agent that's using it.
Run this once after `mem0 init --agent` if you didn't pass --agent-caller.
Idempotent — re-running just overwrites the value.
Example:
mem0 identify claude-code
"""
from mem0_cli.commands.identify_cmd import run_identify
run_identify(name)
# (entity_app registered at module level, below sub-group definitions)
@@ -1198,11 +1247,28 @@ def main() -> None:
import sys
# Allow --json/--agent anywhere in the command line (not just before subcommand).
_json_flags = {"--json", "--agent"}
if any(a in _json_flags for a in sys.argv[1:]):
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
argv_rest = sys.argv[1:]
is_init = "init" in argv_rest
_global_flags = {"--json"} if is_init else {"--json", "--agent"}
if any(a in _global_flags for a in argv_rest):
from mem0_cli.state import set_agent_mode
set_agent_mode(True)
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
app()
try:
app()
finally:
# Surface any unclaimed Agent Mode notice once per command, after the
# primary output. In JSON/agent mode the notice is folded into the
# envelope by format_json_envelope, so skip the stderr banner there
# to avoid duplicate output.
from mem0_cli.state import is_agent_mode, take_notice
notice = take_notice()
if notice and not is_agent_mode():
from rich.console import Console
Console(stderr=True).print(f"\n[yellow]🔔 {notice}[/yellow]\n")
+21 -2
View File
@@ -30,7 +30,7 @@ class PlatformBackend(Backend):
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
from mem0_cli.state import is_agent_mode
from mem0_cli.state import capture_notice, is_agent_mode
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
resp = self._client.request(method, path, **kwargs)
@@ -48,7 +48,26 @@ class PlatformBackend(Backend):
resp.raise_for_status()
if resp.status_code == 204:
return {}
return resp.json()
data = resp.json()
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
# fallback for endpoints that return non-dict / non-dict-leading
# payloads) and stash it for end-of-command surfacing.
notice = None
if isinstance(data, dict) and "mem0_notice" in data:
notice = data.pop("mem0_notice")
elif (
isinstance(data, list)
and data
and isinstance(data[0], dict)
and "mem0_notice" in data[0]
):
notice = data[0].pop("mem0_notice")
if notice is None:
notice = resp.headers.get("X-Mem0-Notice-Message") or None
capture_notice(notice)
return data
def add(
self,
+4 -2
View File
@@ -87,10 +87,12 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
}
print(_json.dumps(envelope))
return
from rich.markup import escape
sym = _sym("✗", "[error]")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
if hint:
console.print(f" [{DIM_COLOR}]{hint}[/]")
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
def print_warning(console: Console, message: str) -> None:
@@ -0,0 +1,239 @@
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from typing import Any
import httpx
import typer
from rich.console import Console
from rich.prompt import Prompt
from mem0_cli.branding import (
BRAND_COLOR,
DIM_COLOR,
print_error,
print_success,
)
from mem0_cli.config import Mem0Config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def _validate_envelope(envelope: Any) -> None:
"""Defend against partial/malformed backend responses.
A backend regression that returns ``{"api_key": null}`` would otherwise be
silently persisted, producing confusing downstream errors far from the
source. Fail fast with a clear message if the required fields are missing.
"""
if not isinstance(envelope, dict):
print_error(err_console, "Bootstrap response was not a JSON object.")
raise typer.Exit(1)
for field in ("api_key", "default_user_id"):
value = envelope.get(field)
if not isinstance(value, str) or not value:
print_error(
err_console,
f"Bootstrap response missing required field {field!r} — please update the CLI.",
)
raise typer.Exit(1)
def bootstrap_via_backend(
config: Mem0Config,
*,
source: str | None = None,
agent_caller: str | None = None,
) -> None:
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
Args:
config: Mem0Config mutated in place with the new platform values.
source: ``--source`` flag passthrough (analytics tag, free-form).
agent_caller: Self-declared agent identity passed via ``--agent-caller``
(e.g. ``claude-code``, ``cursor``). May be None when the caller
omitted the flag; the agent can backfill later via
``mem0 identify <name>``. Sent to the backend in the request body
and saved into ``platform.agent_caller`` for local introspection.
Raises typer.Exit(1) on failure.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
body: dict[str, Any] = {}
if source:
body["source"] = source
if agent_caller:
body["agent_caller"] = agent_caller
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
f"{base_url}/api/v1/auth/agent_mode/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json=body,
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error contacting Mem0: {exc}")
raise typer.Exit(1) from exc
if resp.status_code == 429:
print_error(err_console, "Rate-limited. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code == 503:
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
raise typer.Exit(1)
if resp.status_code != 200:
detail = resp.text
try:
err_body = resp.json()
detail = err_body.get("error") or err_body.get("detail") or resp.text
except (json.JSONDecodeError, ValueError, AttributeError):
pass
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
# translates to a generic 403 "You do not have permission to perform
# this action." That's opaque — surface as the rate-limit it actually is.
if resp.status_code == 403 and "permission" in str(detail).lower():
print_error(
err_console,
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
)
raise typer.Exit(1)
print_error(err_console, f"Bootstrap failed: {detail}")
raise typer.Exit(1)
envelope = resp.json()
_validate_envelope(envelope)
config.platform.api_key = envelope["api_key"]
config.platform.base_url = base_url
config.platform.agent_mode = True
config.platform.created_via = "agent_mode"
config.platform.agent_caller = agent_caller or ""
config.platform.claimed_at = ""
config.platform.default_user_id = envelope["default_user_id"]
# Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.user_id = envelope["default_user_id"]
save_config(config)
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
notice = envelope.get("mem0_notice")
if notice:
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
else:
# Fallback if the backend hasn't deployed the unified notice yet.
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
if not agent_caller:
console.print(
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
)
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
`/.../verify/`) and adds the local agent-mode API key in the verify body
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
upgrade-in-place transaction inline and returns claim result.
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
`user_email`. The api_key value itself never changes.
"""
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
if not config.platform.api_key or not config.platform.agent_mode:
print_error(
err_console,
"This command requires an active Agent Mode config. Run `mem0 init` first.",
)
raise typer.Exit(1)
raw_key = config.platform.api_key
with httpx.Client(timeout=30.0) as client:
# Step 1: request OTP (unless --code provided)
if not code:
send = client.post(
f"{base_url}/api/v1/auth/email_code/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={"email": email},
)
if send.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if send.status_code != 200:
try:
detail = send.json().get("error", send.text)
except Exception:
detail = send.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, f"Verification code sent to {email}. Check your inbox.")
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint=f"Re-run: mem0 init --email {email} --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 2: verify + claim in one shot
verify = client.post(
f"{base_url}/api/v1/auth/email_code/verify/",
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
json={
"email": email,
"code": code.strip(),
"agent_mode_api_key": raw_key,
},
)
if verify.status_code != 200:
try:
err_body = verify.json()
detail = err_body.get("error", verify.text)
code_str = err_body.get("code", "")
except (json.JSONDecodeError, ValueError, AttributeError):
detail = verify.text
code_str = ""
print_error(err_console, f"Claim failed: {detail}")
if code_str == "email_already_claimed":
console.print(
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.[/]"
)
raise typer.Exit(1)
claim_body = verify.json()
if not claim_body.get("claimed"):
print_error(err_console, f"Unexpected verify response: {claim_body}")
raise typer.Exit(1)
config.platform.agent_mode = False
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
config.platform.user_email = email
config.platform.created_via = "email"
save_config(config)
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,75 @@
"""mem0 identify — declare which agent owns the current agent-mode key.
Used when `mem0 init --agent` ran without --agent-caller, so the backend
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
its own row with its real identity. Idempotent — running it again just
overwrites.
"""
from __future__ import annotations
import httpx
import typer
from rich.console import Console
from mem0_cli.branding import print_error, print_success
from mem0_cli.config import load_config, save_config
console = Console()
err_console = Console(stderr=True)
_SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
}
def run_identify(name: str) -> None:
"""PATCH the active agent-mode key's agent_caller field."""
config = load_config()
if not config.platform.api_key:
print_error(
err_console,
"No API key configured. Run `mem0 init --agent` first.",
)
raise typer.Exit(1)
if not config.platform.agent_mode:
print_error(
err_console,
"This command only works on unclaimed agent-mode keys.",
)
raise typer.Exit(1)
name = (name or "").strip()
if not name:
print_error(err_console, "Agent name is required.")
raise typer.Exit(1)
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
try:
with httpx.Client(timeout=30.0) as client:
resp = client.patch(
f"{base_url}/api/v1/auth/agent_mode/caller/",
headers={
**_SOURCE_HEADERS,
"Authorization": f"Token {config.platform.api_key}",
"Content-Type": "application/json",
},
json={"agent_caller": name},
)
except httpx.HTTPError as exc:
print_error(err_console, f"Network error: {exc}")
raise typer.Exit(1) from exc
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Identify failed: {detail}")
raise typer.Exit(1)
canonical = resp.json().get("agent_caller", name)
config.platform.agent_caller = canonical
save_config(config)
print_success(console, f"Identified as {canonical}.")
+149 -1
View File
@@ -103,6 +103,25 @@ def _validate_email(email: str) -> None:
raise typer.Exit(1)
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
"""Validate api_key against /v1/ping/.
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
Network errors, timeouts, and 5xx responses return True so we prefer
reusing an existing key over silently minting a new shadow on a transient
blip (which would also clobber config + plugin-sync targets).
"""
try:
resp = httpx.get(
f"{base_url.rstrip('/')}/v1/ping/",
headers={"Authorization": f"Token {api_key}"},
timeout=timeout,
)
except httpx.HTTPError:
return True # unknown — prefer reuse
return resp.status_code not in (401, 403)
def _email_login(
email: str,
code: str | None,
@@ -182,21 +201,143 @@ def run_init(
email: str | None = None,
code: str | None = None,
force: bool = False,
source: str | None = None,
agent: bool = False,
agent_caller: str | None = None,
) -> None:
"""Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped
(non-interactive mode). When running in a non-TTY without the required
flags, an error message is printed.
Agent Mode dispatch (no email/api-key flags):
- If existing config has an active API key → reuse (existing_key path).
- Else if any positive agent signal (--agent, --json global, agent env
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
- Else fall through to the interactive wizard.
Claim dispatch:
- If `--email` is set AND existing config has `agent_mode=true`, run the
claim device-flow against the existing key instead of minting a new
email-based key.
"""
from mem0_cli.agent_detect import detect_agent_caller
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
from mem0_cli.state import is_agent_mode as _global_agent_mode
from mem0_cli.telemetry import capture_event
def _fire_init(mode: str, *, claimed: bool = False) -> None:
"""Fire cli.init telemetry with M1-M6 properties."""
props: dict = {"command": "init", "mode": mode}
if agent_caller:
# Self-declared via --agent-caller; not sniffed from env vars.
props["agent_caller"] = agent_caller
if source:
props["signup_source"] = source
if claimed:
props["claimed_agent_mode"] = True
capture_event("cli.init", props)
config = Mem0Config()
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
config.platform.base_url = base_url
if code and not email:
print_error(err_console, "--code requires --email.")
raise typer.Exit(1)
# ── Email + existing agent-mode config → claim flow ─────────────────
if email and CONFIG_FILE.exists():
existing = load_config()
if existing.platform.agent_mode and existing.platform.api_key:
email = email.strip().lower()
_validate_email(email)
print_info(console, f"Claiming Agent Mode account to {email}...")
claim_via_otp(existing, email=email, code=code)
_fire_init("email", claimed=True)
return
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
# short-circuit before the guard prompts. Rule 3 mints only when there
# is no valid key to reuse — in that case overwriting is correct.
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
if not api_key and not email and _agent_ctx:
from mem0_cli.output import format_json_envelope
from mem0_cli.state import is_agent_mode as _is_json_mode
def _emit_reuse(source: str) -> None:
if _is_json_mode():
format_json_envelope(
console,
command="init",
data={
"api_key_saved": False,
"api_key_source": source,
"agent_mode": False,
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
)
else:
msg = (
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
if source == "env"
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
)
print_success(console, msg)
def _maybe_identify(key: str) -> None:
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
reused key. Silent no-op on any failure — reuse must not break.
"""
if not agent_caller:
return
try:
resp = httpx.patch(
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
headers={
"Authorization": f"Token {key}",
"Content-Type": "application/json",
},
json={"agent_caller": agent_caller},
timeout=10.0,
)
# Also reflect in local config so introspection matches backend.
if resp.status_code == 200 and CONFIG_FILE.exists():
try:
cfg = load_config()
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
save_config(cfg)
except Exception:
pass
except httpx.HTTPError:
pass
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
if _env_key and _ping_key(_env_key, base_url):
_maybe_identify(_env_key)
_emit_reuse("env")
_fire_init("existing_key")
return
# Rule 2: existing config api_key valid → reuse.
if CONFIG_FILE.exists():
_existing = load_config()
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
_maybe_identify(_existing.platform.api_key)
_emit_reuse("config")
_fire_init("existing_key")
return
# Rule 3: mint a fresh shadow (no valid key to reuse).
# agent_caller is the agent's self-declared identity from --agent-caller
# (Proof Editor-style). Env-var auto-detect is still used above to
# decide we're in an agent context, but never to fill identity.
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
_fire_init("agent")
return
# Warn if an existing config with an API key would be overwritten
if not force and CONFIG_FILE.exists():
existing = load_config()
@@ -242,6 +383,7 @@ def run_init(
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
@@ -258,6 +400,8 @@ def run_init(
return
# ── API key flow (existing) ───────────────────────────────────────
# (Agent Mode branch runs earlier — see above, before the existing-config
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
if not sys.stdin.isatty():
@@ -265,7 +409,7 @@ def run_init(
print_error(
err_console,
"Non-interactive terminal detected and --api-key is required.",
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
)
raise typer.Exit(1)
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
@@ -273,6 +417,7 @@ def run_init(
# Fully non-interactive when both flags provided
if api_key and user_id:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
config.defaults.user_id = user_id
_validate_platform(config)
save_config(config)
@@ -313,6 +458,7 @@ def run_init(
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.platform.user_email = email_addr
config.platform.created_via = "email"
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
@@ -331,6 +477,7 @@ def run_init(
# API key flow
if api_key:
config.platform.api_key = api_key
config.platform.created_via = "api_key"
else:
_setup_platform(config)
@@ -370,6 +517,7 @@ def _setup_platform(config: Mem0Config) -> None:
raise typer.Exit(1)
config.platform.api_key = api_key
config.platform.created_via = "api_key"
def _setup_defaults(config: Mem0Config) -> None:
+31
View File
@@ -28,6 +28,14 @@ class PlatformConfig:
api_key: str = ""
base_url: str = DEFAULT_BASE_URL
user_email: str = ""
# Agent Mode (unclaimed-shadow signup)
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
agent_caller: str = (
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
)
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
@dataclass
@@ -83,6 +91,11 @@ def load_config() -> Mem0Config:
config.platform.api_key = plat.get("api_key", "")
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
config.platform.user_email = plat.get("user_email", "")
config.platform.agent_mode = bool(plat.get("agent_mode", False))
config.platform.created_via = plat.get("created_via", "")
config.platform.agent_caller = plat.get("agent_caller", "")
config.platform.claimed_at = plat.get("claimed_at", "")
config.platform.default_user_id = plat.get("default_user_id", "")
defaults = data.get("defaults", {})
config.defaults.user_id = defaults.get("user_id", "")
@@ -136,6 +149,11 @@ def save_config(config: Mem0Config) -> None:
"api_key": config.platform.api_key,
"base_url": config.platform.base_url,
"user_email": config.platform.user_email,
"agent_mode": config.platform.agent_mode,
"created_via": config.platform.created_via,
"agent_caller": config.platform.agent_caller,
"claimed_at": config.platform.claimed_at,
"default_user_id": config.platform.default_user_id,
},
"telemetry": {
"anonymous_id": config.telemetry.anonymous_id,
@@ -147,6 +165,19 @@ def save_config(config: Mem0Config) -> None:
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
# Propagate the active api_key to ecosystem touchpoints (Claude Code
# plugin env injection, shell rc exports). Idempotent — only updates
# EXISTING entries; never creates new ones. Best-effort: any IOError
# in the sync is swallowed so config.json is always the authoritative
# write, never blocked by plugin-state issues.
if config.platform.api_key:
try:
from mem0_cli.plugin_sync import sync_api_key
sync_api_key(config.platform.api_key)
except Exception:
pass
def redact_key(key: str) -> str:
"""Redact an API key for display: m0-xxx...xxx"""
+19
View File
@@ -229,6 +229,16 @@ def format_json_envelope(
if error:
envelope["error"] = error
envelope["data"] = data
# If the platform flagged this as an unclaimed Agent Mode account, surface
# the notice inside the JSON envelope so an agent consuming the output
# sees it without needing to inspect HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
@@ -323,6 +333,15 @@ def format_agent_envelope(
if count is not None:
envelope["count"] = count
envelope["data"] = sanitize_agent_data(command, data)
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
# agent reading the JSON output sees it without inspecting HTTP headers.
from mem0_cli.state import take_notice
notice = take_notice()
if notice:
envelope["mem0_notice"] = notice
console.print_json(json.dumps(envelope, default=str))
+119
View File
@@ -0,0 +1,119 @@
"""Sync the active Mem0 API key into other ecosystem touchpoints.
Why this exists:
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
vars or their own config files. Without a sync, an agent-mode bootstrap
mints a new key into config.json but the plugin's MCP keeps using the
old key from env — silent surprise.
Design:
- Update ONLY entries that already exist (never create new ones)
- Preserve all surrounding content / formatting / other keys
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
- Idempotent — re-running with the same key is a no-op
- Skip on dry_run
Targets currently handled:
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
Out of scope (deliberately not touched):
- Codex / Cursor MCP configs — would require schema-aware edits and
those tools don't have mem0 entries by default
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import tempfile
from pathlib import Path
# Files we know how to update safely.
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
def sync_api_key(api_key: str) -> list[str]:
"""Propagate ``api_key`` into known ecosystem touchpoints.
Returns the list of paths actually updated. Empty list means nothing
needed updating (either targets didn't exist or already had this value).
"""
if not api_key:
return []
updated: list[str] = []
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
updated.append(str(_CLAUDE_SETTINGS))
for rc in _SHELL_RCS:
if _update_shell_rc(rc, api_key):
updated.append(str(rc))
return updated
def _update_claude_settings(path: Path, api_key: str) -> bool:
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
if not path.is_file():
return False
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return False
env = data.get("env")
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
# No existing entry — don't create one.
return False
if env["MEM0_API_KEY"] == api_key:
return False # already in sync
env["MEM0_API_KEY"] = api_key
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
return True
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
# end-of-file is preserved when MEM0_API_KEY is the last line.
_RC_LINE = re.compile(
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
re.MULTILINE,
)
def _update_shell_rc(path: Path, api_key: str) -> bool:
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
if not path.is_file():
return False
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
match = _RC_LINE.search(text)
if not match:
return False # no existing line
if match.group(3) == api_key:
return False
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
_atomic_write_text(path, new_text)
return True
def _atomic_write_text(path: Path, content: str) -> None:
"""Write content to path atomically (temp + rename)."""
dirname = path.parent
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
# Preserve mode if the original existed.
if path.exists():
os.chmod(tmp_path, path.stat().st_mode & 0o777)
os.replace(tmp_path, path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
+21
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
_agent_mode: bool = False
_current_command: str = ""
_pending_notice: str = ""
def is_agent_mode() -> bool:
@@ -22,3 +23,23 @@ def get_current_command() -> str:
def set_current_command(name: str) -> None:
global _current_command
_current_command = name
def capture_notice(notice: str | None) -> None:
"""Stash a Mem0 backend notice for end-of-command surfacing.
Called from the platform backend after each response so the notice can
be printed once per command (regardless of how many sub-requests fired).
Last-write-wins is fine — the message text is identical across requests.
"""
global _pending_notice
if notice:
_pending_notice = notice
def take_notice() -> str:
"""Return and clear the pending notice."""
global _pending_notice
msg = _pending_notice
_pending_notice = ""
return msg
+4 -2
View File
@@ -87,7 +87,6 @@ def capture_event(
try:
from mem0_cli import __version__
from mem0_cli.config import CONFIG_FILE, load_config, save_config
from mem0_cli.state import is_agent_mode
config = load_config()
distinct_id = pre_resolved_email or _get_distinct_id()
@@ -107,6 +106,9 @@ def capture_event(
with contextlib.suppress(Exception):
save_config(config)
# M4: every cli.* event carries agent_mode based on the config flag
# (unclaimed Agent Mode key). This is the growth-doc property used to
# join init → add → search funnels in PostHog.
payload = {
"api_key": POSTHOG_API_KEY,
"distinct_id": distinct_id,
@@ -115,7 +117,7 @@ def capture_event(
"source": "CLI",
"language": "python",
"cli_version": __version__,
"agent_mode": is_agent_mode(),
"agent_mode": bool(config.platform.agent_mode),
"python_version": sys.version,
"os": sys.platform,
"os_version": platform.version(),
+157
View File
@@ -0,0 +1,157 @@
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
sync so that the Python and Node CLIs expose an identical surface for the
Agent Mode entrypoint. If you add a flag here, add the same assertion on
the Node side (and vice versa).
Network-bound bootstrap is covered by the platform-side E2E suite
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
the CLI surface that ships in the binary.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
if home_dir:
env["HOME"] = home_dir
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
text=True,
env=env,
timeout=15,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
def clean_home(tmp_path):
return str(tmp_path)
class TestInitFlagSurface:
"""`mem0 init --help` must expose the Agent Mode flags."""
def test_init_help_lists_agent_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--agent" in result.stdout
def test_init_help_describes_agent_mode(self):
result = _run(["init", "--help"])
assert result.returncode == 0
# Description must mention what --agent actually does so an agent
# reading the help can self-discover the bootstrap entrypoint.
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
def test_init_help_lists_source_flag(self):
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--source" in result.stdout
def test_init_help_lists_email_and_code(self):
# Claim flow flags must remain present alongside Agent Mode flags.
result = _run(["init", "--help"])
assert result.returncode == 0
assert "--email" in result.stdout
assert "--code" in result.stdout
class TestArgvPreprocessing:
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
Regression for the bug where the top-level `--agent` JSON-alias was
stripped from ``sys.argv`` before Typer could bind it to the init
subcommand, making ``mem0 init --agent`` indistinguishable from a
plain ``mem0 init`` (interactive wizard).
"""
def test_init_with_agent_reaches_subcommand(self, clean_home):
# We can't hit a real backend in unit tests, so we point the CLI at
# a guaranteed-dead URL and assert the failure is the bootstrap
# request failing — proving the --agent flag was honored and the
# bootstrap branch ran, not the interactive wizard.
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
capture_output=True,
text=True,
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
"FORCE_COLOR": "0",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr).lower()
# Either we got a connection/network error from the bootstrap POST,
# or the CLI surfaced an Agent Mode-specific failure message.
assert (
"agent" in combined
or "connect" in combined
or "network" in combined
or "fetch" in combined
or "bootstrap" in combined
), f"Expected bootstrap attempt, got: {combined!r}"
class TestJsonEnvelopeParity:
"""`mem0 init --agent --json` should produce a JSON envelope on success.
Without a live backend we can only assert the failure shape: when the
backend is unreachable, the CLI must still exit non-zero AND not crash
on a Python traceback (which would mean we leaked an exception past
the agent-mode handler).
"""
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
result = subprocess.run(
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
capture_output=True,
text=True,
env={
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
"HOME": clean_home,
"MEM0_BASE_URL": "http://127.0.0.1:1",
"FORCE_COLOR": "0",
},
timeout=15,
)
combined = _strip_ansi(result.stdout + result.stderr)
assert "Traceback (most recent call last)" not in combined
assert result.returncode != 0
class TestInitInCommandList:
"""`mem0 --help` must list `init` so agents walking the top-level help
can discover the Agent Mode entrypoint without prior knowledge."""
def test_top_level_help_lists_init(self):
result = _run(["--help"])
assert result.returncode == 0
assert "init" in result.stdout
+206
View File
@@ -0,0 +1,206 @@
"""Unit tests for init internals — decision tree primitives + plugin sync.
These tests exercise the units that the high-level subprocess parity tests in
``test_agent_mode.py`` deliberately can't reach:
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
flap silently mints a new shadow over a working key).
- ``plugin_sync`` must only update entries that already exist, preserve
trailing newlines, and never mangle other lines.
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
real cause instead of DRF's opaque "You do not have permission" string.
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
behavioral assertion here, mirror it on the Node side and vice versa.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import pytest
from mem0_cli.commands.init_cmd import _ping_key
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
# ── _ping_key ──────────────────────────────────────────────────────────────
class _Resp:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
assert _ping_key("k", "http://x") is True
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
assert _ping_key("k", "http://x") is False
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
assert _ping_key("k", "http://x") is False
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
# Transient upstream failure must NOT cause a shadow to be minted.
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
assert _ping_key("k", "http://x") is True
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
def boom(*a, **kw):
raise httpx.ConnectError("nope")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(*a, **kw):
raise httpx.ReadTimeout("slow")
monkeypatch.setattr(httpx, "get", boom)
assert _ping_key("k", "http://x") is True
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is True
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
changed = _update_shell_rc(rc, "newkey")
assert changed is False
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
rc = tmp_path / ".zshrc"
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
rc.write_text(original, encoding="utf-8")
_update_shell_rc(rc, "newkey")
after = rc.read_text(encoding="utf-8")
assert "alias ll='ls -la'\n" in after
assert "export OTHER=keepme\n" in after
assert "# my zshrc\n" in after
assert 'export MEM0_API_KEY="newkey"\n' in after
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
rc = tmp_path / ".zshrc"
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
assert _update_shell_rc(rc, "same") is False
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
rc = tmp_path / ".zshrc" # does not exist
assert _update_shell_rc(rc, "x") is False
# ── plugin_sync._update_claude_settings ────────────────────────────────────
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
# Original content unchanged.
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
changed = _update_claude_settings(settings, "newkey")
assert changed is False
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
encoding="utf-8",
)
changed = _update_claude_settings(settings, "fresh")
assert changed is True
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["env"]["MEM0_API_KEY"] == "fresh"
assert data["env"]["OTHER"] == "y" # other keys preserved
def test_claude_settings_idempotent(tmp_path) -> None:
import json
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
assert _update_claude_settings(settings, "same") is False
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
settings = tmp_path / "settings.json"
settings.write_text("{ this is not json", encoding="utf-8")
assert _update_claude_settings(settings, "x") is False
# ── bootstrap rate-limit translation ──────────────────────────────────────
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
from mem0_cli.config import Mem0Config
fake_resp = MagicMock()
fake_resp.status_code = 403
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
fake_resp.json = MagicMock(
return_value={"detail": "You do not have permission to perform this action."}
)
class _Client:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, *a, **kw):
return fake_resp
monkeypatch.setattr(httpx, "Client", _Client)
cfg = Mem0Config()
cfg.platform.base_url = "https://api.mem0.ai"
import typer
with pytest.raises(typer.Exit):
bootstrap_via_backend(cfg)
captured = capsys.readouterr()
combined = captured.out + captured.err
assert "Daily Agent Mode signup limit reached" in combined
assert "permission to perform this action" not in combined
+2
View File
@@ -5,3 +5,5 @@ openapi: get /v1/event/{event_id}/
---
Retrieve details about a specific event by passing its `event_id`. This endpoint is particularly helpful for tracking the status, payload, and completion details of asynchronous memory operations.
For `POST /v3/memories/add/`, the event confirms that the write pipeline completed. Temporal reasoning enrichment runs asynchronously by default, so the event may be `SUCCEEDED` slightly before temporal ranking signals are available to subsequent `search` calls.
@@ -83,4 +83,3 @@ The request is queued for background processing. The response contains an `event
<Info>
Poll the event status via `GET /v1/event/{event_id}/`. Status will be `SUCCEEDED` or `FAILED` once processing completes.
</Info>
@@ -64,4 +64,3 @@ memories = client.get_all(
<Info>
The response is a paginated envelope with `count`, `next`, `previous`, and `results`. Use `page` and `page_size` query params to step through results.
</Info>
@@ -49,6 +49,7 @@ related_memories = client.search(
{
"id": "ea925981-272f-40dd-b576-be64e4871429",
"memory": "Likes to play cricket and plays cricket on weekends.",
"user_id": "alice",
"metadata": {
"category": "hobbies"
},
+16 -1
View File
@@ -4,6 +4,21 @@ 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**
@@ -112,4 +127,4 @@ Major expansion of the provider ecosystem:
First skill launch — a dedicated Mem0 skill providing platform API reference, quickstart patterns, and integration examples directly inside agent sessions. Available on [skills.sh](https://skills.sh) for any compatible AI coding agent.
</Update>
</Update>
+11 -1
View File
@@ -4,6 +4,17 @@ 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:**
@@ -301,4 +312,3 @@ mode: "wide"
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
</Update>
+18 -1
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, and entity boost into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries. Per-signal scores are not exposed on the response ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, entity signals, and temporal boosts into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
- **`search()` default `rerank` is now `False`** — Pass `rerank=True` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
- **`top_k` default changed 100 → 20** in `Memory.get_all()` and `Memory.search()` (sync + async). Pass `top_k=100` explicitly to restore the old behavior ([#4843](https://github.com/mem0ai/mem0/pull/4843))
- **Entity ID validation:** `user_id` / `agent_id` / `run_id` are trimmed; empty-string and whitespace-only values now raise `ValueError` ([#4843](https://github.com/mem0ai/mem0/pull/4843))
@@ -1323,6 +1323,23 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
<Tab title="CLI">
<Update label="2026-05-14" description="Python v0.2.5 / Node v0.2.5">
**New Features:**
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in <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,6 +156,10 @@ const memories = memory.search("food preferences", {
Expect an array of memory documents. Platform responses include vectors, metadata, and timestamps; OSS returns your stored schema.
</Info>
<Note>
On Mem0 Platform v3, time-aware queries use Temporal Reasoning internally while preserving the normal search response shape. See <Link href="/platform/features/temporal-reasoning">Temporal Reasoning</Link>.
</Note>
## Filter patterns
Filters help narrow down search results. Common use cases:
@@ -251,4 +255,4 @@ For the full list of filter logic, comparison operators, and optional search par
icon="rocket"
href="/cookbooks/operations/support-inbox"
/>
</CardGroup>
</CardGroup>
+3 -2
View File
@@ -72,7 +72,8 @@
"platform/features/entity-scoped-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
"platform/features/custom-categories"
"platform/features/custom-categories",
"platform/features/temporal-reasoning"
]
},
{
@@ -1144,4 +1145,4 @@
"destination": "/introduction"
}
]
}
}
+7
View File
@@ -11,6 +11,12 @@
- Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`).
- Source repo: https://github.com/mem0ai/mem0
### Don't have a Mem0 API key?
Run: `mem0 init --agent --agent-caller <your-name> --json` — replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init to attribute the signup.
This mints an evaluation key in <5 seconds — no email, no dashboard, no OTP required. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email <your-email>` to transfer ownership (memories preserved, same API key, no agent disruption).
## Install
- Python SDK: `pip install mem0ai`
@@ -185,6 +191,7 @@ 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.
+3 -2
View File
@@ -42,7 +42,7 @@ Previously, when an agent said something like "I've booked your flight for March
### Retrieval is hybrid now
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, or entities that appear across multiple memories. The response shape is unchanged:
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, entities that appear across multiple memories, and time-aware queries (via Temporal Reasoning). The response shape is unchanged:
```json
{
@@ -58,7 +58,7 @@ Search now uses hybrid retrieval, which improves ranking quality — especially
}
```
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries.
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries. Temporal signals are applied internally during ranking and are not returned as extra client-facing fields.
## API Changes
@@ -289,6 +289,7 @@ If your application previously read graph relations from the API response (`rela
- **V1 and V2 endpoints continue to work.** There is no requirement to migrate to V3 endpoints immediately.
- **Existing memories are preserved.** The new algorithm does not modify or re-process previously stored memories.
- **Search response shape is unchanged.** The top-level `score` and `results[]` array are the same; existing code that reads `score` continues to work. What changed is the scoring method behind the number (multi-signal fusion instead of pure cosine), so the absolute values shift even when ranking stays comparable.
- **Search remains backward-compatible at the top level.** Existing code that reads `results[]` and `score` continues to work. Temporal signals are applied internally during retrieval and do not change the client response shape.
- **List response shape changed.** `get_all` now returns a paginated envelope (`{count, next, previous, results}`) instead of a bare `{results: [...]}`. Update code that reads `response["results"]` to continue working, or switch to the client SDKs which handle both shapes.
## Performance Improvements
+17 -2
View File
@@ -419,7 +419,7 @@
},
"results": {
"type": "array",
"description": "Array of results produced by the event."
"description": "Array of results produced by the event. For add events, this confirms the write completed; temporal reasoning enrichment runs asynchronously by default."
},
"created_at": {
"type": "string",
@@ -2071,7 +2071,7 @@
"memories"
],
"summary": "Search memories (V3)",
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval — the returned `score` is a combined `[0, 1]` value; per-signal component scores are not exposed on the response. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
"operationId": "memories_search_v3",
"requestBody": {
"required": true,
@@ -2112,6 +2112,21 @@
"type": "boolean",
"default": false,
"description": "Apply the managed reranker for better ordering (adds latency)."
},
"reference_date": {
"oneOf": [
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "string"
}
],
"nullable": true,
"description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime."
}
}
},
+26 -28
View File
@@ -59,6 +59,14 @@ The toggle lives on the project. You enable decay by patching the project's `dec
The toggle is exposed on the standard project-update endpoint, the same place where `multilingual` and `custom_categories` live.
<CodeGroup>
```python Python
client.project.update(decay=True)
```
```javascript JavaScript
await client.project.update({ decay: true });
```
```bash cURL
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
-H "Authorization: Token $MEM0_API_KEY" \
@@ -66,34 +74,6 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR
-d '{"decay": true}'
```
```python Python
import os
import requests
org_id = os.environ["MEM0_ORG_ID"]
project_id = os.environ["MEM0_PROJECT_ID"]
requests.patch(
f"https://api.mem0.ai/api/v1/orgs/organizations/{org_id}/projects/{project_id}/",
headers={"Authorization": f"Token {os.environ['MEM0_API_KEY']}"},
json={"decay": True},
)
```
```javascript Node.js
const res = await fetch(
`https://api.mem0.ai/api/v1/orgs/organizations/${process.env.MEM0_ORG_ID}/projects/${process.env.MEM0_PROJECT_ID}/`,
{
method: "PATCH",
headers: {
Authorization: `Token ${process.env.MEM0_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ decay: true }),
},
);
```
```json Response
{ "message": "Updated decay" }
```
@@ -104,6 +84,16 @@ const res = await fetch(
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
<CodeGroup>
```python Python
response = client.project.get(fields=["decay"])
print(response["decay"])
```
```javascript JavaScript
const response = await client.project.get({ fields: ["decay"] });
console.log(response.decay);
```
```bash cURL
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
-H "Authorization: Token $MEM0_API_KEY"
@@ -119,6 +109,14 @@ curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID
The toggle is fully reversible. Setting it to `false` immediately restores the pre-decay ranking; nothing about your stored memories is modified or lost.
<CodeGroup>
```python Python
client.project.update(decay=False)
```
```javascript JavaScript
await client.project.update({ decay: false });
```
```bash cURL
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
-H "Authorization: Token $MEM0_API_KEY" \
@@ -0,0 +1,145 @@
---
title: Temporal Reasoning
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories."
icon: "clock"
badge: "v3"
---
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results.
<Info>
**Use Temporal Reasoning when…**
- Users ask questions like "what happened last week?" or "what do I have coming up?"
- Your app stores both past events and future plans for the same person
- You want time-aware retrieval without building your own date-parsing layer
</Info>
<Warning>
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
</Warning>
## Configure access
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client:
```python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
```
## How it works
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time.
A query like `what did I do last week?` should return a completed past event — not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
### Memory types Temporal Reasoning handles
| Type | What it represents | Example |
| --- | --- | --- |
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
| Preference | A stable preference or habit | "I prefer morning meetings." |
Results come back in the normal search response shape — Temporal Reasoning affects ranking, not the response format.
## Configure it
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
Two parameters give you precise control when you need it:
- `timestamp` on `add()` — anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0
- `reference_date` on `search()` — resolves relative phrases like `last week` against a fixed point in time
<CodeGroup>
```python Python
from datetime import datetime, timezone
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Import a historical memory anchored to when it happened
client.add(
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
user_id="jordan",
timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()),
)
# Search with a relative query anchored to a known date
results = client.search(
"what did I do last week?",
filters={"user_id": "jordan"},
reference_date="2025-03-21T00:00:00Z",
)
```
```javascript JavaScript
import { MemoryClient } from "mem0ai";
const client = new MemoryClient({ apiKey: "your-api-key" });
// Import a historical memory anchored to when it happened
await client.add(
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
{
userId: "jordan",
timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000),
}
);
// Search with a relative query anchored to a known date
const results = await client.search("what did I do last week?", {
filters: { user_id: "jordan" },
referenceDate: "2025-03-21T00:00:00Z",
});
```
</CodeGroup>
<Tip>
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
</Tip>
## Supported query patterns
<AccordionGroup>
<Accordion title="Historical questions">
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
</Accordion>
<Accordion title="Upcoming questions">
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
</Accordion>
<Accordion title="Current-state questions">
Examples: `right now`, `currently`, `where do I work now?`
</Accordion>
<Accordion title="As-of questions">
Examples: `as of March 2025`, `where was I living as of 2024?`
</Accordion>
<Accordion title="Duration questions">
Examples: `how long have I lived here?`, `since when have I worked there?`
</Accordion>
</AccordionGroup>
## Verify the feature is working
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
## Best practices
- Use explicit dates in source conversations when events or plans matter temporally.
- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor.
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
- Use `reference_date` in automated tests and reproducible demos.
<CardGroup cols={1}>
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
Anchor imported memories to when they actually happened.
</Card>
</CardGroup>
<Snippet file="get-help.mdx" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.1.1",
"version": "0.1.2",
"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",
+38
View File
@@ -2,6 +2,18 @@
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, and **Codex**.
## Quick path for agents
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup available), run:
```bash
mem0 init --agent --json
```
This mints an evaluation API key in <5 seconds — no email, no OTP, no browser. The key is written to your config and exported as `MEM0_API_KEY` for the current shell. Use it immediately against the Mem0 Platform or the MCP server. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, the same API key keeps working, and the agent isn't disrupted.
Humans setting up Mem0 by hand should continue with Step 1 below.
## Step 1: Set your API key
> **You must complete this step before installing the plugin.**
@@ -157,6 +169,32 @@ After installing, confirm the MCP server is connected:
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
## Updating the plugin
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
- **Cursor:** quit and relaunch.
- **Codex:** restart the editor session.
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
## Optional: tune categories for coding workflows
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
```bash
# Dry-run first -- prints current vs proposed, no changes:
python mem0-plugin/scripts/setup_coding_categories.py
# Actually write:
python mem0-plugin/scripts/setup_coding_categories.py --apply
```
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
## MCP Tools
Once installed, the following tools are available:
-4
View File
@@ -15,10 +15,6 @@
"preCompact": [
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
},
{
"command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py",
"timeout": 30
}
],
"stop": [
-6
View File
@@ -30,12 +30,6 @@
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
"statusMessage": "Preparing pre-compaction summary..."
},
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py",
"statusMessage": "Saving session state to mem0...",
"timeout": 30
}
]
}
+59
View File
@@ -0,0 +1,59 @@
"""Resolve mem0 user_id with deterministic priority.
Resolution priority:
1. MEM0_USER_ID env var (explicit override)
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
4. Fallback: $USER, else "default"
Same MEM0_API_KEY across machines yields the same user_id, which fixes
the "47 user buckets per account" symptom from running on multiple
laptops with different $USER values.
"""
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime, timezone
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
def resolve_user_id() -> str:
explicit = os.environ.get("MEM0_USER_ID", "").strip()
if explicit:
return explicit
api_key = os.environ.get("MEM0_API_KEY", "").strip()
if api_key:
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
fingerprint = digest[:8]
try:
with open(_CACHE_PATH, "r") as f:
cached = json.load(f)
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
return cached["user_id"]
except (OSError, json.JSONDecodeError):
pass
derived = "mem0-" + digest[:12]
try:
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
with open(_CACHE_PATH, "w") as f:
json.dump(
{
"user_id": derived,
"source": "api_key",
"api_key_fingerprint": fingerprint,
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
f,
)
except OSError:
pass
return derived
return os.environ.get("USER") or "default"
+57
View File
@@ -0,0 +1,57 @@
# Source this file. Sets MEM0_RESOLVED_USER_ID.
#
# Resolution priority:
# 1. MEM0_USER_ID env var (explicit override)
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
# 4. Fallback: $USER, else "default"
#
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
# the "47 user buckets per account" symptom from running on multiple
# laptops with different $USER values.
_mem0_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | cut -d' ' -f1
else
shasum -a 256 | cut -d' ' -f1
fi
}
_mem0_resolve_identity() {
if [ -n "${MEM0_USER_ID:-}" ]; then
printf '%s' "$MEM0_USER_ID"
return
fi
local api_key="${MEM0_API_KEY:-}"
local cache="$HOME/.mem0/identity.json"
if [ -n "$api_key" ]; then
local digest
digest=$(printf '%s' "$api_key" | _mem0_sha256)
local fp="${digest:0:8}"
if [ -f "$cache" ]; then
local cached_fp cached_id
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
printf '%s' "$cached_id"
return
fi
fi
local derived="mem0-${digest:0:12}"
mkdir -p "$HOME/.mem0" 2>/dev/null && \
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
printf '%s' "$derived"
return
fi
printf '%s' "${USER:-default}"
}
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
export MEM0_RESOLVED_USER_ID
+5 -1
View File
@@ -13,6 +13,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
@@ -22,7 +26,7 @@ if [ -z "$FILE_PATH" ]; then
fi
case "$FILE_PATH" in
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
*/MEMORY.md|*/.claude/memory/*)
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
exit 2
;;
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Capture the post-compaction summary into mem0.
PreCompact hooks fire BEFORE the summary is generated, so they can't
store the actual compact-summary text. This script runs at
SessionStart with source=compact, reads the transcript, finds the
most recent entry flagged isCompactSummary=true, and stores it as a
memory tagged metadata.type=compact_summary.
Input: JSON on stdin with transcript_path, session_id, source
Output: stderr logs only (exit 0 always -- must not block)
Spawned in the background by on_session_start.sh; the user-facing
bootstrap text continues without waiting on the network.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
log = logging.getLogger("mem0-compact-summary")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 2000
MAX_SUMMARY_CHARS = 50000
# Compact summaries describe a single session's state -- stale after a quarter.
COMPACT_SUMMARY_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
try:
with open(filepath, "rb") as f:
f.seek(0, 2)
file_size = f.tell()
if file_size == 0:
return []
chunk_size = min(file_size, n * 4096)
f.seek(max(0, file_size - chunk_size))
data = f.read().decode("utf-8", errors="replace")
return data.splitlines()[-n:]
except OSError:
return []
def find_compact_summary(lines: list[str]) -> str:
"""Walk transcript backwards, return text content of the most recent
entry flagged isCompactSummary=true. Empty string if none found."""
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not entry.get("isCompactSummary"):
continue
message = entry.get("message", {})
content = message.get("content", [])
if isinstance(content, str):
return content[:MAX_SUMMARY_CHARS]
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
return ""
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
body = {
"messages": [{"role": "user", "content": summary}],
"user_id": user_id,
"metadata": {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
},
"infer": False,
"expiration_date": expires,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v1/memories/",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status in (200, 201):
log.info("Compact summary stored")
return True
log.warning("API returned status %d", resp.status)
return False
except urllib.error.URLError as e:
log.warning("API call failed: %s", e)
return False
def main():
api_key = os.environ.get("MEM0_API_KEY", "")
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
return
try:
hook_input = json.loads(sys.stdin.read())
except (json.JSONDecodeError, OSError):
log.debug("No valid JSON on stdin")
return
transcript_path = hook_input.get("transcript_path", "")
if not transcript_path:
log.debug("No transcript_path provided")
return
session_id = hook_input.get("session_id", "")
user_id = resolve_user_id()
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
log.debug("Transcript empty or unreadable: %s", transcript_path)
return
summary = find_compact_summary(lines)
if not summary:
log.debug("No isCompactSummary entry found")
return
log.info("Capturing compact summary (%d chars)", len(summary))
store_summary(api_key, summary, user_id, session_id)
if __name__ == "__main__":
try:
main()
except Exception as e:
log.error("Unexpected error: %s", e)
sys.exit(0)
+26 -4
View File
@@ -18,8 +18,12 @@ import json
import logging
import os
import sys
import urllib.request
import urllib.error
import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
log = logging.getLogger("mem0-capture")
log.setLevel(logging.DEBUG)
@@ -27,11 +31,25 @@ _handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
API_URL = "https://api.mem0.ai"
MAX_TAIL_LINES = 500
MAX_USER_MESSAGES = 30
MAX_BASH_COMMANDS = 20
MAX_ASSISTANT_TEXT = 10000
# session_state captures churn fast (active codebase, files in flight). Past
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
# stored separately by the agent without an expiration_date.
SESSION_STATE_EXPIRY_DAYS = 90
def tail_lines(filepath: str, n: int) -> list[str]:
@@ -149,8 +167,9 @@ def build_content(state: dict, source: str) -> str:
return "\n".join(parts)
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
"""Store session state as a memory via the Mem0 REST API."""
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
body = {
"messages": [
{"role": "user", "content": content}
@@ -159,7 +178,9 @@ def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
"metadata": {
"type": "session_state",
"source": source,
"session_id": session_id,
},
"expiration_date": expires,
}
data = json.dumps(body).encode("utf-8")
@@ -207,7 +228,8 @@ def main():
log.debug("No transcript_path provided")
return
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
session_id = hook_input.get("session_id", "")
user_id = resolve_user_id()
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
@@ -228,7 +250,7 @@ def main():
len(state["bash_commands"]),
)
store_memory(api_key, content, user_id, source)
store_memory(api_key, content, user_id, source, session_id)
if __name__ == "__main__":
+20 -6
View File
@@ -5,12 +5,16 @@
# the full context before it gets compressed.
#
# Output: Text instructions injected into Claude's context.
# Claude still has the full conversation and can write an accurate summary.
# A companion Python script (on_pre_compact.py) also runs to capture
# transcript state directly via the Mem0 REST API as a safety net.
# Claude still has the full conversation and can write an accurate summary,
# which it stores via add_memory(infer=False) so the platform preserves
# the structure verbatim instead of running a second extraction pass.
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
cat <<'EOF'
## CRITICAL: Pre-Compaction Session Summary
@@ -18,7 +22,9 @@ Context compaction is about to happen. You are about to lose most of your conver
### Step 1: Store session summary
Call `add_memory` with a thorough summary covering ALL of the following:
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
```
## Session Summary (Pre-Compaction)
@@ -44,11 +50,19 @@ Call `add_memory` with a thorough summary covering ALL of the following:
the post-compaction agent continue without asking redundant questions]
```
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
Tool call shape:
```
add_memory(
messages=[{"role":"user","content":"<the summary above>"}],
user_id="<the active user_id from the SessionStart bootstrap>",
metadata={"type":"session_state","source":"pre-compaction"},
infer=False,
)
```
### Step 2: Store any unstored learnings
If there are learnings from this session that you haven't stored yet, store them as separate memories:
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
- Failed approaches -> metadata `{"type": "anti_pattern"}`
- Successful strategies -> metadata `{"type": "task_learning"}`
- Architecture decisions -> metadata `{"type": "decision"}`
+37 -4
View File
@@ -11,9 +11,34 @@
# even if jq is missing or stdin is malformed.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
# Skip the bootstrap entirely if no API key is configured -- the agent
# would otherwise be told to call mem0 MCP tools that will all fail.
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
# Identity line is emitted before every bootstrap variant so the agent
# uses the same user_id the hooks resolved. Without this, the agent's
# search_memories/add_memory MCP calls may bind to a different bucket
# than what the hooks write to.
echo "## Mem0 Identity"
echo ""
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
echo ""
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
echo ""
if [ "$SOURCE" = "startup" ]; then
cat <<'EOF'
## Mem0 Session Bootstrap
@@ -40,14 +65,22 @@ Continue where you left off.
EOF
elif [ "$SOURCE" = "compact" ]; then
# Capture the just-generated compact summary in the background.
# PreCompact fires too early to see this entry; SessionStart-compact
# is the first place isCompactSummary=true is in the transcript.
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
cat <<'EOF'
## Mem0 Post-Compaction Recovery
Context was just compacted. You may have lost important session context.
Context was just compacted. The Claude Code-generated compact summary
is being captured to mem0 in the background as `metadata.type=compact_summary`.
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
2. Check for any session state memories that were saved before compaction.
3. Continue working based on the recovered context.
1. Call `search_memories` to reload context, layering up to three angles:
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
2. Continue working from the recovered context.
EOF
fi
+4
View File
@@ -12,6 +12,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
+4
View File
@@ -17,6 +17,10 @@
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
+4
View File
@@ -9,6 +9,10 @@
set -euo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
+8 -1
View File
@@ -13,6 +13,10 @@
# must never block the user's prompt.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
@@ -26,7 +30,10 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
USER_ID="$MEM0_RESOLVED_USER_ID"
cat <<EOF
## Memory check
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Replace mem0's default category taxonomy with one tuned for coding workflows.
mem0 auto-tags every memory with one or more `categories`. By default the list
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
This script replaces the project's category list with a coding-focused one.
The change is project-level (per the platform docs, per-request overrides are
not supported on the managed API). Run once per project; future memories will
be tagged using the new list automatically.
Usage:
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
python setup_coding_categories.py --apply # actually call project.update()
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
CODING_CATEGORIES = [
{
"architecture_decisions": (
"Design choices, system structure, technology selection, trade-offs evaluated, "
"and architectural patterns adopted in the project."
)
},
{
"anti_patterns": (
"Approaches that failed, debugging dead-ends, common mistakes to avoid, "
"and lessons learned from things that didn't work."
)
},
{
"task_learnings": (
"Strategies and approaches that succeeded for specific tasks, including tooling "
"tricks, workflow shortcuts, and effective problem-solving patterns."
)
},
{
"tooling_setup": (
"Development environment, build tools, dependencies, package managers, deploy "
"pipelines, and configuration steps for the project."
)
},
{
"bug_fixes": (
"Specific bug fixes with root cause analysis, the fix applied, and how the bug "
"was diagnosed -- useful for recognising similar issues later."
)
},
{
"coding_conventions": (
"Code style, naming patterns, file organisation, error-handling conventions, "
"and team agreements about how code is written in this project."
)
},
{
"user_preferences": (
"User's stated preferences for tools, libraries, languages, formatting, "
"and ways of working."
)
},
]
def _print_categories(label: str, cats):
print(f"=== {label} ===")
if cats:
print(json.dumps(cats, indent=2))
else:
print("(none / using mem0 defaults)")
print()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument(
"--apply",
action="store_true",
help="Actually call project.update(). Without this flag, runs in dry-run mode.",
)
args = ap.parse_args()
if not os.environ.get("MEM0_API_KEY"):
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
return 1
try:
from mem0 import MemoryClient
except ImportError:
print(
"ERROR: the mem0ai Python SDK is not installed.\n"
"Install with: pip install mem0ai\n"
"Then re-run this script.",
file=sys.stderr,
)
return 1
try:
client = MemoryClient()
except Exception as e:
print(
f"ERROR initialising MemoryClient: {e}\n"
"Most commonly this is an invalid MEM0_API_KEY -- check the key at "
"https://app.mem0.ai/dashboard/api-keys",
file=sys.stderr,
)
return 1
try:
current = client.project.get(fields=["custom_categories"])
current_cats = current.get("custom_categories") if isinstance(current, dict) else None
except Exception as e:
print(f"ERROR fetching current categories: {e}", file=sys.stderr)
return 1
_print_categories("Current project categories", current_cats)
_print_categories("Proposed coding categories", CODING_CATEGORIES)
if not args.apply:
print("Dry-run only -- no changes made. Re-run with --apply to write.")
return 0
print("Applying coding categories...")
try:
response = client.project.update(custom_categories=CODING_CATEGORIES)
except Exception as e:
print(f"ERROR applying update: {e}", file=sys.stderr)
return 1
print("Done.", response if response else "")
return 0
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -97,8 +97,47 @@ Extract key learnings and store them using the `add_memory` tool:
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
- **Conventions established** -> Include metadata `{"type": "convention"}`
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
### Expiration: high-churn vs durable
Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes.
| Type | Expiration | Why |
|---|---|---|
| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. |
| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. |
`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule.
### Recency filter on recall
When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface:
```python
# Last 90 days only
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
```
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
### Use `infer=False` for already-structured content
When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it.
```python
add_memory(
messages=[{"role": "user", "content": "<your structured fact>"}],
user_id="<active user_id>",
metadata={"type": "decision"},
infer=False,
)
```
Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure.
## Before losing context
If context is about to be compacted or the session is ending, store a comprehensive session summary:
+2
View File
@@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key"
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=mem0-plugin-skill
> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email or dashboard. The human can claim later with `mem0 init --email <your-email>`.
## Step 2: Initialize the client
**Python:**
+2
View File
@@ -4,3 +4,5 @@ __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,6 +19,18 @@ openclaw --version
|------------------|----------------|
| `>= 2026.4.25` | Fully supported |
## Quick path for agents
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup), mint an evaluation API key in <5 seconds:
```bash
mem0 init --agent --json
```
No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY` — pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id <id>` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, same API key keeps working, no agent disruption.
Humans should follow the Quick Start below.
## Quick Start
### Platform (Mem0 Cloud)
+2
View File
@@ -154,3 +154,5 @@ known-first-party = ["mem0", "mem0_cli"]
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).
+1195
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -38,7 +38,23 @@ Both packages install a `mem0` binary with identical commands, options, and outp
## Setup
**Interactive wizard:**
**Autonomous Mem0 setup for agents (no email, no dashboard):**
```bash
mem0 init --agent --agent-caller <your-name> --json
```
Replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init — it PATCHes the same key, idempotent, no disruption.
This mints an evaluation API key in <5 seconds. Use it immediately. Print this line to the human:
> Mem0 memory is running in Agent Mode. Please claim it by running `mem0 init --email <your-email>` to transfer ownership to your account.
Never commit API keys, `.env`, or `~/.mem0/config.json`.
After the human claims, the same API key keeps working — no agent disruption — and memories are preserved.
**Interactive wizard (for humans):**
```bash
mem0 init
```
@@ -35,12 +35,16 @@ Interactive setup wizard. Configures API key and default user ID.
| `--email <addr>` | string | - | Login via email verification code instead of API key. |
| `--code <code>` | string | - | Verification code (use with `--email` for fully non-interactive login). |
| `--force` | boolean | false | Overwrite existing config without confirmation. |
| `--agent` | boolean | false | Bootstrap an Agent Mode account (no email required). |
| `--agent-caller <name>` | string | - | Self-declared agent identity for Agent Mode (e.g. `claude-code`, `cursor`). |
| `--source <channel>` | string | - | Channel attribution for signup analytics. |
**Behavior:**
- If `~/.mem0/config.json` already exists with an API key, warns and asks for confirmation (or errors in non-TTY unless `--force` is set).
- **Email login flow** (`--email`): sends a 6-digit code to the email via `POST /api/v1/auth/email_code/`. If `--code` is also given, verifies immediately. On success, saves API key, org_id, and project_id. Cannot be combined with `--api-key`.
- **API key flow**: if both `--api-key` and `--user-id` are given, runs fully non-interactively. Otherwise prompts for missing values.
- **Agent Mode flow** (`--agent`): POSTs to `/api/v1/auth/agent_mode/`, mints a shadow API key in <5s with no email required. Pass `--agent-caller <your-name>` to attribute the signup to your AI agent identity. If omitted, run `mem0 identify <your-name>` afterward.
- In non-TTY without sufficient flags, prints a usage hint and exits with error.
**Examples:**
@@ -50,6 +54,30 @@ mem0 init --api-key m0-xxx --user-id alice
mem0 init --api-key m0-xxx --user-id alice --force
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
mem0 init --agent --agent-caller claude-code # AI agent self-identifies during bootstrap
```
---
### `mem0 identify`
Tag your active Agent Mode key with the AI agent that's using it. Run this once after `mem0 init --agent` if you didn't pass `--agent-caller`. Idempotent — re-running just overwrites the value.
**Usage:** `mem0 identify <name>`
**Argument:** `<name>` — the AI agent identity (e.g. `claude-code`, `cursor`, `codex`, `cline`, `aider`, or a custom string).
**Behavior:**
- PATCHes `/api/v1/auth/agent_mode/caller/` with `Authorization: Token <current-api-key>` and body `{agent_caller}`.
- Only works on unclaimed agent-mode keys (`platform.agent_mode=true` in config).
- Backend sanitizes the value: lowercases, drops anything outside `[a-z0-9._/-]`, truncates to 32 chars.
**Examples:**
```bash
mem0 identify claude-code
mem0 identify cursor
mem0 identify my-custom-bot
```
---
+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 missing → **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`)
exits with code 2 and the name of the missing key.
If `MEM0_API_KEY` is missing AND the track is **Platform** → **default to Agent Mode**: run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Cache the key to `.env` (with user consent) and continue. Tell the user to claim later with `mem0 init --email <their-email>` — same key, no agent disruption.
If missing AND **CI mode** (`MEM0_INTEGRATE_CI=1`) → exit with code 2 and the name of the missing key.
Never echo key values into `trace.jsonl`. Persist to `.env` only with
explicit user consent, and append `.env` to `.gitignore` if not already there.
+2
View File
@@ -46,6 +46,8 @@ export MEM0_API_KEY="m0-your-api-key"
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=skill-mem0
> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), substituting your agent identity (e.g. `claude-code`, `cursor`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. The human can claim later with `mem0 init --email <your-email>`.
## Step 2: Initialize the client
**Python:**
+838
View File
@@ -0,0 +1,838 @@
from __future__ import annotations
import json
import os
import subprocess
import threading
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "oss-to-platform-migrate.sh"
class MigrationHTTPServer:
def __init__(
self,
*,
ping_emails: dict[str, str] | None = None,
verify_api_key: str = "verified-key",
verify_status: int = 200,
qdrant_api_key: str = "qdrant-key",
qdrant_collection: str = "mem0",
qdrant_pages: list[dict[str, Any]] | None = None,
platform_memories: list[dict[str, Any]] | None = None,
) -> None:
self.ping_emails = ping_emails or {}
self.verify_api_key = verify_api_key
self.verify_status = verify_status
self.qdrant_api_key = qdrant_api_key
self.qdrant_collection = qdrant_collection
self.qdrant_pages = qdrant_pages or [{"points": [], "next_page_offset": None}]
self.platform_memories = platform_memories or []
self.requests: list[dict[str, Any]] = []
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
self.url = f"http://127.0.0.1:{self._server.server_port}"
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
def __enter__(self) -> "MigrationHTTPServer":
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self._server.shutdown()
self._server.server_close()
self._thread.join(timeout=5)
def _handler(self) -> type[BaseHTTPRequestHandler]:
owner = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format: str, *_args: object) -> None:
return
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b""
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _record(self, body: dict[str, Any] | None = None) -> None:
owner.requests.append(
{
"method": self.command,
"path": self.path,
"headers": dict(self.headers),
"body": body or {},
}
)
def do_GET(self) -> None:
self._record()
if self.path == "/v1/ping/":
auth = self.headers.get("Authorization", "")
token = auth.removeprefix("Token ")
email = owner.ping_emails.get(token)
if email:
self._send_json(200, {"user_email": email})
else:
self._send_json(401, {"detail": "Invalid token"})
return
if self.path == f"/collections/{owner.qdrant_collection}":
if self.headers.get("api-key") != owner.qdrant_api_key:
self._send_json(401, {"status": {"error": "unauthorized"}})
else:
self._send_json(200, {"result": {"status": "green"}, "status": "ok"})
return
self._send_json(404, {"detail": "Not found"})
def do_POST(self) -> None:
body = self._read_json()
self._record(body)
parsed = urlparse(self.path)
if self.path == "/posthog":
self._send_json(200, {"ok": True})
return
if self.path == "/api/v1/auth/email_code/":
self._send_json(200, {"sent": True})
return
if self.path == "/api/v1/auth/email_code/verify/":
if owner.verify_status != 200:
self._send_json(owner.verify_status, {"error": "bad verification code"})
else:
self._send_json(200, {"api_key": owner.verify_api_key})
return
if self.path == f"/collections/{owner.qdrant_collection}/points/scroll":
if self.headers.get("api-key") != owner.qdrant_api_key:
self._send_json(401, {"status": {"error": "unauthorized"}})
return
offset = body.get("offset")
page_index = int(offset) if offset is not None else 0
page = owner.qdrant_pages[page_index]
self._send_json(
200,
{
"result": {
"points": page["points"],
"next_page_offset": page.get("next_page_offset"),
},
"status": "ok",
},
)
return
if parsed.path == "/v3/memories/":
query = parse_qs(parsed.query)
page = int(query.get("page", ["1"])[0])
page_size = int(query.get("page_size", ["100"])[0])
filters = body.get("filters") if isinstance(body.get("filters"), dict) else {}
filtered = owner.platform_memories
for key in ("user_id", "agent_id", "run_id"):
if key in filters:
filtered = [memory for memory in filtered if memory.get(key) == filters[key]]
start = (page - 1) * page_size
end = start + page_size
page_results = filtered[start:end]
next_url = (
f"{owner.url}/v3/memories/?page={page + 1}&page_size={page_size}"
if end < len(filtered)
else None
)
self._send_json(
200,
{
"count": len(filtered),
"next": next_url,
"previous": None,
"results": page_results,
},
)
return
if parsed.path == "/v3/memories/add/":
memory_id = f"platform-{len(owner.platform_memories) + 1}"
message = (body.get("messages") or [{}])[0]
memory = {
"id": memory_id,
"memory": message.get("content"),
"metadata": body.get("metadata"),
"user_id": body.get("user_id"),
"agent_id": body.get("agent_id"),
"run_id": body.get("run_id"),
}
owner.platform_memories.append(memory)
self._send_json(
200,
{
"message": "Memories stored successfully",
"status": "SUCCEEDED",
"event_id": "event-1",
"results": [{"id": memory_id, "data": {"memory": message.get("content")}, "event": "ADD"}],
},
)
return
self._send_json(404, {"detail": "Not found"})
return Handler
def alias_marker(anon_id: str, email: str) -> str:
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
def write_config(mem0_dir: Path, data: dict[str, Any]) -> None:
mem0_dir.mkdir(parents=True, exist_ok=True)
(mem0_dir / "config.json").write_text(json.dumps(data), encoding="utf-8")
def read_config(mem0_dir: Path) -> dict[str, Any]:
return json.loads((mem0_dir / "config.json").read_text(encoding="utf-8"))
def run_migration_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
raw_config: str | None = None,
) -> tuple[subprocess.CompletedProcess[str], Path]:
mem0_dir = tmp_path / "mem0"
if config is not None:
write_config(mem0_dir, config)
if raw_config is not None:
mem0_dir.mkdir(parents=True, exist_ok=True)
(mem0_dir / "config.json").write_text(raw_config, encoding="utf-8")
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
["bash", str(SCRIPT), "--auth-only", "--base-url", server.url, *args],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir
def run_export_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
qdrant_api_key: str = "qdrant-key",
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
mem0_dir = tmp_path / "mem0"
output_path = tmp_path / "export.json"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"QDRANT_API_KEY": qdrant_api_key,
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--export-only",
"--qdrant-url",
server.url,
"--qdrant-collection",
server.qdrant_collection,
"--output",
str(output_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir, output_path
def run_import_script(
tmp_path: Path,
server: MigrationHTTPServer,
input_path: Path,
*args: str,
config: dict[str, Any] | None = None,
api_key: str = "import-key",
) -> tuple[subprocess.CompletedProcess[str], Path]:
mem0_dir = tmp_path / "mem0"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"MEM0_API_KEY": api_key,
}
)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--import-only",
"--base-url",
server.url,
"--input",
str(input_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir
def run_full_script(
tmp_path: Path,
server: MigrationHTTPServer,
*args: str,
config: dict[str, Any] | None = None,
qdrant_api_key: str = "qdrant-key",
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
mem0_dir = tmp_path / "mem0"
output_path = tmp_path / "full-export.json"
if config is not None:
write_config(mem0_dir, config)
env = os.environ.copy()
env.update(
{
"MEM0_DIR": str(mem0_dir),
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
"QDRANT_API_KEY": qdrant_api_key,
}
)
env.pop("MEM0_API_KEY", None)
env.pop("MEM0_BASE_URL", None)
result = subprocess.run(
[
"bash",
str(SCRIPT),
"--base-url",
server.url,
"--qdrant-url",
server.url,
"--qdrant-collection",
server.qdrant_collection,
"--output",
str(output_path),
*args,
],
capture_output=True,
text=True,
env=env,
start_new_session=True,
timeout=20,
check=False,
)
return result, mem0_dir, output_path
def posthog_events(server: MigrationHTTPServer) -> list[dict[str, Any]]:
return [request["body"] for request in server.requests if request["path"] == "/posthog"]
def test_existing_api_key_authenticates_and_stitches_ids(tmp_path: Path) -> None:
config = {
"user_id": "oss-123",
"platform": {"api_key": "stored-key", "base_url": "https://api.mem0.ai"},
"telemetry": {"anonymous_id": "cli-456"},
}
with MigrationHTTPServer(ping_emails={"stored-key": "bob@example.com"}) as server:
result, mem0_dir = run_migration_script(tmp_path, server, "--yes", config=config)
assert result.returncode == 0, result.stderr
assert "Authenticated as bob@example.com" in result.stdout
assert not any(request["path"] == "/api/v1/auth/email_code/verify/" for request in server.requests)
updated = read_config(mem0_dir)
assert updated["platform"] == config["platform"]
assert alias_marker("oss-123", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
assert alias_marker("cli-456", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.started" in event_names
assert "oss.migrate.authenticated" in event_names
assert event_names.count("$identify") == 2
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
assert authenticated["distinct_id"] == "bob@example.com"
assert authenticated["properties"]["local_anonymous_id"] == "oss-123"
assert authenticated["properties"]["authenticated_email"] == "bob@example.com"
def test_email_code_authenticates_without_persisting_credentials(tmp_path: Path) -> None:
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"Alice@Example.COM",
"--code",
"123456",
)
assert result.returncode == 0, result.stderr
assert "Authenticated as alice@example.com" in result.stdout
verify_request = next(request for request in server.requests if request["path"] == "/api/v1/auth/email_code/verify/")
assert verify_request["body"] == {"email": "alice@example.com", "code": "123456"}
assert not any(request["path"] == "/api/v1/auth/email_code/" for request in server.requests)
updated = read_config(mem0_dir)
assert "api_key" not in updated.get("platform", {})
assert "user_email" not in updated.get("platform", {})
assert updated["user_id"]
assert alias_marker(updated["user_id"], "alice@example.com") in updated["telemetry"]["aliased_pairs"]
events = posthog_events(server)
assert [event["event"] for event in events].count("$identify") == 1
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
assert authenticated["properties"]["auth_method"] == "email_code"
def test_invalid_stored_key_falls_back_to_email_code(tmp_path: Path) -> None:
config = {
"user_id": "oss-fallback",
"platform": {"api_key": "bad-key", "base_url": "https://api.mem0.ai"},
}
with MigrationHTTPServer(ping_emails={"verified-key": "new@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"new@example.com",
"--code",
"123456",
config=config,
)
assert result.returncode == 0, result.stderr
assert "Stored Mem0 Platform API key is invalid or expired" in result.stdout
assert "Authenticated as new@example.com" in result.stdout
ping_tokens = [
request["headers"]["Authorization"].removeprefix("Token ")
for request in server.requests
if request["path"] == "/v1/ping/"
]
assert ping_tokens == ["bad-key", "verified-key"]
updated = read_config(mem0_dir)
assert updated["platform"] == config["platform"]
assert alias_marker("oss-fallback", "new@example.com") in updated["telemetry"]["aliased_pairs"]
def test_email_code_failure_reports_failed_telemetry(tmp_path: Path) -> None:
with MigrationHTTPServer(verify_status=400) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"fail@example.com",
"--code",
"bad",
)
assert result.returncode == 1
assert "Verification failed: bad verification code" in result.stderr
updated = read_config(mem0_dir)
assert "telemetry" not in updated or "aliased_pairs" not in updated["telemetry"]
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.started" in event_names
assert "oss.migrate.failed" in event_names
assert "$identify" not in event_names
failed = next(event for event in events if event["event"] == "oss.migrate.failed")
assert "Verification failed" in failed["properties"]["error"]
def test_malformed_config_does_not_crash_and_authenticates(tmp_path: Path) -> None:
with MigrationHTTPServer(ping_emails={"verified-key": "malformed@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"malformed@example.com",
"--code",
"123456",
raw_config="{not valid json",
)
assert result.returncode == 0, result.stderr
assert "Authenticated as malformed@example.com" in result.stdout
updated = read_config(mem0_dir)
assert updated["user_id"]
assert alias_marker(updated["user_id"], "malformed@example.com") in updated["telemetry"]["aliased_pairs"]
def test_weird_telemetry_shape_does_not_crash(tmp_path: Path) -> None:
config = {"user_id": "oss-weird-telemetry", "telemetry": "not-an-object"}
with MigrationHTTPServer(ping_emails={"verified-key": "weird@example.com"}) as server:
result, mem0_dir = run_migration_script(
tmp_path,
server,
"--email",
"weird@example.com",
"--code",
"123456",
config=config,
)
assert result.returncode == 0, result.stderr
updated = read_config(mem0_dir)
assert isinstance(updated["telemetry"], dict)
assert alias_marker("oss-weird-telemetry", "weird@example.com") in updated["telemetry"]["aliased_pairs"]
def test_missing_python3_prints_clear_shell_error(tmp_path: Path) -> None:
env = os.environ.copy()
env["PATH"] = str(tmp_path)
result = subprocess.run(
["/bin/bash", str(SCRIPT), "--help"],
capture_output=True,
text=True,
env=env,
timeout=20,
check=False,
)
assert result.returncode == 1
assert "python3 is required to run the Mem0 migration" in result.stderr
def test_curl_piped_help_works() -> None:
result = subprocess.run(
["bash", "-c", f"curl -fsSL file://{SCRIPT} | bash -s -- --help"],
capture_output=True,
text=True,
timeout=20,
check=False,
)
assert result.returncode == 0, result.stderr
assert "Migrate Python OSS hosted-Qdrant memories" in result.stdout
def test_export_qdrant_memories_to_json_without_vectors_or_api_key(tmp_path: Path) -> None:
pages = [
{
"points": [
{
"id": "point-1",
"vector": [0.1, 0.2],
"payload": {
"data": "User likes dark mode",
"hash": "hash-1",
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z",
"user_id": "alice",
"agent_id": "agent-1",
"run_id": "run-1",
"actor_id": "actor-1",
"role": "user",
"topic": "preferences",
"text_lemmatized": "user like dark mode",
},
}
],
"next_page_offset": 1,
},
{
"points": [
{
"id": "point-2",
"vector": [0.3, 0.4],
"payload": {
"data": "User prefers concise answers",
"hash": "hash-2",
"user_id": "alice",
"metadata_note": "extra",
},
}
],
"next_page_offset": None,
},
]
with MigrationHTTPServer(qdrant_pages=pages) as server:
result, _mem0_dir, output_path = run_export_script(
tmp_path,
server,
"--user-id",
"alice",
"--qdrant-page-size",
"1",
config={"user_id": "oss-export-user"},
)
assert result.returncode == 0, result.stderr
assert "Exported 2 memories" in result.stdout
artifact = json.loads(output_path.read_text(encoding="utf-8"))
assert artifact["kind"] == "mem0_oss_qdrant_export"
assert artifact["source"]["sdk"] == "python"
assert artifact["source"]["vector_store"] == "qdrant"
assert artifact["source"]["storage"] == "hosted"
assert artifact["source"]["filters"]["user_id"] == "alice"
assert artifact["record_count"] == 2
assert artifact["local_anonymous_id"] == "oss-export-user"
first = artifact["records"][0]
assert first["id"] == "point-1"
assert first["memory"] == "User likes dark mode"
assert first["hash"] == "hash-1"
assert first["user_id"] == "alice"
assert first["agent_id"] == "agent-1"
assert first["run_id"] == "run-1"
assert first["actor_id"] == "actor-1"
assert first["role"] == "user"
assert first["metadata"] == {"topic": "preferences"}
assert all("vector" not in record for record in artifact["records"])
assert "qdrant-key" not in output_path.read_text(encoding="utf-8")
scroll_requests = [request for request in server.requests if request["path"].endswith("/points/scroll")]
assert len(scroll_requests) == 2
assert scroll_requests[0]["body"]["with_vector"] is False
assert scroll_requests[0]["body"]["filter"] == {"must": [{"key": "user_id", "match": {"value": "alice"}}]}
assert scroll_requests[1]["body"]["offset"] == 1
def test_export_requires_scope_or_all(tmp_path: Path) -> None:
with MigrationHTTPServer() as server:
result, _mem0_dir, output_path = run_export_script(tmp_path, server)
assert result.returncode == 1
assert "Export requires --user-id, --agent-id, --run-id, or --all" in result.stderr
assert not output_path.exists()
assert not any(request["path"].endswith("/points/scroll") for request in server.requests)
def test_export_all_uses_no_qdrant_filter(tmp_path: Path) -> None:
with MigrationHTTPServer(qdrant_pages=[{"points": [], "next_page_offset": None}]) as server:
result, _mem0_dir, output_path = run_export_script(tmp_path, server, "--all")
assert result.returncode == 0, result.stderr
artifact = json.loads(output_path.read_text(encoding="utf-8"))
assert artifact["record_count"] == 0
assert artifact["records"] == []
scroll_request = next(request for request in server.requests if request["path"].endswith("/points/scroll"))
assert "filter" not in scroll_request["body"]
def test_export_invalid_qdrant_credentials_fail_clearly(tmp_path: Path) -> None:
with MigrationHTTPServer(qdrant_api_key="correct-key") as server:
result, _mem0_dir, output_path = run_export_script(
tmp_path,
server,
"--user-id",
"alice",
qdrant_api_key="wrong-key",
)
assert result.returncode == 1
assert "Qdrant authentication failed" in result.stderr
assert not output_path.exists()
def test_import_platform_memories_from_export_json(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
input_path.write_text(
json.dumps(
{
"source": {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"},
"records": [
{
"id": "local-1",
"memory": "User likes barbecue",
"hash": "hash-1",
"created_at": "2026-05-07T00:00:00Z",
"user_id": "alice",
"metadata": {"topic": "food"},
}
],
}
),
encoding="utf-8",
)
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 1" in result.stdout
assert "Failed: 0" in result.stdout
add_request = next(request for request in server.requests if request["path"] == "/v3/memories/add/")
body = add_request["body"]
assert body["messages"] == [{"role": "user", "content": "User likes barbecue"}]
assert body["user_id"] == "alice"
assert body["infer"] is False
assert body["source"] == "migration"
assert body["timestamp"] == 1778112000
assert body["metadata"]["topic"] == "food"
assert body["metadata"]["mem0_migration_source"] == "python_oss_qdrant"
assert body["metadata"]["mem0_migration_collection"] == "mem0_test"
assert body["metadata"]["mem0_migration_local_id"] == "local-1"
assert body["metadata"]["mem0_migration_local_hash"] == "hash-1"
events = posthog_events(server)
assert "oss.migrate.completed" in [event["event"] for event in events]
def test_import_skips_existing_identical_memory(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
record = {"id": "local-1", "memory": "User likes barbecue", "hash": "hash-1", "user_id": "alice"}
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
existing = [
{
"id": "platform-1",
"memory": "User likes barbecue",
"user_id": "alice",
"metadata": {
"mem0_migration_import_key": import_key,
"mem0_migration_local_hash": "hash-1",
},
}
]
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 0" in result.stdout
assert "Skipped existing identical: 1" in result.stdout
assert "Changed existing: 0" in result.stdout
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
def test_import_reports_changed_existing_without_update_or_add(tmp_path: Path) -> None:
input_path = tmp_path / "import.json"
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
record = {"id": "local-1", "memory": "User likes brisket", "hash": "hash-new", "user_id": "alice"}
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
existing = [
{
"id": "platform-1",
"memory": "User likes barbecue",
"user_id": "alice",
"metadata": {
"mem0_migration_import_key": import_key,
"mem0_migration_local_hash": "hash-old",
},
}
]
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
assert result.returncode == 0, result.stderr
assert "Imported: 0" in result.stdout
assert "Skipped existing identical: 0" in result.stdout
assert "Changed existing: 1" in result.stdout
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
review_path_line = next(line for line in result.stdout.splitlines() if line.startswith("Review file: "))
review_path = Path(review_path_line.removeprefix("Review file: "))
review = json.loads(review_path.read_text(encoding="utf-8"))
assert review["records"][0]["status"] == "changed_existing"
assert review["records"][0]["platform_memory_id"] == "platform-1"
def test_full_flow_auth_export_and_imports_memories(tmp_path: Path) -> None:
qdrant_pages = [
{
"points": [
{
"id": "point-1",
"payload": {
"data": "User likes barbecue",
"hash": "hash-1",
"created_at": "2026-05-07T00:00:00Z",
"user_id": "alice",
},
}
],
"next_page_offset": None,
}
]
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}, qdrant_pages=qdrant_pages) as server:
result, _mem0_dir, output_path = run_full_script(
tmp_path,
server,
"--email",
"alice@example.com",
"--code",
"123456",
"--user-id",
"alice",
)
assert result.returncode == 0, result.stderr
assert "Phase 1/3: Authenticate with Mem0 Platform" in result.stdout
assert "Phase 2/3: Export Python OSS memories from hosted Qdrant" in result.stdout
assert "Phase 3/3: Import memories into Mem0 Platform" in result.stdout
assert "Imported: 1" in result.stdout
assert output_path.exists()
assert any(request["path"] == "/v3/memories/add/" for request in server.requests)
events = posthog_events(server)
event_names = [event["event"] for event in events]
assert "oss.migrate.authenticated" in event_names
assert "oss.migrate.completed" in event_names