Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e00815589 | ||
|
|
e2fd8dc7a8 | ||
|
|
d19cde4a15 | ||
|
|
565a76960b | ||
|
|
d987243584 | ||
|
|
4ca67801c0 | ||
|
|
739402946c | ||
|
|
240168aefe | ||
|
|
07b9ad519d | ||
|
|
6ba334ef94 | ||
|
|
8bd7b913c6 | ||
|
|
367512b525 | ||
|
|
79e04c56f6 | ||
|
|
7b109d90de | ||
|
|
f687951b1a |
@@ -12,7 +12,7 @@
|
|||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./mem0-plugin",
|
"source": "./mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||||
"version": "0.1.3"
|
"version": "0.1.2"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,26 +86,6 @@ 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>
|
||||||
|
|
||||||
### Sign up as an agent
|
|
||||||
|
|
||||||
AI agents can mint a working Mem0 API key in under five seconds — no email, no dashboard, no OTP. Four commands end-to-end:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Install
|
|
||||||
npm install -g @mem0/cli # or: pip install mem0-cli
|
|
||||||
|
|
||||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
|
||||||
mem0 init --agent --agent-caller claude-code
|
|
||||||
|
|
||||||
# 3. Add a memory
|
|
||||||
mem0 add "I am using mem0"
|
|
||||||
|
|
||||||
# 4. Search
|
|
||||||
mem0 search "am I using mem0"
|
|
||||||
```
|
|
||||||
|
|
||||||
The human owner can claim the account later with `mem0 init --email <their-email>` — same key, memories preserved. Full guide: [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup).
|
|
||||||
|
|
||||||
| | 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 |
|
||||||
@@ -153,7 +133,6 @@ See the [self-hosted docs](https://docs.mem0.ai/open-source/overview) for config
|
|||||||
|
|
||||||
1. Sign up on [Mem0 Platform](https://app.mem0.ai?utm_source=oss&utm_medium=readme)
|
1. Sign up on [Mem0 Platform](https://app.mem0.ai?utm_source=oss&utm_medium=readme)
|
||||||
2. Embed the memory layer via SDK or API keys
|
2. Embed the memory layer via SDK or API keys
|
||||||
3. Using hosted Qdrant vectors? See the [Platform migration guide](https://docs.mem0.ai/migration/oss-to-platform) to import them into Mem0 Platform.
|
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -503,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "init",
|
"name": "init",
|
||||||
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
|
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
|
||||||
"usage": "mem0 init [OPTIONS]",
|
"usage": "mem0 init [OPTIONS]",
|
||||||
"needsBackend": false,
|
"needsBackend": false,
|
||||||
"needsConfig": false,
|
"needsConfig": false,
|
||||||
@@ -516,9 +516,7 @@
|
|||||||
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
|
{ "name": "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)." }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/cli",
|
"name": "@mem0/cli",
|
||||||
"version": "0.2.6",
|
"version": "0.2.4",
|
||||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Detect whether the CLI is being invoked from inside an AI-agent context.
|
|
||||||
*
|
|
||||||
* Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
|
|
||||||
* agent runtime env var is present. The return value is a context **trigger
|
|
||||||
* only** — the canonical agent identity is self-declared by the agent via
|
|
||||||
* `--agent-caller <name>` (Proof Editor-style) and never sniffed from env
|
|
||||||
* vars to fill the `agent_caller` field on the APIKey row.
|
|
||||||
*
|
|
||||||
* Returns a short name or null. Honest reporting depends on `--agent-caller`;
|
|
||||||
* this list is just enough to enable the zero-friction auto-bootstrap UX.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const AGENT_CALLER_ENV: ReadonlyArray<readonly [string, readonly string[]]> = [
|
|
||||||
["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
|
|
||||||
["cursor", ["CURSOR_AGENT", "CURSOR_SESSION_ID"]],
|
|
||||||
["codex", ["CODEX_CLI", "OPENAI_CODEX"]],
|
|
||||||
["cline", ["CLINE_AGENT", "CLINE"]],
|
|
||||||
["continue", ["CONTINUE_AGENT", "CONTINUE_SESSION"]],
|
|
||||||
["aider", ["AIDER_SESSION"]],
|
|
||||||
["goose", ["GOOSE_AGENT"]],
|
|
||||||
["windsurf", ["WINDSURF_AGENT"]],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function detectAgentCaller(): string | null {
|
|
||||||
for (const [name, envVars] of AGENT_CALLER_ENV) {
|
|
||||||
if (envVars.some((v) => process.env[v])) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlatformConfig } from "../config.js";
|
import type { PlatformConfig } from "../config.js";
|
||||||
import { captureNotice, isAgentMode } from "../state.js";
|
import { isAgentMode } from "../state.js";
|
||||||
import { CLI_VERSION } from "../version.js";
|
import { CLI_VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
APIError,
|
APIError,
|
||||||
@@ -90,39 +90,7 @@ 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(
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* Agent Mode commands — bootstrap (unattended signup) and OTP-based claim.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import readline from "node:readline";
|
|
||||||
import { colors, printError, printInfo, printSuccess } from "../branding.js";
|
|
||||||
import { type Mem0Config, saveConfig } from "../config.js";
|
|
||||||
|
|
||||||
const { brand, dim } = colors;
|
|
||||||
|
|
||||||
const SOURCE_HEADERS = {
|
|
||||||
"X-Mem0-Source": "cli",
|
|
||||||
"X-Mem0-Client-Language": "node",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface BootstrapEnvelope {
|
|
||||||
api_key: string;
|
|
||||||
default_user_id: string;
|
|
||||||
org_id: string;
|
|
||||||
project_id: string;
|
|
||||||
mcp_url?: string;
|
|
||||||
smoke_test_url?: string;
|
|
||||||
claim_command?: string;
|
|
||||||
mem0_notice?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidEnvelope(v: unknown): v is BootstrapEnvelope {
|
|
||||||
return (
|
|
||||||
!!v &&
|
|
||||||
typeof v === "object" &&
|
|
||||||
typeof (v as BootstrapEnvelope).api_key === "string" &&
|
|
||||||
(v as BootstrapEnvelope).api_key.length > 0 &&
|
|
||||||
typeof (v as BootstrapEnvelope).default_user_id === "string" &&
|
|
||||||
(v as BootstrapEnvelope).default_user_id.length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/v1/auth/agent_mode/ and mutate config in place.
|
|
||||||
*
|
|
||||||
* @param config - Mem0Config mutated in place with the new platform values.
|
|
||||||
* @param source - `--source` flag passthrough (analytics tag, free-form).
|
|
||||||
* @param agentCaller - Self-declared agent identity passed via `--agent-caller`
|
|
||||||
* (e.g. `claude-code`, `cursor`). May be null when the caller omitted the
|
|
||||||
* flag; the agent can backfill later via `mem0 identify <name>`. Sent to the
|
|
||||||
* backend in the request body and saved into `platform.agentCaller` for
|
|
||||||
* local introspection.
|
|
||||||
*/
|
|
||||||
export async function bootstrapViaBackend(
|
|
||||||
config: Mem0Config,
|
|
||||||
{
|
|
||||||
source,
|
|
||||||
agentCaller,
|
|
||||||
}: { source?: string | null; agentCaller?: string | null } = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
|
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
if (source) body.source = source;
|
|
||||||
if (agentCaller) body.agent_caller = agentCaller;
|
|
||||||
|
|
||||||
let resp: Response;
|
|
||||||
try {
|
|
||||||
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
...SOURCE_HEADERS,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
printError(
|
|
||||||
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resp.status === 429) {
|
|
||||||
printError("Rate-limited. Try again in a few minutes.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (resp.status === 503) {
|
|
||||||
printError("Agent Mode is temporarily disabled. Try again later.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!resp.ok) {
|
|
||||||
let detail: string = resp.statusText;
|
|
||||||
try {
|
|
||||||
const errBody = (await resp.json()) as {
|
|
||||||
error?: string;
|
|
||||||
detail?: string;
|
|
||||||
};
|
|
||||||
detail = errBody.error ?? errBody.detail ?? resp.statusText;
|
|
||||||
} catch {
|
|
||||||
/* leave detail as statusText */
|
|
||||||
}
|
|
||||||
// Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
|
||||||
// translates to a generic 403 "You do not have permission to perform
|
|
||||||
// this action." That's opaque — surface it as the rate-limit message
|
|
||||||
// it actually is.
|
|
||||||
if (resp.status === 403 && /permission/i.test(detail)) {
|
|
||||||
printError(
|
|
||||||
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
printError(`Bootstrap failed: ${detail}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envelope = (await resp.json()) as BootstrapEnvelope;
|
|
||||||
if (!isValidEnvelope(envelope)) {
|
|
||||||
// Defend against partial/malformed backend responses (e.g. {api_key: null}).
|
|
||||||
// Without this guard, the typed `string` field is silently set to
|
|
||||||
// undefined/null and persisted, producing confusing downstream errors.
|
|
||||||
printError(
|
|
||||||
"Bootstrap response missing required fields — please update the CLI.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.platform.apiKey = envelope.api_key;
|
|
||||||
config.platform.baseUrl = baseUrl;
|
|
||||||
config.platform.agentMode = true;
|
|
||||||
config.platform.createdVia = "agent_mode";
|
|
||||||
config.platform.agentCaller = agentCaller ?? "";
|
|
||||||
config.platform.claimedAt = "";
|
|
||||||
config.platform.defaultUserId = envelope.default_user_id;
|
|
||||||
// Adopt the slug-derived user_id as the default scope for memory ops.
|
|
||||||
config.defaults.userId = envelope.default_user_id;
|
|
||||||
saveConfig(config);
|
|
||||||
|
|
||||||
printSuccess(
|
|
||||||
`Agent Mode active. Default user_id: ${envelope.default_user_id}`,
|
|
||||||
);
|
|
||||||
if (envelope.mem0_notice) {
|
|
||||||
console.log(`\n\x1b[33m🔔 ${envelope.mem0_notice}\x1b[0m\n`);
|
|
||||||
} else {
|
|
||||||
// Fallback for older backends without the unified notice field.
|
|
||||||
const claimCmd = envelope.claim_command ?? "mem0 init --email <your-email>";
|
|
||||||
console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agentCaller) {
|
|
||||||
console.log(
|
|
||||||
` ${dim("If you are an AI agent, identify yourself: `mem0 identify <your-name>` (e.g. claude-code, cursor).")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim an existing Agent Mode account via OTP — no browser, no polling.
|
|
||||||
*
|
|
||||||
* Hits /api/v1/auth/email_code/ to send a verification code, prompts for it
|
|
||||||
* interactively (or accepts via `code`), then sends it to /verify/ alongside
|
|
||||||
* `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place
|
|
||||||
* inline and returns the claim result.
|
|
||||||
*/
|
|
||||||
export async function claimViaOtp(
|
|
||||||
config: Mem0Config,
|
|
||||||
{ email, code }: { email: string; code?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
|
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
if (!config.platform.apiKey || !config.platform.agentMode) {
|
|
||||||
printError(
|
|
||||||
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawKey = config.platform.apiKey;
|
|
||||||
|
|
||||||
// Step 1: request OTP (unless --code was supplied)
|
|
||||||
if (!code) {
|
|
||||||
const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
if (sendResp.status === 429) {
|
|
||||||
printError("Too many attempts. Try again in a few minutes.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!sendResp.ok) {
|
|
||||||
let detail: string = sendResp.statusText;
|
|
||||||
try {
|
|
||||||
const errBody = (await sendResp.json()) as { error?: string };
|
|
||||||
if (errBody.error) detail = errBody.error;
|
|
||||||
} catch {
|
|
||||||
/* leave as statusText */
|
|
||||||
}
|
|
||||||
printError(`Failed to send code: ${detail}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
printSuccess(`Verification code sent to ${email}. Check your inbox.`);
|
|
||||||
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
printError(
|
|
||||||
"No --code provided and terminal is non-interactive.",
|
|
||||||
`Re-run: mem0 init --email ${email} --code <code>`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log();
|
|
||||||
code = await promptLine(` ${brand("Verification Code")}`);
|
|
||||||
if (!code) {
|
|
||||||
printError("Code is required.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: verify + claim atomically
|
|
||||||
const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
code: code.trim(),
|
|
||||||
agent_mode_api_key: rawKey,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verifyResp.ok) {
|
|
||||||
let detail: string = verifyResp.statusText;
|
|
||||||
let errCode = "";
|
|
||||||
try {
|
|
||||||
const errBody = (await verifyResp.json()) as {
|
|
||||||
error?: string;
|
|
||||||
code?: string;
|
|
||||||
};
|
|
||||||
if (errBody.error) detail = errBody.error;
|
|
||||||
if (errBody.code) errCode = errBody.code;
|
|
||||||
} catch {
|
|
||||||
/* leave as statusText */
|
|
||||||
}
|
|
||||||
printError(`Claim failed: ${detail}`);
|
|
||||||
if (errCode === "email_already_claimed") {
|
|
||||||
console.log(
|
|
||||||
` ${dim("Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* mem0 identify — declare which agent owns the current agent-mode key.
|
|
||||||
*
|
|
||||||
* Used when `mem0 init --agent` ran without --agent-caller, so the backend
|
|
||||||
* saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
|
|
||||||
* its own row with its real identity. Idempotent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { printError, printSuccess } from "../branding.js";
|
|
||||||
import { loadConfig, saveConfig } from "../config.js";
|
|
||||||
|
|
||||||
const SOURCE_HEADERS = {
|
|
||||||
"X-Mem0-Source": "cli",
|
|
||||||
"X-Mem0-Client-Language": "node",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export async function runIdentify(name: string): Promise<void> {
|
|
||||||
const config = loadConfig();
|
|
||||||
if (!config.platform.apiKey) {
|
|
||||||
printError("No API key configured. Run `mem0 init --agent` first.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!config.platform.agentMode) {
|
|
||||||
printError("This command only works on unclaimed agent-mode keys.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clean = (name ?? "").trim();
|
|
||||||
if (!clean) {
|
|
||||||
printError("Agent name is required.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
|
|
||||||
/\/+$/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp: Response;
|
|
||||||
try {
|
|
||||||
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/caller/`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
...SOURCE_HEADERS,
|
|
||||||
Authorization: `Token ${config.platform.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ agent_caller: clean }),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
printError(
|
|
||||||
`Network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
let detail: string = resp.statusText;
|
|
||||||
try {
|
|
||||||
const body = (await resp.json()) as { error?: string };
|
|
||||||
if (body.error) detail = body.error;
|
|
||||||
} catch {
|
|
||||||
/* leave as statusText */
|
|
||||||
}
|
|
||||||
printError(`Identify failed: ${detail}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await resp.json()) as { agent_caller?: string };
|
|
||||||
const canonical = body.agent_caller ?? clean;
|
|
||||||
config.platform.agentCaller = canonical;
|
|
||||||
saveConfig(config);
|
|
||||||
printSuccess(`Identified as ${canonical}.`);
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,6 @@ import {
|
|||||||
redactKey,
|
redactKey,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
} from "../config.js";
|
} from "../config.js";
|
||||||
import { formatJsonEnvelope } from "../output.js";
|
|
||||||
import { isAgentMode } from "../state.js";
|
|
||||||
|
|
||||||
const { brand, dim } = colors;
|
const { brand, dim } = colors;
|
||||||
|
|
||||||
@@ -35,65 +33,6 @@ function validateEmail(email: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal — exported for unit tests. */
|
|
||||||
export async function pingKey(
|
|
||||||
apiKey: string,
|
|
||||||
baseUrl: string,
|
|
||||||
timeoutMs = 5000,
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
|
|
||||||
// Network errors, timeouts, and 5xx responses return true so we prefer
|
|
||||||
// reusing an existing key over silently minting a new shadow on a transient
|
|
||||||
// blip (which would also clobber config + plugin-sync targets).
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
|
|
||||||
headers: { Authorization: `Token ${apiKey}` },
|
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
|
||||||
});
|
|
||||||
return resp.status !== 401 && resp.status !== 403;
|
|
||||||
} catch {
|
|
||||||
return true; // unknown — prefer reuse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybeIdentify(
|
|
||||||
key: string,
|
|
||||||
baseUrl: string,
|
|
||||||
agentCaller: string | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
|
||||||
// reused key. Silent no-op on any failure — reuse must not break.
|
|
||||||
if (!agentCaller) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetch(
|
|
||||||
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Token ${key}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ agent_caller: agentCaller }),
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (resp.ok) {
|
|
||||||
try {
|
|
||||||
const body = (await resp.json()) as { agent_caller?: string };
|
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
|
||||||
const cfg = loadConfig();
|
|
||||||
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
|
|
||||||
saveConfig(cfg);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* swallow — best effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* swallow — best effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emailLogin(
|
async function emailLogin(
|
||||||
email: string,
|
email: string,
|
||||||
code: string | undefined,
|
code: string | undefined,
|
||||||
@@ -257,7 +196,6 @@ async function setupPlatform(config: Mem0Config): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.platform.apiKey = apiKey;
|
config.platform.apiKey = apiKey;
|
||||||
config.platform.createdVia = "api_key";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupDefaults(config: Mem0Config): Promise<void> {
|
async function setupDefaults(config: Mem0Config): Promise<void> {
|
||||||
@@ -311,35 +249,14 @@ export async function runInit(
|
|||||||
email?: string;
|
email?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
agent?: boolean;
|
|
||||||
source?: string;
|
|
||||||
agentCaller?: string;
|
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): 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 config = createDefaultConfig();
|
||||||
const savedConfig = loadConfig();
|
const savedConfig = loadConfig();
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
process.env.MEM0_BASE_URL ||
|
process.env.MEM0_BASE_URL ||
|
||||||
savedConfig.platform.baseUrl ||
|
savedConfig.platform.baseUrl ||
|
||||||
DEFAULT_BASE_URL;
|
DEFAULT_BASE_URL;
|
||||||
config.platform.baseUrl = baseUrl;
|
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
if (opts.code && !opts.email) {
|
if (opts.code && !opts.email) {
|
||||||
@@ -351,84 +268,6 @@ export async function runInit(
|
|||||||
process.exit(1);
|
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
|
// Warn if an existing config with an API key would be overwritten
|
||||||
if (
|
if (
|
||||||
!opts.force &&
|
!opts.force &&
|
||||||
@@ -485,7 +324,6 @@ export async function runInit(
|
|||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
config.platform.userEmail = email;
|
config.platform.userEmail = email;
|
||||||
config.platform.createdVia = "email";
|
|
||||||
config.defaults.userId =
|
config.defaults.userId =
|
||||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
@@ -501,15 +339,13 @@ export async function runInit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── API key flow ──────────────────────────────────────────────────────────
|
// ── 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
|
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
if (!opts.apiKey) {
|
if (!opts.apiKey) {
|
||||||
printError(
|
printError(
|
||||||
"Non-interactive terminal detected and --api-key is required.",
|
"Non-interactive terminal detected and --api-key is required.",
|
||||||
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
"Usage: mem0 init --api-key <key> [--user-id <id>]",
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -520,7 +356,6 @@ export async function runInit(
|
|||||||
// Non-interactive: both flags provided
|
// Non-interactive: both flags provided
|
||||||
if (opts.apiKey && opts.userId) {
|
if (opts.apiKey && opts.userId) {
|
||||||
config.platform.apiKey = opts.apiKey;
|
config.platform.apiKey = opts.apiKey;
|
||||||
config.platform.createdVia = "api_key";
|
|
||||||
config.defaults.userId = opts.userId;
|
config.defaults.userId = opts.userId;
|
||||||
await validatePlatform(config);
|
await validatePlatform(config);
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
@@ -568,7 +403,6 @@ export async function runInit(
|
|||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
config.platform.userEmail = email;
|
config.platform.userEmail = email;
|
||||||
config.platform.createdVia = "email";
|
|
||||||
config.defaults.userId =
|
config.defaults.userId =
|
||||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export interface PlatformConfig {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
userEmail: 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 {
|
export interface DefaultsConfig {
|
||||||
@@ -60,11 +54,6 @@ export function createDefaultConfig(): Mem0Config {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
userEmail: "",
|
userEmail: "",
|
||||||
agentMode: false,
|
|
||||||
createdVia: "",
|
|
||||||
agentCaller: "",
|
|
||||||
claimedAt: "",
|
|
||||||
defaultUserId: "",
|
|
||||||
},
|
},
|
||||||
telemetry: {
|
telemetry: {
|
||||||
anonymousId: "",
|
anonymousId: "",
|
||||||
@@ -90,11 +79,6 @@ export function loadConfig(): Mem0Config {
|
|||||||
config.platform.apiKey = plat.api_key ?? "";
|
config.platform.apiKey = plat.api_key ?? "";
|
||||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||||
config.platform.userEmail = plat.user_email ?? "";
|
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 ?? {};
|
const defaults = data.defaults ?? {};
|
||||||
config.defaults.userId = defaults.user_id ?? "";
|
config.defaults.userId = defaults.user_id ?? "";
|
||||||
@@ -134,11 +118,6 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
api_key: config.platform.apiKey,
|
api_key: config.platform.apiKey,
|
||||||
base_url: config.platform.baseUrl,
|
base_url: config.platform.baseUrl,
|
||||||
user_email: config.platform.userEmail,
|
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: {
|
telemetry: {
|
||||||
anonymous_id: config.telemetry.anonymousId,
|
anonymous_id: config.telemetry.anonymousId,
|
||||||
@@ -147,20 +126,6 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
|
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
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 {
|
export function redactKey(key: string): string {
|
||||||
|
|||||||
+4
-70
@@ -13,12 +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 {
|
import { setAgentMode } from "./state.js";
|
||||||
isAgentMode,
|
|
||||||
setAgentMode,
|
|
||||||
setCurrentCommand,
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -146,11 +141,6 @@ program
|
|||||||
.description(
|
.description(
|
||||||
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
|
`◆ 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.")
|
.option("--version", "Show version and exit.")
|
||||||
.on("option:version", () => {
|
.on("option:version", () => {
|
||||||
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
||||||
@@ -159,7 +149,7 @@ program
|
|||||||
.option("--json", "Output as JSON for agent/programmatic use.")
|
.option("--json", "Output as JSON for agent/programmatic use.")
|
||||||
.option(
|
.option(
|
||||||
"--agent",
|
"--agent",
|
||||||
"Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent <cmd>`. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.",
|
"Output as JSON for agent/programmatic use. (alias: --json)",
|
||||||
)
|
)
|
||||||
.usage("<command> [options]")
|
.usage("<command> [options]")
|
||||||
.helpOption("--help", "Show this message and exit.")
|
.helpOption("--help", "Show this message and exit.")
|
||||||
@@ -176,14 +166,6 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|||||||
parentName && parentName !== "mem0"
|
parentName && parentName !== "mem0"
|
||||||
? `${parentName}.${commandName}`
|
? `${parentName}.${commandName}`
|
||||||
: 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);
|
const isAgent = !!(program.opts().json || program.opts().agent);
|
||||||
captureEvent(
|
captureEvent(
|
||||||
`cli.${fullCommand}`,
|
`cli.${fullCommand}`,
|
||||||
@@ -211,32 +193,11 @@ 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(
|
|
||||||
"--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(
|
.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",
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.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");
|
const { runInit } = await import("./commands/init.js");
|
||||||
await runInit({
|
await runInit({
|
||||||
apiKey: opts.apiKey,
|
apiKey: opts.apiKey,
|
||||||
@@ -244,24 +205,9 @@ program
|
|||||||
email: opts.email,
|
email: opts.email,
|
||||||
code: opts.code,
|
code: opts.code,
|
||||||
force: opts.force,
|
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 ───────────────────────────────────────────────────────────
|
// ── Memory: add ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -823,16 +769,4 @@ program
|
|||||||
|
|
||||||
// ── Entrypoint ────────────────────────────────────────────────────────────
|
// ── Entrypoint ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Surface any unclaimed Agent Mode notice once per command, after the primary
|
program.parse();
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -245,15 +244,6 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,12 +356,6 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sync the active Mem0 API key into other ecosystem touchpoints.
|
|
||||||
*
|
|
||||||
* Why: the CLI canonical state is ~/.mem0/config.json. MCP servers
|
|
||||||
* (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or
|
|
||||||
* their own config files. Without a sync, agent-mode bootstrap mints a
|
|
||||||
* new key into config.json but the plugin's MCP keeps using the old
|
|
||||||
* key from env — silent surprise.
|
|
||||||
*
|
|
||||||
* Design:
|
|
||||||
* - Update ONLY entries that already exist; never create new ones
|
|
||||||
* - Preserve surrounding content, formatting, other keys
|
|
||||||
* - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt
|
|
||||||
* - Idempotent — re-running with the same key is a no-op
|
|
||||||
*
|
|
||||||
* Targets:
|
|
||||||
* - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
|
|
||||||
* - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines
|
|
||||||
*
|
|
||||||
* Out of scope: Codex / Cursor MCP configs and the plugin's own
|
|
||||||
* <plugin-dir>/.api_key file (plugin-managed, different schema).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
|
|
||||||
const SHELL_RCS = [
|
|
||||||
path.join(os.homedir(), ".zshrc"),
|
|
||||||
path.join(os.homedir(), ".bashrc"),
|
|
||||||
path.join(os.homedir(), ".bash_profile"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved
|
|
||||||
// when the MEM0_API_KEY export is the last line of the rc file.
|
|
||||||
const RC_LINE_RE =
|
|
||||||
/^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m;
|
|
||||||
|
|
||||||
export function syncApiKey(apiKey: string): string[] {
|
|
||||||
if (!apiKey) return [];
|
|
||||||
const updated: string[] = [];
|
|
||||||
if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) {
|
|
||||||
updated.push(CLAUDE_SETTINGS);
|
|
||||||
}
|
|
||||||
for (const rc of SHELL_RCS) {
|
|
||||||
if (updateShellRc(rc, apiKey)) updated.push(rc);
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
|
|
||||||
export function updateClaudeSettings(
|
|
||||||
filePath: string,
|
|
||||||
apiKey: string,
|
|
||||||
): boolean {
|
|
||||||
if (!fs.existsSync(filePath)) return false;
|
|
||||||
let raw: string;
|
|
||||||
let data: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(filePath, "utf-8");
|
|
||||||
data = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const env = data.env;
|
|
||||||
if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) {
|
|
||||||
return false; // no existing entry — don't create one
|
|
||||||
}
|
|
||||||
const envObj = env as Record<string, string>;
|
|
||||||
if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync
|
|
||||||
envObj.MEM0_API_KEY = apiKey;
|
|
||||||
atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
|
|
||||||
export function updateShellRc(filePath: string, apiKey: string): boolean {
|
|
||||||
if (!fs.existsSync(filePath)) return false;
|
|
||||||
let text: string;
|
|
||||||
try {
|
|
||||||
text = fs.readFileSync(filePath, "utf-8");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const match = text.match(RC_LINE_RE);
|
|
||||||
if (!match) return false; // no existing line
|
|
||||||
if (match[3] === apiKey) return false;
|
|
||||||
const newText = text.replace(
|
|
||||||
RC_LINE_RE,
|
|
||||||
(_full, prefix) => `${prefix}"${apiKey}"`,
|
|
||||||
);
|
|
||||||
atomicWriteText(filePath, newText);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function atomicWriteText(filePath: string, content: string): void {
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tmp, content, "utf-8");
|
|
||||||
// Preserve permissions if original existed.
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
try {
|
|
||||||
const mode = fs.statSync(filePath).mode & 0o777;
|
|
||||||
fs.chmodSync(tmp, mode);
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs.renameSync(tmp, filePath);
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tmp);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
let _agentMode = false;
|
let _agentMode = false;
|
||||||
let _currentCommand = "";
|
let _currentCommand = "";
|
||||||
let _pendingNotice = "";
|
|
||||||
|
|
||||||
export function isAgentMode(): boolean {
|
export function isAgentMode(): boolean {
|
||||||
return _agentMode;
|
return _agentMode;
|
||||||
@@ -22,19 +21,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -115,9 +115,6 @@ export function captureEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4: every cli.* event carries agent_mode based on the config flag
|
|
||||||
// (unclaimed Agent Mode key). This is the growth-doc property used to
|
|
||||||
// join init → add → search funnels in PostHog.
|
|
||||||
const payload = {
|
const payload = {
|
||||||
api_key: POSTHOG_API_KEY,
|
api_key: POSTHOG_API_KEY,
|
||||||
distinct_id: distinctId,
|
distinct_id: distinctId,
|
||||||
@@ -126,7 +123,6 @@ export function captureEvent(
|
|||||||
source: "CLI",
|
source: "CLI",
|
||||||
language: "node",
|
language: "node",
|
||||||
cli_version: CLI_VERSION,
|
cli_version: CLI_VERSION,
|
||||||
agent_mode: Boolean(config.platform.agentMode),
|
|
||||||
node_version: process.version,
|
node_version: process.version,
|
||||||
os: process.platform,
|
os: process.platform,
|
||||||
...properties,
|
...properties,
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
|
|
||||||
*
|
|
||||||
* Mirror of `cli/python/tests/test_agent_mode.py` — both files MUST stay
|
|
||||||
* in sync so that the Python and Node CLIs expose an identical surface
|
|
||||||
* for the Agent Mode entrypoint. If you add a flag here, add the same
|
|
||||||
* assertion on the Python side (and vice versa).
|
|
||||||
*
|
|
||||||
* Network-bound bootstrap is covered by the platform-side E2E suite
|
|
||||||
* (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify
|
|
||||||
* the CLI surface that ships in the binary.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
function run(
|
|
||||||
args: string[],
|
|
||||||
opts: { home?: string; env?: Record<string, string> } = {},
|
|
||||||
): { stdout: string; stderr: string; exitCode: number } {
|
|
||||||
const env = { ...process.env };
|
|
||||||
for (const key of Object.keys(env)) {
|
|
||||||
if (key.startsWith("MEM0_")) delete env[key];
|
|
||||||
}
|
|
||||||
if (opts.home) env.HOME = opts.home;
|
|
||||||
if (opts.env) Object.assign(env, opts.env);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, {
|
|
||||||
cwd: path.join(__dirname, ".."),
|
|
||||||
env,
|
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
return { stdout, stderr: "", exitCode: 0 };
|
|
||||||
} catch (e: any) {
|
|
||||||
return {
|
|
||||||
stdout: e.stdout ?? "",
|
|
||||||
stderr: e.stderr ?? "",
|
|
||||||
exitCode: e.status ?? 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanHome(): string {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("init flag surface", () => {
|
|
||||||
it("init --help lists --agent", () => {
|
|
||||||
const result = run(["init", "--help"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(result.stdout).toContain("--agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("init --help describes Agent Mode", () => {
|
|
||||||
const result = run(["init", "--help"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
// Description must mention what --agent actually does so an agent
|
|
||||||
// reading the help can self-discover the bootstrap entrypoint.
|
|
||||||
expect(
|
|
||||||
result.stdout.includes("Agent Mode") ||
|
|
||||||
result.stdout.toLowerCase().includes("unattended"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("init --help lists --source", () => {
|
|
||||||
const result = run(["init", "--help"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(result.stdout).toContain("--source");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("init --help lists --email and --code", () => {
|
|
||||||
const result = run(["init", "--help"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(result.stdout).toContain("--email");
|
|
||||||
expect(result.stdout).toContain("--code");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("argv preprocessing — --agent reaches init subcommand", () => {
|
|
||||||
// Regression for the bug where the global --agent JSON-alias swallowed
|
|
||||||
// the init-level --agent flag, making `mem0 init --agent` behave like
|
|
||||||
// the plain interactive wizard.
|
|
||||||
|
|
||||||
it("init --agent triggers bootstrap branch (not the wizard)", () => {
|
|
||||||
const home = cleanHome();
|
|
||||||
const result = run(["init", "--agent"], {
|
|
||||||
home,
|
|
||||||
env: {
|
|
||||||
MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole
|
|
||||||
FORCE_COLOR: "0",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const combined = (result.stdout + result.stderr).toLowerCase();
|
|
||||||
// Either bootstrap-attempt error, or a connection/network error —
|
|
||||||
// both prove the --agent path executed (the wizard would prompt for
|
|
||||||
// input and succeed/hang, not surface a network error).
|
|
||||||
expect(
|
|
||||||
combined.includes("agent") ||
|
|
||||||
combined.includes("connect") ||
|
|
||||||
combined.includes("network") ||
|
|
||||||
combined.includes("fetch") ||
|
|
||||||
combined.includes("bootstrap"),
|
|
||||||
).toBe(true);
|
|
||||||
fs.rmSync(home, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("JSON envelope on network failure", () => {
|
|
||||||
it("init --agent --json does not leak a stack trace when backend is unreachable", () => {
|
|
||||||
const home = cleanHome();
|
|
||||||
const result = run(["init", "--agent", "--json"], {
|
|
||||||
home,
|
|
||||||
env: {
|
|
||||||
MEM0_BASE_URL: "http://127.0.0.1:1",
|
|
||||||
FORCE_COLOR: "0",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const combined = result.stdout + result.stderr;
|
|
||||||
// No raw Node stack should escape the agent-mode handler.
|
|
||||||
expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/);
|
|
||||||
expect(combined).not.toContain("UnhandledPromiseRejection");
|
|
||||||
expect(result.exitCode).not.toBe(0);
|
|
||||||
fs.rmSync(home, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("top-level help lists init", () => {
|
|
||||||
// `mem0 --help` must list `init` so agents walking the top-level help
|
|
||||||
// can discover the Agent Mode entrypoint without prior knowledge.
|
|
||||||
|
|
||||||
it("--help lists init", () => {
|
|
||||||
const result = run(["--help"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(result.stdout).toContain("init");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for init internals — decision tree primitives + plugin sync.
|
|
||||||
*
|
|
||||||
* Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay
|
|
||||||
* in sync — if you add a behavioral assertion here, mirror it on the Python
|
|
||||||
* side and vice versa.
|
|
||||||
*
|
|
||||||
* - `pingKey` must NOT treat network errors as "invalid key" (else a VPN
|
|
||||||
* flap silently mints a new shadow over a working key).
|
|
||||||
* - `plugin_sync` must only update entries that already exist, preserve
|
|
||||||
* trailing newlines, and never mangle other lines.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { pingKey } from "../src/commands/init.js";
|
|
||||||
import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js";
|
|
||||||
|
|
||||||
// ── pingKey ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("pingKey — network vs auth distinction", () => {
|
|
||||||
const origFetch = globalThis.fetch;
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = origFetch;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for 200", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response);
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for 401 (definitively invalid)", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response);
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for 403 (definitively invalid)", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response);
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for 5xx (transient upstream — prefer reuse)", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response);
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true on network error (prefer reuse over re-mint)", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true on timeout (prefer reuse)", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted"));
|
|
||||||
await expect(pingKey("k", "http://x")).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── updateShellRc ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("updateShellRc — exists-only contract", () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates existing export and preserves trailing newline", () => {
|
|
||||||
const rc = path.join(tmpDir, ".zshrc");
|
|
||||||
fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n');
|
|
||||||
expect(updateShellRc(rc, "newkey")).toBe(true);
|
|
||||||
expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT create a new export when none exists", () => {
|
|
||||||
const rc = path.join(tmpDir, ".zshrc");
|
|
||||||
fs.writeFileSync(rc, "alias ll='ls -la'\n");
|
|
||||||
expect(updateShellRc(rc, "newkey")).toBe(false);
|
|
||||||
expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves surrounding content", () => {
|
|
||||||
const rc = path.join(tmpDir, ".zshrc");
|
|
||||||
const original =
|
|
||||||
"# my zshrc\n" +
|
|
||||||
"alias ll='ls -la'\n" +
|
|
||||||
"export MEM0_API_KEY='old'\n" +
|
|
||||||
"export OTHER=keepme\n";
|
|
||||||
fs.writeFileSync(rc, original);
|
|
||||||
updateShellRc(rc, "newkey");
|
|
||||||
const after = fs.readFileSync(rc, "utf-8");
|
|
||||||
expect(after).toContain("alias ll='ls -la'\n");
|
|
||||||
expect(after).toContain("export OTHER=keepme\n");
|
|
||||||
expect(after).toContain("# my zshrc\n");
|
|
||||||
expect(after).toContain('export MEM0_API_KEY="newkey"\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent when value already matches", () => {
|
|
||||||
const rc = path.join(tmpDir, ".zshrc");
|
|
||||||
fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n');
|
|
||||||
expect(updateShellRc(rc, "same")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op for missing files", () => {
|
|
||||||
const rc = path.join(tmpDir, ".zshrc"); // does not exist
|
|
||||||
expect(updateShellRc(rc, "x")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── updateClaudeSettings ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("updateClaudeSettings — never creates entries", () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not create env block when none exists", () => {
|
|
||||||
const settings = path.join(tmpDir, "settings.json");
|
|
||||||
fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 }));
|
|
||||||
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
|
|
||||||
expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({
|
|
||||||
otherKey: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not create MEM0_API_KEY entry in existing env block", () => {
|
|
||||||
const settings = path.join(tmpDir, "settings.json");
|
|
||||||
fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } }));
|
|
||||||
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates existing entry and preserves siblings", () => {
|
|
||||||
const settings = path.join(tmpDir, "settings.json");
|
|
||||||
fs.writeFileSync(
|
|
||||||
settings,
|
|
||||||
JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2),
|
|
||||||
);
|
|
||||||
expect(updateClaudeSettings(settings, "fresh")).toBe(true);
|
|
||||||
const data = JSON.parse(fs.readFileSync(settings, "utf-8"));
|
|
||||||
expect(data.env.MEM0_API_KEY).toBe("fresh");
|
|
||||||
expect(data.env.OTHER).toBe("y");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent when value already matches", () => {
|
|
||||||
const settings = path.join(tmpDir, "settings.json");
|
|
||||||
fs.writeFileSync(
|
|
||||||
settings,
|
|
||||||
JSON.stringify({ env: { MEM0_API_KEY: "same" } }),
|
|
||||||
);
|
|
||||||
expect(updateClaudeSettings(settings, "same")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op for malformed JSON", () => {
|
|
||||||
const settings = path.join(tmpDir, "settings.json");
|
|
||||||
fs.writeFileSync(settings, "{ this is not json");
|
|
||||||
expect(updateClaudeSettings(settings, "x")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0-cli"
|
name = "mem0-cli"
|
||||||
version = "0.2.6"
|
version = "0.2.4"
|
||||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Detect whether the CLI is being invoked from inside an AI-agent context.
|
|
||||||
|
|
||||||
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
|
|
||||||
agent runtime env var is present. The return value is a context **trigger
|
|
||||||
only** — the canonical agent identity is self-declared by the agent via
|
|
||||||
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
|
|
||||||
vars to fill the ``agent_caller`` field on the APIKey row.
|
|
||||||
|
|
||||||
Returns a short name or None. The list is curated, not exhaustive — env
|
|
||||||
vars we don't recognise fall through to None (caller treated as
|
|
||||||
non-agent). Honest reporting depends on ``--agent-caller``; this list is
|
|
||||||
just enough to enable the zero-friction auto-bootstrap UX.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
||||||
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
|
|
||||||
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
|
|
||||||
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
|
|
||||||
("cline", ("CLINE_AGENT", "CLINE")),
|
|
||||||
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
|
|
||||||
("aider", ("AIDER_SESSION",)),
|
|
||||||
("goose", ("GOOSE_AGENT",)),
|
|
||||||
("windsurf", ("WINDSURF_AGENT",)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_agent_caller() -> str | None:
|
|
||||||
"""Return a canonical agent name if any agent env var is set, else None."""
|
|
||||||
for name, env_vars in _AGENT_CALLER_ENV:
|
|
||||||
if any(os.environ.get(v) for v in env_vars):
|
|
||||||
return name
|
|
||||||
return None
|
|
||||||
@@ -237,14 +237,6 @@ def main_callback(
|
|||||||
cmd_version()
|
cmd_version()
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
if ctx.invoked_subcommand:
|
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)
|
_fire_telemetry(ctx.invoked_subcommand)
|
||||||
|
|
||||||
|
|
||||||
@@ -859,19 +851,6 @@ def init(
|
|||||||
force: bool = typer.Option(
|
force: bool = typer.Option(
|
||||||
False, "--force", help="Overwrite existing config without confirmation."
|
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:
|
) -> None:
|
||||||
"""Interactive setup wizard for mem0 CLI.
|
"""Interactive setup wizard for mem0 CLI.
|
||||||
|
|
||||||
@@ -880,38 +859,10 @@ def init(
|
|||||||
mem0 init --api-key m0-xxx --user-id alice
|
mem0 init --api-key m0-xxx --user-id alice
|
||||||
mem0 init --email alice@company.com
|
mem0 init --email alice@company.com
|
||||||
mem0 init --email alice@company.com --code 482901
|
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
|
from mem0_cli.commands.init_cmd import run_init
|
||||||
|
|
||||||
run_init(
|
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
|
||||||
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)
|
# (entity_app registered at module level, below sub-group definitions)
|
||||||
@@ -1247,28 +1198,11 @@ 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).
|
||||||
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
|
_json_flags = {"--json", "--agent"}
|
||||||
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
|
if any(a in _json_flags for a in sys.argv[1:]):
|
||||||
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 argv_rest if a not in _global_flags]
|
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
|
||||||
|
|
||||||
try:
|
|
||||||
app()
|
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")
|
|
||||||
|
|||||||
@@ -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 capture_notice, is_agent_mode
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
|
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,26 +48,7 @@ class PlatformBackend(Backend):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
data = resp.json()
|
return 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,
|
||||||
|
|||||||
@@ -87,12 +87,10 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
|
|||||||
}
|
}
|
||||||
print(_json.dumps(envelope))
|
print(_json.dumps(envelope))
|
||||||
return
|
return
|
||||||
from rich.markup import escape
|
|
||||||
|
|
||||||
sym = _sym("✗", "[error]")
|
sym = _sym("✗", "[error]")
|
||||||
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
|
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
|
||||||
if hint:
|
if hint:
|
||||||
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
|
console.print(f" [{DIM_COLOR}]{hint}[/]")
|
||||||
|
|
||||||
|
|
||||||
def print_warning(console: Console, message: str) -> None:
|
def print_warning(console: Console, message: str) -> None:
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import typer
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.prompt import Prompt
|
|
||||||
|
|
||||||
from mem0_cli.branding import (
|
|
||||||
BRAND_COLOR,
|
|
||||||
DIM_COLOR,
|
|
||||||
print_error,
|
|
||||||
print_success,
|
|
||||||
)
|
|
||||||
from mem0_cli.config import Mem0Config, save_config
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
err_console = Console(stderr=True)
|
|
||||||
|
|
||||||
_SOURCE_HEADERS = {
|
|
||||||
"X-Mem0-Source": "cli",
|
|
||||||
"X-Mem0-Client-Language": "python",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_envelope(envelope: Any) -> None:
|
|
||||||
"""Defend against partial/malformed backend responses.
|
|
||||||
|
|
||||||
A backend regression that returns ``{"api_key": null}`` would otherwise be
|
|
||||||
silently persisted, producing confusing downstream errors far from the
|
|
||||||
source. Fail fast with a clear message if the required fields are missing.
|
|
||||||
"""
|
|
||||||
if not isinstance(envelope, dict):
|
|
||||||
print_error(err_console, "Bootstrap response was not a JSON object.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
for field in ("api_key", "default_user_id"):
|
|
||||||
value = envelope.get(field)
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
f"Bootstrap response missing required field {field!r} — please update the CLI.",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_via_backend(
|
|
||||||
config: Mem0Config,
|
|
||||||
*,
|
|
||||||
source: str | None = None,
|
|
||||||
agent_caller: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Mem0Config mutated in place with the new platform values.
|
|
||||||
source: ``--source`` flag passthrough (analytics tag, free-form).
|
|
||||||
agent_caller: Self-declared agent identity passed via ``--agent-caller``
|
|
||||||
(e.g. ``claude-code``, ``cursor``). May be None when the caller
|
|
||||||
omitted the flag; the agent can backfill later via
|
|
||||||
``mem0 identify <name>``. Sent to the backend in the request body
|
|
||||||
and saved into ``platform.agent_caller`` for local introspection.
|
|
||||||
|
|
||||||
Raises typer.Exit(1) on failure.
|
|
||||||
"""
|
|
||||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
||||||
body: dict[str, Any] = {}
|
|
||||||
if source:
|
|
||||||
body["source"] = source
|
|
||||||
if agent_caller:
|
|
||||||
body["agent_caller"] = agent_caller
|
|
||||||
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
|
||||||
resp = client.post(
|
|
||||||
f"{base_url}/api/v1/auth/agent_mode/",
|
|
||||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
||||||
json=body,
|
|
||||||
)
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
print_error(err_console, f"Network error contacting Mem0: {exc}")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
|
|
||||||
if resp.status_code == 429:
|
|
||||||
print_error(err_console, "Rate-limited. Try again in a few minutes.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
if resp.status_code == 503:
|
|
||||||
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
detail = resp.text
|
|
||||||
try:
|
|
||||||
err_body = resp.json()
|
|
||||||
detail = err_body.get("error") or err_body.get("detail") or resp.text
|
|
||||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
|
||||||
# translates to a generic 403 "You do not have permission to perform
|
|
||||||
# this action." That's opaque — surface as the rate-limit it actually is.
|
|
||||||
if resp.status_code == 403 and "permission" in str(detail).lower():
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
print_error(err_console, f"Bootstrap failed: {detail}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
envelope = resp.json()
|
|
||||||
_validate_envelope(envelope)
|
|
||||||
config.platform.api_key = envelope["api_key"]
|
|
||||||
config.platform.base_url = base_url
|
|
||||||
config.platform.agent_mode = True
|
|
||||||
config.platform.created_via = "agent_mode"
|
|
||||||
config.platform.agent_caller = agent_caller or ""
|
|
||||||
config.platform.claimed_at = ""
|
|
||||||
config.platform.default_user_id = envelope["default_user_id"]
|
|
||||||
# Adopt the slug-derived user_id as the default scope for memory ops.
|
|
||||||
config.defaults.user_id = envelope["default_user_id"]
|
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
|
|
||||||
notice = envelope.get("mem0_notice")
|
|
||||||
if notice:
|
|
||||||
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
|
||||||
else:
|
|
||||||
# Fallback if the backend hasn't deployed the unified notice yet.
|
|
||||||
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
|
|
||||||
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
|
|
||||||
|
|
||||||
if not agent_caller:
|
|
||||||
console.print(
|
|
||||||
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
|
|
||||||
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
|
|
||||||
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
|
|
||||||
|
|
||||||
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
|
|
||||||
`/.../verify/`) and adds the local agent-mode API key in the verify body
|
|
||||||
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
|
|
||||||
upgrade-in-place transaction inline and returns claim result.
|
|
||||||
|
|
||||||
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
|
|
||||||
`user_email`. The api_key value itself never changes.
|
|
||||||
"""
|
|
||||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
||||||
if not config.platform.api_key or not config.platform.agent_mode:
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
raw_key = config.platform.api_key
|
|
||||||
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
|
||||||
# Step 1: request OTP (unless --code provided)
|
|
||||||
if not code:
|
|
||||||
send = client.post(
|
|
||||||
f"{base_url}/api/v1/auth/email_code/",
|
|
||||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
||||||
json={"email": email},
|
|
||||||
)
|
|
||||||
if send.status_code == 429:
|
|
||||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
if send.status_code != 200:
|
|
||||||
try:
|
|
||||||
detail = send.json().get("error", send.text)
|
|
||||||
except Exception:
|
|
||||||
detail = send.text
|
|
||||||
print_error(err_console, f"Failed to send code: {detail}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
print_success(console, f"Verification code sent to {email}. Check your inbox.")
|
|
||||||
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"No --code provided and terminal is non-interactive.",
|
|
||||||
hint=f"Re-run: mem0 init --email {email} --code <code>",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
console.print()
|
|
||||||
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
|
||||||
if not code:
|
|
||||||
print_error(err_console, "Code is required.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Step 2: verify + claim in one shot
|
|
||||||
verify = client.post(
|
|
||||||
f"{base_url}/api/v1/auth/email_code/verify/",
|
|
||||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
|
||||||
json={
|
|
||||||
"email": email,
|
|
||||||
"code": code.strip(),
|
|
||||||
"agent_mode_api_key": raw_key,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if verify.status_code != 200:
|
|
||||||
try:
|
|
||||||
err_body = verify.json()
|
|
||||||
detail = err_body.get("error", verify.text)
|
|
||||||
code_str = err_body.get("code", "")
|
|
||||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
|
||||||
detail = verify.text
|
|
||||||
code_str = ""
|
|
||||||
print_error(err_console, f"Claim failed: {detail}")
|
|
||||||
if code_str == "email_already_claimed":
|
|
||||||
console.print(
|
|
||||||
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.[/]"
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
claim_body = verify.json()
|
|
||||||
if not claim_body.get("claimed"):
|
|
||||||
print_error(err_console, f"Unexpected verify response: {claim_body}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
config.platform.agent_mode = False
|
|
||||||
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
|
|
||||||
config.platform.user_email = email
|
|
||||||
config.platform.created_via = "email"
|
|
||||||
save_config(config)
|
|
||||||
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow_iso() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""mem0 identify — declare which agent owns the current agent-mode key.
|
|
||||||
|
|
||||||
Used when `mem0 init --agent` ran without --agent-caller, so the backend
|
|
||||||
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
|
|
||||||
its own row with its real identity. Idempotent — running it again just
|
|
||||||
overwrites.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import typer
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
from mem0_cli.branding import print_error, print_success
|
|
||||||
from mem0_cli.config import load_config, save_config
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
err_console = Console(stderr=True)
|
|
||||||
|
|
||||||
_SOURCE_HEADERS = {
|
|
||||||
"X-Mem0-Source": "cli",
|
|
||||||
"X-Mem0-Client-Language": "python",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_identify(name: str) -> None:
|
|
||||||
"""PATCH the active agent-mode key's agent_caller field."""
|
|
||||||
config = load_config()
|
|
||||||
if not config.platform.api_key:
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"No API key configured. Run `mem0 init --agent` first.",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
if not config.platform.agent_mode:
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"This command only works on unclaimed agent-mode keys.",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
name = (name or "").strip()
|
|
||||||
if not name:
|
|
||||||
print_error(err_console, "Agent name is required.")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
|
||||||
resp = client.patch(
|
|
||||||
f"{base_url}/api/v1/auth/agent_mode/caller/",
|
|
||||||
headers={
|
|
||||||
**_SOURCE_HEADERS,
|
|
||||||
"Authorization": f"Token {config.platform.api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={"agent_caller": name},
|
|
||||||
)
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
print_error(err_console, f"Network error: {exc}")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
try:
|
|
||||||
detail = resp.json().get("error", resp.text)
|
|
||||||
except Exception:
|
|
||||||
detail = resp.text
|
|
||||||
print_error(err_console, f"Identify failed: {detail}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
canonical = resp.json().get("agent_caller", name)
|
|
||||||
config.platform.agent_caller = canonical
|
|
||||||
save_config(config)
|
|
||||||
print_success(console, f"Identified as {canonical}.")
|
|
||||||
@@ -103,25 +103,6 @@ def _validate_email(email: str) -> None:
|
|||||||
raise typer.Exit(1)
|
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(
|
def _email_login(
|
||||||
email: str,
|
email: str,
|
||||||
code: str | None,
|
code: str | None,
|
||||||
@@ -201,143 +182,21 @@ def run_init(
|
|||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
source: str | None = None,
|
|
||||||
agent: bool = False,
|
|
||||||
agent_caller: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive setup wizard for mem0 CLI.
|
"""Interactive setup wizard for mem0 CLI.
|
||||||
|
|
||||||
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
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
|
(non-interactive mode). When running in a non-TTY without the required
|
||||||
flags, an error message is printed.
|
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()
|
config = Mem0Config()
|
||||||
|
|
||||||
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
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:
|
if code and not email:
|
||||||
print_error(err_console, "--code requires --email.")
|
print_error(err_console, "--code requires --email.")
|
||||||
raise typer.Exit(1)
|
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
|
# Warn if an existing config with an API key would be overwritten
|
||||||
if not force and CONFIG_FILE.exists():
|
if not force and CONFIG_FILE.exists():
|
||||||
existing = load_config()
|
existing = load_config()
|
||||||
@@ -383,7 +242,6 @@ def run_init(
|
|||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
config.platform.user_email = email
|
config.platform.user_email = email
|
||||||
config.platform.created_via = "email"
|
|
||||||
config.defaults.user_id = (
|
config.defaults.user_id = (
|
||||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||||
)
|
)
|
||||||
@@ -400,8 +258,6 @@ def run_init(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── API key flow (existing) ───────────────────────────────────────
|
# ── 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
|
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||||
if not sys.stdin.isatty():
|
if not sys.stdin.isatty():
|
||||||
@@ -409,7 +265,7 @@ def run_init(
|
|||||||
print_error(
|
print_error(
|
||||||
err_console,
|
err_console,
|
||||||
"Non-interactive terminal detected and --api-key is required.",
|
"Non-interactive terminal detected and --api-key is required.",
|
||||||
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||||
@@ -417,7 +273,6 @@ def run_init(
|
|||||||
# Fully non-interactive when both flags provided
|
# Fully non-interactive when both flags provided
|
||||||
if api_key and user_id:
|
if api_key and user_id:
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
config.platform.created_via = "api_key"
|
|
||||||
config.defaults.user_id = user_id
|
config.defaults.user_id = user_id
|
||||||
_validate_platform(config)
|
_validate_platform(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
@@ -458,7 +313,6 @@ def run_init(
|
|||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
config.platform.user_email = email_addr
|
config.platform.user_email = email_addr
|
||||||
config.platform.created_via = "email"
|
|
||||||
config.defaults.user_id = (
|
config.defaults.user_id = (
|
||||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||||
)
|
)
|
||||||
@@ -477,7 +331,6 @@ def run_init(
|
|||||||
# API key flow
|
# API key flow
|
||||||
if api_key:
|
if api_key:
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
config.platform.created_via = "api_key"
|
|
||||||
else:
|
else:
|
||||||
_setup_platform(config)
|
_setup_platform(config)
|
||||||
|
|
||||||
@@ -517,7 +370,6 @@ def _setup_platform(config: Mem0Config) -> None:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
config.platform.created_via = "api_key"
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_defaults(config: Mem0Config) -> None:
|
def _setup_defaults(config: Mem0Config) -> None:
|
||||||
|
|||||||
@@ -28,14 +28,6 @@ class PlatformConfig:
|
|||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = DEFAULT_BASE_URL
|
base_url: str = DEFAULT_BASE_URL
|
||||||
user_email: str = ""
|
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
|
@dataclass
|
||||||
@@ -91,11 +83,6 @@ def load_config() -> Mem0Config:
|
|||||||
config.platform.api_key = plat.get("api_key", "")
|
config.platform.api_key = plat.get("api_key", "")
|
||||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
||||||
config.platform.user_email = plat.get("user_email", "")
|
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", {})
|
defaults = data.get("defaults", {})
|
||||||
config.defaults.user_id = defaults.get("user_id", "")
|
config.defaults.user_id = defaults.get("user_id", "")
|
||||||
@@ -149,11 +136,6 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
"api_key": config.platform.api_key,
|
"api_key": config.platform.api_key,
|
||||||
"base_url": config.platform.base_url,
|
"base_url": config.platform.base_url,
|
||||||
"user_email": config.platform.user_email,
|
"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": {
|
"telemetry": {
|
||||||
"anonymous_id": config.telemetry.anonymous_id,
|
"anonymous_id": config.telemetry.anonymous_id,
|
||||||
@@ -165,19 +147,6 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
|
|
||||||
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
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:
|
def redact_key(key: str) -> str:
|
||||||
"""Redact an API key for display: m0-xxx...xxx"""
|
"""Redact an API key for display: m0-xxx...xxx"""
|
||||||
|
|||||||
@@ -229,16 +229,6 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
@@ -333,15 +323,6 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
"""Sync the active Mem0 API key into other ecosystem touchpoints.
|
|
||||||
|
|
||||||
Why this exists:
|
|
||||||
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
|
|
||||||
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
|
|
||||||
vars or their own config files. Without a sync, an agent-mode bootstrap
|
|
||||||
mints a new key into config.json but the plugin's MCP keeps using the
|
|
||||||
old key from env — silent surprise.
|
|
||||||
|
|
||||||
Design:
|
|
||||||
- Update ONLY entries that already exist (never create new ones)
|
|
||||||
- Preserve all surrounding content / formatting / other keys
|
|
||||||
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
|
|
||||||
- Idempotent — re-running with the same key is a no-op
|
|
||||||
- Skip on dry_run
|
|
||||||
|
|
||||||
Targets currently handled:
|
|
||||||
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
|
|
||||||
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
|
|
||||||
|
|
||||||
Out of scope (deliberately not touched):
|
|
||||||
- Codex / Cursor MCP configs — would require schema-aware edits and
|
|
||||||
those tools don't have mem0 entries by default
|
|
||||||
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Files we know how to update safely.
|
|
||||||
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
||||||
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
|
|
||||||
|
|
||||||
|
|
||||||
def sync_api_key(api_key: str) -> list[str]:
|
|
||||||
"""Propagate ``api_key`` into known ecosystem touchpoints.
|
|
||||||
|
|
||||||
Returns the list of paths actually updated. Empty list means nothing
|
|
||||||
needed updating (either targets didn't exist or already had this value).
|
|
||||||
"""
|
|
||||||
if not api_key:
|
|
||||||
return []
|
|
||||||
updated: list[str] = []
|
|
||||||
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
|
|
||||||
updated.append(str(_CLAUDE_SETTINGS))
|
|
||||||
for rc in _SHELL_RCS:
|
|
||||||
if _update_shell_rc(rc, api_key):
|
|
||||||
updated.append(str(rc))
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
def _update_claude_settings(path: Path, api_key: str) -> bool:
|
|
||||||
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
|
|
||||||
if not path.is_file():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
with path.open("r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
return False
|
|
||||||
env = data.get("env")
|
|
||||||
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
|
|
||||||
# No existing entry — don't create one.
|
|
||||||
return False
|
|
||||||
if env["MEM0_API_KEY"] == api_key:
|
|
||||||
return False # already in sync
|
|
||||||
env["MEM0_API_KEY"] = api_key
|
|
||||||
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
|
|
||||||
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
|
|
||||||
# end-of-file is preserved when MEM0_API_KEY is the last line.
|
|
||||||
_RC_LINE = re.compile(
|
|
||||||
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
|
|
||||||
re.MULTILINE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_shell_rc(path: Path, api_key: str) -> bool:
|
|
||||||
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
|
|
||||||
if not path.is_file():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
match = _RC_LINE.search(text)
|
|
||||||
if not match:
|
|
||||||
return False # no existing line
|
|
||||||
if match.group(3) == api_key:
|
|
||||||
return False
|
|
||||||
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
|
|
||||||
_atomic_write_text(path, new_text)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write_text(path: Path, content: str) -> None:
|
|
||||||
"""Write content to path atomically (temp + rename)."""
|
|
||||||
dirname = path.parent
|
|
||||||
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
# Preserve mode if the original existed.
|
|
||||||
if path.exists():
|
|
||||||
os.chmod(tmp_path, path.stat().st_mode & 0o777)
|
|
||||||
os.replace(tmp_path, path)
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
raise
|
|
||||||
@@ -4,7 +4,6 @@ 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:
|
||||||
@@ -23,23 +22,3 @@ 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
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ def capture_event(
|
|||||||
try:
|
try:
|
||||||
from mem0_cli import __version__
|
from mem0_cli import __version__
|
||||||
from mem0_cli.config import CONFIG_FILE, load_config, save_config
|
from mem0_cli.config import CONFIG_FILE, load_config, save_config
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
distinct_id = pre_resolved_email or _get_distinct_id()
|
distinct_id = pre_resolved_email or _get_distinct_id()
|
||||||
@@ -106,9 +107,6 @@ def capture_event(
|
|||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
save_config(config)
|
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 = {
|
payload = {
|
||||||
"api_key": POSTHOG_API_KEY,
|
"api_key": POSTHOG_API_KEY,
|
||||||
"distinct_id": distinct_id,
|
"distinct_id": distinct_id,
|
||||||
@@ -117,7 +115,7 @@ def capture_event(
|
|||||||
"source": "CLI",
|
"source": "CLI",
|
||||||
"language": "python",
|
"language": "python",
|
||||||
"cli_version": __version__,
|
"cli_version": __version__,
|
||||||
"agent_mode": bool(config.platform.agent_mode),
|
"agent_mode": is_agent_mode(),
|
||||||
"python_version": sys.version,
|
"python_version": sys.version,
|
||||||
"os": sys.platform,
|
"os": sys.platform,
|
||||||
"os_version": platform.version(),
|
"os_version": platform.version(),
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
|
|
||||||
|
|
||||||
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
|
|
||||||
sync so that the Python and Node CLIs expose an identical surface for the
|
|
||||||
Agent Mode entrypoint. If you add a flag here, add the same assertion on
|
|
||||||
the Node side (and vice versa).
|
|
||||||
|
|
||||||
Network-bound bootstrap is covered by the platform-side E2E suite
|
|
||||||
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
|
|
||||||
the CLI surface that ships in the binary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_ansi(text: str) -> str:
|
|
||||||
return _ANSI_RE.sub("", text)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
|
|
||||||
env = os.environ.copy()
|
|
||||||
for key in list(env.keys()):
|
|
||||||
if key.startswith("MEM0_"):
|
|
||||||
del env[key]
|
|
||||||
env.pop("FORCE_COLOR", None)
|
|
||||||
if home_dir:
|
|
||||||
env["HOME"] = home_dir
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "mem0_cli", *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env=env,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
return subprocess.CompletedProcess(
|
|
||||||
args=result.args,
|
|
||||||
returncode=result.returncode,
|
|
||||||
stdout=_strip_ansi(result.stdout),
|
|
||||||
stderr=_strip_ansi(result.stderr),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def clean_home(tmp_path):
|
|
||||||
return str(tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitFlagSurface:
|
|
||||||
"""`mem0 init --help` must expose the Agent Mode flags."""
|
|
||||||
|
|
||||||
def test_init_help_lists_agent_flag(self):
|
|
||||||
result = _run(["init", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "--agent" in result.stdout
|
|
||||||
|
|
||||||
def test_init_help_describes_agent_mode(self):
|
|
||||||
result = _run(["init", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
# Description must mention what --agent actually does so an agent
|
|
||||||
# reading the help can self-discover the bootstrap entrypoint.
|
|
||||||
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
|
|
||||||
|
|
||||||
def test_init_help_lists_source_flag(self):
|
|
||||||
result = _run(["init", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "--source" in result.stdout
|
|
||||||
|
|
||||||
def test_init_help_lists_email_and_code(self):
|
|
||||||
# Claim flow flags must remain present alongside Agent Mode flags.
|
|
||||||
result = _run(["init", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "--email" in result.stdout
|
|
||||||
assert "--code" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class TestArgvPreprocessing:
|
|
||||||
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
|
|
||||||
|
|
||||||
Regression for the bug where the top-level `--agent` JSON-alias was
|
|
||||||
stripped from ``sys.argv`` before Typer could bind it to the init
|
|
||||||
subcommand, making ``mem0 init --agent`` indistinguishable from a
|
|
||||||
plain ``mem0 init`` (interactive wizard).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_init_with_agent_reaches_subcommand(self, clean_home):
|
|
||||||
# We can't hit a real backend in unit tests, so we point the CLI at
|
|
||||||
# a guaranteed-dead URL and assert the failure is the bootstrap
|
|
||||||
# request failing — proving the --agent flag was honored and the
|
|
||||||
# bootstrap branch ran, not the interactive wizard.
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env={
|
|
||||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
|
||||||
"HOME": clean_home,
|
|
||||||
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
|
|
||||||
"FORCE_COLOR": "0",
|
|
||||||
},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
combined = _strip_ansi(result.stdout + result.stderr).lower()
|
|
||||||
# Either we got a connection/network error from the bootstrap POST,
|
|
||||||
# or the CLI surfaced an Agent Mode-specific failure message.
|
|
||||||
assert (
|
|
||||||
"agent" in combined
|
|
||||||
or "connect" in combined
|
|
||||||
or "network" in combined
|
|
||||||
or "fetch" in combined
|
|
||||||
or "bootstrap" in combined
|
|
||||||
), f"Expected bootstrap attempt, got: {combined!r}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestJsonEnvelopeParity:
|
|
||||||
"""`mem0 init --agent --json` should produce a JSON envelope on success.
|
|
||||||
|
|
||||||
Without a live backend we can only assert the failure shape: when the
|
|
||||||
backend is unreachable, the CLI must still exit non-zero AND not crash
|
|
||||||
on a Python traceback (which would mean we leaked an exception past
|
|
||||||
the agent-mode handler).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
env={
|
|
||||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
|
||||||
"HOME": clean_home,
|
|
||||||
"MEM0_BASE_URL": "http://127.0.0.1:1",
|
|
||||||
"FORCE_COLOR": "0",
|
|
||||||
},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
combined = _strip_ansi(result.stdout + result.stderr)
|
|
||||||
assert "Traceback (most recent call last)" not in combined
|
|
||||||
assert result.returncode != 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitInCommandList:
|
|
||||||
"""`mem0 --help` must list `init` so agents walking the top-level help
|
|
||||||
can discover the Agent Mode entrypoint without prior knowledge."""
|
|
||||||
|
|
||||||
def test_top_level_help_lists_init(self):
|
|
||||||
result = _run(["--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "init" in result.stdout
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Unit tests for init internals — decision tree primitives + plugin sync.
|
|
||||||
|
|
||||||
These tests exercise the units that the high-level subprocess parity tests in
|
|
||||||
``test_agent_mode.py`` deliberately can't reach:
|
|
||||||
|
|
||||||
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
|
|
||||||
flap silently mints a new shadow over a working key).
|
|
||||||
- ``plugin_sync`` must only update entries that already exist, preserve
|
|
||||||
trailing newlines, and never mangle other lines.
|
|
||||||
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
|
|
||||||
real cause instead of DRF's opaque "You do not have permission" string.
|
|
||||||
|
|
||||||
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
|
|
||||||
behavioral assertion here, mirror it on the Node side and vice versa.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mem0_cli.commands.init_cmd import _ping_key
|
|
||||||
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
|
|
||||||
|
|
||||||
# ── _ping_key ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
def __init__(self, status_code: int) -> None:
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
|
|
||||||
assert _ping_key("k", "http://x") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
|
|
||||||
assert _ping_key("k", "http://x") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
|
|
||||||
assert _ping_key("k", "http://x") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
# Transient upstream failure must NOT cause a shadow to be minted.
|
|
||||||
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
|
|
||||||
assert _ping_key("k", "http://x") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
|
|
||||||
def boom(*a, **kw):
|
|
||||||
raise httpx.ConnectError("nope")
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "get", boom)
|
|
||||||
assert _ping_key("k", "http://x") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
def boom(*a, **kw):
|
|
||||||
raise httpx.ReadTimeout("slow")
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "get", boom)
|
|
||||||
assert _ping_key("k", "http://x") is True
|
|
||||||
|
|
||||||
|
|
||||||
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
|
|
||||||
rc = tmp_path / ".zshrc"
|
|
||||||
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
|
|
||||||
changed = _update_shell_rc(rc, "newkey")
|
|
||||||
assert changed is True
|
|
||||||
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
|
|
||||||
rc = tmp_path / ".zshrc"
|
|
||||||
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
|
|
||||||
changed = _update_shell_rc(rc, "newkey")
|
|
||||||
assert changed is False
|
|
||||||
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
|
|
||||||
rc = tmp_path / ".zshrc"
|
|
||||||
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
|
|
||||||
rc.write_text(original, encoding="utf-8")
|
|
||||||
_update_shell_rc(rc, "newkey")
|
|
||||||
after = rc.read_text(encoding="utf-8")
|
|
||||||
assert "alias ll='ls -la'\n" in after
|
|
||||||
assert "export OTHER=keepme\n" in after
|
|
||||||
assert "# my zshrc\n" in after
|
|
||||||
assert 'export MEM0_API_KEY="newkey"\n' in after
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
|
|
||||||
rc = tmp_path / ".zshrc"
|
|
||||||
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
|
|
||||||
assert _update_shell_rc(rc, "same") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
|
|
||||||
rc = tmp_path / ".zshrc" # does not exist
|
|
||||||
assert _update_shell_rc(rc, "x") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── plugin_sync._update_claude_settings ────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
|
|
||||||
import json
|
|
||||||
|
|
||||||
settings = tmp_path / "settings.json"
|
|
||||||
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
|
|
||||||
changed = _update_claude_settings(settings, "newkey")
|
|
||||||
assert changed is False
|
|
||||||
# Original content unchanged.
|
|
||||||
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
|
|
||||||
|
|
||||||
|
|
||||||
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
|
|
||||||
import json
|
|
||||||
|
|
||||||
settings = tmp_path / "settings.json"
|
|
||||||
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
|
|
||||||
changed = _update_claude_settings(settings, "newkey")
|
|
||||||
assert changed is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
|
|
||||||
import json
|
|
||||||
|
|
||||||
settings = tmp_path / "settings.json"
|
|
||||||
settings.write_text(
|
|
||||||
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
changed = _update_claude_settings(settings, "fresh")
|
|
||||||
assert changed is True
|
|
||||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
||||||
assert data["env"]["MEM0_API_KEY"] == "fresh"
|
|
||||||
assert data["env"]["OTHER"] == "y" # other keys preserved
|
|
||||||
|
|
||||||
|
|
||||||
def test_claude_settings_idempotent(tmp_path) -> None:
|
|
||||||
import json
|
|
||||||
|
|
||||||
settings = tmp_path / "settings.json"
|
|
||||||
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
|
|
||||||
assert _update_claude_settings(settings, "same") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
|
|
||||||
settings = tmp_path / "settings.json"
|
|
||||||
settings.write_text("{ this is not json", encoding="utf-8")
|
|
||||||
assert _update_claude_settings(settings, "x") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ── bootstrap rate-limit translation ──────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
|
|
||||||
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
|
|
||||||
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
|
|
||||||
from mem0_cli.config import Mem0Config
|
|
||||||
|
|
||||||
fake_resp = MagicMock()
|
|
||||||
fake_resp.status_code = 403
|
|
||||||
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
|
|
||||||
fake_resp.json = MagicMock(
|
|
||||||
return_value={"detail": "You do not have permission to perform this action."}
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Client:
|
|
||||||
def __init__(self, *a, **kw):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *a):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def post(self, *a, **kw):
|
|
||||||
return fake_resp
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "Client", _Client)
|
|
||||||
cfg = Mem0Config()
|
|
||||||
cfg.platform.base_url = "https://api.mem0.ai"
|
|
||||||
import typer
|
|
||||||
|
|
||||||
with pytest.raises(typer.Exit):
|
|
||||||
bootstrap_via_backend(cfg)
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
combined = captured.out + captured.err
|
|
||||||
assert "Daily Agent Mode signup limit reached" in combined
|
|
||||||
assert "permission to perform this action" not in combined
|
|
||||||
@@ -1323,30 +1323,6 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
|
|||||||
|
|
||||||
<Tab title="CLI">
|
<Tab title="CLI">
|
||||||
|
|
||||||
<Update label="2026-05-16" description="Python v0.2.6 / Node v0.2.6">
|
|
||||||
|
|
||||||
**Bug Fixes:**
|
|
||||||
- **Claim flow error message:** The `email_already_claimed` tip in `mem0 init --email` previously suggested running `mem0 link <key>` — a command that doesn't exist. Replaced with honest copy pointing the user to sign in at app.mem0.ai with their existing credentials ([#5152](https://github.com/mem0ai/mem0/pull/5152))
|
|
||||||
|
|
||||||
</Update>
|
|
||||||
|
|
||||||
<Update label="2026-05-14" description="Python v0.2.5 / Node v0.2.5">
|
|
||||||
|
|
||||||
**New Features:**
|
|
||||||
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in under 5 seconds with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email <their-email>` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **Self-declared agent identity:** Agents pass `--agent-caller <name>` (e.g. `claude-code`, `cursor`, `codex`) on `mem0 init --agent` so signups attribute to the right tool in analytics. Proof Editor-style — the agent declares itself rather than the CLI sniffing it from env vars ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **`mem0 identify <name>`:** New subcommand to self-tag an Agent Mode key after the fact when the agent forgot to pass `--agent-caller` on init. Idempotent — re-running just overwrites ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **Plugin sync:** `~/.claude/settings.json::env::MEM0_API_KEY` and `~/.zshrc`/`.bashrc` `export MEM0_API_KEY=` lines stay in sync with `~/.mem0/config.json` automatically. Idempotent — only updates EXISTING entries, never creates new ones ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **Claim flow:** `mem0 init --email <email>` claims an existing Agent Mode shadow via OTP. Upgrade-in-place — the API key never changes, memories transfer to the human's account ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
|
|
||||||
**Bug Fixes:**
|
|
||||||
- **Decision tree network resilience:** `pingKey` now distinguishes network errors from invalid keys — returns false ONLY on HTTP 401/403, returns true on connection failures / timeouts / 5xx. Prevents a VPN flap from silently rotating the user's API key and rewriting plugin-sync targets ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **Rate-limit error clarity:** DRF's opaque `"You do not have permission"` 403 from Agent Mode rate limits is now translated to `"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC."` ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **JSON envelope `command` field:** `mem0 init --agent --json` error envelopes now populate the `command` field correctly instead of returning an empty string ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
- **Bootstrap envelope validation:** Defends against partial/malformed backend responses (e.g. `{api_key: null}`) silently persisting null/undefined into typed string fields ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
|
||||||
|
|
||||||
</Update>
|
|
||||||
|
|
||||||
<Update label="2026-04-22" description="Python v0.2.4 / Node v0.2.4">
|
<Update label="2026-04-22" description="Python v0.2.4 / Node v0.2.4">
|
||||||
|
|
||||||
**New Features:**
|
**New Features:**
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
"icon": "rocket",
|
"icon": "rocket",
|
||||||
"pages": [
|
"pages": [
|
||||||
"platform/overview",
|
"platform/overview",
|
||||||
"platform/agent-signup",
|
|
||||||
"vibecoding",
|
"vibecoding",
|
||||||
"platform/mem0-mcp",
|
"platform/mem0-mcp",
|
||||||
"platform/cli",
|
"platform/cli",
|
||||||
|
|||||||
@@ -157,27 +157,27 @@ mode: "custom"
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/platform/agent-signup"
|
href="/platform/cli"
|
||||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
|
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className="block dark:hidden aspect-[2/1] w-full object-cover"
|
className="block dark:hidden aspect-[2/1] w-full object-cover"
|
||||||
src="/images/docs thumbnails/light/CLI.png"
|
src="/images/docs thumbnails/light/CLI.png"
|
||||||
alt="Sign up as an agent thumbnail"
|
alt="CLI thumbnail"
|
||||||
style={{pointerEvents: "none"}}
|
style={{pointerEvents: "none"}}
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
className="hidden dark:block aspect-[2/1] w-full object-cover"
|
className="hidden dark:block aspect-[2/1] w-full object-cover"
|
||||||
src="/images/docs thumbnails/dark/CLI.png"
|
src="/images/docs thumbnails/dark/CLI.png"
|
||||||
alt="Sign up as an agent thumbnail"
|
alt="CLI thumbnail"
|
||||||
style={{pointerEvents: "none"}}
|
style={{pointerEvents: "none"}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-3 text-left">
|
||||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||||
Sign up as an agent
|
CLI
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||||
For AI agents: mint a Mem0 API key in under five seconds — no email, no dashboard. Four commands to your first memory.
|
Manage memories directly from your terminal. Built for developers and AI agents.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,12 +11,6 @@
|
|||||||
- 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 --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
|
## Install
|
||||||
|
|
||||||
- Python SDK: `pip install mem0ai`
|
- Python SDK: `pip install mem0ai`
|
||||||
@@ -159,7 +153,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
|||||||
- [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app.
|
- [Introduction](https://docs.mem0.ai/introduction) [Both]: Use when the user wants a one-page overview of how memory fits between the LLM and the app.
|
||||||
- [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor.
|
- [Vibe Code with Mem0](https://docs.mem0.ai/vibecoding) [Both]: Use when the user is in Claude Code, Cursor, or Windsurf and wants memory wired into their editor.
|
||||||
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, sub-50ms retrieval, dashboard.
|
- [Platform Overview](https://docs.mem0.ai/platform/overview) [Platform]: Use when the user picks the managed product - 4-line integration, sub-50ms retrieval, dashboard.
|
||||||
- [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup) [Platform]: Use when an AI agent needs to mint a Mem0 API key autonomously - four commands, no email or dashboard, human claims ownership later.
|
|
||||||
- [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted.
|
- [Platform vs Open Source](https://docs.mem0.ai/platform/platform-vs-oss) [Both]: Use when the user is deciding between managed and self-hosted.
|
||||||
- [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`.
|
- [Platform Quickstart](https://docs.mem0.ai/platform/quickstart) [Platform]: Use for the first Platform integration - API key plus `MemoryClient.add/search`.
|
||||||
- [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal.
|
- [Platform CLI](https://docs.mem0.ai/platform/cli) [Platform]: Use when the user wants to manage Platform memories from the terminal.
|
||||||
|
|||||||
@@ -14,15 +14,11 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
|
|||||||
| --------------------- | -------------- | ---------------------------- |
|
| --------------------- | -------------- | ---------------------------- |
|
||||||
| Infrastructure & Code | Low (~30 mins) | None (Parallel run possible) |
|
| Infrastructure & Code | Low (~30 mins) | None (Parallel run possible) |
|
||||||
|
|
||||||
<Info>
|
|
||||||
Using Mem0 Open Source with **hosted Qdrant**? You can migrate your existing memories to Mem0 Platform with a one-line script below.
|
|
||||||
</Info>
|
|
||||||
|
|
||||||
<Info>
|
<Info>
|
||||||
**Why migrate to Platform?**
|
**Why migrate to Platform?**
|
||||||
|
|
||||||
- **Time to Market**: Set up in 5 minutes vs 30+ minutes for OSS configuration
|
- **Time to Market**: Set up in 5 minutes vs 30+ minutes for OSS configuration
|
||||||
- **Enterprise Ready**: Audit logs, workspace governance, and dedicated support
|
- **Enterprise Ready**: SOC2 Type II compliance, GDPR support, audit logs
|
||||||
- **Advanced Features**: Webhooks, memory export, analytics dashboard, custom categories
|
- **Advanced Features**: Webhooks, memory export, analytics dashboard, custom categories
|
||||||
- **Multi-tenancy**: Organizations, projects, and team management out of the box
|
- **Multi-tenancy**: Organizations, projects, and team management out of the box
|
||||||
- **Zero Infrastructure**: No vector database, LLM provider, or maintenance overhead
|
- **Zero Infrastructure**: No vector database, LLM provider, or maintenance overhead
|
||||||
@@ -38,21 +34,7 @@ Move your Mem0 implementation to managed infrastructure with enterprise features
|
|||||||
|
|
||||||
## Migrate
|
## Migrate
|
||||||
|
|
||||||
### 1. Import Memories Into Platform
|
### 1. Install or Update SDK
|
||||||
|
|
||||||
If your Mem0 Open Source setup uses **hosted Qdrant** as the vector store, you can import your existing memories to Mem0 Platform with one command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/scripts/oss-to-platform-migrate.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
This migration script currently supports **hosted Qdrant only**. Support for local Qdrant, pgvector, and other vector stores is coming soon.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
If you are using a different vector store and want to migrate to Platform, please contact Mem0 support and we’ll send you a custom migration script for your setup.
|
|
||||||
|
|
||||||
### 2. Install or Update SDK
|
|
||||||
|
|
||||||
Ensure you have the latest version of the SDK, which supports both OSS and Platform clients.
|
Ensure you have the latest version of the SDK, which supports both OSS and Platform clients.
|
||||||
|
|
||||||
@@ -60,7 +42,7 @@ Ensure you have the latest version of the SDK, which supports both OSS and Platf
|
|||||||
pip install mem0ai --upgrade
|
pip install mem0ai --upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Update Initialization
|
### 2. Update Initialization
|
||||||
|
|
||||||
Switch from the local `Memory` class to the managed `MemoryClient`.
|
Switch from the local `Memory` class to the managed `MemoryClient`.
|
||||||
|
|
||||||
@@ -93,7 +75,7 @@ client = MemoryClient(api_key="m0-...")
|
|||||||
Run `client.get_all(filters={"user_id": "test_connection"})` to verify your API key works. It should return an empty list or valid results.
|
Run `client.get_all(filters={"user_id": "test_connection"})` to verify your API key works. It should return an empty list or valid results.
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
### 4. Update Retrieval Calls (Critical)
|
### 3. Update Retrieval Calls (Critical)
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
**Critical Change**: Platform uses v2 endpoints that require filtering parameters to be nested inside a `filters` dictionary.
|
**Critical Change**: Platform uses v2 endpoints that require filtering parameters to be nested inside a `filters` dictionary.
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ Auth is on by default. Protected endpoints require either a JWT (from the dashbo
|
|||||||
| Mode | How to send it | When to use it |
|
| Mode | How to send it | When to use it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Bearer JWT | `Authorization: Bearer <access_token>` | Dashboard sessions; tokens come from `POST /auth/login` and refresh via `POST /auth/refresh` |
|
| Bearer JWT | `Authorization: Bearer <access_token>` | Dashboard sessions; tokens come from `POST /auth/login` and refresh via `POST /auth/refresh` |
|
||||||
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access scoped to a single dashboard user |
|
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access tied to the single admin user today. Per-user scoping arrives with the upcoming multi-user invite flow |
|
||||||
| Legacy `ADMIN_API_KEY` | `X-API-Key: <env value>` | Back-compat for deployments that set the `ADMIN_API_KEY` env var |
|
| Legacy `ADMIN_API_KEY` | `X-API-Key: <env value>` | Back-compat for deployments that set the `ADMIN_API_KEY` env var |
|
||||||
| `AUTH_DISABLED=true` | — | Local development only; bypasses auth entirely |
|
| `AUTH_DISABLED=true` | — | Local development only; bypasses auth entirely |
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ curl -X POST http://localhost:8888/memories \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-user keys inherit the creating user's scope. List or revoke them via `GET /api-keys` and `DELETE /api-keys/{id}`.
|
List or revoke per-user keys via `GET /api-keys` and `DELETE /api-keys/{id}`. The dashboard user issued the key remains its owner; admin-only routes such as `/configure`, `/reset`, `/entities`, and `/requests` require the issuing user to have the admin role.
|
||||||
|
|
||||||
### Legacy `ADMIN_API_KEY`
|
### Legacy `ADMIN_API_KEY`
|
||||||
|
|
||||||
@@ -246,8 +246,8 @@ The OSS REST server exposes the following endpoints. None use the `/v1/` prefix.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `POST` | `/configure` | Set memory configuration. Rejects unbundled providers with a 400 |
|
| `POST` | `/configure` | **Admin only.** Set memory configuration. Rejects unbundled providers with a 400 |
|
||||||
| `GET` | `/configure` | Get the current memory configuration |
|
| `GET` | `/configure` | **Admin only.** Get the current memory configuration |
|
||||||
| `GET` | `/configure/providers` | List the LLM and embedder providers bundled in the container |
|
| `GET` | `/configure/providers` | List the LLM and embedder providers bundled in the container |
|
||||||
| `POST` | `/memories` | Create memories |
|
| `POST` | `/memories` | Create memories |
|
||||||
| `GET` | `/memories` | Get all memories (filter by `user_id`, `agent_id`, or `run_id`) |
|
| `GET` | `/memories` | Get all memories (filter by `user_id`, `agent_id`, or `run_id`) |
|
||||||
@@ -257,14 +257,14 @@ The OSS REST server exposes the following endpoints. None use the `/v1/` prefix.
|
|||||||
| `DELETE` | `/memories` | Delete all memories for an identifier |
|
| `DELETE` | `/memories` | Delete all memories for an identifier |
|
||||||
| `GET` | `/memories/{memory_id}/history` | Get memory history |
|
| `GET` | `/memories/{memory_id}/history` | Get memory history |
|
||||||
| `POST` | `/search` | Search memories |
|
| `POST` | `/search` | Search memories |
|
||||||
| `POST` | `/reset` | Reset all memories |
|
| `POST` | `/reset` | **Admin only.** Reset all memories |
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `GET` | `/auth/setup-status` | Returns `{needsSetup: bool}`. Open, no auth required |
|
| `GET` | `/auth/setup-status` | Returns `{needsSetup: bool}`. Open, no auth required |
|
||||||
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts are provisioned by the existing admin. |
|
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts will be provisioned by the existing admin (multi-user invite flow coming soon). |
|
||||||
| `POST` | `/auth/login` | Exchange email and password for access and refresh JWTs |
|
| `POST` | `/auth/login` | Exchange email and password for access and refresh JWTs |
|
||||||
| `POST` | `/auth/refresh` | Exchange a refresh token for a new access token |
|
| `POST` | `/auth/refresh` | Exchange a refresh token for a new access token |
|
||||||
| `GET` | `/auth/me` | Get the current authenticated user (JWT required) |
|
| `GET` | `/auth/me` | Get the current authenticated user (JWT required) |
|
||||||
@@ -285,14 +285,14 @@ All `/api-keys` endpoints require a JWT.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `GET` | `/requests?limit=N` | Recent API call log (JWT or admin key) |
|
| `GET` | `/requests?limit=N` | **Admin only.** Recent API call log |
|
||||||
|
|
||||||
### Entities
|
### Entities
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| `GET` | `/entities` | Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
|
| `GET` | `/entities` | **Admin only.** Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
|
||||||
| `DELETE` | `/entities/{entity_type}/{entity_id}` | Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
|
| `DELETE` | `/entities/{entity_type}/{entity_id}` | **Admin only.** Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
|
||||||
|
|
||||||
The `/auth/*`, `/api-keys`, `/requests`, and `/entities` routes are new to the self-hosted server and primarily back the dashboard, but you can call them directly from your own tooling.
|
The `/auth/*`, `/api-keys`, `/requests`, and `/entities` routes are new to the self-hosted server and primarily back the dashboard, but you can call them directly from your own tooling.
|
||||||
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
---
|
|
||||||
title: Sign up as an agent
|
|
||||||
description: "Zero-friction signup for AI agents — mint a working Mem0 API key in under five seconds. No email, no dashboard, no OTP."
|
|
||||||
icon: "robot"
|
|
||||||
iconType: "solid"
|
|
||||||
---
|
|
||||||
|
|
||||||
If you're an AI agent (Claude Code, Cursor, Codex, Cline, Aider, Windsurf, or any other autonomous coding tool), you don't need a human to set up Mem0 for you. Four commands — install, sign up, push a memory, search it — and you have persistent memory.
|
|
||||||
|
|
||||||
## The 4-command demo
|
|
||||||
|
|
||||||
<CodeGroup>
|
|
||||||
```bash npm
|
|
||||||
# 1. Install
|
|
||||||
npm install -g @mem0/cli
|
|
||||||
|
|
||||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
|
||||||
mem0 init --agent --agent-caller claude-code
|
|
||||||
|
|
||||||
# 3. Push a memory
|
|
||||||
mem0 add "I am using mem0"
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
mem0 search "am I using mem0"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash pip
|
|
||||||
# 1. Install
|
|
||||||
pip install mem0-cli
|
|
||||||
|
|
||||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
|
||||||
mem0 init --agent --agent-caller claude-code
|
|
||||||
|
|
||||||
# 3. Push a memory
|
|
||||||
mem0 add "I am using mem0"
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
mem0 search "am I using mem0"
|
|
||||||
```
|
|
||||||
</CodeGroup>
|
|
||||||
|
|
||||||
That's it — under 30 seconds from a fresh shell to a working memory store.
|
|
||||||
|
|
||||||
## Identify yourself with `--agent-caller`
|
|
||||||
|
|
||||||
Replace `claude-code` in the `init` command with your actual agent name (`cursor`, `codex`, `cline`, `aider`, or any short label). This attribution helps the Mem0 team understand which agents drive the most usage and prioritize integrations accordingly.
|
|
||||||
|
|
||||||
<Tip>
|
|
||||||
**Forgot `--agent-caller` on init?** Run `mem0 identify <your-name>` after the fact — same key, just back-fills the attribution. Idempotent.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
## What happened when you ran `mem0 init --agent`?
|
|
||||||
|
|
||||||
- A **shadow account** was minted on the Mem0 backend with a fresh API key.
|
|
||||||
- The key was saved to `~/.mem0/config.json` (mode 0600) on the machine you ran the command on.
|
|
||||||
- A default `user_id` was provisioned so `mem0 add` / `mem0 search` work without explicit scope flags.
|
|
||||||
- The account is **unclaimed** — no human owns it yet.
|
|
||||||
|
|
||||||
Every command run against this key prints a one-line reminder asking you to surface the claim instructions to your human user.
|
|
||||||
|
|
||||||
## Claiming the account (for the human owner)
|
|
||||||
|
|
||||||
When the human is ready to take ownership of the agent's account — and all the memories it has accumulated — they run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mem0 init --email you@yourcompany.com
|
|
||||||
```
|
|
||||||
|
|
||||||
The CLI detects the existing Agent Mode config, sends a verification code, and upgrades the shadow account in-place:
|
|
||||||
|
|
||||||
- **The API key never changes** — the agent isn't disrupted.
|
|
||||||
- **All memories transfer** to the human's account.
|
|
||||||
- **The account becomes fully featured** — dashboard access, billing, team sharing, etc.
|
|
||||||
|
|
||||||
Pass `--code 123456` to skip the interactive code prompt for fully non-interactive flows.
|
|
||||||
|
|
||||||
## Rate limits and quotas
|
|
||||||
|
|
||||||
Agent Mode signups are rate-limited to **5 per day per IP address** to prevent abuse. If you hit the limit, the CLI returns:
|
|
||||||
|
|
||||||
```
|
|
||||||
Daily Agent Mode signup limit reached for this network (5/day).
|
|
||||||
Try again from a different IP or after midnight UTC.
|
|
||||||
```
|
|
||||||
|
|
||||||
Unclaimed agent accounts get the standard Mem0 free-tier quotas. The human owner can upgrade after claiming.
|
|
||||||
|
|
||||||
## What's next
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="CLI Reference" icon="terminal" href="/platform/cli">
|
|
||||||
Full command-by-command reference for `mem0 add`, `mem0 search`, `mem0 list`, and the rest.
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Memory Operations" icon="database" href="/core-concepts/memory-operations/add">
|
|
||||||
How `add`, `search`, `update`, and `delete` work under the hood.
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Mem0 MCP" icon="plug" href="/platform/mem0-mcp">
|
|
||||||
Connect agents to Mem0 via the Model Context Protocol — alternative integration path.
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Platform Overview" icon="star" href="/platform/overview">
|
|
||||||
The full Mem0 Platform feature set once you claim your account.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
@@ -25,10 +25,6 @@ pip install mem0-cli
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
<Tip>
|
|
||||||
**Looking for Agent Mode signup?** See [Sign up as an agent](/platform/agent-signup) — install, signup, first memory in four commands.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Run the interactive setup wizard to configure your API key:
|
Run the interactive setup wizard to configure your API key:
|
||||||
@@ -99,10 +95,6 @@ mem0 init --api-key m0-xxx --user-id alice --force
|
|||||||
| `--code` | Verification code (use with `--email` for non-interactive login) |
|
| `--code` | Verification code (use with `--email` for non-interactive login) |
|
||||||
| `--force` | Overwrite existing config without confirmation |
|
| `--force` | Overwrite existing config without confirmation |
|
||||||
|
|
||||||
<Note>
|
|
||||||
AI agents should use `mem0 init --agent` — see [Sign up as an agent](/platform/agent-signup).
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
### `mem0 add`
|
### `mem0 add`
|
||||||
|
|
||||||
Add a memory from text, a JSON messages array, a file, or stdin.
|
Add a memory from text, a JSON messages array, a file, or stdin.
|
||||||
|
|||||||
@@ -142,24 +142,6 @@ iconType: "solid"
|
|||||||
Setting this environment variable will prevent Mem0 from collecting and sending any usage data, ensuring complete privacy for your application.
|
Setting this environment variable will prevent Mem0 from collecting and sending any usage data, ensuring complete privacy for your application.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="How do I delete my Mem0 account?">
|
|
||||||
You can delete your Mem0 account at any time directly from the dashboard:
|
|
||||||
|
|
||||||
1. Sign in at [app.mem0.ai](https://app.mem0.ai).
|
|
||||||
2. Go to **Settings → Account**.
|
|
||||||
3. Click **Delete account** and confirm.
|
|
||||||
|
|
||||||
Deletion is immediate and irreversible. The following is removed:
|
|
||||||
|
|
||||||
- Your user profile and login credentials
|
|
||||||
- All memories, agents, and runs you created
|
|
||||||
- API keys and access tokens issued to your account
|
|
||||||
- Organizations you solely own, along with their data
|
|
||||||
- Your membership in any shared organizations (the orgs themselves are not affected)
|
|
||||||
|
|
||||||
Any application still using your old API keys will start receiving `401 Unauthorized` responses immediately. If you'd like to use Mem0 again later, you can create a new account at any time — it will start fresh with no data carried over.
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
|
|||||||
|
|
||||||
- **Personalized replies**: Memories persist across users and agents, cutting prompt bloat and repeat questions.
|
- **Personalized replies**: Memories persist across users and agents, cutting prompt bloat and repeat questions.
|
||||||
- **Hosted stack**: Mem0 runs the vector store, graph services, and rerankers—no provisioning, tuning, or maintenance.
|
- **Hosted stack**: Mem0 runs the vector store, graph services, and rerankers—no provisioning, tuning, or maintenance.
|
||||||
- **Enterprise controls**: Audit logs and workspace governance ship by default for production readiness.
|
- **Enterprise controls**: SOC 2, audit logs, and workspace governance ship by default for production readiness.
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
<Accordion title="What you get with Mem0 Platform" icon="sparkles">
|
<Accordion title="What you get with Mem0 Platform" icon="sparkles">
|
||||||
@@ -22,7 +22,7 @@ Mem0 is the memory engine that keeps conversations contextual so users never rep
|
|||||||
| Fast setup | Add a few lines of code and you’re production-ready—no vector database or LLM configuration required. |
|
| Fast setup | Add a few lines of code and you’re production-ready—no vector database or LLM configuration required. |
|
||||||
| Production scale | Automatic scaling, high availability, and managed infrastructure so you focus on product work. |
|
| Production scale | Automatic scaling, high availability, and managed infrastructure so you focus on product work. |
|
||||||
| Advanced features | Graph memory, webhooks, multimodal support, and custom categories are ready to enable. |
|
| Advanced features | Graph memory, webhooks, multimodal support, and custom categories are ready to enable. |
|
||||||
| Enterprise ready | Audit logs, workspace governance, and dedicated support keep security and governance covered. |
|
| Enterprise ready | SOC 2 Type II, GDPR compliance, and dedicated support keep security and governance covered. |
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ iconType: "solid"
|
|||||||
|
|
||||||
Get started with Mem0 Platform's hosted API in under 5 minutes. This guide shows you how to authenticate and store your first memory.
|
Get started with Mem0 Platform's hosted API in under 5 minutes. This guide shows you how to authenticate and store your first memory.
|
||||||
|
|
||||||
<Note>
|
|
||||||
**Are you an AI agent?** See [Sign up as an agent](/platform/agent-signup) — mint a working API key in four commands, no email or dashboard required.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-quickstart" rel="nofollow">Sign up here</a>)
|
- Mem0 Platform account (<a href="https://app.mem0.ai?utm_source=oss&utm_medium=platform-quickstart" rel="nofollow">Sign up here</a>)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"version": "0.1.3",
|
"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.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to the Mem0 plugin will be documented in this file.
|
|
||||||
|
|
||||||
## 0.1.3
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **user_id resolution no longer derives from `MEM0_API_KEY`.** v0.1.2 changed the resolver to fall back to `"mem0-" + sha256(MEM0_API_KEY)[:12]` ahead of `$USER`, which silently moved every existing user to a new bucket on update. Memories written under the previous `$USER` value became unreachable from the plugin. Resolution is now back to `MEM0_USER_ID` → `$USER` → `"default"`.
|
|
||||||
- Dropped the "regardless of which machine you're on" line from the SessionStart bootstrap, since cross-machine consolidation now requires setting `MEM0_USER_ID` explicitly.
|
|
||||||
|
|
||||||
### Notes for users upgrading from 0.1.2
|
|
||||||
|
|
||||||
- The `~/.mem0/identity.json` cache file is no longer read or written. Safe to delete.
|
|
||||||
- If you wrote memories during the v0.1.2 window, they live under `mem0-<sha256(api_key)[:12]>`. To recover: temporarily `export MEM0_USER_ID=mem0-<hash>`, search/export, then unset.
|
|
||||||
- Want a single bucket across machines (the original goal of #5076)? Set `MEM0_USER_ID` explicitly in your shell profile. The plugin will not auto-derive one.
|
|
||||||
|
|
||||||
## 0.1.2
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Deterministic `user_id` resolver (`_identity.sh` / `_identity.py`) — **reverted in 0.1.3, see above.**
|
|
||||||
- SessionStart-compact handler (`capture_compact_summary.py`) that stores the post-compaction summary as a memory with `metadata.type=compact_summary`.
|
|
||||||
- Coding-taxonomy setup script (`setup_coding_categories.py`) — one-shot `project.update(custom_categories=[...])` for `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`.
|
|
||||||
- Opt-in hook logging via `MEM0_DEBUG=1` → `~/.mem0/hooks.log`.
|
|
||||||
- `mem0-mcp` skill replacing the Claude-Code-specific `mem0-codex` skill.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `session_id` now written to memory metadata (`on_pre_compact.py`).
|
|
||||||
- SessionStart bootstrap exits silently when `MEM0_API_KEY` is unset.
|
|
||||||
- `block_memory_write.sh` regex tightened to `MEMORY.md` / `.claude/memory/*` — no longer blocks `docs/memory/*.md`.
|
|
||||||
- Removed duplicate PreCompact write path (kept agent-driven, dropped the parallel Python REST entry from `hooks.json` / `cursor-hooks.json`).
|
|
||||||
- Hook-side captures (`session_state`, `compact_summary`) now set `expiration_date = today + 90 days`.
|
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
- Cursor plugin fully functional (`#4547`).
|
|
||||||
- Codex plugin support and integration docs (`#4665`).
|
|
||||||
- Codex lifecycle hooks via opt-in installer (`#4917`).
|
|
||||||
- Removed invalid keys from Claude plugin config (`#4821`).
|
|
||||||
|
|
||||||
## 0.1.0
|
|
||||||
|
|
||||||
- Initial release: Mem0 plugin for Claude Code and Cursor (`#4518`).
|
|
||||||
@@ -2,18 +2,6 @@
|
|||||||
|
|
||||||
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, and **Codex**.
|
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.**
|
||||||
|
|||||||
@@ -1,17 +1,59 @@
|
|||||||
"""Resolve mem0 user_id.
|
"""Resolve mem0 user_id with deterministic priority.
|
||||||
|
|
||||||
Resolution priority:
|
Resolution priority:
|
||||||
1. MEM0_USER_ID env var (explicit override)
|
1. MEM0_USER_ID env var (explicit override)
|
||||||
2. $USER, else "default"
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||||
|
|
||||||
|
|
||||||
def resolve_user_id() -> str:
|
def resolve_user_id() -> str:
|
||||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||||
if explicit:
|
if explicit:
|
||||||
return 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"
|
return os.environ.get("USER") or "default"
|
||||||
|
|||||||
@@ -2,13 +2,54 @@
|
|||||||
#
|
#
|
||||||
# Resolution priority:
|
# Resolution priority:
|
||||||
# 1. MEM0_USER_ID env var (explicit override)
|
# 1. MEM0_USER_ID env var (explicit override)
|
||||||
# 2. $USER, else "default"
|
# 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() {
|
_mem0_resolve_identity() {
|
||||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||||
printf '%s' "$MEM0_USER_ID"
|
printf '%s' "$MEM0_USER_ID"
|
||||||
return
|
return
|
||||||
fi
|
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}"
|
printf '%s' "${USER:-default}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ echo "## Mem0 Identity"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps the agent's MCP calls aligned with the bucket the hooks write to."
|
echo "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 ""
|
echo ""
|
||||||
|
|
||||||
if [ "$SOURCE" = "startup" ]; then
|
if [ "$SOURCE" = "startup" ]; then
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export MEM0_API_KEY="m0-your-api-key"
|
|||||||
|
|
||||||
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=mem0-plugin-skill
|
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:**
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export interface SearchMemoryOptions {
|
|||||||
topK?: number;
|
topK?: number;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
rerank?: boolean;
|
rerank?: boolean;
|
||||||
latestOnly?: boolean;
|
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,6 @@ export interface GetAllMemoryOptions {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
latestOnly?: boolean;
|
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,24 +63,6 @@ describe("MemoryClient - search()", () => {
|
|||||||
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
|
expect(getFetchBody(call!).filters).toEqual({ user_id: "u1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serializes latestOnly as latest_only", async () => {
|
|
||||||
const extra = new Map<string, { status: number; body: unknown }>();
|
|
||||||
extra.set("/v3/memories/search/", {
|
|
||||||
status: 200,
|
|
||||||
body: { results: [] },
|
|
||||||
});
|
|
||||||
const mock = setupMockFetch(extra);
|
|
||||||
|
|
||||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
|
||||||
await client.search("test", {
|
|
||||||
filters: { user_id: "u1" },
|
|
||||||
latestOnly: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = findFetchCall(mock, "/v3/memories/search/", "POST");
|
|
||||||
expect(getFetchBody(call!).latest_only).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes complex OR filters through to the API body", async () => {
|
test("passes complex OR filters through to the API body", async () => {
|
||||||
const extra = new Map<string, { status: number; body: unknown }>();
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
extra.set("/v3/memories/search/", {
|
extra.set("/v3/memories/search/", {
|
||||||
@@ -282,22 +264,4 @@ describe("MemoryClient - getAll() entity param rejection", () => {
|
|||||||
await client.getAll({ filters: { user_id: "u1" } });
|
await client.getAll({ filters: { user_id: "u1" } });
|
||||||
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
|
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("serializes latestOnly as latest_only", async () => {
|
|
||||||
const extra = new Map<string, { status: number; body: unknown }>();
|
|
||||||
extra.set("/v3/memories/", {
|
|
||||||
status: 200,
|
|
||||||
body: { results: [] },
|
|
||||||
});
|
|
||||||
const mock = setupMockFetch(extra);
|
|
||||||
|
|
||||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
|
||||||
await client.getAll({
|
|
||||||
filters: { user_id: "u1" },
|
|
||||||
latestOnly: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const call = findFetchCall(mock, "/v3/memories/", "POST");
|
|
||||||
expect(getFetchBody(call!).latest_only).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ 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)
|
||||||
|
|||||||
@@ -156,4 +156,3 @@ known_first_party = ["mem0", "mem0_cli"]
|
|||||||
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
||||||
# black-equivalent profile here matches the formatter behaviour ruff applies.
|
# 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).
|
# Plugin-version bumps need a touch here to fire required CI checks (path-filter trap).
|
||||||
# Last touched: plugin v0.1.3
|
|
||||||
|
|||||||
@@ -184,3 +184,27 @@ async def require_auth(
|
|||||||
return default_user
|
return default_user
|
||||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_admin(request: Request, user: User | None) -> None:
|
||||||
|
"""Single source of truth for admin gating.
|
||||||
|
|
||||||
|
Allows: legacy ADMIN_API_KEY env (X-API-Key header), AUTH_DISABLED=true,
|
||||||
|
or a registered User with role == 'admin'. Raises 403 otherwise.
|
||||||
|
|
||||||
|
Pure function — reusable from FastAPI deps and inline route guards.
|
||||||
|
"""
|
||||||
|
auth_type = getattr(request.state, "auth_type", "none")
|
||||||
|
if auth_type in {"admin_api_key", "disabled"}:
|
||||||
|
return
|
||||||
|
if user is not None and user.role == "admin":
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required.")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(
|
||||||
|
request: Request,
|
||||||
|
user: User | None = Depends(verify_auth),
|
||||||
|
) -> None:
|
||||||
|
"""FastAPI dependency. Admin-only gate; returns nothing."""
|
||||||
|
_ensure_admin(request, user)
|
||||||
|
|||||||
+14
-6
@@ -13,7 +13,7 @@ from slowapi import _rate_limit_exceeded_handler
|
|||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth
|
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, _ensure_admin, require_admin, verify_auth
|
||||||
from errors import (
|
from errors import (
|
||||||
UpstreamError,
|
UpstreamError,
|
||||||
install_request_id_logging,
|
install_request_id_logging,
|
||||||
@@ -301,7 +301,7 @@ async def log_requests(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/configure", summary="Get current Mem0 configuration")
|
@app.get("/configure", summary="Get current Mem0 configuration")
|
||||||
def get_config(_auth=Depends(verify_auth)):
|
def get_config(_admin=Depends(require_admin)):
|
||||||
return _redact_config(get_current_config())
|
return _redact_config(get_current_config())
|
||||||
|
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ def list_bundled_providers(_auth=Depends(verify_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/configure", summary="Configure Mem0")
|
@app.post("/configure", summary="Configure Mem0")
|
||||||
def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)):
|
def set_config(config: Dict[str, Any], _admin=Depends(require_admin)):
|
||||||
"""Set memory configuration."""
|
"""Set memory configuration."""
|
||||||
_validate_bundled_providers(config)
|
_validate_bundled_providers(config)
|
||||||
update_config(config)
|
update_config(config)
|
||||||
@@ -386,19 +386,27 @@ def _list_all_memories(limit: int = ALL_MEMORIES_LIMIT) -> Dict[str, Any]:
|
|||||||
|
|
||||||
@app.get("/memories", summary="Get memories")
|
@app.get("/memories", summary="Get memories")
|
||||||
def get_all_memories(
|
def get_all_memories(
|
||||||
|
request: Request,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
run_id: Optional[str] = None,
|
run_id: Optional[str] = None,
|
||||||
agent_id: Optional[str] = None,
|
agent_id: Optional[str] = None,
|
||||||
_auth=Depends(verify_auth),
|
user: User | None = Depends(verify_auth),
|
||||||
):
|
):
|
||||||
"""Retrieve stored memories. Lists all memories when no identifier is provided."""
|
"""Retrieve stored memories. Lists all memories when no identifier is provided.
|
||||||
|
|
||||||
|
Note: the unfiltered listing branch is admin-only — it would otherwise leak
|
||||||
|
every payload in the vector store across tenants once multi-user lands.
|
||||||
|
Filtered queries remain available to any authenticated caller."""
|
||||||
try:
|
try:
|
||||||
if not any([user_id, run_id, agent_id]):
|
if not any([user_id, run_id, agent_id]):
|
||||||
|
_ensure_admin(request, user)
|
||||||
return _list_all_memories()
|
return _list_all_memories()
|
||||||
filters = {
|
filters = {
|
||||||
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
|
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
|
||||||
}
|
}
|
||||||
return get_memory_instance().get_all(filters=filters)
|
return get_memory_instance().get_all(filters=filters)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
raise upstream_error()
|
raise upstream_error()
|
||||||
|
|
||||||
@@ -473,7 +481,7 @@ def delete_all_memories(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/reset", summary="Reset all memories")
|
@app.post("/reset", summary="Reset all memories")
|
||||||
def reset_memory(_auth=Depends(verify_auth)):
|
def reset_memory(_admin=Depends(require_admin)):
|
||||||
"""Completely reset stored memories."""
|
"""Completely reset stored memories."""
|
||||||
try:
|
try:
|
||||||
get_memory_instance().reset()
|
get_memory_instance().reset()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, Literal, Optional
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from auth import verify_auth
|
from auth import require_admin
|
||||||
from errors import upstream_error
|
from errors import upstream_error
|
||||||
from schemas import MessageResponse
|
from schemas import MessageResponse
|
||||||
from server_state import get_memory_instance
|
from server_state import get_memory_instance
|
||||||
@@ -42,7 +42,7 @@ def _parse_timestamp(value: Any) -> Optional[datetime]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[Entity])
|
@router.get("", response_model=list[Entity])
|
||||||
def list_entities(_auth=Depends(verify_auth)):
|
def list_entities(_admin=Depends(require_admin)):
|
||||||
buckets: dict[tuple[EntityType, str], dict[str, Any]] = defaultdict(
|
buckets: dict[tuple[EntityType, str], dict[str, Any]] = defaultdict(
|
||||||
lambda: {"total_memories": 0, "created_at": None, "updated_at": None}
|
lambda: {"total_memories": 0, "created_at": None, "updated_at": None}
|
||||||
)
|
)
|
||||||
@@ -69,7 +69,7 @@ def list_entities(_auth=Depends(verify_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
|
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
|
||||||
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)):
|
def delete_entity(entity_type: EntityType, entity_id: str, _admin=Depends(require_admin)):
|
||||||
try:
|
try:
|
||||||
get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id})
|
get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_admin
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from models import RequestLog, User
|
from models import RequestLog
|
||||||
|
|
||||||
router = APIRouter(prefix="/requests", tags=["requests"])
|
router = APIRouter(prefix="/requests", tags=["requests"])
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ API_KEY_AUTH_TYPES = ("api_key", "admin_api_key")
|
|||||||
|
|
||||||
@router.get("", response_model=list[RequestLogItem])
|
@router.get("", response_model=list[RequestLogItem])
|
||||||
def list_requests(
|
def list_requests(
|
||||||
user: User = Depends(require_auth),
|
_admin=Depends(require_admin),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -38,23 +38,7 @@ Both packages install a `mem0` binary with identical commands, options, and outp
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
**Autonomous Mem0 setup for agents (no email, no dashboard):**
|
**Interactive wizard:**
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
mem0 init
|
mem0 init
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -35,16 +35,12 @@ Interactive setup wizard. Configures API key and default user ID.
|
|||||||
| `--email <addr>` | string | - | Login via email verification code instead of API key. |
|
| `--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). |
|
| `--code <code>` | string | - | Verification code (use with `--email` for fully non-interactive login). |
|
||||||
| `--force` | boolean | false | Overwrite existing config without confirmation. |
|
| `--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:**
|
**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).
|
- 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`.
|
- **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.
|
- **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.
|
- In non-TTY without sufficient flags, prints a usage hint and exits with error.
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
@@ -54,30 +50,6 @@ mem0 init --api-key m0-xxx --user-id alice
|
|||||||
mem0 init --api-key m0-xxx --user-id alice --force
|
mem0 init --api-key m0-xxx --user-id alice --force
|
||||||
mem0 init --email alice@company.com
|
mem0 init --email alice@company.com
|
||||||
mem0 init --email alice@company.com --code 482901
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 `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 → **interactive mode** asks; **CI mode** (`MEM0_INTEGRATE_CI=1`)
|
||||||
If missing AND **CI mode** (`MEM0_INTEGRATE_CI=1`) → exit with code 2 and the name of the missing key.
|
exits 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.
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export MEM0_API_KEY="m0-your-api-key"
|
|||||||
|
|
||||||
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=skill-mem0
|
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
|
## Step 2: Initialize the client
|
||||||
|
|
||||||
**Python:**
|
**Python:**
|
||||||
|
|||||||
Reference in New Issue
Block a user