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.
This commit is contained in:
Mgeeeek
2026-05-14 02:11:39 +05:30
parent 1c92c466c4
commit a2516269cc
22 changed files with 583 additions and 35 deletions
+2
View File
@@ -86,6 +86,8 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra
## 🚀 Quickstart Guide <a name="quickstart"></a> ## 🚀 Quickstart Guide <a name="quickstart"></a>
> **Agent Mode — for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. Use it 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 | | | Library | Self-Hosted Server | Cloud Platform |
|---|---------|-------------------|----------------| |---|---------|-------------------|----------------|
| **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use | | **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use |
+4 -2
View File
@@ -503,7 +503,7 @@
}, },
{ {
"name": "init", "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]", "usage": "mem0 init [OPTIONS]",
"needsBackend": false, "needsBackend": false,
"needsConfig": 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": "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": "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": "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)." }
] ]
}, },
{ {
+34 -2
View File
@@ -3,7 +3,7 @@
*/ */
import type { PlatformConfig } from "../config.js"; import type { PlatformConfig } from "../config.js";
import { isAgentMode } from "../state.js"; import { captureNotice, isAgentMode } from "../state.js";
import { CLI_VERSION } from "../version.js"; import { CLI_VERSION } from "../version.js";
import { import {
APIError, APIError,
@@ -90,7 +90,39 @@ export class PlatformBackend implements Backend {
if (resp.status === 204) { if (resp.status === 204) {
return {}; 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( async add(
+32 -9
View File
@@ -21,13 +21,17 @@ export interface BootstrapEnvelope {
mcp_url?: string; mcp_url?: string;
smoke_test_url?: string; smoke_test_url?: string;
claim_command?: string; claim_command?: string;
mem0_notice?: string;
} }
export async function bootstrapViaBackend( export async function bootstrapViaBackend(
config: Mem0Config, config: Mem0Config,
{ source }: { source?: string | null } = {}, { source }: { source?: string | null } = {},
): Promise<void> { ): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (source) body.source = source; if (source) body.source = source;
@@ -43,7 +47,9 @@ export async function bootstrapViaBackend(
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });
} catch (err) { } catch (err) {
printError(`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`); printError(
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1); process.exit(1);
} }
@@ -79,10 +85,16 @@ export async function bootstrapViaBackend(
config.defaults.userId = envelope.default_user_id; config.defaults.userId = envelope.default_user_id;
saveConfig(config); saveConfig(config);
printSuccess(`Agent Mode active. Default user_id: ${envelope.default_user_id}`); printSuccess(
console.log( `Agent Mode active. Default user_id: ${envelope.default_user_id}`,
` ${dim(`To claim this account later: ${envelope.claim_command ?? "mem0 init --email <your-email>"}`)}`,
); );
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}`)}`);
}
} }
/** /**
@@ -97,9 +109,14 @@ export async function claimViaOtp(
config: Mem0Config, config: Mem0Config,
{ email, code }: { email: string; code?: string }, { email, code }: { email: string; code?: string },
): Promise<void> { ): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(/\/+$/, ""); const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
if (!config.platform.apiKey || !config.platform.agentMode) { if (!config.platform.apiKey || !config.platform.agentMode) {
printError("This command requires an active Agent Mode config. Run `mem0 init` first."); printError(
"This command requires an active Agent Mode config. Run `mem0 init` first.",
);
process.exit(1); process.exit(1);
} }
@@ -163,7 +180,10 @@ export async function claimViaOtp(
let detail: string = verifyResp.statusText; let detail: string = verifyResp.statusText;
let errCode = ""; let errCode = "";
try { try {
const errBody = (await verifyResp.json()) as { error?: string; code?: string }; const errBody = (await verifyResp.json()) as {
error?: string;
code?: string;
};
if (errBody.error) detail = errBody.error; if (errBody.error) detail = errBody.error;
if (errBody.code) errCode = errBody.code; if (errBody.code) errCode = errBody.code;
} catch { } catch {
@@ -178,7 +198,10 @@ export async function claimViaOtp(
process.exit(1); process.exit(1);
} }
const body = (await verifyResp.json()) as { claimed?: boolean; claimed_at?: string }; const body = (await verifyResp.json()) as {
claimed?: boolean;
claimed_at?: string;
};
if (!body.claimed) { if (!body.claimed) {
printError(`Unexpected verify response: ${JSON.stringify(body)}`); printError(`Unexpected verify response: ${JSON.stringify(body)}`);
process.exit(1); process.exit(1);
+11 -5
View File
@@ -258,7 +258,10 @@ export async function runInit(
const { isAgentMode } = await import("../state.js"); const { isAgentMode } = await import("../state.js");
const { captureEvent } = await import("../telemetry.js"); const { captureEvent } = await import("../telemetry.js");
const fireInit = (mode: "agent" | "email" | "api_key" | "existing_key", claimed = false) => { const fireInit = (
mode: "agent" | "email" | "api_key" | "existing_key",
claimed = false,
) => {
const props: Record<string, unknown> = { command: "init", mode }; const props: Record<string, unknown> = { command: "init", mode };
const caller = detectAgentCaller(); const caller = detectAgentCaller();
if (caller) props.agent_caller = caller; if (caller) props.agent_caller = caller;
@@ -286,7 +289,12 @@ export async function runInit(
} }
// ── Claim flow: --email against an existing agent-mode config ─────────── // ── Claim flow: --email against an existing agent-mode config ───────────
if (opts.email && fs.existsSync(CONFIG_FILE) && savedConfig.platform.agentMode && savedConfig.platform.apiKey) { if (
opts.email &&
fs.existsSync(CONFIG_FILE) &&
savedConfig.platform.agentMode &&
savedConfig.platform.apiKey
) {
const email = opts.email.trim().toLowerCase(); const email = opts.email.trim().toLowerCase();
validateEmail(email); validateEmail(email);
printInfo(`Claiming Agent Mode account to ${email}...`); printInfo(`Claiming Agent Mode account to ${email}...`);
@@ -370,9 +378,7 @@ export async function runInit(
// recognized agent env var. Pure "no TTY" alone is NOT enough — pipe // recognized agent env var. Pure "no TTY" alone is NOT enough — pipe
// users would get surprised by a silent shadow signup. // users would get surprised by a silent shadow signup.
const agentCtx = const agentCtx =
opts.agent === true || opts.agent === true || isAgentMode() || detectAgentCaller() !== null;
isAgentMode() ||
detectAgentCaller() !== null;
if (!opts.apiKey && !opts.email && agentCtx) { if (!opts.apiKey && !opts.email && agentCtx) {
await bootstrapViaBackend(config, { source: opts.source ?? null }); await bootstrapViaBackend(config, { source: opts.source ?? null });
fireInit("agent"); fireInit("agent");
+23 -4
View File
@@ -13,7 +13,7 @@ import { colors, printError, printWarning } from "./branding.js";
import type { Mem0Config } from "./config.js"; import type { Mem0Config } from "./config.js";
import { loadConfig, saveConfig } from "./config.js"; import { loadConfig, saveConfig } from "./config.js";
import { richFormatHelp } from "./help.js"; import { richFormatHelp } from "./help.js";
import { setAgentMode } from "./state.js"; import { isAgentMode, setAgentMode, takeNotice } from "./state.js";
import { captureEvent } from "./telemetry.js"; import { captureEvent } from "./telemetry.js";
import { CLI_VERSION } from "./version.js"; import { CLI_VERSION } from "./version.js";
@@ -197,8 +197,15 @@ program
"Verification code (use with --email for non-interactive login).", "Verification code (use with --email for non-interactive login).",
) )
.option("--force", "Overwrite existing config without confirmation.", false) .option("--force", "Overwrite existing config without confirmation.", false)
.option("--agent", "Bootstrap an unattended Agent Mode account (no email required).", false) .option(
.option("--source <channel>", "Channel attribution for signup (e.g. github, hn, ph).") "--agent",
"Bootstrap an unattended Agent Mode account (no email required).",
false,
)
.option(
"--source <channel>",
"Channel attribution for signup (e.g. github, hn, ph).",
)
.addHelpText( .addHelpText(
"after", "after",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present", "\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\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",
@@ -777,4 +784,16 @@ program
// ── Entrypoint ──────────────────────────────────────────────────────────── // ── 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 boxen from "boxen";
import Table from "cli-table3"; import Table from "cli-table3";
import { colors, sym } from "./branding.js"; import { colors, sym } from "./branding.js";
import { takeNotice } from "./state.js";
const { brand, accent, success, error: errorColor, dim } = colors; 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.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error; if (opts.error) envelope.error = opts.error;
envelope.data = opts.data; 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)); console.log(JSON.stringify(envelope, null, 2));
} }
@@ -356,6 +366,12 @@ export function formatAgentEnvelope(opts: {
} }
if (opts.count !== undefined) envelope.count = opts.count; if (opts.count !== undefined) envelope.count = opts.count;
envelope.data = sanitizeAgentData(opts.command, opts.data); 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)); console.log(JSON.stringify(envelope, null, 2));
} }
+17
View File
@@ -5,6 +5,7 @@
let _agentMode = false; let _agentMode = false;
let _currentCommand = ""; let _currentCommand = "";
let _pendingNotice = "";
export function isAgentMode(): boolean { export function isAgentMode(): boolean {
return _agentMode; return _agentMode;
@@ -21,3 +22,19 @@ export function getCurrentCommand(): string {
export function setCurrentCommand(name: string): void { export function setCurrentCommand(name: string): void {
_currentCommand = name; _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;
}
+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");
});
});
+24 -5
View File
@@ -856,7 +856,9 @@ def init(
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)." False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
), ),
source: str | None = typer.Option( source: str | None = typer.Option(
None, "--source", help="Channel attribution for signup (e.g. github, hn, ph).", None,
"--source",
help="Channel attribution for signup (e.g. github, hn, ph).",
), ),
) -> None: ) -> None:
"""Interactive setup wizard for mem0 CLI. """Interactive setup wizard for mem0 CLI.
@@ -1215,11 +1217,28 @@ def main() -> None:
import sys import sys
# Allow --json/--agent anywhere in the command line (not just before subcommand). # Allow --json/--agent anywhere in the command line (not just before subcommand).
_json_flags = {"--json", "--agent"} # Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
if any(a in _json_flags for a in sys.argv[1:]): # 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 from mem0_cli.state import set_agent_mode
set_agent_mode(True) 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: 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" self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
resp = self._client.request(method, path, **kwargs) resp = self._client.request(method, path, **kwargs)
@@ -48,7 +48,26 @@ class PlatformBackend(Backend):
resp.raise_for_status() resp.raise_for_status()
if resp.status_code == 204: if resp.status_code == 204:
return {} 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( def add(
self, self,
@@ -15,7 +15,6 @@ from mem0_cli.branding import (
BRAND_COLOR, BRAND_COLOR,
DIM_COLOR, DIM_COLOR,
print_error, print_error,
print_info,
print_success, print_success,
) )
from mem0_cli.config import Mem0Config, save_config from mem0_cli.config import Mem0Config, save_config
@@ -81,7 +80,13 @@ def bootstrap_via_backend(
save_config(config) save_config(config)
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}") print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
console.print(f" [{DIM_COLOR}]To claim this account later: {envelope.get('claim_command', 'mem0 init --email <your-email>')}[/]") 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}[/]")
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None: def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
@@ -161,7 +166,9 @@ def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) ->
code_str = "" code_str = ""
print_error(err_console, f"Claim failed: {detail}") print_error(err_console, f"Claim failed: {detail}")
if code_str == "email_already_claimed": 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.[/]") 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) raise typer.Exit(1)
body = verify.json() body = verify.json()
+19
View File
@@ -229,6 +229,16 @@ def format_json_envelope(
if error: if error:
envelope["error"] = error envelope["error"] = error
envelope["data"] = data 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)) console.print_json(json.dumps(envelope, default=str))
@@ -323,6 +333,15 @@ def format_agent_envelope(
if count is not None: if count is not None:
envelope["count"] = count envelope["count"] = count
envelope["data"] = sanitize_agent_data(command, data) 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)) console.print_json(json.dumps(envelope, default=str))
+21
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
_agent_mode: bool = False _agent_mode: bool = False
_current_command: str = "" _current_command: str = ""
_pending_notice: str = ""
def is_agent_mode() -> bool: def is_agent_mode() -> bool:
@@ -22,3 +23,23 @@ def get_current_command() -> str:
def set_current_command(name: str) -> None: def set_current_command(name: str) -> None:
global _current_command global _current_command
_current_command = name _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
+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
+6
View File
@@ -11,6 +11,12 @@
- Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`). - Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`).
- Source repo: https://github.com/mem0ai/mem0 - Source repo: https://github.com/mem0ai/mem0
### Don't have a Mem0 API key?
Run: `mem0 init --agent --json`
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 ## Install
- Python SDK: `pip install mem0ai` - Python SDK: `pip install mem0ai`
+12
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**. 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 ## Step 1: Set your API key
> **You must complete this step before installing the plugin.** > **You must complete this step before installing the plugin.**
+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 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 ## Step 2: Initialize the client
**Python:** **Python:**
+12
View File
@@ -19,6 +19,18 @@ openclaw --version
|------------------|----------------| |------------------|----------------|
| `>= 2026.4.25` | Fully supported | | `>= 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 ## Quick Start
### Platform (Mem0 Cloud) ### Platform (Mem0 Cloud)
+15 -1
View File
@@ -38,7 +38,21 @@ Both packages install a `mem0` binary with identical commands, options, and outp
## Setup ## Setup
**Interactive wizard:** **Autonomous Mem0 setup for agents (no email, no dashboard):**
```bash
mem0 init --agent --json
```
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 ```bash
mem0 init mem0 init
``` ```
+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 | | OSS (default LLM) | `OPENAI_API_KEY` | https://platform.openai.com/api-keys |
If present in env → continue. If present in env → continue.
If missing → **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`) If `MEM0_API_KEY` is missing AND the track is **Platform** → **default to Agent Mode**: run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email/dashboard friction. 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.
exits with code 2 and the name of the missing key. 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 Never echo key values into `trace.jsonl`. Persist to `.env` only with
explicit user consent, and append `.env` to `.gitignore` if not already there. 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 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 --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 ## Step 2: Initialize the client
**Python:** **Python:**