Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c1861a59 | |||
| 674bb92423 | |||
| a723cb485a | |||
| 21043bab1f | |||
| 43b222ca57 |
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.1.2"
|
||||
"version": "0.1.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
|
||||
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||
| `mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills |
|
||||
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/` |
|
||||
| `skills/` | Claude Code skill definitions — `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/` |
|
||||
| `docs/` | Documentation site (Mintlify) |
|
||||
| `tests/` | Python SDK tests (pytest) |
|
||||
| `evaluation/` | Benchmarking framework — LOCOMO evals, experiment runner, score generation |
|
||||
@@ -387,9 +387,7 @@ Model Context Protocol support in multiple places:
|
||||
### Plugin & Skills System
|
||||
|
||||
- `mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
|
||||
- `skills/` contains structured skill definitions for AI agents, split into two categories:
|
||||
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
|
||||
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch. The two are loosely coupled via `.mem0-integration/` artifacts.
|
||||
- `skills/` contains structured skill definitions for AI agents, covering SDK usage, CLI workflows, and Vercel AI SDK patterns.
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
| Benchmark | Old | New | Tokens | Latency p50 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
|
||||
| **LongMemEval** | 67.8 | **94.8** | 6.8K | 1.09s |
|
||||
| **LongMemEval** | 67.8 | **93.4** | 6.8K | 1.09s |
|
||||
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
|
||||
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
|
||||
|
||||
@@ -58,13 +58,12 @@ All benchmarks run on the same production-representative model stack. Single-pas
|
||||
- **Agent-generated facts are first-class** -- when an agent confirms an action, that information is now stored with equal weight.
|
||||
- **Entity linking** -- entities are extracted, embedded, and linked across memories for retrieval boosting.
|
||||
- **Multi-signal retrieval** -- semantic, BM25 keyword, and entity matching scored in parallel and fused.
|
||||
- **Temporal Reasoning** -- time-aware retrieval that ranks the right dated instance for queries about current state, past events, and upcoming plans.
|
||||
|
||||
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgrade instructions. The [evaluation framework](https://github.com/mem0ai/memory-benchmarks) is open-sourced so anyone can reproduce the numbers.
|
||||
|
||||
## Research Highlights
|
||||
- **91.6 on LoCoMo** -- +20 points over the previous algorithm
|
||||
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
|
||||
- **93.4 on LongMemEval** -- +26 points, with +53.6 on assistant memory recall
|
||||
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
|
||||
- [Read the full paper](https://mem0.ai/research)
|
||||
|
||||
@@ -86,8 +85,6 @@ See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgra
|
||||
|
||||
## 🚀 Quickstart Guide <a name="quickstart"></a>
|
||||
|
||||
> **Agent Mode — for AI agents setting up Mem0 autonomously.** Run `mem0 init --agent --agent-caller <your-name> --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`), replacing `<your-name>` with your AI agent identity (e.g. `claude-code`, `cursor`, `codex`). This mints an evaluation Mem0 API key in <5 seconds — no email, no dashboard, no OTP. If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init. Use the key immediately against the Platform or the MCP server. The human owner can claim ownership later with `mem0 init --email <their-email>`: memories transfer, the same key keeps working, and the agent isn't disrupted.
|
||||
|
||||
| | Library | Self-Hosted Server | Cloud Platform |
|
||||
|---|---------|-------------------|----------------|
|
||||
| **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use |
|
||||
@@ -150,27 +147,6 @@ mem0 search "What does Alice prefer?" --user-id alice
|
||||
|
||||
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
Teach your AI coding assistant (Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any tool that supports the skills standard) how to build with Mem0. Two categories:
|
||||
|
||||
**Reference skills — always on** (SDK knowledge loaded into the assistant's context):
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
|
||||
```
|
||||
|
||||
**Pipeline skills — run on demand** (execute an end-to-end workflow in an existing repo):
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Mem0 requires an LLM to function, with `gpt-5-mini` from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview).
|
||||
|
||||
+2
-4
@@ -503,7 +503,7 @@
|
||||
},
|
||||
{
|
||||
"name": "init",
|
||||
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
|
||||
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
|
||||
"usage": "mem0 init [OPTIONS]",
|
||||
"needsBackend": false,
|
||||
"needsConfig": false,
|
||||
@@ -516,9 +516,7 @@
|
||||
{ "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." },
|
||||
{ "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
|
||||
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." },
|
||||
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." },
|
||||
{ "name": "agent", "flags": ["--agent"], "type": "boolean", "default": false, "help": "Bootstrap an unattended Agent Mode account (no email required)." },
|
||||
{ "name": "source", "flags": ["--source"], "type": "string", "default": null, "help": "Channel attribution for signup (e.g. github, hn, ph)." }
|
||||
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.4",
|
||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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 { captureNotice, isAgentMode } from "../state.js";
|
||||
import { isAgentMode } from "../state.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
import {
|
||||
APIError,
|
||||
@@ -90,39 +90,7 @@ export class PlatformBackend implements Backend {
|
||||
if (resp.status === 204) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// Pull the unclaimed-Agent-Mode notice out of the body (or the header
|
||||
// fallback for endpoints returning non-dict / non-dict-leading payloads)
|
||||
// and stash for end-of-command surfacing.
|
||||
let notice: string | null = null;
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
!Array.isArray(data) &&
|
||||
"mem0_notice" in data
|
||||
) {
|
||||
notice = (data as Record<string, unknown>).mem0_notice as string;
|
||||
// biome-ignore lint/performance/noDelete: intentional strip so downstream consumers don't see duplicate notice
|
||||
delete (data as Record<string, unknown>).mem0_notice;
|
||||
} else if (
|
||||
Array.isArray(data) &&
|
||||
data.length > 0 &&
|
||||
typeof data[0] === "object" &&
|
||||
data[0] !== null &&
|
||||
"mem0_notice" in data[0]
|
||||
) {
|
||||
notice = (data[0] as Record<string, unknown>).mem0_notice as string;
|
||||
// biome-ignore lint/performance/noDelete: see above.
|
||||
delete (data[0] as Record<string, unknown>).mem0_notice;
|
||||
}
|
||||
if (!notice) {
|
||||
notice = resp.headers.get("X-Mem0-Notice-Message") ?? null;
|
||||
}
|
||||
captureNotice(notice);
|
||||
|
||||
return data;
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async add(
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Agent Mode commands — bootstrap (unattended signup) and OTP-based claim.
|
||||
*/
|
||||
|
||||
import readline from "node:readline";
|
||||
import { colors, printError, printInfo, printSuccess } from "../branding.js";
|
||||
import { type Mem0Config, saveConfig } from "../config.js";
|
||||
|
||||
const { brand, dim } = colors;
|
||||
|
||||
const SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "node",
|
||||
} as const;
|
||||
|
||||
export interface BootstrapEnvelope {
|
||||
api_key: string;
|
||||
default_user_id: string;
|
||||
org_id: string;
|
||||
project_id: string;
|
||||
mcp_url?: string;
|
||||
smoke_test_url?: string;
|
||||
claim_command?: string;
|
||||
mem0_notice?: string;
|
||||
}
|
||||
|
||||
function isValidEnvelope(v: unknown): v is BootstrapEnvelope {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === "object" &&
|
||||
typeof (v as BootstrapEnvelope).api_key === "string" &&
|
||||
(v as BootstrapEnvelope).api_key.length > 0 &&
|
||||
typeof (v as BootstrapEnvelope).default_user_id === "string" &&
|
||||
(v as BootstrapEnvelope).default_user_id.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/agent_mode/ and mutate config in place.
|
||||
*
|
||||
* @param config - Mem0Config mutated in place with the new platform values.
|
||||
* @param source - `--source` flag passthrough (analytics tag, free-form).
|
||||
* @param agentCaller - Self-declared agent identity passed via `--agent-caller`
|
||||
* (e.g. `claude-code`, `cursor`). May be null when the caller omitted the
|
||||
* flag; the agent can backfill later via `mem0 identify <name>`. Sent to the
|
||||
* backend in the request body and saved into `platform.agentCaller` for
|
||||
* local introspection.
|
||||
*/
|
||||
export async function bootstrapViaBackend(
|
||||
config: Mem0Config,
|
||||
{
|
||||
source,
|
||||
agentCaller,
|
||||
}: { source?: string | null; agentCaller?: string | null } = {},
|
||||
): Promise<void> {
|
||||
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (source) body.source = source;
|
||||
if (agentCaller) body.agent_caller = agentCaller;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...SOURCE_HEADERS,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch (err) {
|
||||
printError(
|
||||
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (resp.status === 429) {
|
||||
printError("Rate-limited. Try again in a few minutes.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (resp.status === 503) {
|
||||
printError("Agent Mode is temporarily disabled. Try again later.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail: string = resp.statusText;
|
||||
try {
|
||||
const errBody = (await resp.json()) as {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
};
|
||||
detail = errBody.error ?? errBody.detail ?? resp.statusText;
|
||||
} catch {
|
||||
/* leave detail as statusText */
|
||||
}
|
||||
// Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
||||
// translates to a generic 403 "You do not have permission to perform
|
||||
// this action." That's opaque — surface it as the rate-limit message
|
||||
// it actually is.
|
||||
if (resp.status === 403 && /permission/i.test(detail)) {
|
||||
printError(
|
||||
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
printError(`Bootstrap failed: ${detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const envelope = (await resp.json()) as BootstrapEnvelope;
|
||||
if (!isValidEnvelope(envelope)) {
|
||||
// Defend against partial/malformed backend responses (e.g. {api_key: null}).
|
||||
// Without this guard, the typed `string` field is silently set to
|
||||
// undefined/null and persisted, producing confusing downstream errors.
|
||||
printError(
|
||||
"Bootstrap response missing required fields — please update the CLI.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.platform.apiKey = envelope.api_key;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.platform.agentMode = true;
|
||||
config.platform.createdVia = "agent_mode";
|
||||
config.platform.agentCaller = agentCaller ?? "";
|
||||
config.platform.claimedAt = "";
|
||||
config.platform.defaultUserId = envelope.default_user_id;
|
||||
// Adopt the slug-derived user_id as the default scope for memory ops.
|
||||
config.defaults.userId = envelope.default_user_id;
|
||||
saveConfig(config);
|
||||
|
||||
printSuccess(
|
||||
`Agent Mode active. Default user_id: ${envelope.default_user_id}`,
|
||||
);
|
||||
if (envelope.mem0_notice) {
|
||||
console.log(`\n\x1b[33m🔔 ${envelope.mem0_notice}\x1b[0m\n`);
|
||||
} else {
|
||||
// Fallback for older backends without the unified notice field.
|
||||
const claimCmd = envelope.claim_command ?? "mem0 init --email <your-email>";
|
||||
console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`);
|
||||
}
|
||||
|
||||
if (!agentCaller) {
|
||||
console.log(
|
||||
` ${dim("If you are an AI agent, identify yourself: `mem0 identify <your-name>` (e.g. claude-code, cursor).")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim an existing Agent Mode account via OTP — no browser, no polling.
|
||||
*
|
||||
* Hits /api/v1/auth/email_code/ to send a verification code, prompts for it
|
||||
* interactively (or accepts via `code`), then sends it to /verify/ alongside
|
||||
* `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place
|
||||
* inline and returns the claim result.
|
||||
*/
|
||||
export async function claimViaOtp(
|
||||
config: Mem0Config,
|
||||
{ email, code }: { email: string; code?: string },
|
||||
): Promise<void> {
|
||||
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
if (!config.platform.apiKey || !config.platform.agentMode) {
|
||||
printError(
|
||||
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rawKey = config.platform.apiKey;
|
||||
|
||||
// Step 1: request OTP (unless --code was supplied)
|
||||
if (!code) {
|
||||
const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
|
||||
method: "POST",
|
||||
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (sendResp.status === 429) {
|
||||
printError("Too many attempts. Try again in a few minutes.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!sendResp.ok) {
|
||||
let detail: string = sendResp.statusText;
|
||||
try {
|
||||
const errBody = (await sendResp.json()) as { error?: string };
|
||||
if (errBody.error) detail = errBody.error;
|
||||
} catch {
|
||||
/* leave as statusText */
|
||||
}
|
||||
printError(`Failed to send code: ${detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
printSuccess(`Verification code sent to ${email}. Check your inbox.`);
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
printError(
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
`Re-run: mem0 init --email ${email} --code <code>`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log();
|
||||
code = await promptLine(` ${brand("Verification Code")}`);
|
||||
if (!code) {
|
||||
printError("Code is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: verify + claim atomically
|
||||
const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, {
|
||||
method: "POST",
|
||||
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code: code.trim(),
|
||||
agent_mode_api_key: rawKey,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!verifyResp.ok) {
|
||||
let detail: string = verifyResp.statusText;
|
||||
let errCode = "";
|
||||
try {
|
||||
const errBody = (await verifyResp.json()) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
};
|
||||
if (errBody.error) detail = errBody.error;
|
||||
if (errBody.code) errCode = errBody.code;
|
||||
} catch {
|
||||
/* leave as statusText */
|
||||
}
|
||||
printError(`Claim failed: ${detail}`);
|
||||
if (errCode === "email_already_claimed") {
|
||||
console.log(
|
||||
` ${dim("Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.")}`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const claimBody = (await verifyResp.json()) as {
|
||||
claimed?: boolean;
|
||||
claimed_at?: string;
|
||||
};
|
||||
if (!claimBody.claimed) {
|
||||
printError(`Unexpected verify response: ${JSON.stringify(claimBody)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.platform.agentMode = false;
|
||||
config.platform.claimedAt = claimBody.claimed_at ?? new Date().toISOString();
|
||||
config.platform.userEmail = email;
|
||||
config.platform.createdVia = "email";
|
||||
saveConfig(config);
|
||||
|
||||
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`);
|
||||
}
|
||||
|
||||
function promptLine(label: string): Promise<string> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${label}: `, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
saveConfig,
|
||||
} from "../config.js";
|
||||
import { formatJsonEnvelope } from "../output.js";
|
||||
import { isAgentMode } from "../state.js";
|
||||
|
||||
const { brand, dim } = colors;
|
||||
|
||||
@@ -35,65 +33,6 @@ function validateEmail(email: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — exported for unit tests. */
|
||||
export async function pingKey(
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
|
||||
// Network errors, timeouts, and 5xx responses return true so we prefer
|
||||
// reusing an existing key over silently minting a new shadow on a transient
|
||||
// blip (which would also clobber config + plugin-sync targets).
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
|
||||
headers: { Authorization: `Token ${apiKey}` },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return resp.status !== 401 && resp.status !== 403;
|
||||
} catch {
|
||||
return true; // unknown — prefer reuse
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeIdentify(
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
agentCaller: string | undefined,
|
||||
): Promise<void> {
|
||||
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
||||
// reused key. Silent no-op on any failure — reuse must not break.
|
||||
if (!agentCaller) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Token ${key}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ agent_caller: agentCaller }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
try {
|
||||
const body = (await resp.json()) as { agent_caller?: string };
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const cfg = loadConfig();
|
||||
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
|
||||
saveConfig(cfg);
|
||||
}
|
||||
} catch {
|
||||
/* swallow — best effort */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* swallow — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function emailLogin(
|
||||
email: string,
|
||||
code: string | undefined,
|
||||
@@ -257,7 +196,6 @@ async function setupPlatform(config: Mem0Config): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
config.platform.apiKey = apiKey;
|
||||
config.platform.createdVia = "api_key";
|
||||
}
|
||||
|
||||
async function setupDefaults(config: Mem0Config): Promise<void> {
|
||||
@@ -311,35 +249,14 @@ export async function runInit(
|
||||
email?: string;
|
||||
code?: string;
|
||||
force?: boolean;
|
||||
agent?: boolean;
|
||||
source?: string;
|
||||
agentCaller?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const { detectAgentCaller } = await import("../agent-detect.js");
|
||||
const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js");
|
||||
const { isAgentMode } = await import("../state.js");
|
||||
const { captureEvent } = await import("../telemetry.js");
|
||||
|
||||
const fireInit = (
|
||||
mode: "agent" | "email" | "api_key" | "existing_key",
|
||||
claimed = false,
|
||||
) => {
|
||||
const props: Record<string, unknown> = { command: "init", mode };
|
||||
// Self-declared via --agent-caller; not sniffed from env vars.
|
||||
if (opts.agentCaller) props.agent_caller = opts.agentCaller;
|
||||
if (opts.source) props.signup_source = opts.source;
|
||||
if (claimed) props.claimed_agent_mode = true;
|
||||
captureEvent("cli.init", props);
|
||||
};
|
||||
|
||||
const config = createDefaultConfig();
|
||||
const savedConfig = loadConfig();
|
||||
const baseUrl =
|
||||
process.env.MEM0_BASE_URL ||
|
||||
savedConfig.platform.baseUrl ||
|
||||
DEFAULT_BASE_URL;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
|
||||
// Guards
|
||||
if (opts.code && !opts.email) {
|
||||
@@ -351,84 +268,6 @@ export async function runInit(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Claim flow: --email against an existing agent-mode config ───────────
|
||||
if (
|
||||
opts.email &&
|
||||
fs.existsSync(CONFIG_FILE) &&
|
||||
savedConfig.platform.agentMode &&
|
||||
savedConfig.platform.apiKey
|
||||
) {
|
||||
const email = opts.email.trim().toLowerCase();
|
||||
validateEmail(email);
|
||||
printInfo(`Claiming Agent Mode account to ${email}...`);
|
||||
await claimViaOtp(savedConfig, { email, code: opts.code });
|
||||
fireInit("email", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Agent Mode path runs BEFORE the existing-config guard ──────────────
|
||||
// Rule 1/2 will REUSE a valid existing key (not overwrite), so we must
|
||||
// short-circuit before the guard prompts the user about overwriting.
|
||||
// Rule 3 only mints when there's no valid key to reuse — in that case
|
||||
// overwriting is what the user wants.
|
||||
const agentCtx =
|
||||
opts.agent === true || isAgentMode() || detectAgentCaller() !== null;
|
||||
if (!opts.apiKey && !opts.email && agentCtx) {
|
||||
const emitReuseEnvelope = (source: "env" | "config") => {
|
||||
if (isAgentMode()) {
|
||||
formatJsonEnvelope({
|
||||
command: "init",
|
||||
data: {
|
||||
api_key_saved: false,
|
||||
api_key_source: source,
|
||||
agent_mode: false,
|
||||
message:
|
||||
"Existing Mem0 API key found and reused. No Agent Mode key was created.",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
printSuccess(
|
||||
source === "env"
|
||||
? "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
|
||||
: "Existing API key in config is valid; reusing it. No new Agent Mode key was minted.",
|
||||
);
|
||||
}
|
||||
};
|
||||
// Rule 1: env MEM0_API_KEY valid → reuse, no new key.
|
||||
const envKey = (process.env.MEM0_API_KEY || "").trim();
|
||||
if (envKey && (await pingKey(envKey, baseUrl))) {
|
||||
await maybeIdentify(envKey, baseUrl, opts.agentCaller);
|
||||
emitReuseEnvelope("env");
|
||||
fireInit("existing_key");
|
||||
return;
|
||||
}
|
||||
// Rule 2: existing config api_key valid → reuse.
|
||||
if (
|
||||
savedConfig.platform.apiKey &&
|
||||
(await pingKey(savedConfig.platform.apiKey, baseUrl))
|
||||
) {
|
||||
await maybeIdentify(
|
||||
savedConfig.platform.apiKey,
|
||||
baseUrl,
|
||||
opts.agentCaller,
|
||||
);
|
||||
emitReuseEnvelope("config");
|
||||
fireInit("existing_key");
|
||||
return;
|
||||
}
|
||||
// Rule 3: mint a fresh shadow (no valid key to reuse).
|
||||
// agent_caller is self-declared via --agent-caller (Proof Editor-style),
|
||||
// not derived from env-var sniffing. detectAgentCaller() above is still
|
||||
// used as a context trigger (does this look like an agent?) but never
|
||||
// to fill identity.
|
||||
await bootstrapViaBackend(config, {
|
||||
source: opts.source ?? null,
|
||||
agentCaller: opts.agentCaller ?? null,
|
||||
});
|
||||
fireInit("agent");
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if an existing config with an API key would be overwritten
|
||||
if (
|
||||
!opts.force &&
|
||||
@@ -485,7 +324,6 @@ export async function runInit(
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.platform.userEmail = email;
|
||||
config.platform.createdVia = "email";
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
@@ -501,15 +339,13 @@ export async function runInit(
|
||||
}
|
||||
|
||||
// ── API key flow ──────────────────────────────────────────────────────────
|
||||
// (Agent Mode branch runs earlier — see above, before the existing-config
|
||||
// guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
|
||||
|
||||
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!opts.apiKey) {
|
||||
printError(
|
||||
"Non-interactive terminal detected and --api-key is required.",
|
||||
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
||||
"Usage: mem0 init --api-key <key> [--user-id <id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -520,7 +356,6 @@ export async function runInit(
|
||||
// Non-interactive: both flags provided
|
||||
if (opts.apiKey && opts.userId) {
|
||||
config.platform.apiKey = opts.apiKey;
|
||||
config.platform.createdVia = "api_key";
|
||||
config.defaults.userId = opts.userId;
|
||||
await validatePlatform(config);
|
||||
saveConfig(config);
|
||||
@@ -568,7 +403,6 @@ export async function runInit(
|
||||
config.platform.apiKey = apiKeyVal;
|
||||
config.platform.baseUrl = baseUrl;
|
||||
config.platform.userEmail = email;
|
||||
config.platform.createdVia = "email";
|
||||
config.defaults.userId =
|
||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ export interface PlatformConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
userEmail: string;
|
||||
// Agent Mode (unclaimed-shadow signup)
|
||||
agentMode: boolean; // true while the key is an unclaimed agent-mode key
|
||||
createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key"
|
||||
agentCaller: string; // canonical agent name when createdVia === "agent_mode" (e.g. "claude-code")
|
||||
claimedAt: string; // ISO timestamp once the agent has been claimed
|
||||
defaultUserId: string; // `user_<slug>` returned by bootstrap; auto-default scope
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
@@ -60,11 +54,6 @@ export function createDefaultConfig(): Mem0Config {
|
||||
apiKey: "",
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
userEmail: "",
|
||||
agentMode: false,
|
||||
createdVia: "",
|
||||
agentCaller: "",
|
||||
claimedAt: "",
|
||||
defaultUserId: "",
|
||||
},
|
||||
telemetry: {
|
||||
anonymousId: "",
|
||||
@@ -90,11 +79,6 @@ export function loadConfig(): Mem0Config {
|
||||
config.platform.apiKey = plat.api_key ?? "";
|
||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||
config.platform.userEmail = plat.user_email ?? "";
|
||||
config.platform.agentMode = Boolean(plat.agent_mode ?? false);
|
||||
config.platform.createdVia = plat.created_via ?? "";
|
||||
config.platform.agentCaller = plat.agent_caller ?? "";
|
||||
config.platform.claimedAt = plat.claimed_at ?? "";
|
||||
config.platform.defaultUserId = plat.default_user_id ?? "";
|
||||
|
||||
const defaults = data.defaults ?? {};
|
||||
config.defaults.userId = defaults.user_id ?? "";
|
||||
@@ -134,11 +118,6 @@ export function saveConfig(config: Mem0Config): void {
|
||||
api_key: config.platform.apiKey,
|
||||
base_url: config.platform.baseUrl,
|
||||
user_email: config.platform.userEmail,
|
||||
agent_mode: config.platform.agentMode,
|
||||
created_via: config.platform.createdVia,
|
||||
agent_caller: config.platform.agentCaller,
|
||||
claimed_at: config.platform.claimedAt,
|
||||
default_user_id: config.platform.defaultUserId,
|
||||
},
|
||||
telemetry: {
|
||||
anonymous_id: config.telemetry.anonymousId,
|
||||
@@ -147,20 +126,6 @@ export function saveConfig(config: Mem0Config): void {
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
||||
|
||||
// Propagate api_key to ecosystem touchpoints (Claude plugin env injection,
|
||||
// shell rc exports). Idempotent — updates only EXISTING entries; never
|
||||
// creates new ones. Best-effort: errors swallowed so config.json is
|
||||
// always authoritative, never blocked by plugin-state issues.
|
||||
if (config.platform.apiKey) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { syncApiKey } = require("./plugin-sync.js");
|
||||
syncApiKey(config.platform.apiKey);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function redactKey(key: string): string {
|
||||
|
||||
+4
-70
@@ -13,12 +13,7 @@ import { colors, printError, printWarning } from "./branding.js";
|
||||
import type { Mem0Config } from "./config.js";
|
||||
import { loadConfig, saveConfig } from "./config.js";
|
||||
import { richFormatHelp } from "./help.js";
|
||||
import {
|
||||
isAgentMode,
|
||||
setAgentMode,
|
||||
setCurrentCommand,
|
||||
takeNotice,
|
||||
} from "./state.js";
|
||||
import { setAgentMode } from "./state.js";
|
||||
import { captureEvent } from "./telemetry.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -146,11 +141,6 @@ program
|
||||
.description(
|
||||
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
|
||||
)
|
||||
// Positional options: flags AFTER a subcommand name belong to that
|
||||
// subcommand, not the global program. Without this, `mem0 init --agent`
|
||||
// routes `--agent` to the program-level alias (for --json) and init's own
|
||||
// `--agent` (Agent Mode bootstrap) silently never fires.
|
||||
.enablePositionalOptions()
|
||||
.option("--version", "Show version and exit.")
|
||||
.on("option:version", () => {
|
||||
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
||||
@@ -159,7 +149,7 @@ program
|
||||
.option("--json", "Output as JSON for agent/programmatic use.")
|
||||
.option(
|
||||
"--agent",
|
||||
"Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent <cmd>`. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.",
|
||||
"Output as JSON for agent/programmatic use. (alias: --json)",
|
||||
)
|
||||
.usage("<command> [options]")
|
||||
.helpOption("--help", "Show this message and exit.")
|
||||
@@ -176,14 +166,6 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
parentName && parentName !== "mem0"
|
||||
? `${parentName}.${commandName}`
|
||||
: commandName;
|
||||
// Stash the active command name in shared state so the JSON
|
||||
// error envelope (printError) can report which command failed
|
||||
// instead of an empty `"command": ""` field.
|
||||
setCurrentCommand(fullCommand);
|
||||
// init fires its own telemetry from runInit with full M1-M6 props
|
||||
// (mode/agent_caller/signup_source/claimed_agent_mode); skip the
|
||||
// auto-fire here so we don't double-count.
|
||||
if (fullCommand === "init") return;
|
||||
const isAgent = !!(program.opts().json || program.opts().agent);
|
||||
captureEvent(
|
||||
`cli.${fullCommand}`,
|
||||
@@ -211,32 +193,11 @@ program
|
||||
"Verification code (use with --email for non-interactive login).",
|
||||
)
|
||||
.option("--force", "Overwrite existing config without confirmation.", false)
|
||||
.option(
|
||||
"--agent",
|
||||
"Bootstrap an unattended Agent Mode account (no email required).",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--source <channel>",
|
||||
"Channel attribution for signup (e.g. github, hn, ph).",
|
||||
)
|
||||
.option(
|
||||
"--agent-caller <name>",
|
||||
"Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
|
||||
)
|
||||
// Accept `--json` at the init level too so the PRD-documented form
|
||||
// `mem0 init --agent --json` works without requiring users to move it
|
||||
// before the subcommand. Effect is identical to the global `--json`:
|
||||
// flip agent-mode output state.
|
||||
.option("--json", "Output as JSON (alias for global `--json`).", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present",
|
||||
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
// `--json` at init level mirrors the global flag — flip agent_mode
|
||||
// state so downstream formatters use JSON envelopes.
|
||||
if (opts.json) setAgentMode(true);
|
||||
const { runInit } = await import("./commands/init.js");
|
||||
await runInit({
|
||||
apiKey: opts.apiKey,
|
||||
@@ -244,24 +205,9 @@ program
|
||||
email: opts.email,
|
||||
code: opts.code,
|
||||
force: opts.force,
|
||||
agent: opts.agent,
|
||||
source: opts.source,
|
||||
agentCaller: opts.agentCaller,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Setup: identify (post-bootstrap agent self-tag) ──────────────────────
|
||||
|
||||
program
|
||||
.command("identify <name>")
|
||||
.description(
|
||||
"Tag your active Agent Mode key with the AI agent that's using it (e.g. claude-code, cursor).",
|
||||
)
|
||||
.action(async (name: string) => {
|
||||
const { runIdentify } = await import("./commands/identify.js");
|
||||
await runIdentify(name);
|
||||
});
|
||||
|
||||
// ── Memory: add ───────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
@@ -823,16 +769,4 @@ program
|
||||
|
||||
// ── Entrypoint ────────────────────────────────────────────────────────────
|
||||
|
||||
// Surface any unclaimed Agent Mode notice once per command, after the primary
|
||||
// output. In JSON/agent mode the notice is folded into the envelope by
|
||||
// formatJsonEnvelope, so skip the stderr banner there to avoid duplication.
|
||||
function surfaceNotice(): void {
|
||||
const notice = takeNotice();
|
||||
if (notice && !isAgentMode()) {
|
||||
process.stderr.write(`\n\x1b[33m🔔 ${notice}\x1b[0m\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
program.parseAsync().finally(() => {
|
||||
surfaceNotice();
|
||||
});
|
||||
program.parse();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import boxen from "boxen";
|
||||
import Table from "cli-table3";
|
||||
import { colors, sym } from "./branding.js";
|
||||
import { takeNotice } from "./state.js";
|
||||
|
||||
const { brand, accent, success, error: errorColor, dim } = colors;
|
||||
|
||||
@@ -245,15 +244,6 @@ export function formatJsonEnvelope(opts: {
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
if (opts.error) envelope.error = opts.error;
|
||||
envelope.data = opts.data;
|
||||
|
||||
// If the platform flagged this as an unclaimed Agent Mode account, surface
|
||||
// the notice inside the JSON envelope so an agent consuming the output
|
||||
// sees it without needing to inspect HTTP headers.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { takeNotice } = require("./state.js");
|
||||
const notice = takeNotice();
|
||||
if (notice) envelope.mem0_notice = notice;
|
||||
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
}
|
||||
|
||||
@@ -366,12 +356,6 @@ export function formatAgentEnvelope(opts: {
|
||||
}
|
||||
if (opts.count !== undefined) envelope.count = opts.count;
|
||||
envelope.data = sanitizeAgentData(opts.command, opts.data);
|
||||
|
||||
// Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
|
||||
// agent reading the JSON output sees it without inspecting HTTP headers.
|
||||
const notice = takeNotice();
|
||||
if (notice) envelope.mem0_notice = notice;
|
||||
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 _currentCommand = "";
|
||||
let _pendingNotice = "";
|
||||
|
||||
export function isAgentMode(): boolean {
|
||||
return _agentMode;
|
||||
@@ -22,19 +21,3 @@ export function getCurrentCommand(): string {
|
||||
export function setCurrentCommand(name: string): void {
|
||||
_currentCommand = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash a Mem0 backend notice (Agent Mode unclaimed reminder) for end-of-
|
||||
* command surfacing. Called from the platform backend after each response so
|
||||
* the notice prints once per command regardless of how many sub-requests
|
||||
* fired. Last-write-wins is fine — the message text is identical.
|
||||
*/
|
||||
export function captureNotice(notice: string | null | undefined): void {
|
||||
if (notice) _pendingNotice = notice;
|
||||
}
|
||||
|
||||
export function takeNotice(): string {
|
||||
const msg = _pendingNotice;
|
||||
_pendingNotice = "";
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -115,9 +115,6 @@ export function captureEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// M4: every cli.* event carries agent_mode based on the config flag
|
||||
// (unclaimed Agent Mode key). This is the growth-doc property used to
|
||||
// join init → add → search funnels in PostHog.
|
||||
const payload = {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
@@ -126,7 +123,6 @@ export function captureEvent(
|
||||
source: "CLI",
|
||||
language: "node",
|
||||
cli_version: CLI_VERSION,
|
||||
agent_mode: Boolean(config.platform.agentMode),
|
||||
node_version: process.version,
|
||||
os: process.platform,
|
||||
...properties,
|
||||
|
||||
@@ -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]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.5"
|
||||
version = "0.2.4"
|
||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -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()
|
||||
raise typer.Exit()
|
||||
if ctx.invoked_subcommand:
|
||||
# Stash the active subcommand name so the JSON error envelope
|
||||
# (print_error in agent mode) can report which command failed
|
||||
# instead of an empty `"command": ""` field.
|
||||
from mem0_cli.state import set_current_command
|
||||
|
||||
set_current_command(ctx.invoked_subcommand)
|
||||
if ctx.invoked_subcommand and ctx.invoked_subcommand != "init":
|
||||
# init fires its own telemetry from init_cmd.run_init with full M1-M6 props.
|
||||
_fire_telemetry(ctx.invoked_subcommand)
|
||||
|
||||
|
||||
@@ -859,19 +851,6 @@ def init(
|
||||
force: bool = typer.Option(
|
||||
False, "--force", help="Overwrite existing config without confirmation."
|
||||
),
|
||||
agent_signal: bool = typer.Option(
|
||||
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
|
||||
),
|
||||
source: str | None = typer.Option(
|
||||
None,
|
||||
"--source",
|
||||
help="Channel attribution for signup (e.g. github, hn, ph).",
|
||||
),
|
||||
agent_caller: str | None = typer.Option(
|
||||
None,
|
||||
"--agent-caller",
|
||||
help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
|
||||
),
|
||||
) -> None:
|
||||
"""Interactive setup wizard for mem0 CLI.
|
||||
|
||||
@@ -880,38 +859,10 @@ def init(
|
||||
mem0 init --api-key m0-xxx --user-id alice
|
||||
mem0 init --email alice@company.com
|
||||
mem0 init --email alice@company.com --code 482901
|
||||
mem0 init --agent --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap
|
||||
mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present
|
||||
"""
|
||||
from mem0_cli.commands.init_cmd import run_init
|
||||
|
||||
run_init(
|
||||
api_key=api_key,
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
code=code,
|
||||
force=force,
|
||||
source=source,
|
||||
agent=agent_signal,
|
||||
agent_caller=agent_caller,
|
||||
)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Setup")
|
||||
def identify(
|
||||
name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."),
|
||||
) -> None:
|
||||
"""Tag your active Agent Mode key with the AI agent that's using it.
|
||||
|
||||
Run this once after `mem0 init --agent` if you didn't pass --agent-caller.
|
||||
Idempotent — re-running just overwrites the value.
|
||||
|
||||
Example:
|
||||
mem0 identify claude-code
|
||||
"""
|
||||
from mem0_cli.commands.identify_cmd import run_identify
|
||||
|
||||
run_identify(name)
|
||||
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
|
||||
|
||||
|
||||
# (entity_app registered at module level, below sub-group definitions)
|
||||
@@ -1247,28 +1198,11 @@ def main() -> None:
|
||||
import sys
|
||||
|
||||
# Allow --json/--agent anywhere in the command line (not just before subcommand).
|
||||
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
|
||||
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
|
||||
argv_rest = sys.argv[1:]
|
||||
is_init = "init" in argv_rest
|
||||
_global_flags = {"--json"} if is_init else {"--json", "--agent"}
|
||||
if any(a in _global_flags for a in argv_rest):
|
||||
_json_flags = {"--json", "--agent"}
|
||||
if any(a in _json_flags for a in sys.argv[1:]):
|
||||
from mem0_cli.state import set_agent_mode
|
||||
|
||||
set_agent_mode(True)
|
||||
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
|
||||
sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags]
|
||||
|
||||
try:
|
||||
app()
|
||||
finally:
|
||||
# Surface any unclaimed Agent Mode notice once per command, after the
|
||||
# primary output. In JSON/agent mode the notice is folded into the
|
||||
# envelope by format_json_envelope, so skip the stderr banner there
|
||||
# to avoid duplicate output.
|
||||
from mem0_cli.state import is_agent_mode, take_notice
|
||||
|
||||
notice = take_notice()
|
||||
if notice and not is_agent_mode():
|
||||
from rich.console import Console
|
||||
|
||||
Console(stderr=True).print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
||||
app()
|
||||
|
||||
@@ -30,7 +30,7 @@ class PlatformBackend(Backend):
|
||||
)
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
from mem0_cli.state import capture_notice, is_agent_mode
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
|
||||
resp = self._client.request(method, path, **kwargs)
|
||||
@@ -48,26 +48,7 @@ class PlatformBackend(Backend):
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
data = resp.json()
|
||||
|
||||
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
|
||||
# fallback for endpoints that return non-dict / non-dict-leading
|
||||
# payloads) and stash it for end-of-command surfacing.
|
||||
notice = None
|
||||
if isinstance(data, dict) and "mem0_notice" in data:
|
||||
notice = data.pop("mem0_notice")
|
||||
elif (
|
||||
isinstance(data, list)
|
||||
and data
|
||||
and isinstance(data[0], dict)
|
||||
and "mem0_notice" in data[0]
|
||||
):
|
||||
notice = data[0].pop("mem0_notice")
|
||||
if notice is None:
|
||||
notice = resp.headers.get("X-Mem0-Notice-Message") or None
|
||||
capture_notice(notice)
|
||||
|
||||
return data
|
||||
return resp.json()
|
||||
|
||||
def add(
|
||||
self,
|
||||
|
||||
@@ -87,12 +87,10 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None
|
||||
}
|
||||
print(_json.dumps(envelope))
|
||||
return
|
||||
from rich.markup import escape
|
||||
|
||||
sym = _sym("✗", "[error]")
|
||||
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
|
||||
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
|
||||
if hint:
|
||||
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
|
||||
console.print(f" [{DIM_COLOR}]{hint}[/]")
|
||||
|
||||
|
||||
def print_warning(console: Console, message: str) -> None:
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from mem0_cli.branding import (
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
print_error,
|
||||
print_success,
|
||||
)
|
||||
from mem0_cli.config import Mem0Config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
|
||||
def _validate_envelope(envelope: Any) -> None:
|
||||
"""Defend against partial/malformed backend responses.
|
||||
|
||||
A backend regression that returns ``{"api_key": null}`` would otherwise be
|
||||
silently persisted, producing confusing downstream errors far from the
|
||||
source. Fail fast with a clear message if the required fields are missing.
|
||||
"""
|
||||
if not isinstance(envelope, dict):
|
||||
print_error(err_console, "Bootstrap response was not a JSON object.")
|
||||
raise typer.Exit(1)
|
||||
for field in ("api_key", "default_user_id"):
|
||||
value = envelope.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
print_error(
|
||||
err_console,
|
||||
f"Bootstrap response missing required field {field!r} — please update the CLI.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def bootstrap_via_backend(
|
||||
config: Mem0Config,
|
||||
*,
|
||||
source: str | None = None,
|
||||
agent_caller: str | None = None,
|
||||
) -> None:
|
||||
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
|
||||
|
||||
Args:
|
||||
config: Mem0Config mutated in place with the new platform values.
|
||||
source: ``--source`` flag passthrough (analytics tag, free-form).
|
||||
agent_caller: Self-declared agent identity passed via ``--agent-caller``
|
||||
(e.g. ``claude-code``, ``cursor``). May be None when the caller
|
||||
omitted the flag; the agent can backfill later via
|
||||
``mem0 identify <name>``. Sent to the backend in the request body
|
||||
and saved into ``platform.agent_caller`` for local introspection.
|
||||
|
||||
Raises typer.Exit(1) on failure.
|
||||
"""
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
body: dict[str, Any] = {}
|
||||
if source:
|
||||
body["source"] = source
|
||||
if agent_caller:
|
||||
body["agent_caller"] = agent_caller
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(
|
||||
f"{base_url}/api/v1/auth/agent_mode/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error contacting Mem0: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Rate-limited. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code == 503:
|
||||
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
detail = resp.text
|
||||
try:
|
||||
err_body = resp.json()
|
||||
detail = err_body.get("error") or err_body.get("detail") or resp.text
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
pass
|
||||
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
||||
# translates to a generic 403 "You do not have permission to perform
|
||||
# this action." That's opaque — surface as the rate-limit it actually is.
|
||||
if resp.status_code == 403 and "permission" in str(detail).lower():
|
||||
print_error(
|
||||
err_console,
|
||||
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
print_error(err_console, f"Bootstrap failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
envelope = resp.json()
|
||||
_validate_envelope(envelope)
|
||||
config.platform.api_key = envelope["api_key"]
|
||||
config.platform.base_url = base_url
|
||||
config.platform.agent_mode = True
|
||||
config.platform.created_via = "agent_mode"
|
||||
config.platform.agent_caller = agent_caller or ""
|
||||
config.platform.claimed_at = ""
|
||||
config.platform.default_user_id = envelope["default_user_id"]
|
||||
# Adopt the slug-derived user_id as the default scope for memory ops.
|
||||
config.defaults.user_id = envelope["default_user_id"]
|
||||
save_config(config)
|
||||
|
||||
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
|
||||
notice = envelope.get("mem0_notice")
|
||||
if notice:
|
||||
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
||||
else:
|
||||
# Fallback if the backend hasn't deployed the unified notice yet.
|
||||
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
|
||||
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
|
||||
|
||||
if not agent_caller:
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
|
||||
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
|
||||
)
|
||||
|
||||
|
||||
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
|
||||
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
|
||||
|
||||
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
|
||||
`/.../verify/`) and adds the local agent-mode API key in the verify body
|
||||
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
|
||||
upgrade-in-place transaction inline and returns claim result.
|
||||
|
||||
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
|
||||
`user_email`. The api_key value itself never changes.
|
||||
"""
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
if not config.platform.api_key or not config.platform.agent_mode:
|
||||
print_error(
|
||||
err_console,
|
||||
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
raw_key = config.platform.api_key
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Step 1: request OTP (unless --code provided)
|
||||
if not code:
|
||||
send = client.post(
|
||||
f"{base_url}/api/v1/auth/email_code/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json={"email": email},
|
||||
)
|
||||
if send.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if send.status_code != 200:
|
||||
try:
|
||||
detail = send.json().get("error", send.text)
|
||||
except Exception:
|
||||
detail = send.text
|
||||
print_error(err_console, f"Failed to send code: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_success(console, f"Verification code sent to {email}. Check your inbox.")
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
print_error(
|
||||
err_console,
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
hint=f"Re-run: mem0 init --email {email} --code <code>",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print()
|
||||
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
||||
if not code:
|
||||
print_error(err_console, "Code is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Step 2: verify + claim in one shot
|
||||
verify = client.post(
|
||||
f"{base_url}/api/v1/auth/email_code/verify/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json={
|
||||
"email": email,
|
||||
"code": code.strip(),
|
||||
"agent_mode_api_key": raw_key,
|
||||
},
|
||||
)
|
||||
|
||||
if verify.status_code != 200:
|
||||
try:
|
||||
err_body = verify.json()
|
||||
detail = err_body.get("error", verify.text)
|
||||
code_str = err_body.get("code", "")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
detail = verify.text
|
||||
code_str = ""
|
||||
print_error(err_console, f"Claim failed: {detail}")
|
||||
if code_str == "email_already_claimed":
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in there and run `mem0 link <key>` to attach this agent.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
claim_body = verify.json()
|
||||
if not claim_body.get("claimed"):
|
||||
print_error(err_console, f"Unexpected verify response: {claim_body}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.platform.agent_mode = False
|
||||
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
|
||||
config.platform.user_email = email
|
||||
config.platform.created_via = "email"
|
||||
save_config(config)
|
||||
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -1,75 +0,0 @@
|
||||
"""mem0 identify — declare which agent owns the current agent-mode key.
|
||||
|
||||
Used when `mem0 init --agent` ran without --agent-caller, so the backend
|
||||
saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
|
||||
its own row with its real identity. Idempotent — running it again just
|
||||
overwrites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import print_error, print_success
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
|
||||
def run_identify(name: str) -> None:
|
||||
"""PATCH the active agent-mode key's agent_caller field."""
|
||||
config = load_config()
|
||||
if not config.platform.api_key:
|
||||
print_error(
|
||||
err_console,
|
||||
"No API key configured. Run `mem0 init --agent` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not config.platform.agent_mode:
|
||||
print_error(
|
||||
err_console,
|
||||
"This command only works on unclaimed agent-mode keys.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
print_error(err_console, "Agent name is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.patch(
|
||||
f"{base_url}/api/v1/auth/agent_mode/caller/",
|
||||
headers={
|
||||
**_SOURCE_HEADERS,
|
||||
"Authorization": f"Token {config.platform.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"agent_caller": name},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Identify failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
canonical = resp.json().get("agent_caller", name)
|
||||
config.platform.agent_caller = canonical
|
||||
save_config(config)
|
||||
print_success(console, f"Identified as {canonical}.")
|
||||
@@ -103,25 +103,6 @@ def _validate_email(email: str) -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
|
||||
"""Validate api_key against /v1/ping/.
|
||||
|
||||
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
|
||||
Network errors, timeouts, and 5xx responses return True so we prefer
|
||||
reusing an existing key over silently minting a new shadow on a transient
|
||||
blip (which would also clobber config + plugin-sync targets).
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url.rstrip('/')}/v1/ping/",
|
||||
headers={"Authorization": f"Token {api_key}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return True # unknown — prefer reuse
|
||||
return resp.status_code not in (401, 403)
|
||||
|
||||
|
||||
def _email_login(
|
||||
email: str,
|
||||
code: str | None,
|
||||
@@ -201,143 +182,21 @@ def run_init(
|
||||
email: str | None = None,
|
||||
code: str | None = None,
|
||||
force: bool = False,
|
||||
source: str | None = None,
|
||||
agent: bool = False,
|
||||
agent_caller: str | None = None,
|
||||
) -> None:
|
||||
"""Interactive setup wizard for mem0 CLI.
|
||||
|
||||
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
||||
(non-interactive mode). When running in a non-TTY without the required
|
||||
flags, an error message is printed.
|
||||
|
||||
Agent Mode dispatch (no email/api-key flags):
|
||||
- If existing config has an active API key → reuse (existing_key path).
|
||||
- Else if any positive agent signal (--agent, --json global, agent env
|
||||
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
|
||||
- Else fall through to the interactive wizard.
|
||||
|
||||
Claim dispatch:
|
||||
- If `--email` is set AND existing config has `agent_mode=true`, run the
|
||||
claim device-flow against the existing key instead of minting a new
|
||||
email-based key.
|
||||
"""
|
||||
from mem0_cli.agent_detect import detect_agent_caller
|
||||
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
|
||||
from mem0_cli.state import is_agent_mode as _global_agent_mode
|
||||
from mem0_cli.telemetry import capture_event
|
||||
|
||||
def _fire_init(mode: str, *, claimed: bool = False) -> None:
|
||||
"""Fire cli.init telemetry with M1-M6 properties."""
|
||||
props: dict = {"command": "init", "mode": mode}
|
||||
if agent_caller:
|
||||
# Self-declared via --agent-caller; not sniffed from env vars.
|
||||
props["agent_caller"] = agent_caller
|
||||
if source:
|
||||
props["signup_source"] = source
|
||||
if claimed:
|
||||
props["claimed_agent_mode"] = True
|
||||
capture_event("cli.init", props)
|
||||
|
||||
config = Mem0Config()
|
||||
|
||||
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
||||
config.platform.base_url = base_url
|
||||
|
||||
if code and not email:
|
||||
print_error(err_console, "--code requires --email.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# ── Email + existing agent-mode config → claim flow ─────────────────
|
||||
if email and CONFIG_FILE.exists():
|
||||
existing = load_config()
|
||||
if existing.platform.agent_mode and existing.platform.api_key:
|
||||
email = email.strip().lower()
|
||||
_validate_email(email)
|
||||
print_info(console, f"Claiming Agent Mode account to {email}...")
|
||||
claim_via_otp(existing, email=email, code=code)
|
||||
_fire_init("email", claimed=True)
|
||||
return
|
||||
|
||||
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
|
||||
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
|
||||
# short-circuit before the guard prompts. Rule 3 mints only when there
|
||||
# is no valid key to reuse — in that case overwriting is correct.
|
||||
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
|
||||
if not api_key and not email and _agent_ctx:
|
||||
from mem0_cli.output import format_json_envelope
|
||||
from mem0_cli.state import is_agent_mode as _is_json_mode
|
||||
|
||||
def _emit_reuse(source: str) -> None:
|
||||
if _is_json_mode():
|
||||
format_json_envelope(
|
||||
console,
|
||||
command="init",
|
||||
data={
|
||||
"api_key_saved": False,
|
||||
"api_key_source": source,
|
||||
"agent_mode": False,
|
||||
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
|
||||
},
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
|
||||
if source == "env"
|
||||
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
|
||||
)
|
||||
print_success(console, msg)
|
||||
|
||||
def _maybe_identify(key: str) -> None:
|
||||
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
||||
reused key. Silent no-op on any failure — reuse must not break.
|
||||
"""
|
||||
if not agent_caller:
|
||||
return
|
||||
try:
|
||||
resp = httpx.patch(
|
||||
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
|
||||
headers={
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"agent_caller": agent_caller},
|
||||
timeout=10.0,
|
||||
)
|
||||
# Also reflect in local config so introspection matches backend.
|
||||
if resp.status_code == 200 and CONFIG_FILE.exists():
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
|
||||
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
|
||||
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
|
||||
if _env_key and _ping_key(_env_key, base_url):
|
||||
_maybe_identify(_env_key)
|
||||
_emit_reuse("env")
|
||||
_fire_init("existing_key")
|
||||
return
|
||||
# Rule 2: existing config api_key valid → reuse.
|
||||
if CONFIG_FILE.exists():
|
||||
_existing = load_config()
|
||||
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
|
||||
_maybe_identify(_existing.platform.api_key)
|
||||
_emit_reuse("config")
|
||||
_fire_init("existing_key")
|
||||
return
|
||||
# Rule 3: mint a fresh shadow (no valid key to reuse).
|
||||
# agent_caller is the agent's self-declared identity from --agent-caller
|
||||
# (Proof Editor-style). Env-var auto-detect is still used above to
|
||||
# decide we're in an agent context, but never to fill identity.
|
||||
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
|
||||
_fire_init("agent")
|
||||
return
|
||||
|
||||
# Warn if an existing config with an API key would be overwritten
|
||||
if not force and CONFIG_FILE.exists():
|
||||
existing = load_config()
|
||||
@@ -383,7 +242,6 @@ def run_init(
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email
|
||||
config.platform.created_via = "email"
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
@@ -400,8 +258,6 @@ def run_init(
|
||||
return
|
||||
|
||||
# ── API key flow (existing) ───────────────────────────────────────
|
||||
# (Agent Mode branch runs earlier — see above, before the existing-config
|
||||
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
|
||||
|
||||
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||
if not sys.stdin.isatty():
|
||||
@@ -409,7 +265,7 @@ def run_init(
|
||||
print_error(
|
||||
err_console,
|
||||
"Non-interactive terminal detected and --api-key is required.",
|
||||
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
||||
hint="Run: mem0 init --api-key <key> [--user-id <id>]",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
@@ -417,7 +273,6 @@ def run_init(
|
||||
# Fully non-interactive when both flags provided
|
||||
if api_key and user_id:
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
config.defaults.user_id = user_id
|
||||
_validate_platform(config)
|
||||
save_config(config)
|
||||
@@ -458,7 +313,6 @@ def run_init(
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email_addr
|
||||
config.platform.created_via = "email"
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
@@ -477,7 +331,6 @@ def run_init(
|
||||
# API key flow
|
||||
if api_key:
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
else:
|
||||
_setup_platform(config)
|
||||
|
||||
@@ -517,7 +370,6 @@ def _setup_platform(config: Mem0Config) -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
|
||||
|
||||
def _setup_defaults(config: Mem0Config) -> None:
|
||||
|
||||
@@ -28,14 +28,6 @@ class PlatformConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = DEFAULT_BASE_URL
|
||||
user_email: str = ""
|
||||
# Agent Mode (unclaimed-shadow signup)
|
||||
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
|
||||
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
|
||||
agent_caller: str = (
|
||||
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
|
||||
)
|
||||
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
|
||||
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -91,11 +83,6 @@ def load_config() -> Mem0Config:
|
||||
config.platform.api_key = plat.get("api_key", "")
|
||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
||||
config.platform.user_email = plat.get("user_email", "")
|
||||
config.platform.agent_mode = bool(plat.get("agent_mode", False))
|
||||
config.platform.created_via = plat.get("created_via", "")
|
||||
config.platform.agent_caller = plat.get("agent_caller", "")
|
||||
config.platform.claimed_at = plat.get("claimed_at", "")
|
||||
config.platform.default_user_id = plat.get("default_user_id", "")
|
||||
|
||||
defaults = data.get("defaults", {})
|
||||
config.defaults.user_id = defaults.get("user_id", "")
|
||||
@@ -149,11 +136,6 @@ def save_config(config: Mem0Config) -> None:
|
||||
"api_key": config.platform.api_key,
|
||||
"base_url": config.platform.base_url,
|
||||
"user_email": config.platform.user_email,
|
||||
"agent_mode": config.platform.agent_mode,
|
||||
"created_via": config.platform.created_via,
|
||||
"agent_caller": config.platform.agent_caller,
|
||||
"claimed_at": config.platform.claimed_at,
|
||||
"default_user_id": config.platform.default_user_id,
|
||||
},
|
||||
"telemetry": {
|
||||
"anonymous_id": config.telemetry.anonymous_id,
|
||||
@@ -165,19 +147,6 @@ def save_config(config: Mem0Config) -> None:
|
||||
|
||||
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||
|
||||
# Propagate the active api_key to ecosystem touchpoints (Claude Code
|
||||
# plugin env injection, shell rc exports). Idempotent — only updates
|
||||
# EXISTING entries; never creates new ones. Best-effort: any IOError
|
||||
# in the sync is swallowed so config.json is always the authoritative
|
||||
# write, never blocked by plugin-state issues.
|
||||
if config.platform.api_key:
|
||||
try:
|
||||
from mem0_cli.plugin_sync import sync_api_key
|
||||
|
||||
sync_api_key(config.platform.api_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display: m0-xxx...xxx"""
|
||||
|
||||
@@ -229,16 +229,6 @@ def format_json_envelope(
|
||||
if error:
|
||||
envelope["error"] = error
|
||||
envelope["data"] = data
|
||||
|
||||
# If the platform flagged this as an unclaimed Agent Mode account, surface
|
||||
# the notice inside the JSON envelope so an agent consuming the output
|
||||
# sees it without needing to inspect HTTP headers.
|
||||
from mem0_cli.state import take_notice
|
||||
|
||||
notice = take_notice()
|
||||
if notice:
|
||||
envelope["mem0_notice"] = notice
|
||||
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
@@ -333,15 +323,6 @@ def format_agent_envelope(
|
||||
if count is not None:
|
||||
envelope["count"] = count
|
||||
envelope["data"] = sanitize_agent_data(command, data)
|
||||
|
||||
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
|
||||
# agent reading the JSON output sees it without inspecting HTTP headers.
|
||||
from mem0_cli.state import take_notice
|
||||
|
||||
notice = take_notice()
|
||||
if notice:
|
||||
envelope["mem0_notice"] = notice
|
||||
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
_current_command: str = ""
|
||||
_pending_notice: str = ""
|
||||
|
||||
|
||||
def is_agent_mode() -> bool:
|
||||
@@ -23,23 +22,3 @@ def get_current_command() -> str:
|
||||
def set_current_command(name: str) -> None:
|
||||
global _current_command
|
||||
_current_command = name
|
||||
|
||||
|
||||
def capture_notice(notice: str | None) -> None:
|
||||
"""Stash a Mem0 backend notice for end-of-command surfacing.
|
||||
|
||||
Called from the platform backend after each response so the notice can
|
||||
be printed once per command (regardless of how many sub-requests fired).
|
||||
Last-write-wins is fine — the message text is identical across requests.
|
||||
"""
|
||||
global _pending_notice
|
||||
if notice:
|
||||
_pending_notice = notice
|
||||
|
||||
|
||||
def take_notice() -> str:
|
||||
"""Return and clear the pending notice."""
|
||||
global _pending_notice
|
||||
msg = _pending_notice
|
||||
_pending_notice = ""
|
||||
return msg
|
||||
|
||||
@@ -87,6 +87,7 @@ def capture_event(
|
||||
try:
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.config import CONFIG_FILE, load_config, save_config
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
config = load_config()
|
||||
distinct_id = pre_resolved_email or _get_distinct_id()
|
||||
@@ -106,9 +107,6 @@ def capture_event(
|
||||
with contextlib.suppress(Exception):
|
||||
save_config(config)
|
||||
|
||||
# M4: every cli.* event carries agent_mode based on the config flag
|
||||
# (unclaimed Agent Mode key). This is the growth-doc property used to
|
||||
# join init → add → search funnels in PostHog.
|
||||
payload = {
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"distinct_id": distinct_id,
|
||||
@@ -117,7 +115,7 @@ def capture_event(
|
||||
"source": "CLI",
|
||||
"language": "python",
|
||||
"cli_version": __version__,
|
||||
"agent_mode": bool(config.platform.agent_mode),
|
||||
"agent_mode": is_agent_mode(),
|
||||
"python_version": sys.version,
|
||||
"os": sys.platform,
|
||||
"os_version": platform.version(),
|
||||
|
||||
@@ -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
|
||||
@@ -5,5 +5,3 @@ openapi: get /v1/event/{event_id}/
|
||||
---
|
||||
|
||||
Retrieve details about a specific event by passing its `event_id`. This endpoint is particularly helpful for tracking the status, payload, and completion details of asynchronous memory operations.
|
||||
|
||||
For `POST /v3/memories/add/`, the event confirms that the write pipeline completed. Temporal reasoning enrichment runs asynchronously by default, so the event may be `SUCCEEDED` slightly before temporal ranking signals are available to subsequent `search` calls.
|
||||
|
||||
@@ -83,3 +83,4 @@ The request is queued for background processing. The response contains an `event
|
||||
<Info>
|
||||
Poll the event status via `GET /v1/event/{event_id}/`. Status will be `SUCCEEDED` or `FAILED` once processing completes.
|
||||
</Info>
|
||||
|
||||
|
||||
@@ -64,3 +64,4 @@ memories = client.get_all(
|
||||
<Info>
|
||||
The response is a paginated envelope with `count`, `next`, `previous`, and `results`. Use `page` and `page_size` query params to step through results.
|
||||
</Info>
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ related_memories = client.search(
|
||||
{
|
||||
"id": "ea925981-272f-40dd-b576-be64e4871429",
|
||||
"memory": "Likes to play cricket and plays cricket on weekends.",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"category": "hobbies"
|
||||
},
|
||||
|
||||
@@ -109,19 +109,6 @@ client.project.update(
|
||||
)
|
||||
```
|
||||
|
||||
#### Toggle Memory Decay
|
||||
|
||||
`decay` is a per-project boolean that turns on [Memory Decay](/platform/features/memory-decay) — a search-time ranking bias that reinforces recently-accessed memories and gently dampens stale ones. The flag is `false` by default; set it via the same project-update endpoint:
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": true}'
|
||||
```
|
||||
|
||||
The current state is returned on every project read (and supports `?fields=decay` for a minimal response). Toggling has no effect on stored memories, only on how v3 search ranks them.
|
||||
|
||||
### Delete Project
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -4,34 +4,6 @@ description: "Major product launches, headline features, and milestones for Mem0
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-13" description="Temporal Reasoning for Mem0 Platform v3">
|
||||
|
||||
**Temporal Reasoning — Time-Aware Retrieval for Platform v3**
|
||||
|
||||
Mem0 Platform v3 can now interpret time-aware memories and queries so assistants retrieve the right information for questions about the past, upcoming plans, and current state.
|
||||
|
||||
- **Time-aware search intent** — Queries like `last week`, `upcoming`, `right now`, and `as of March 2025` return contextually appropriate results automatically
|
||||
- **Enabled by default** — No per-request toggle required for v3 writes or searches
|
||||
- **Anchored relative queries** — `reference_date` anchors relative search phrases for tests, backfills, and reproducible demos
|
||||
- **Normal response shape** — Temporal reasoning affects ranking while preserving existing client response patterns
|
||||
|
||||
See [Temporal Reasoning](/platform/features/temporal-reasoning) for usage details.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-08" description="Memory Decay">
|
||||
|
||||
**Memory Decay — Recently-Used Memories Surface Higher, Automatically**
|
||||
|
||||
Per-project search-time ranking bias that boosts recently-touched memories and gently dampens stale ones. Off by default; opt in per project via the `decay` field on the project endpoint, or via `client.project.update(decay=True)` in the SDKs (Python `v2.0.2` / TypeScript `v3.0.3`).
|
||||
|
||||
- **Soft bias, never a filter.** The scaling factor stays in `0.3×–1.5×`. Decay can reorder candidates but never zeros them out — anything that surfaced before decay can still surface after.
|
||||
- **Reinforcement loop.** Every memory returned in a search has its access history updated, so frequently-used facts naturally float to the top over time.
|
||||
- **Public score still clamped to `[0, 1]`.** Existing API contract preserved; no client-side changes needed.
|
||||
- **v3 search only**, fully reversible. See [Memory Decay docs](/platform/features/memory-decay).
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-14" description="Mem0 SDK v2.0.0 / v3.0.0">
|
||||
|
||||
**New Memory Algorithm — State-of-the-Art Accuracy at ~3-4x Lower Cost**
|
||||
@@ -127,4 +99,4 @@ Major expansion of the provider ecosystem:
|
||||
|
||||
First skill launch — a dedicated Mem0 skill providing platform API reference, quickstart patterns, and integration examples directly inside agent sessions. Available on [skills.sh](https://skills.sh) for any compatible AI coding agent.
|
||||
|
||||
</Update>
|
||||
</Update>
|
||||
@@ -4,36 +4,6 @@ description: "Release notes for the OpenClaw plugin and agent harness."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-04-29" description="v1.0.11">
|
||||
|
||||
**New Features:**
|
||||
- **Skills-mode auto-setup:** `enableSkillsConfig()` now runs automatically after onboarding — enables triage, recall (with reranking + keyword search), and dream consolidation with `tools.profile = "full"` and disables the built-in session-memory hook to avoid conflicts
|
||||
- **Memory runtime capability:** Plugin now exposes `runtime.getMemorySearchManager()` and `resolveMemoryBackendConfig()` on the registered memory capability, enabling OpenClaw gateway to query memory status and backend config directly
|
||||
- **Dimension-aware collections:** OSS wizard detects embedder dimension changes and creates a new collection (`mem0_<dims>d`) automatically, with a warning about old memories being inaccessible under the new embedder
|
||||
- **Tool documentation in skills:** Both `memory-triage` and `memory-dream` SKILL.md files now include full tool reference sections listing all available tools with parameters
|
||||
|
||||
**Improvements:**
|
||||
- **Auto-capture and auto-recall default to enabled:** `autoCapture` and `autoRecall` now default to `true` (was `false`). Manifest descriptions updated accordingly. Ignored in skills mode
|
||||
- **`memory_update` over delete+add:** Skills now prefer `memory_update` for in-place edits — atomic and preserves edit history. Consolidation pattern updated: update best memory, delete redundant ones
|
||||
- **Search threshold lowered:** Default `searchThreshold` reduced from `0.5` to `0.1` for broader recall. Removed hardcoded `0.6` recall-specific override — all searches now use the configured threshold
|
||||
- **Embedder dimension propagation:** Vector store config auto-resolves dimensions from embedder config when not explicitly set. Syncs `dimension` and `embeddingModelDims` fields for Qdrant/PGVector compatibility
|
||||
- **Config file write safety:** `writeFullConfig()` now re-reads and deep-merges the `plugins` section before writing, preserving `installs` and `slots` written by the OpenClaw gateway
|
||||
- **Additional embedder models:** Added `mxbai-embed-large` (1024), `all-minilm` (384), and `snowflake-arctic-embed` (1024) to known embedder dimensions
|
||||
|
||||
**Security:**
|
||||
- Bumped `protobufjs` to `>=7.5.5` via pnpm overrides (GHSA-xq3m-2v4x-88gg) ([#5012](https://github.com/mem0ai/mem0/pull/5012))
|
||||
|
||||
**Fixes:**
|
||||
- Moved `bootstrapTelemetryFlag()` and removed `ensureInstallRecord()` from module-level side effects — both now run inside `register()` to avoid crashes when loaded outside OpenClaw gateway
|
||||
- Fixed OSS history DB path resolution: absolute paths no longer passed through `resolvePath()`, preventing double-prefix bugs
|
||||
- Manifest `providerAuthEnvVars` replaced with spec-compliant `setup.providers` format using `id` + `envVars`
|
||||
|
||||
**Dependencies:**
|
||||
- Bumped `mem0ai` from `3.0.1` to `3.0.2`
|
||||
- Bumped `pluginApi` and `minGatewayVersion` compat to `>=2026.4.24`
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-23" description="v1.0.10">
|
||||
|
||||
**Security:**
|
||||
|
||||
@@ -4,24 +4,6 @@ description: "Release notes for the Mem0 hosted platform — backend, dashboard,
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-13" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Memory:** Added Temporal Reasoning for Platform v3 to improve ranking for time-aware queries such as `last week`, `upcoming`, `right now`, and `as of ...`
|
||||
- **Search:** Added `reference_date` support to anchor relative temporal queries for tests, backfills, and reproducible demos
|
||||
|
||||
**Improvements:**
|
||||
- **API:** Temporal reasoning preserves the normal client response shape for search and get-all results
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-04" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Memory Decay:** Per-project search-time ranking bias that boosts recently-used memories and gently dampens stale ones. Opt-in via `decay` on the project endpoint; off by default. The scaling factor stays in `0.3×–1.5×`, the public `score` remains clamped to `[0, 1]`, and the bias never filters a candidate out. See [Memory Decay docs](/platform/features/memory-decay).
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-16" description="">
|
||||
|
||||
**Improvements:**
|
||||
@@ -312,3 +294,4 @@ mode: "wide"
|
||||
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
+1
-44
@@ -7,20 +7,6 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2026-05-08" description="v2.0.2">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
|
||||
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
|
||||
|
||||
**New Features:**
|
||||
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
|
||||
|
||||
**Improvements:**
|
||||
- **Plugin:** Hand `mem0` search decisions to the agent ([#4992](https://github.com/mem0ai/mem0/pull/4992))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-25" description="v2.0.1">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -55,7 +41,7 @@ mode: "wide"
|
||||
**Breaking Changes:**
|
||||
- **`add()` returns ADD-only events** — No more `"UPDATE"` or `"DELETE"` events. Memories accumulate; nothing is overwritten ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`search()` default `threshold` is now `0.1`** — Pass `threshold=0.0` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, entity signals, and temporal boosts into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
|
||||
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, and entity boost into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries. Per-signal scores are not exposed on the response ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
|
||||
- **`search()` default `rerank` is now `False`** — Pass `rerank=True` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`top_k` default changed 100 → 20** in `Memory.get_all()` and `Memory.search()` (sync + async). Pass `top_k=100` explicitly to restore the old behavior ([#4843](https://github.com/mem0ai/mem0/pull/4843))
|
||||
- **Entity ID validation:** `user_id` / `agent_id` / `run_id` are trimmed; empty-string and whitespace-only values now raise `ValueError` ([#4843](https://github.com/mem0ai/mem0/pull/4843))
|
||||
@@ -924,18 +910,6 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
|
||||
</Tab>
|
||||
|
||||
<Tab title="TypeScript">
|
||||
<Update label="2026-05-08" description="v3.0.3">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Telemetry:** Stitch OSS and platform PostHog identities on `MemoryClient` init so `$identify` events fire and a single user is no longer tracked as two or three disconnected personas ([#5040](https://github.com/mem0ai/mem0/pull/5040))
|
||||
- **Vector Stores:** Fix inverted vector distance in PGVector implementation ([#4944](https://github.com/mem0ai/mem0/pull/4944))
|
||||
- **Security:** Harden against SQL injection and prompt injection ([#4997](https://github.com/mem0ai/mem0/pull/4997))
|
||||
|
||||
**New Features:**
|
||||
- **SDK:** Expose `decay` on `project.update` ([#5062](https://github.com/mem0ai/mem0/pull/5062))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-25" description="v3.0.2">
|
||||
|
||||
**Bug Fixes:**
|
||||
@@ -1323,23 +1297,6 @@ See the [TypeScript SDK migration guide](https://docs.mem0.ai/migration/ts-v2-to
|
||||
|
||||
<Tab title="CLI">
|
||||
|
||||
<Update label="2026-05-14" description="Python v0.2.5 / Node v0.2.5">
|
||||
|
||||
**New Features:**
|
||||
- **Agent Mode (`mem0 init --agent`):** Zero-friction signup for AI agents — mints a working Mem0 API key in <5s with no email, no dashboard, no OTP. Returns an unclaimed shadow account the human can later claim with `mem0 init --email <their-email>` (memories preserved, same key keeps working) ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Self-declared agent identity:** Agents pass `--agent-caller <name>` (e.g. `claude-code`, `cursor`, `codex`) on `mem0 init --agent` so signups attribute to the right tool in analytics. Proof Editor-style — the agent declares itself rather than the CLI sniffing it from env vars ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **`mem0 identify <name>`:** New subcommand to self-tag an Agent Mode key after the fact when the agent forgot to pass `--agent-caller` on init. Idempotent — re-running just overwrites ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Plugin sync:** `~/.claude/settings.json::env::MEM0_API_KEY` and `~/.zshrc`/`.bashrc` `export MEM0_API_KEY=` lines stay in sync with `~/.mem0/config.json` automatically. Idempotent — only updates EXISTING entries, never creates new ones ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Claim flow:** `mem0 init --email <email>` claims an existing Agent Mode shadow via OTP. Upgrade-in-place — the API key never changes, memories transfer to the human's account ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Decision tree network resilience:** `pingKey` now distinguishes network errors from invalid keys — returns false ONLY on HTTP 401/403, returns true on connection failures / timeouts / 5xx. Prevents a VPN flap from silently rotating the user's API key and rewriting plugin-sync targets ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Rate-limit error clarity:** DRF's opaque `"You do not have permission"` 403 from Agent Mode rate limits is now translated to `"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC."` ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **JSON envelope `command` field:** `mem0 init --agent --json` error envelopes now populate the `command` field correctly instead of returning an empty string ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
- **Bootstrap envelope validation:** Defends against partial/malformed backend responses (e.g. `{api_key: null}`) silently persisting null/undefined into typed string fields ([#5123](https://github.com/mem0ai/mem0/pull/5123))
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-04-22" description="Python v0.2.4 / Node v0.2.4">
|
||||
|
||||
**New Features:**
|
||||
|
||||
@@ -156,10 +156,6 @@ const memories = memory.search("food preferences", {
|
||||
Expect an array of memory documents. Platform responses include vectors, metadata, and timestamps; OSS returns your stored schema.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
On Mem0 Platform v3, time-aware queries use Temporal Reasoning internally while preserving the normal search response shape. See <Link href="/platform/features/temporal-reasoning">Temporal Reasoning</Link>.
|
||||
</Note>
|
||||
|
||||
## Filter patterns
|
||||
|
||||
Filters help narrow down search results. Common use cases:
|
||||
@@ -255,4 +251,4 @@ For the full list of filter logic, comparison operators, and optional search par
|
||||
icon="rocket"
|
||||
href="/cookbooks/operations/support-inbox"
|
||||
/>
|
||||
</CardGroup>
|
||||
</CardGroup>
|
||||
+3
-5
@@ -72,8 +72,7 @@
|
||||
"platform/features/entity-scoped-memory",
|
||||
"platform/features/async-client",
|
||||
"platform/features/multimodal-support",
|
||||
"platform/features/custom-categories",
|
||||
"platform/features/temporal-reasoning"
|
||||
"platform/features/custom-categories"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -84,8 +83,7 @@
|
||||
"platform/advanced-memory-operations",
|
||||
"platform/features/criteria-retrieval",
|
||||
"platform/features/contextual-add",
|
||||
"platform/features/custom-instructions",
|
||||
"platform/features/memory-decay"
|
||||
"platform/features/custom-instructions"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1145,4 +1143,4 @@
|
||||
"destination": "/introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OpenClaw
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with skills-based memory extraction and recall."
|
||||
description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with auto-recall and auto-capture support."
|
||||
---
|
||||
|
||||
Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents with the `@mem0/openclaw-mem0` plugin. Your agent forgets everything between sessions — this plugin fixes that by automatically watching conversations, extracting what matters, and bringing it back when relevant.
|
||||
@@ -12,12 +12,11 @@ Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents
|
||||
</Frame>
|
||||
|
||||
The plugin provides:
|
||||
1. **Triage** — The agent extracts durable facts from conversations using a structured protocol with importance gates and domain overlays
|
||||
2. **Recall** — Before each turn, relevant memories are retrieved with reranking and injected into context
|
||||
3. **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, prunes stale entries
|
||||
4. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
1. **Auto-Recall** — Before the agent responds, memories matching the current message are injected into context
|
||||
2. **Auto-Capture** — After the agent responds, the exchange is sent to Mem0 which decides what's worth keeping
|
||||
3. **Agent Tools** — Eight tools for explicit memory operations during conversations
|
||||
|
||||
Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
Both auto-recall and auto-capture are opt-in (`autoRecall: true`, `autoCapture: true` in config). Once enabled, they run silently with no manual intervention required.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -25,12 +24,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 2026.4.25` | Fully supported |
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -101,9 +100,9 @@ You no longer need manual config editing to get started. Everything happens insi
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active with skills-based memory (triage, recall, and dream) running automatically.
|
||||
That's it. No API key, no config file editing, no environment variables. The plugin is now active and auto-capture and auto-recall are running on every turn.
|
||||
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey`, `userId`, and `skills` config into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
<Note>The chat flow uses the same underlying config as manual setup — it writes `apiKey` and `userId` into `openclaw.json` for you. You can still open the file to inspect or override values afterward.</Note>
|
||||
|
||||
#### Option 2: Manual Config
|
||||
|
||||
@@ -132,19 +131,7 @@ That's it. No API key, no config file editing, no environment variables. The plu
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice", // any unique identifier you choose for this user
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
"userId": "alice" // any unique identifier you choose for this user
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,8 +328,8 @@ openclaw mem0 status --json
|
||||
|-----|------|---------|-------------|
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Which backend to use |
|
||||
| `userId` | `string` | OS username | Scope memories per user |
|
||||
| `autoRecall` | `boolean` | `true` | Inject memories before each turn. Ignored when `skills` is configured. |
|
||||
| `autoCapture` | `boolean` | `true` | Store facts after each turn. Ignored when `skills` is configured. |
|
||||
| `autoRecall` | `boolean` | `false` | Inject memories before each turn (opt-in) |
|
||||
| `autoCapture` | `boolean` | `false` | Store facts after each turn (opt-in) |
|
||||
| `topK` | `number` | `5` | Max memories per recall |
|
||||
| `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
|
||||
|
||||
@@ -439,11 +426,9 @@ If `openclaw plugins update` fails:
|
||||
| **Platform** | Conversations sent to `api.mem0.ai` for extraction and storage | Mem0 cloud |
|
||||
| **Open-source** | Embeddings generated via configured provider (default: OpenAI API). Vectors stored locally. | `~/.mem0/vector_store.db` (SQLite) |
|
||||
|
||||
### Auto-Capture and Auto-Recall
|
||||
### Enabling Auto-Capture and Auto-Recall
|
||||
|
||||
Auto-capture and auto-recall are **enabled by default**. When skills mode is configured (the default after `openclaw mem0 init`), these are ignored in favor of the skills-based triage/recall/dream protocol.
|
||||
|
||||
To disable either:
|
||||
Auto-capture and auto-recall are disabled by default (opt-in). To enable either or both:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -451,8 +436,8 @@ To disable either:
|
||||
"entries": {
|
||||
"openclaw-mem0": {
|
||||
"config": {
|
||||
"autoCapture": false, // disable automatic fact extraction
|
||||
"autoRecall": false // disable automatic memory injection
|
||||
"autoCapture": true, // send conversations to Mem0 for extraction
|
||||
"autoRecall": true // inject relevant memories into context
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,7 +445,7 @@ To disable either:
|
||||
}
|
||||
```
|
||||
|
||||
The agent can always use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
Without these enabled, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly — only the automatic background behavior is off.
|
||||
|
||||
### Credential Protection
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
- Live MCP server: https://mcp.mem0.ai (see `platform/mem0-mcp`).
|
||||
- Source repo: https://github.com/mem0ai/mem0
|
||||
|
||||
### Don't have a Mem0 API key?
|
||||
|
||||
Run: `mem0 init --agent --agent-caller <your-name> --json` — replace `<your-name>` with the AI agent you are (e.g. `claude-code`, `cursor`, `codex`). If you forgot to pass `--agent-caller`, run `mem0 identify <your-name>` after init to attribute the signup.
|
||||
|
||||
This mints an evaluation key in <5 seconds — no email, no dashboard, no OTP required. Use it immediately against the Platform API or MCP server. When the human is ready, claim with `mem0 init --email <your-email>` to transfer ownership (memories preserved, same API key, no agent disruption).
|
||||
|
||||
## Install
|
||||
|
||||
- Python SDK: `pip install mem0ai`
|
||||
@@ -191,10 +185,8 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
### Features - Advanced Retrieval
|
||||
- [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval.
|
||||
- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity.
|
||||
- [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering.
|
||||
- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn.
|
||||
- [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform.
|
||||
- [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones — opt-in per project, search-time only, never filters candidates out.
|
||||
- [Advanced Memory Operations](https://docs.mem0.ai/platform/advanced-memory-operations) [Platform]: Use when basic CRUD is not enough - batch ops, complex filters, workflows.
|
||||
|
||||
### Features - Data Management
|
||||
|
||||
@@ -42,7 +42,7 @@ Previously, when an agent said something like "I've booked your flight for March
|
||||
|
||||
### Retrieval is hybrid now
|
||||
|
||||
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, entities that appear across multiple memories, and time-aware queries (via Temporal Reasoning). The response shape is unchanged:
|
||||
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, or entities that appear across multiple memories. The response shape is unchanged:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -58,7 +58,7 @@ Search now uses hybrid retrieval, which improves ranking quality — especially
|
||||
}
|
||||
```
|
||||
|
||||
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries. Temporal signals are applied internally during ranking and are not returned as extra client-facing fields.
|
||||
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries.
|
||||
|
||||
## API Changes
|
||||
|
||||
@@ -289,7 +289,6 @@ If your application previously read graph relations from the API response (`rela
|
||||
- **V1 and V2 endpoints continue to work.** There is no requirement to migrate to V3 endpoints immediately.
|
||||
- **Existing memories are preserved.** The new algorithm does not modify or re-process previously stored memories.
|
||||
- **Search response shape is unchanged.** The top-level `score` and `results[]` array are the same; existing code that reads `score` continues to work. What changed is the scoring method behind the number (multi-signal fusion instead of pure cosine), so the absolute values shift even when ranking stays comparable.
|
||||
- **Search remains backward-compatible at the top level.** Existing code that reads `results[]` and `score` continues to work. Temporal signals are applied internally during retrieval and do not change the client response shape.
|
||||
- **List response shape changed.** `get_all` now returns a paginated envelope (`{count, next, previous, results}`) instead of a bare `{results: [...]}`. Update code that reads `response["results"]` to continue working, or switch to the client SDKs which handle both shapes.
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
+2
-17
@@ -419,7 +419,7 @@
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"description": "Array of results produced by the event. For add events, this confirms the write completed; temporal reasoning enrichment runs asynchronously by default."
|
||||
"description": "Array of results produced by the event."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
@@ -2071,7 +2071,7 @@
|
||||
"memories"
|
||||
],
|
||||
"summary": "Search memories (V3)",
|
||||
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
|
||||
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval — the returned `score` is a combined `[0, 1]` value; per-signal component scores are not exposed on the response. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
|
||||
"operationId": "memories_search_v3",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -2112,21 +2112,6 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Apply the managed reranker for better ordering (adds latency)."
|
||||
},
|
||||
"reference_date": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ For detailed per-client instructions, see the [Mem0 MCP Quickstart](/platform/me
|
||||
|
||||
## Available tools
|
||||
|
||||
The MCP server exposes 11 memory tools to your AI client:
|
||||
The MCP server exposes 9 memory tools to your AI client:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
@@ -66,8 +66,6 @@ The MCP server exposes 11 memory tools to your AI client:
|
||||
| `delete_entities` | Remove user/agent/app entities |
|
||||
| `get_memory` | Retrieve single memory by ID |
|
||||
| `list_entities` | View stored entities |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
title: Memory Decay
|
||||
description: "Boost recently-used memories and gently dampen stale ones at search time, without filtering anything out."
|
||||
---
|
||||
|
||||
# Memory Decay
|
||||
|
||||
Older memories drift in relevance at different speeds. A user's coffee order matters every morning; a one-off project name from last quarter rarely matters again. Memory Decay makes that intuition explicit at search time: every time a memory is returned in a search it gets a small reinforcement, and memories that haven't been touched in a while have their ranking score gently dampened.
|
||||
|
||||
It is **a soft ranking bias, never a filter.** Decay never zeroes a candidate out — at worst it scales its score by `0.3×`. Anything that would have surfaced without decay can still surface with decay on, just with a different ranking among similarly-scored results.
|
||||
|
||||
<Info>
|
||||
**Use Memory Decay when…**
|
||||
- Search results are crowded with old facts the user no longer cares about.
|
||||
- You want recently-used memories to drift to the top automatically — without writing custom scoring logic.
|
||||
- You want this preference applied per project so cohorts can be compared side-by-side.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Memory Decay is **opt-in per project** and **off by default**. Search behavior is bit-identical to today until you turn it on. The toggle applies to v3 search only.
|
||||
</Warning>
|
||||
|
||||
## How it works
|
||||
|
||||
Every memory carries a small piece of bookkeeping: when was it last retrieved, and how often. Memory Decay turns that history into a *scaling factor* in the range `0.3×` to `1.5×` and multiplies it into the ranking score at search time.
|
||||
|
||||
| Memory state | Scaling factor | Ranking effect |
|
||||
|---|---|---|
|
||||
| Just accessed | ≈ **1.5×** | Strong boost |
|
||||
| Touched today | 1.2 – 1.4× | Mild boost |
|
||||
| Idle for a few days | 0.6 – 1.0× | Mild dampening |
|
||||
| Idle for weeks | 0.4 – 0.6× | Stronger dampening |
|
||||
| Idle for many months / years | ≈ **0.3×** | Floor — never lower |
|
||||
|
||||
The bounds matter: `0.3` is the floor and `1.5` is the ceiling, so decay can meaningfully reorder candidates without ever dominating the underlying relevance score.
|
||||
|
||||
At search time the pipeline:
|
||||
|
||||
1. Widens the candidate pool (`top_k × 3`, with a floor of 50) so reordering has room.
|
||||
2. Multiplies each candidate's score by its scaling factor.
|
||||
3. Sorts on the unclamped product so the full `0.3×–1.5×` range can rearrange candidates.
|
||||
4. Returns the public `score` clamped to `[0, 1]` so the API contract is preserved.
|
||||
5. Truncates to the `top_k` you requested.
|
||||
6. Records a fire-and-forget reinforcement against each returned memory — its access history grows by one, capped at the most recent 20 touches.
|
||||
|
||||
Memories created before decay was enabled don't yet have an access history. They use a sensible fallback: their `updated_at` is treated as a single past touch, so the same scale above applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band, a long-stale one sits closer to the floor. Once surfaced in a search after decay is on, they accumulate access history naturally and behave like any other memory.
|
||||
|
||||
## Configure access
|
||||
|
||||
- Set `MEM0_API_KEY` in your environment, or pass it to the SDK constructor.
|
||||
- Initialize the client with the organization and project you want to scope to.
|
||||
|
||||
The toggle lives on the project. You enable decay by patching the project's `decay` field; everything else — your `add` calls, your `search` calls, your application code — stays exactly the same.
|
||||
|
||||
## Enable decay for a project
|
||||
|
||||
### 1. Turn the flag on
|
||||
|
||||
The toggle is exposed on the standard project-update endpoint, the same place where `multilingual` and `custom_categories` live.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=True)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: true });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": true}'
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "message": "Updated decay" }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 2. Confirm the state
|
||||
|
||||
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
response = client.project.get(fields=["decay"])
|
||||
print(response["decay"])
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
const response = await client.project.get({ fields: ["decay"] });
|
||||
console.log(response.decay);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
|
||||
-H "Authorization: Token $MEM0_API_KEY"
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "decay": true }
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 3. Turn it back off
|
||||
|
||||
The toggle is fully reversible. Setting it to `false` immediately restores the pre-decay ranking; nothing about your stored memories is modified or lost.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=False)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: false });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decay": false}'
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
The toggle is idempotent. Re-applying the same value is a no-op, and access history accumulated while decay was on is preserved if you flip it back on later.
|
||||
</Note>
|
||||
|
||||
## What changes when decay is on
|
||||
|
||||
- **Search ranking reorders.** A relevant memory you reinforced an hour ago will tend to outrank an equally-relevant memory that was last touched a month ago.
|
||||
- **The candidate pool over-fetches** to give the scaling factor room to reorder. You still get exactly the `top_k` you requested, but the items returned can come from a deeper slice of the pre-decay ranking than before.
|
||||
- **The public `score` field stays in `[0, 1]`.** Even when the internal product exceeds 1, the field returned to the client is clamped, so existing assertions and downstream UI logic continue to work.
|
||||
|
||||
## What stays the same
|
||||
|
||||
- **Public API shape** — every endpoint accepts the same parameters and returns the same fields. You don't touch your client code.
|
||||
- **Threshold semantics on the request side** — your `threshold` is still applied during candidate selection.
|
||||
- **Memory creation and storage** — every new memory still lands the same way. Decay is a search-time concern.
|
||||
- **Per-memory data** — categories, metadata, timestamps, embeddings: untouched.
|
||||
|
||||
<Warning>
|
||||
Because the scaling factor is applied *after* the threshold filter has already run, an item that passed the request `threshold` can come back with a public `score` slightly below it (a stale candidate dampened by `0.3×`). This is intentional — decay is a soft bias, not a filter. If you require a hard `score >= threshold` invariant on the response, filter client-side after the call.
|
||||
</Warning>
|
||||
|
||||
## Lifecycle of a memory under decay
|
||||
|
||||
| Stage | Scaling factor | Effect |
|
||||
|---|---|---|
|
||||
| Just added | ≈ 1.5× | Strong boost — fresh facts surface easily. |
|
||||
| Reinforced on a recent search | 1.2 – 1.5× | Sustains its boost for the next several searches. |
|
||||
| Idle for a few days | 0.6 – 1.0× | Falls back into the neutral band. |
|
||||
| Idle for weeks | 0.4 – 0.6× | Mild dampening — can still surface for strong matches. |
|
||||
| Pre-decay legacy memory (no access history) | 0.3 – 1.0× | Falls back to `updated_at`: recently-updated entries land near 1.0×, long-stale entries approach the 0.3× floor. |
|
||||
|
||||
The reinforcement is bounded: each memory tracks at most the last 20 access timestamps, so the boost stays well-behaved no matter how many times a memory is retrieved.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Will decay ever drop a result that would otherwise surface?**
|
||||
No. The floor is `0.3×` — the scaling factor can dampen a score, never zero it. Threshold filtering happens *before* decay, so any candidate that cleared the threshold is in the pool decay reorders.
|
||||
|
||||
**Why is the public score sometimes below my requested threshold?**
|
||||
The threshold is applied to the candidate pool pre-decay; the scaling factor then reshapes scores in the `0.3×–1.5×` band. A stale-but-relevant candidate can come back with a final score slightly under your threshold by design — the candidate stays visible but visibly dampened. Filter client-side if you need a hard floor on the response.
|
||||
|
||||
**Does decay change how I add memories?**
|
||||
No. The `client.add(...)` path is unchanged. Decay is a search-time ranking adjustment.
|
||||
|
||||
**What if I had memories before turning decay on?**
|
||||
They use a fallback: the memory's `updated_at` is treated as a single historical touch, so the same scaling applies based on how stale that update is — a recently-updated legacy memory enters near the neutral band (~1.0×), a long-stale one closer to the floor (~0.3×). Once retrieved they accumulate access history and behave like any other memory.
|
||||
|
||||
**Can I tune how aggressively decay scales scores?**
|
||||
Not in this version. The current scaling is calibrated to be conservative — wide enough to meaningfully reorder candidates, narrow enough to never dominate the underlying relevance score. Per-project tuning is on the roadmap.
|
||||
|
||||
**Can I see the scaling factor per result?**
|
||||
Internal scoring details are persisted on the search Event for support and debugging. They aren't exposed in the public response by design — the response surface stays a single `score` field.
|
||||
|
||||
**Does decay interact with reranking?**
|
||||
Yes — they layer cleanly. The reranker produces a richer relevance score; decay then biases that score by reinforcement history before final truncation to `top_k`.
|
||||
|
||||
## What's next
|
||||
|
||||
This release is deliberately the simplest version of decay we could ship — every memory contributes to ranking through its access history alone, so the signal can be evaluated in isolation. On the roadmap:
|
||||
|
||||
- **Category-aware weighting.** A fact tagged `health` will be able to carry more weight than a passing observation tagged `misc`, so important categories don't get dampened the same way as noise.
|
||||
- **Auto-tuning per project.** Project-scoped automatic adjustment of how aggressively decay scales scores, based on observed access patterns — replacing the fixed scaling band with one that fits your workload.
|
||||
|
||||
Both extensions are forward-compatible — no migration on your side will be needed when they ship.
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
title: Temporal Reasoning
|
||||
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories."
|
||||
icon: "clock"
|
||||
badge: "v3"
|
||||
---
|
||||
|
||||
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results.
|
||||
|
||||
<Info>
|
||||
**Use Temporal Reasoning when…**
|
||||
- Users ask questions like "what happened last week?" or "what do I have coming up?"
|
||||
- Your app stores both past events and future plans for the same person
|
||||
- You want time-aware retrieval without building your own date-parsing layer
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
|
||||
</Warning>
|
||||
|
||||
## Configure access
|
||||
|
||||
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client:
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time.
|
||||
|
||||
A query like `what did I do last week?` should return a completed past event — not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
|
||||
|
||||
### Memory types Temporal Reasoning handles
|
||||
|
||||
| Type | What it represents | Example |
|
||||
| --- | --- | --- |
|
||||
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
|
||||
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
|
||||
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
|
||||
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
|
||||
| Preference | A stable preference or habit | "I prefer morning meetings." |
|
||||
|
||||
Results come back in the normal search response shape — Temporal Reasoning affects ranking, not the response format.
|
||||
|
||||
## Configure it
|
||||
|
||||
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
|
||||
|
||||
Two parameters give you precise control when you need it:
|
||||
|
||||
- `timestamp` on `add()` — anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0
|
||||
- `reference_date` on `search()` — resolves relative phrases like `last week` against a fixed point in time
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from datetime import datetime, timezone
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Import a historical memory anchored to when it happened
|
||||
client.add(
|
||||
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
|
||||
user_id="jordan",
|
||||
timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()),
|
||||
)
|
||||
|
||||
# Search with a relative query anchored to a known date
|
||||
results = client.search(
|
||||
"what did I do last week?",
|
||||
filters={"user_id": "jordan"},
|
||||
reference_date="2025-03-21T00:00:00Z",
|
||||
)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
import { MemoryClient } from "mem0ai";
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
// Import a historical memory anchored to when it happened
|
||||
await client.add(
|
||||
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
|
||||
{
|
||||
userId: "jordan",
|
||||
timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
// Search with a relative query anchored to a known date
|
||||
const results = await client.search("what did I do last week?", {
|
||||
filters: { user_id: "jordan" },
|
||||
referenceDate: "2025-03-21T00:00:00Z",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
|
||||
</Tip>
|
||||
|
||||
## Supported query patterns
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Historical questions">
|
||||
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
|
||||
</Accordion>
|
||||
<Accordion title="Upcoming questions">
|
||||
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
|
||||
</Accordion>
|
||||
<Accordion title="Current-state questions">
|
||||
Examples: `right now`, `currently`, `where do I work now?`
|
||||
</Accordion>
|
||||
<Accordion title="As-of questions">
|
||||
Examples: `as of March 2025`, `where was I living as of 2024?`
|
||||
</Accordion>
|
||||
<Accordion title="Duration questions">
|
||||
Examples: `how long have I lived here?`, `since when have I worked there?`
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Verify the feature is working
|
||||
|
||||
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
|
||||
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
|
||||
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Use explicit dates in source conversations when events or plans matter temporally.
|
||||
- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor.
|
||||
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
|
||||
- Use `reference_date` in automated tests and reproducible demos.
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
|
||||
Anchor imported memories to when they actually happened.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Snippet file="get-help.mdx" />
|
||||
@@ -46,8 +46,6 @@ The MCP server exposes these memory tools to your AI client:
|
||||
| `delete_all_memories` | Bulk delete all memories in scope |
|
||||
| `delete_entities` | Delete a user/agent/app/run entity and its memories |
|
||||
| `list_entities` | Enumerate users/agents/apps/runs stored in Mem0 |
|
||||
| `list_events` | List memory operation events with filters and pagination |
|
||||
| `get_event_status` | Check the status of an async memory operation by `event_id` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-24
@@ -22,35 +22,13 @@ We follow the llms.txt standard:
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Mem0 ships two kinds of skills for AI coding assistants. Both work with Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any assistant that supports the skills standard.
|
||||
|
||||
### Reference skills — always on
|
||||
|
||||
Teach your assistant Mem0's SDK surface so it writes correct code in everyday development:
|
||||
Teach your coding assistant how to build with Mem0:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
|
||||
```
|
||||
|
||||
- `mem0` — Python and TypeScript SDKs (Platform + OSS), plus framework integrations (LangChain, CrewAI, OpenAI Agents, LangGraph, LlamaIndex, etc.)
|
||||
- `mem0-cli` — terminal workflows for the `mem0` CLI (both Node and Python builds)
|
||||
- `mem0-vercel-ai-sdk` — `@mem0/vercel-ai-provider` and `createMem0`
|
||||
|
||||
### Pipeline skills — run on demand
|
||||
|
||||
Let your assistant execute an end-to-end workflow in an existing repo. Invoked as slash commands:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||
```
|
||||
|
||||
- `/mem0-integrate` — wire Mem0 into an existing repository using a goal-driven, test-first pipeline. Detects the stack, asks whether to use Platform or OSS, writes failing tests first, and keeps the integration additive and feature-flagged.
|
||||
- `/mem0-test-integration` — verify what `/mem0-integrate` produced. Runs the repo's native test suite and a real end-to-end smoke flow against your API key, then produces a scorecard.
|
||||
|
||||
See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog.
|
||||
Works with Claude Code, Cursor, Windsurf, and any assistant that supports skills. Once installed, your assistant understands Mem0's full API, framework integrations, and common patterns.
|
||||
|
||||
## MCP Server Setup
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.1",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
Add persistent memory to your AI workflows. Store, retrieve, and manage memories across sessions using the Mem0 Platform. Works with **Claude Code** (CLI), **Claude Cowork** (desktop app), **Cursor**, and **Codex**.
|
||||
|
||||
## Quick path for agents
|
||||
|
||||
If you're an AI agent setting up Mem0 autonomously (no human dashboard signup available), run:
|
||||
|
||||
```bash
|
||||
mem0 init --agent --json
|
||||
```
|
||||
|
||||
This mints an evaluation API key in <5 seconds — no email, no OTP, no browser. The key is written to your config and exported as `MEM0_API_KEY` for the current shell. Use it immediately against the Mem0 Platform or the MCP server. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, the same API key keeps working, and the agent isn't disrupted.
|
||||
|
||||
Humans setting up Mem0 by hand should continue with Step 1 below.
|
||||
|
||||
## Step 1: Set your API key
|
||||
|
||||
> **You must complete this step before installing the plugin.**
|
||||
@@ -169,32 +157,6 @@ After installing, confirm the MCP server is connected:
|
||||
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
||||
|
||||
## Updating the plugin
|
||||
|
||||
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
|
||||
|
||||
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
|
||||
- **Cursor:** quit and relaunch.
|
||||
- **Codex:** restart the editor session.
|
||||
|
||||
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
|
||||
|
||||
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
|
||||
|
||||
## Optional: tune categories for coding workflows
|
||||
|
||||
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
|
||||
|
||||
```bash
|
||||
# Dry-run first -- prints current vs proposed, no changes:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py
|
||||
|
||||
# Actually write:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py --apply
|
||||
```
|
||||
|
||||
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Once installed, the following tools are available:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"statusMessage": "Checking memory relevance...",
|
||||
"statusMessage": "Searching mem0 memories...",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
||||
},
|
||||
{
|
||||
"command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
|
||||
"statusMessage": "Preparing pre-compaction summary..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
||||
"statusMessage": "Saving session state to mem0...",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,7 +57,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"statusMessage": "Checking memory relevance...",
|
||||
"statusMessage": "Searching mem0 memories...",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Resolve mem0 user_id with deterministic priority.
|
||||
|
||||
Resolution priority:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
4. Fallback: $USER, else "default"
|
||||
|
||||
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
the "47 user buckets per account" symptom from running on multiple
|
||||
laptops with different $USER values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||
|
||||
|
||||
def resolve_user_id() -> str:
|
||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if api_key:
|
||||
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||
fingerprint = digest[:8]
|
||||
|
||||
try:
|
||||
with open(_CACHE_PATH, "r") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||
return cached["user_id"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
derived = "mem0-" + digest[:12]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||
with open(_CACHE_PATH, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"user_id": derived,
|
||||
"source": "api_key",
|
||||
"api_key_fingerprint": fingerprint,
|
||||
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return derived
|
||||
|
||||
return os.environ.get("USER") or "default"
|
||||
@@ -1,57 +0,0 @@
|
||||
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. MEM0_USER_ID env var (explicit override)
|
||||
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
# 4. Fallback: $USER, else "default"
|
||||
#
|
||||
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
# the "47 user buckets per account" symptom from running on multiple
|
||||
# laptops with different $USER values.
|
||||
|
||||
_mem0_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_USER_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local api_key="${MEM0_API_KEY:-}"
|
||||
local cache="$HOME/.mem0/identity.json"
|
||||
|
||||
if [ -n "$api_key" ]; then
|
||||
local digest
|
||||
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||
local fp="${digest:0:8}"
|
||||
|
||||
if [ -f "$cache" ]; then
|
||||
local cached_fp cached_id
|
||||
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||
printf '%s' "$cached_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local derived="mem0-${digest:0:12}"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||
printf '%s' "$derived"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s' "${USER:-default}"
|
||||
}
|
||||
|
||||
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||
export MEM0_RESOLVED_USER_ID
|
||||
@@ -13,10 +13,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||
@@ -26,7 +22,7 @@ if [ -z "$FILE_PATH" ]; then
|
||||
fi
|
||||
|
||||
case "$FILE_PATH" in
|
||||
*/MEMORY.md|*/.claude/memory/*)
|
||||
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
|
||||
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
|
||||
exit 2
|
||||
;;
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the post-compaction summary into mem0.
|
||||
|
||||
PreCompact hooks fire BEFORE the summary is generated, so they can't
|
||||
store the actual compact-summary text. This script runs at
|
||||
SessionStart with source=compact, reads the transcript, finds the
|
||||
most recent entry flagged isCompactSummary=true, and stores it as a
|
||||
memory tagged metadata.type=compact_summary.
|
||||
|
||||
Input: JSON on stdin with transcript_path, session_id, source
|
||||
Output: stderr logs only (exit 0 always -- must not block)
|
||||
|
||||
Spawned in the background by on_session_start.sh; the user-facing
|
||||
bootstrap text continues without waiting on the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 2000
|
||||
MAX_SUMMARY_CHARS = 50000
|
||||
# Compact summaries describe a single session's state -- stale after a quarter.
|
||||
COMPACT_SUMMARY_EXPIRY_DAYS = 90
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return []
|
||||
chunk_size = min(file_size, n * 4096)
|
||||
f.seek(max(0, file_size - chunk_size))
|
||||
data = f.read().decode("utf-8", errors="replace")
|
||||
return data.splitlines()[-n:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def find_compact_summary(lines: list[str]) -> str:
|
||||
"""Walk transcript backwards, return text content of the most recent
|
||||
entry flagged isCompactSummary=true. Empty string if none found."""
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not entry.get("isCompactSummary"):
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
content = message.get("content", [])
|
||||
if isinstance(content, str):
|
||||
return content[:MAX_SUMMARY_CHARS]
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
|
||||
return ""
|
||||
|
||||
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary}],
|
||||
"user_id": user_id,
|
||||
"metadata": {
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
},
|
||||
"infer": False,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
log.info("Compact summary stored")
|
||||
return True
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
|
||||
try:
|
||||
hook_input = json.loads(sys.stdin.read())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
log.debug("No valid JSON on stdin")
|
||||
return
|
||||
|
||||
transcript_path = hook_input.get("transcript_path", "")
|
||||
if not transcript_path:
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
log.debug("Transcript empty or unreadable: %s", transcript_path)
|
||||
return
|
||||
|
||||
summary = find_compact_summary(lines)
|
||||
if not summary:
|
||||
log.debug("No isCompactSummary entry found")
|
||||
return
|
||||
|
||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||
store_summary(api_key, summary, user_id, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
@@ -18,12 +18,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
import urllib.error
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -31,25 +27,11 @@ _handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 500
|
||||
MAX_USER_MESSAGES = 30
|
||||
MAX_BASH_COMMANDS = 20
|
||||
MAX_ASSISTANT_TEXT = 10000
|
||||
# session_state captures churn fast (active codebase, files in flight). Past
|
||||
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
|
||||
# stored separately by the agent without an expiration_date.
|
||||
SESSION_STATE_EXPIRY_DAYS = 90
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
@@ -167,9 +149,8 @@ def build_content(state: dict, source: str) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
||||
"""Store session state as a memory via the Mem0 REST API."""
|
||||
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
"messages": [
|
||||
{"role": "user", "content": content}
|
||||
@@ -178,9 +159,7 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_
|
||||
"metadata": {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
},
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
@@ -228,8 +207,7 @@ def main():
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -250,7 +228,7 @@ def main():
|
||||
len(state["bash_commands"]),
|
||||
)
|
||||
|
||||
store_memory(api_key, content, user_id, source, session_id)
|
||||
store_memory(api_key, content, user_id, source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,16 +5,12 @@
|
||||
# the full context before it gets compressed.
|
||||
#
|
||||
# Output: Text instructions injected into Claude's context.
|
||||
# Claude still has the full conversation and can write an accurate summary,
|
||||
# which it stores via add_memory(infer=False) so the platform preserves
|
||||
# the structure verbatim instead of running a second extraction pass.
|
||||
# Claude still has the full conversation and can write an accurate summary.
|
||||
# A companion Python script (on_pre_compact.py) also runs to capture
|
||||
# transcript state directly via the Mem0 REST API as a safety net.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
## CRITICAL: Pre-Compaction Session Summary
|
||||
|
||||
@@ -22,9 +18,7 @@ Context compaction is about to happen. You are about to lose most of your conver
|
||||
|
||||
### Step 1: Store session summary
|
||||
|
||||
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
|
||||
|
||||
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
|
||||
Call `add_memory` with a thorough summary covering ALL of the following:
|
||||
|
||||
```
|
||||
## Session Summary (Pre-Compaction)
|
||||
@@ -50,19 +44,11 @@ Call `add_memory` with `infer=False` and a thorough summary covering ALL of the
|
||||
the post-compaction agent continue without asking redundant questions]
|
||||
```
|
||||
|
||||
Tool call shape:
|
||||
```
|
||||
add_memory(
|
||||
messages=[{"role":"user","content":"<the summary above>"}],
|
||||
user_id="<the active user_id from the SessionStart bootstrap>",
|
||||
metadata={"type":"session_state","source":"pre-compaction"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
|
||||
|
||||
### Step 2: Store any unstored learnings
|
||||
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories:
|
||||
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
||||
- Successful strategies -> metadata `{"type": "task_learning"}`
|
||||
- Architecture decisions -> metadata `{"type": "decision"}`
|
||||
|
||||
@@ -11,34 +11,9 @@
|
||||
# even if jq is missing or stdin is malformed.
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
# Skip the bootstrap entirely if no API key is configured -- the agent
|
||||
# would otherwise be told to call mem0 MCP tools that will all fail.
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Identity line is emitted before every bootstrap variant so the agent
|
||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||
# than what the hooks write to.
|
||||
echo "## Mem0 Identity"
|
||||
echo ""
|
||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo ""
|
||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||
echo ""
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
@@ -65,22 +40,14 @@ Continue where you left off.
|
||||
EOF
|
||||
|
||||
elif [ "$SOURCE" = "compact" ]; then
|
||||
# Capture the just-generated compact summary in the background.
|
||||
# PreCompact fires too early to see this entry; SessionStart-compact
|
||||
# is the first place isCompactSummary=true is in the transcript.
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||
|
||||
cat <<'EOF'
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Context was just compacted. The Claude Code-generated compact summary
|
||||
is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||
Context was just compacted. You may have lost important session context.
|
||||
|
||||
1. Call `search_memories` to reload context, layering up to three angles:
|
||||
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
|
||||
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
|
||||
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
|
||||
2. Continue working from the recovered context.
|
||||
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
|
||||
2. Check for any session state memories that were saved before compaction.
|
||||
3. Continue working based on the recovered context.
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
|
||||
@@ -1,72 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: UserPromptSubmit
|
||||
#
|
||||
# Fires on every user message. Instead of pre-searching mem0 with the
|
||||
# raw prompt, this injects a decision rubric telling the agent when
|
||||
# and how to search itself. The agent has more context than this
|
||||
# script does -- let it decide.
|
||||
# Fires on every user message. Searches mem0 for relevant memories
|
||||
# and injects them into Claude's context before processing.
|
||||
#
|
||||
# Input: JSON on stdin (prompt, session_id, cwd, transcript_path)
|
||||
# Output: Decision rubric injected into Claude's context (exit 0)
|
||||
# Input: JSON on stdin with prompt, session_id, cwd, transcript_path
|
||||
# Output: Matching memories as context text (exit 0)
|
||||
#
|
||||
# Skips search for very short prompts (< 20 chars) and when
|
||||
# MEM0_API_KEY is not set. Uses a 3s timeout to minimize latency.
|
||||
|
||||
# Intentionally omit -e so the script always exits 0 even if jq fails --
|
||||
# must never block the user's prompt.
|
||||
# Intentionally omit -e so the script always exits 0 even if
|
||||
# curl or jq fail — must never block the user's prompt.
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
||||
|
||||
# Acknowledgements and short replies don't warrant memory context
|
||||
# Skip trivial prompts — not worth a network call
|
||||
if [ ${#PROMPT} -lt 20 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No API key means the agent can't search anyway
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
API_KEY="${MEM0_API_KEY:-}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
||||
|
||||
cat <<EOF
|
||||
## Memory check
|
||||
# Build request body safely via jq to avoid injection
|
||||
BODY=$(jq -n --arg query "$PROMPT" --arg user_id "$USER_ID" \
|
||||
'{query: $query, filters: {user_id: $user_id}, top_k: 5}')
|
||||
|
||||
Before responding, decide whether persistent memory context from mem0 would
|
||||
improve your answer. The agent -- not this hook -- owns this decision.
|
||||
# Search mem0 for memories relevant to this prompt
|
||||
RESPONSE=$(curl -s --max-time 3 \
|
||||
-X POST "https://api.mem0.ai/v2/memories/search/" \
|
||||
-H "Authorization: Token $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
2>/dev/null || echo "")
|
||||
|
||||
**Search WHEN** the user:
|
||||
- references past work, decisions, or things "we" built
|
||||
- asks "how should we...", "best way to...", or any decision-style question
|
||||
- hits an error, bug, or asks for debugging help
|
||||
- requests work that touches their stack, tools, conventions, or preferences
|
||||
- starts a non-trivial task in a known project
|
||||
if [ -z "$RESPONSE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
**Skip WHEN:**
|
||||
- the prompt is an acknowledgement or continuation
|
||||
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
# Extract memories from response (API returns a flat array)
|
||||
MEMORIES=$(echo "$RESPONSE" | jq -r '
|
||||
if type == "array" then . else .results // [] end |
|
||||
if length == 0 then empty else
|
||||
"## Relevant memories from mem0\n\n" +
|
||||
(map(select(.memory != null) | "- " + .memory) | join("\n"))
|
||||
end
|
||||
' 2>/dev/null || echo "")
|
||||
|
||||
**If searching, do it well:**
|
||||
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
|
||||
query that echoes the user's prompt.
|
||||
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
|
||||
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits.
|
||||
- Empty results are normal -- proceed without context.
|
||||
EOF
|
||||
if [ -n "$MEMORIES" ]; then
|
||||
echo "$MEMORIES"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace mem0's default category taxonomy with one tuned for coding workflows.
|
||||
|
||||
mem0 auto-tags every memory with one or more `categories`. By default the list
|
||||
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
|
||||
This script replaces the project's category list with a coding-focused one.
|
||||
|
||||
The change is project-level (per the platform docs, per-request overrides are
|
||||
not supported on the managed API). Run once per project; future memories will
|
||||
be tagged using the new list automatically.
|
||||
|
||||
Usage:
|
||||
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
|
||||
python setup_coding_categories.py --apply # actually call project.update()
|
||||
|
||||
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
CODING_CATEGORIES = [
|
||||
{
|
||||
"architecture_decisions": (
|
||||
"Design choices, system structure, technology selection, trade-offs evaluated, "
|
||||
"and architectural patterns adopted in the project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"anti_patterns": (
|
||||
"Approaches that failed, debugging dead-ends, common mistakes to avoid, "
|
||||
"and lessons learned from things that didn't work."
|
||||
)
|
||||
},
|
||||
{
|
||||
"task_learnings": (
|
||||
"Strategies and approaches that succeeded for specific tasks, including tooling "
|
||||
"tricks, workflow shortcuts, and effective problem-solving patterns."
|
||||
)
|
||||
},
|
||||
{
|
||||
"tooling_setup": (
|
||||
"Development environment, build tools, dependencies, package managers, deploy "
|
||||
"pipelines, and configuration steps for the project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"bug_fixes": (
|
||||
"Specific bug fixes with root cause analysis, the fix applied, and how the bug "
|
||||
"was diagnosed -- useful for recognising similar issues later."
|
||||
)
|
||||
},
|
||||
{
|
||||
"coding_conventions": (
|
||||
"Code style, naming patterns, file organisation, error-handling conventions, "
|
||||
"and team agreements about how code is written in this project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"user_preferences": (
|
||||
"User's stated preferences for tools, libraries, languages, formatting, "
|
||||
"and ways of working."
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _print_categories(label: str, cats):
|
||||
print(f"=== {label} ===")
|
||||
if cats:
|
||||
print(json.dumps(cats, indent=2))
|
||||
else:
|
||||
print("(none / using mem0 defaults)")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually call project.update(). Without this flag, runs in dry-run mode.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.environ.get("MEM0_API_KEY"):
|
||||
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR: the mem0ai Python SDK is not installed.\n"
|
||||
"Install with: pip install mem0ai\n"
|
||||
"Then re-run this script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
client = MemoryClient()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"ERROR initialising MemoryClient: {e}\n"
|
||||
"Most commonly this is an invalid MEM0_API_KEY -- check the key at "
|
||||
"https://app.mem0.ai/dashboard/api-keys",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
current = client.project.get(fields=["custom_categories"])
|
||||
current_cats = current.get("custom_categories") if isinstance(current, dict) else None
|
||||
except Exception as e:
|
||||
print(f"ERROR fetching current categories: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_print_categories("Current project categories", current_cats)
|
||||
_print_categories("Proposed coding categories", CODING_CATEGORIES)
|
||||
|
||||
if not args.apply:
|
||||
print("Dry-run only -- no changes made. Re-run with --apply to write.")
|
||||
return 0
|
||||
|
||||
print("Applying coding categories...")
|
||||
try:
|
||||
response = client.project.update(custom_categories=CODING_CATEGORIES)
|
||||
except Exception as e:
|
||||
print(f"ERROR applying update: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Done.", response if response else "")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: mem0-codex
|
||||
description: >
|
||||
Mem0 persistent memory integration for Codex. Automatically retrieve relevant
|
||||
memories at the start of each task, store key learnings when tasks complete,
|
||||
and capture session state before context is lost. Use the mem0 MCP tools
|
||||
(add_memory, search_memories, get_memories, etc.) for all memory operations.
|
||||
---
|
||||
|
||||
# Mem0 Memory Protocol for Codex
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
|
||||
|
||||
## On every new task
|
||||
|
||||
1. Call `search_memories` with a query related to the current task or project to load relevant context.
|
||||
2. Review returned memories to understand what has been learned in prior sessions.
|
||||
3. If appropriate, call `get_memories` to browse all stored memories for this user.
|
||||
|
||||
## After completing significant work
|
||||
|
||||
Extract key learnings and store them using the `add_memory` tool:
|
||||
|
||||
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
||||
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
||||
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
||||
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
## Before losing context
|
||||
|
||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||
|
||||
```
|
||||
## Session Summary
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[Important file paths with what changed]
|
||||
|
||||
### Current State
|
||||
[What is in progress, pending items, next steps]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state"}`
|
||||
|
||||
## Memory hygiene
|
||||
|
||||
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
|
||||
- Only store genuinely useful learnings. Skip trivial interactions.
|
||||
- Use specific, searchable language in memory content.
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
name: mem0-mcp
|
||||
description: >
|
||||
Mem0 memory protocol for agents using the mem0 MCP tools (Claude Code, Cursor,
|
||||
Codex, and any other MCP-aware runtime). Decide deliberately when memory context
|
||||
would help, run targeted searches with metadata filters when it would, and store
|
||||
key learnings as work completes. Use the mem0 MCP tools (add_memory,
|
||||
search_memories, get_memories, etc.) for all memory operations.
|
||||
---
|
||||
|
||||
# Mem0 MCP Memory Protocol
|
||||
|
||||
You have access to persistent memory via the mem0 MCP tools. Follow this protocol to maintain context across sessions.
|
||||
|
||||
## On every new task
|
||||
|
||||
Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately.
|
||||
|
||||
### Decide: search or skip?
|
||||
|
||||
**Search WHEN** the user:
|
||||
- references past work, decisions, or things "we" built
|
||||
- asks "how should we...", "best way to...", or any decision-style question
|
||||
- hits an error, bug, or asks for debugging help
|
||||
- requests work that touches their stack, tools, conventions, or preferences
|
||||
- starts a non-trivial task in a known project
|
||||
|
||||
**Skip WHEN:**
|
||||
- the prompt is an acknowledgement or continuation ("ok", "thanks", "continue")
|
||||
- the user is *stating* new info — that's a write trigger (`add_memory`), not a search
|
||||
- it's a pure syntax / factual question answerable from general knowledge
|
||||
- you already searched this scope earlier in the turn
|
||||
|
||||
Empty results are normal. Proceed without context — they don't mean the system is broken.
|
||||
|
||||
### How to search well
|
||||
|
||||
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
|
||||
|
||||
**Query phrasing:**
|
||||
- Use **nouns**, not sentences. `"auth module decisions"` beats `"what did we decide about auth"`.
|
||||
- Strip conversational filler. *"remember when we picked Postgres?"* → search `"Postgres choice"`.
|
||||
- Use entity names, not pronouns. Resolve "that thing" from recent context first.
|
||||
- Don't search on meta-questions ("what was that?") — use recent context or `get_memories` ordered by `created_at`.
|
||||
|
||||
**Metadata filters** match the same `type` values written under "After completing significant work" below.
|
||||
|
||||
Two rules from the v2 filter spec:
|
||||
|
||||
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
|
||||
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
|
||||
|
||||
Combine `user_id` with one metadata clause per call:
|
||||
|
||||
| `metadata.type` clause | Use for |
|
||||
|--------|---------|
|
||||
| `{"metadata": {"type": "decision"}}` | design / architecture / "how should we" questions |
|
||||
| `{"metadata": {"type": "anti_pattern"}}` | debugging, error handling, things that failed before |
|
||||
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
|
||||
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
|
||||
|
||||
Full filter (replace `<your_user_id>` with the active user_id from your runtime):
|
||||
```python
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]}
|
||||
```
|
||||
|
||||
### Worked example
|
||||
|
||||
User asks: *"Refactor the auth module to use JWT."*
|
||||
|
||||
Don't:
|
||||
```python
|
||||
search_memories(query="Refactor the auth module to use JWT")
|
||||
# Hits whatever shares words. Misses prior decisions and preferences.
|
||||
```
|
||||
|
||||
Do (parallel — substitute the active `user_id` for `<your_user_id>`):
|
||||
```python
|
||||
search_memories(query="auth module decisions",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]})
|
||||
search_memories(query="JWT",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}]})
|
||||
search_memories(query="auth refactor failures",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
search_memories(query="auth",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "user_preference"}}]})
|
||||
```
|
||||
|
||||
## After completing significant work
|
||||
|
||||
Extract key learnings and store them using the `add_memory` tool:
|
||||
|
||||
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
||||
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
||||
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
||||
- **User preferences observed** -> Include metadata `{"type": "user_preference"}`
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
|
||||
|
||||
### Expiration: high-churn vs durable
|
||||
|
||||
Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes.
|
||||
|
||||
| Type | Expiration | Why |
|
||||
|---|---|---|
|
||||
| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. |
|
||||
| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. |
|
||||
|
||||
`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule.
|
||||
|
||||
### Recency filter on recall
|
||||
|
||||
When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface:
|
||||
|
||||
```python
|
||||
# Last 90 days only
|
||||
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
```
|
||||
|
||||
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
|
||||
|
||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
### Use `infer=False` for already-structured content
|
||||
|
||||
When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it.
|
||||
|
||||
```python
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "<your structured fact>"}],
|
||||
user_id="<active user_id>",
|
||||
metadata={"type": "decision"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure.
|
||||
|
||||
## Before losing context
|
||||
|
||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||
|
||||
```
|
||||
## Session Summary
|
||||
|
||||
### User's Goal
|
||||
[What the user originally asked for]
|
||||
|
||||
### What Was Accomplished
|
||||
[Numbered list of tasks completed]
|
||||
|
||||
### Key Decisions Made
|
||||
[Architectural choices, trade-offs discussed]
|
||||
|
||||
### Files Created or Modified
|
||||
[Important file paths with what changed]
|
||||
|
||||
### Current State
|
||||
[What is in progress, pending items, next steps]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state"}`
|
||||
|
||||
## Memory hygiene
|
||||
|
||||
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
|
||||
- Only store genuinely useful learnings. Skip trivial interactions.
|
||||
- Use specific, searchable language in memory content.
|
||||
@@ -46,8 +46,6 @@ export MEM0_API_KEY="m0-your-api-key"
|
||||
|
||||
Get an API key at: https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=mem0-plugin-skill
|
||||
|
||||
> **Don't have a `MEM0_API_KEY`?** Run `mem0 init --agent --json` (after `pip install mem0-cli` or `npm install -g @mem0/cli`) to mint an evaluation key without email or dashboard. The human can claim later with `mem0 init --email <your-email>`.
|
||||
|
||||
## Step 2: Initialize the client
|
||||
|
||||
**Python:**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0ai",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.2",
|
||||
"description": "The Memory Layer For Your AI Apps",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Best-effort read/write of ~/.mem0/config.json from the TS SDK.
|
||||
*
|
||||
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
|
||||
* distinct_id values here, and the TS MemoryClient reads those on init to
|
||||
* fire $identify and merge them into the email identity.
|
||||
*
|
||||
* Node-only. Browsers (no `process.versions.node`) no-op.
|
||||
*/
|
||||
|
||||
export interface Mem0AnonIds {
|
||||
oss?: string;
|
||||
cli?: string;
|
||||
aliasedPairs: string[];
|
||||
}
|
||||
|
||||
interface NodeFs {
|
||||
fs: typeof import("fs");
|
||||
path: typeof import("path");
|
||||
crypto: typeof import("crypto");
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
async function getNodeFs(): Promise<NodeFs | null> {
|
||||
if (typeof process === "undefined" || !process.versions?.node) return null;
|
||||
try {
|
||||
const [fs, path, os, crypto] = await Promise.all([
|
||||
import("fs"),
|
||||
import("path"),
|
||||
import("os"),
|
||||
import("crypto"),
|
||||
]);
|
||||
const fsMod = (fs as any).default ?? fs;
|
||||
const pathMod = (path as any).default ?? path;
|
||||
const osMod = (os as any).default ?? os;
|
||||
const cryptoMod = (crypto as any).default ?? crypto;
|
||||
const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0");
|
||||
return {
|
||||
fs: fsMod,
|
||||
path: pathMod,
|
||||
crypto: cryptoMod,
|
||||
configPath: pathMod.join(dir, "config.json"),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfig(node: NodeFs): Record<string, any> | null {
|
||||
try {
|
||||
if (!node.fs.existsSync(node.configPath)) return null;
|
||||
const parsed = JSON.parse(node.fs.readFileSync(node.configPath, "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(node: NodeFs, config: Record<string, any>): void {
|
||||
node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true });
|
||||
node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4));
|
||||
}
|
||||
|
||||
function aliasPairMarker(node: NodeFs, anonId: string, email: string): string {
|
||||
return node.crypto
|
||||
.createHash("sha256")
|
||||
.update(`${anonId}\0${email}`, "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function randomUserId(node: NodeFs): string {
|
||||
if (typeof node.crypto.randomUUID === "function") {
|
||||
return node.crypto.randomUUID();
|
||||
}
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateMem0UserId(): Promise<string | null> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return null;
|
||||
try {
|
||||
const config = loadConfig(node) ?? {};
|
||||
if (typeof config.user_id === "string" && config.user_id) {
|
||||
return config.user_id;
|
||||
}
|
||||
const userId = randomUserId(node);
|
||||
config.user_id = userId;
|
||||
writeConfig(node, config);
|
||||
return userId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return null;
|
||||
const config = loadConfig(node);
|
||||
if (!config) return null;
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
return {
|
||||
oss: typeof config.user_id === "string" ? config.user_id : undefined,
|
||||
cli:
|
||||
typeof telemetry.anonymous_id === "string"
|
||||
? telemetry.anonymous_id
|
||||
: undefined,
|
||||
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs.filter(
|
||||
(item: unknown) => typeof item === "string",
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function isMem0Aliased(
|
||||
anonId: string,
|
||||
email: string,
|
||||
): Promise<boolean> {
|
||||
if (!anonId || !email) return false;
|
||||
const node = await getNodeFs();
|
||||
if (!node) return false;
|
||||
const config = loadConfig(node);
|
||||
if (!config) return false;
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs
|
||||
: [];
|
||||
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
|
||||
}
|
||||
|
||||
export async function markMem0Aliased(
|
||||
anonId: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return;
|
||||
try {
|
||||
const config = loadConfig(node) ?? {};
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
const aliasedPairs = Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs
|
||||
: [];
|
||||
const marker = aliasPairMarker(node, anonId, email);
|
||||
if (!aliasedPairs.includes(marker)) {
|
||||
aliasedPairs.push(marker);
|
||||
}
|
||||
telemetry.aliased_pairs = aliasedPairs;
|
||||
config.telemetry = telemetry;
|
||||
writeConfig(node, config);
|
||||
} catch {
|
||||
// Best-effort: read-only filesystems and unwritable paths just skip.
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,7 @@ import {
|
||||
CreateMemoryExportPayload,
|
||||
GetMemoryExportPayload,
|
||||
} from "./mem0.types";
|
||||
import {
|
||||
captureClientEvent,
|
||||
generateHash,
|
||||
isTelemetryEnabled,
|
||||
telemetry,
|
||||
} from "./telemetry";
|
||||
import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
readMem0AnonIds,
|
||||
} from "./config";
|
||||
import { captureClientEvent, generateHash } from "./telemetry";
|
||||
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
@@ -129,8 +118,6 @@ export default class MemoryClient {
|
||||
this.telemetryId = generateHash(this.apiKey);
|
||||
}
|
||||
|
||||
await this._maybeAliasAnonToEmail();
|
||||
|
||||
captureClientEvent("init", this, {
|
||||
client_type: "MemoryClient",
|
||||
}).catch((error: any) => {
|
||||
@@ -145,30 +132,6 @@ export default class MemoryClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async _maybeAliasAnonToEmail(): Promise<void> {
|
||||
if (!isTelemetryEnabled()) return;
|
||||
try {
|
||||
const email = this.telemetryId;
|
||||
if (!email || !email.includes("@")) return;
|
||||
const sharedAnonId = await getOrCreateMem0UserId();
|
||||
const anonIds = await readMem0AnonIds();
|
||||
if (!anonIds && !sharedAnonId) return;
|
||||
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
|
||||
(id): id is string => !!id && id !== email,
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
for (const anonId of candidates) {
|
||||
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
|
||||
seen.add(anonId);
|
||||
if (await telemetry.captureIdentify(anonId, email)) {
|
||||
await markMem0Aliased(anonId, email);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to alias telemetry identity:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private _captureEvent(methodName: string, args: any[]) {
|
||||
captureClientEvent(methodName, this, {
|
||||
success: true,
|
||||
|
||||
@@ -50,13 +50,6 @@ export interface PromptUpdatePayload {
|
||||
memoryDepth?: string | null;
|
||||
usecaseSetting?: string | number;
|
||||
multilingual?: boolean;
|
||||
/**
|
||||
* Toggle Memory Decay for this project. When `true`, search-time ranking
|
||||
* boosts recently-used memories and gently dampens stale ones; when `false`,
|
||||
* ranking is restored to the pre-decay behaviour. Off by default.
|
||||
* See https://docs.mem0.ai/platform/features/memory-decay
|
||||
*/
|
||||
decay?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,8 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
async captureEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties = {},
|
||||
): Promise<boolean> {
|
||||
if (!MEM0_TELEMETRY) return false;
|
||||
async captureEvent(distinctId: string, eventName: string, properties = {}) {
|
||||
if (!MEM0_TELEMETRY) return;
|
||||
|
||||
const eventProperties = {
|
||||
client_version: version,
|
||||
@@ -65,50 +61,9 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Telemetry event capture failed:", await response.text());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Telemetry event capture failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async captureIdentify(anonId: string, email: string): Promise<boolean> {
|
||||
if (!MEM0_TELEMETRY) return false;
|
||||
if (!anonId || !email || anonId === email) return false;
|
||||
|
||||
const payload = {
|
||||
api_key: this.apiKey,
|
||||
distinct_id: email,
|
||||
event: "$identify",
|
||||
properties: {
|
||||
$anon_distinct_id: anonId,
|
||||
client_source: "typescript",
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(this.host, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Telemetry identify capture failed:",
|
||||
await response.text(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Telemetry identify capture failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,10 +72,6 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
}
|
||||
}
|
||||
|
||||
function isTelemetryEnabled(): boolean {
|
||||
return MEM0_TELEMETRY;
|
||||
}
|
||||
|
||||
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
|
||||
|
||||
async function captureClientEvent(
|
||||
@@ -150,4 +101,4 @@ async function captureClientEvent(
|
||||
);
|
||||
}
|
||||
|
||||
export { telemetry, captureClientEvent, generateHash, isTelemetryEnabled };
|
||||
export { telemetry, captureClientEvent, generateHash };
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface TelemetryClient {
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: Record<string, any>,
|
||||
): Promise<boolean>;
|
||||
): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
/**
|
||||
* Tests for PostHog identity stitching in the TS MemoryClient.
|
||||
*
|
||||
* Covers $identify firing, idempotency via pair markers, and the node/browser
|
||||
* gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json.
|
||||
*/
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { MemoryClient } from "../mem0";
|
||||
import { telemetry } from "../telemetry";
|
||||
import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
readMem0AnonIds,
|
||||
} from "../config";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
||||
|
||||
installConsoleSuppression();
|
||||
|
||||
function setupMockFetchWithPostHog(): jest.Mock {
|
||||
return setupMockFetch(
|
||||
new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── config.ts (node-only fs read/write) ──────────────────────
|
||||
|
||||
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
||||
let tmpHome: string;
|
||||
const originalMem0Dir = process.env.MEM0_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-test-"));
|
||||
process.env.MEM0_DIR = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tmpHome)) {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
if (originalMem0Dir === undefined) {
|
||||
delete process.env.MEM0_DIR;
|
||||
} else {
|
||||
process.env.MEM0_DIR = originalMem0Dir;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when config file does not exist", async () => {
|
||||
expect(await readMem0AnonIds()).toBeNull();
|
||||
});
|
||||
|
||||
test("reads OSS user_id only", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const ids = await readMem0AnonIds();
|
||||
expect(ids).toEqual({
|
||||
oss: "oss-uuid",
|
||||
cli: undefined,
|
||||
aliasedPairs: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("reads CLI anonymous_id and aliased_pairs", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
|
||||
}),
|
||||
);
|
||||
const ids = await readMem0AnonIds();
|
||||
expect(ids).toEqual({
|
||||
oss: undefined,
|
||||
cli: "cli-anon",
|
||||
aliasedPairs: ["pair-marker"],
|
||||
});
|
||||
});
|
||||
|
||||
test("getOrCreateMem0UserId creates and reuses shared SDK user_id", async () => {
|
||||
const first = await getOrCreateMem0UserId();
|
||||
const second = await getOrCreateMem0UserId();
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBe(first);
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBe(first);
|
||||
});
|
||||
|
||||
test("returns null on malformed JSON", async () => {
|
||||
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
||||
expect(await readMem0AnonIds()).toBeNull();
|
||||
});
|
||||
|
||||
test("markMem0Aliased preserves other fields", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon" },
|
||||
}),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBe("oss-uuid");
|
||||
expect(written.telemetry.anonymous_id).toBe("cli-anon");
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("markMem0Aliased creates telemetry section when missing", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("markMem0Aliased tracks each pair independently", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
|
||||
expect(await isMem0Aliased("other-uuid", "user@example.com")).toBe(false);
|
||||
expect(await isMem0Aliased("oss-uuid", "other@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("markMem0Aliased does not throw when target dir is unwritable", async () => {
|
||||
// Point at a path that cannot be written to (a file-as-dir collision).
|
||||
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
||||
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
|
||||
await expect(
|
||||
markMem0Aliased("oss-uuid", "user@example.com"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── telemetry.captureIdentify ───────────────────────────────
|
||||
|
||||
describe("telemetry.captureIdentify", () => {
|
||||
test("fires $identify with $anon_distinct_id", async () => {
|
||||
const fetchMock = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => "ok",
|
||||
})) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
|
||||
await telemetry.captureIdentify("anon-uuid", "user@example.com");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = (fetchMock as jest.Mock).mock.calls[0];
|
||||
const payload = JSON.parse(init.body);
|
||||
expect(payload.event).toBe("$identify");
|
||||
expect(payload.distinct_id).toBe("user@example.com");
|
||||
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
||||
expect(payload.properties.$process_person_profile).toBeUndefined();
|
||||
});
|
||||
|
||||
test("skips when anon equals email", async () => {
|
||||
const fetchMock = jest.fn() as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
await telemetry.captureIdentify("user@example.com", "user@example.com");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips when either input is empty", async () => {
|
||||
const fetchMock = jest.fn() as unknown as typeof fetch;
|
||||
global.fetch = fetchMock as any;
|
||||
await telemetry.captureIdentify("", "user@example.com");
|
||||
await telemetry.captureIdentify("anon", "");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MemoryClient init aliasing ──────────────────────────────
|
||||
|
||||
describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
let tmpHome: string;
|
||||
const originalMem0Dir = process.env.MEM0_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-init-"));
|
||||
process.env.MEM0_DIR = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tmpHome)) {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
if (originalMem0Dir === undefined) {
|
||||
delete process.env.MEM0_DIR;
|
||||
} else {
|
||||
process.env.MEM0_DIR = originalMem0Dir;
|
||||
}
|
||||
});
|
||||
|
||||
// Construct a non-initialised client so we can call _maybeAliasAnonToEmail
|
||||
// in isolation (the real constructor's _initializeClient also fires it).
|
||||
function makeStubClient(telemetryId: string): MemoryClient {
|
||||
const client = Object.create(MemoryClient.prototype) as MemoryClient;
|
||||
(client as any).apiKey = TEST_API_KEY;
|
||||
(client as any).host = "https://api.mem0.ai";
|
||||
(client as any).telemetryId = telemetryId;
|
||||
return client;
|
||||
}
|
||||
|
||||
test("fires $identify on first init and persists pair marker", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(1);
|
||||
const body = JSON.parse(identifyCalls[0][1].body);
|
||||
expect(body.distinct_id).toBe("test@example.com");
|
||||
expect(body.properties.$anon_distinct_id).toBe("oss-uuid");
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("platform-first init creates shared anon ID and identifies it", async () => {
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.user_id).toBeTruthy();
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(1);
|
||||
const body = JSON.parse(identifyCalls[0][1].body);
|
||||
expect(body.distinct_id).toBe("test@example.com");
|
||||
expect(body.properties.$anon_distinct_id).toBe(written.user_id);
|
||||
});
|
||||
|
||||
test("second init does not refire $identify", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: {},
|
||||
}),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "test@example.com");
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("fires $identify for both OSS and CLI anon ids", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon" },
|
||||
}),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(2);
|
||||
const anonIds = identifyCalls.map(
|
||||
(c: [string, RequestInit]) =>
|
||||
JSON.parse(c[1].body as string).properties.$anon_distinct_id,
|
||||
);
|
||||
expect(anonIds).toContain("oss-uuid");
|
||||
expect(anonIds).toContain("cli-anon");
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("noop when telemetryId is not an email", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetch();
|
||||
|
||||
const client = makeStubClient("not-an-email");
|
||||
await (client as any)._maybeAliasAnonToEmail();
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("does not throw when config read fails", async () => {
|
||||
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
||||
setupMockFetch();
|
||||
|
||||
const client = makeStubClient("test@example.com");
|
||||
await expect(
|
||||
(client as any)._maybeAliasAnonToEmail(),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("noop when telemetry disabled — no fs read, no fs write, no events", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
);
|
||||
const fetchMock = setupMockFetch();
|
||||
|
||||
jest.resetModules();
|
||||
const original = process.env.MEM0_TELEMETRY;
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
try {
|
||||
const { MemoryClient: ColdClient } = await import("../mem0");
|
||||
const client = Object.create(ColdClient.prototype);
|
||||
client.apiKey = TEST_API_KEY;
|
||||
client.host = "https://api.mem0.ai";
|
||||
client.telemetryId = "test@example.com";
|
||||
await client._maybeAliasAnonToEmail();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.MEM0_TELEMETRY;
|
||||
else process.env.MEM0_TELEMETRY = original;
|
||||
jest.resetModules();
|
||||
}
|
||||
|
||||
const identifyCalls = (fetchMock.mock.calls as any[]).filter(
|
||||
([, init]: [string, RequestInit]) => {
|
||||
if (!init?.body) return false;
|
||||
return JSON.parse(init.body as string).event === "$identify";
|
||||
},
|
||||
);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
|
||||
const written = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
);
|
||||
expect(written.telemetry?.aliased_pairs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Browser env path (no process.versions.node) ─────────────
|
||||
|
||||
describe("config.ts in browser-like environment", () => {
|
||||
test("readMem0AnonIds returns null when not Node", async () => {
|
||||
const originalProcess = global.process;
|
||||
// @ts-expect-error force-undefining global to simulate a browser
|
||||
delete global.process;
|
||||
try {
|
||||
jest.resetModules();
|
||||
const { readMem0AnonIds: browserRead } = await import("../config");
|
||||
expect(await browserRead()).toBeNull();
|
||||
} finally {
|
||||
global.process = originalProcess;
|
||||
jest.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
ScoredResult,
|
||||
} from "../utils/scoring";
|
||||
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
||||
import { getOrCreateMem0UserId } from "../../../client/config";
|
||||
|
||||
// Entity params that must be passed via filters - check both snake_case and camelCase
|
||||
const ENTITY_PARAMS = [
|
||||
@@ -467,12 +466,7 @@ export class Memory {
|
||||
this.telemetryId === "anonymous" ||
|
||||
this.telemetryId === "anonymous-supabase"
|
||||
) {
|
||||
this.telemetryId =
|
||||
(await getOrCreateMem0UserId()) ||
|
||||
(await this.vectorStore.getUserId());
|
||||
try {
|
||||
await this.vectorStore.setUserId(this.telemetryId);
|
||||
} catch {}
|
||||
this.telemetryId = await this.vectorStore.getUserId();
|
||||
}
|
||||
return this.telemetryId;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import type { Client as ClientType } from "pg";
|
||||
import pkg from "pg";
|
||||
const { Client, escapeIdentifier } = pkg;
|
||||
const { Client } = pkg;
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
|
||||
const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,127}$/;
|
||||
|
||||
function validateIdentifier(
|
||||
name: string,
|
||||
label: string = "identifier",
|
||||
): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid ${label} '${name}': only letters, digits, and underscores are allowed, ` +
|
||||
`must start with a letter or underscore, and be at most 128 characters.`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function escapeFilterKey(key: string): string {
|
||||
if (!SAFE_IDENTIFIER_RE.test(key)) {
|
||||
throw new Error(
|
||||
`Invalid filter key '${key}': only letters, digits, and underscores are allowed.`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface PGVectorConfig extends VectorStoreConfig {
|
||||
dbname?: string;
|
||||
user: string;
|
||||
@@ -49,13 +25,10 @@ export class PGVector implements VectorStore {
|
||||
private _initPromise?: Promise<void>;
|
||||
|
||||
constructor(config: PGVectorConfig) {
|
||||
this.collectionName = validateIdentifier(
|
||||
config.collectionName || "memories",
|
||||
"collectionName",
|
||||
);
|
||||
this.collectionName = config.collectionName || "memories";
|
||||
this.useDiskann = config.diskann || false;
|
||||
this.useHnsw = config.hnsw || false;
|
||||
this.dbName = validateIdentifier(config.dbname || "vector_store", "dbname");
|
||||
this.dbName = config.dbname || "vector_store";
|
||||
this.config = config;
|
||||
|
||||
this.client = new Client({
|
||||
@@ -68,10 +41,6 @@ export class PGVector implements VectorStore {
|
||||
this.initialize().catch(console.error);
|
||||
}
|
||||
|
||||
private col(): string {
|
||||
return escapeIdentifier(this.collectionName);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this._doInitialize();
|
||||
@@ -133,28 +102,31 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
private async createDatabase(dbName: string): Promise<void> {
|
||||
await this.client.query(`CREATE DATABASE ${escapeIdentifier(dbName)}`);
|
||||
// Create database (cannot be parameterized)
|
||||
await this.client.query(`CREATE DATABASE ${dbName}`);
|
||||
}
|
||||
|
||||
private async createCol(embeddingModelDims: number): Promise<void> {
|
||||
const dims = Math.floor(embeddingModelDims);
|
||||
// Create the table
|
||||
await this.client.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.col()} (
|
||||
CREATE TABLE IF NOT EXISTS ${this.collectionName} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector(${dims}),
|
||||
vector vector(${embeddingModelDims}),
|
||||
payload JSONB
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes based on configuration
|
||||
if (this.useDiskann && embeddingModelDims < 2000) {
|
||||
try {
|
||||
// Check if vectorscale extension is available
|
||||
const result = await this.client.query(
|
||||
"SELECT * FROM pg_extension WHERE extname = 'vectorscale'",
|
||||
);
|
||||
if (result.rows.length > 0) {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_diskann_idx")}
|
||||
ON ${this.col()}
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_diskann_idx
|
||||
ON ${this.collectionName}
|
||||
USING diskann (vector);
|
||||
`);
|
||||
}
|
||||
@@ -164,8 +136,8 @@ export class PGVector implements VectorStore {
|
||||
} else if (this.useHnsw) {
|
||||
try {
|
||||
await this.client.query(`
|
||||
CREATE INDEX IF NOT EXISTS ${escapeIdentifier(this.collectionName + "_hnsw_idx")}
|
||||
ON ${this.col()}
|
||||
CREATE INDEX IF NOT EXISTS ${this.collectionName}_hnsw_idx
|
||||
ON ${this.collectionName}
|
||||
USING hnsw (vector vector_cosine_ops);
|
||||
`);
|
||||
} catch (error) {
|
||||
@@ -181,15 +153,16 @@ export class PGVector implements VectorStore {
|
||||
): Promise<void> {
|
||||
const values = vectors.map((vector, i) => ({
|
||||
id: ids[i],
|
||||
vector: `[${vector.join(",")}]`,
|
||||
vector: `[${vector.join(",")}]`, // Format vector as string with square brackets
|
||||
payload: payloads[i],
|
||||
}));
|
||||
|
||||
const query = `
|
||||
INSERT INTO ${this.col()} (id, vector, payload)
|
||||
INSERT INTO ${this.collectionName} (id, vector, payload)
|
||||
VALUES ($1, $2::vector, $3::jsonb)
|
||||
`;
|
||||
|
||||
// Execute inserts in parallel using Promise.all
|
||||
await Promise.all(
|
||||
values.map((value) =>
|
||||
this.client.query(query, [value.id, value.vector, value.payload]),
|
||||
@@ -209,8 +182,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -223,7 +195,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'textLemmatized'), plainto_tsquery('simple', $1)) AS score, payload
|
||||
FROM ${this.col()}
|
||||
FROM ${this.collectionName}
|
||||
WHERE to_tsvector('simple', payload->>'textLemmatized') @@ plainto_tsquery('simple', $1)
|
||||
${filterClause}
|
||||
ORDER BY score DESC
|
||||
@@ -249,14 +221,13 @@ export class PGVector implements VectorStore {
|
||||
filters?: SearchFilters,
|
||||
): Promise<VectorStoreResult[]> {
|
||||
const filterConditions: string[] = [];
|
||||
const queryVector = `[${query.join(",")}]`;
|
||||
const queryVector = `[${query.join(",")}]`; // Format query vector as string with square brackets
|
||||
const filterValues: any[] = [queryVector, topK];
|
||||
let filterIndex = 3;
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${filterIndex}`);
|
||||
filterConditions.push(`payload->>'${key}' = $${filterIndex}`);
|
||||
filterValues.push(value);
|
||||
filterIndex++;
|
||||
}
|
||||
@@ -269,7 +240,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const searchQuery = `
|
||||
SELECT id, vector <=> $1::vector AS distance, payload
|
||||
FROM ${this.col()}
|
||||
FROM ${this.collectionName}
|
||||
${filterClause}
|
||||
ORDER BY distance
|
||||
LIMIT $2
|
||||
@@ -280,13 +251,13 @@ export class PGVector implements VectorStore {
|
||||
return result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
payload: row.payload,
|
||||
score: Math.max(0, Math.min(1, 1 - Number(row.distance))),
|
||||
score: row.distance,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(vectorId: string): Promise<VectorStoreResult | null> {
|
||||
const result = await this.client.query(
|
||||
`SELECT id, payload FROM ${this.col()} WHERE id = $1`,
|
||||
`SELECT id, payload FROM ${this.collectionName} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
|
||||
@@ -303,10 +274,10 @@ export class PGVector implements VectorStore {
|
||||
vector: number[],
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const vectorStr = `[${vector.join(",")}]`;
|
||||
const vectorStr = `[${vector.join(",")}]`; // Format vector as string with square brackets
|
||||
await this.client.query(
|
||||
`
|
||||
UPDATE ${this.col()}
|
||||
UPDATE ${this.collectionName}
|
||||
SET vector = $1::vector, payload = $2::jsonb
|
||||
WHERE id = $3
|
||||
`,
|
||||
@@ -315,13 +286,14 @@ export class PGVector implements VectorStore {
|
||||
}
|
||||
|
||||
async delete(vectorId: string): Promise<void> {
|
||||
await this.client.query(`DELETE FROM ${this.col()} WHERE id = $1`, [
|
||||
vectorId,
|
||||
]);
|
||||
await this.client.query(
|
||||
`DELETE FROM ${this.collectionName} WHERE id = $1`,
|
||||
[vectorId],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCol(): Promise<void> {
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.col()}`);
|
||||
await this.client.query(`DROP TABLE IF EXISTS ${this.collectionName}`);
|
||||
}
|
||||
|
||||
private async listCols(): Promise<string[]> {
|
||||
@@ -343,8 +315,7 @@ export class PGVector implements VectorStore {
|
||||
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const safeKey = escapeFilterKey(key);
|
||||
filterConditions.push(`payload->>'${safeKey}' = $${paramIndex}`);
|
||||
filterConditions.push(`payload->>'${key}' = $${paramIndex}`);
|
||||
filterValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -357,14 +328,14 @@ export class PGVector implements VectorStore {
|
||||
|
||||
const listQuery = `
|
||||
SELECT id, payload
|
||||
FROM ${this.col()}
|
||||
FROM ${this.collectionName}
|
||||
${filterClause}
|
||||
LIMIT $${paramIndex}
|
||||
`;
|
||||
|
||||
const countQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM ${this.col()}
|
||||
FROM ${this.collectionName}
|
||||
${filterClause}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/// <reference types="jest" />
|
||||
|
||||
const searchRows = [
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
distance: "0",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
distance: "0.006116251198662548",
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
distance: "1",
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
distance: "2",
|
||||
},
|
||||
];
|
||||
|
||||
function mockPgQuery(sql: string) {
|
||||
if (sql.includes("SELECT 1 FROM pg_database")) {
|
||||
return { rows: [{ "?column?": 1 }] };
|
||||
}
|
||||
|
||||
if (sql.includes("FROM information_schema.tables")) {
|
||||
return { rows: [{ table_name: "memories" }] };
|
||||
}
|
||||
|
||||
if (sql.includes("vector <=> $1::vector AS distance")) {
|
||||
return { rows: searchRows };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
jest.mock("pg", () => {
|
||||
const clients: any[] = [];
|
||||
|
||||
const Client = jest.fn().mockImplementation((config: any) => {
|
||||
const client = {
|
||||
config,
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
end: jest.fn().mockResolvedValue(undefined),
|
||||
query: jest
|
||||
.fn()
|
||||
.mockImplementation(async (sql: string) => mockPgQuery(sql)),
|
||||
};
|
||||
|
||||
clients.push(client);
|
||||
return client;
|
||||
});
|
||||
|
||||
const escapeIdentifier = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { Client, escapeIdentifier },
|
||||
Client,
|
||||
escapeIdentifier,
|
||||
__mock: { Client, clients },
|
||||
};
|
||||
});
|
||||
|
||||
import { PGVector } from "../src/vector_stores/pgvector";
|
||||
|
||||
describe("PGVector - search()", () => {
|
||||
beforeEach(() => {
|
||||
const pg = require("pg");
|
||||
pg.__mock.Client.mockClear();
|
||||
pg.__mock.clients.length = 0;
|
||||
});
|
||||
|
||||
test("returns similarity score (1 - distance) clamped to [0, 1]", async () => {
|
||||
const store = new PGVector({
|
||||
collectionName: "memories",
|
||||
user: "postgres",
|
||||
password: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
embeddingModelDims: 3,
|
||||
dimension: 3,
|
||||
} as any);
|
||||
|
||||
await store.initialize();
|
||||
|
||||
const results = await store.search([1, 0, 0], 4);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
id: "a",
|
||||
payload: { data: "exactly x-axis" },
|
||||
score: 1,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
payload: { data: "close to x-axis" },
|
||||
score: 0.9938837488013375,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
payload: { data: "y-axis" },
|
||||
score: 0,
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
payload: { data: "opposite x-axis" },
|
||||
score: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const pg = require("pg");
|
||||
expect(pg.__mock.Client).toHaveBeenCalledTimes(2);
|
||||
|
||||
const activeClient = pg.__mock.clients[1];
|
||||
expect(activeClient.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("vector <=> $1::vector AS distance"),
|
||||
["[1,0,0]", 4],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,5 +4,3 @@ __version__ = importlib.metadata.version("mem0ai")
|
||||
|
||||
from mem0.client.main import AsyncMemoryClient, MemoryClient # noqa
|
||||
from mem0.memory.main import AsyncMemory, Memory # noqa
|
||||
|
||||
|
||||
|
||||
+2
-30
@@ -19,8 +19,8 @@ from mem0.client.types import (
|
||||
from mem0.client.utils import api_error_handler
|
||||
|
||||
# Exception classes are referenced in docstrings only
|
||||
from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config
|
||||
from mem0.memory.telemetry import capture_client_event, client_telemetry
|
||||
from mem0.memory.setup import get_user_id, setup_config
|
||||
from mem0.memory.telemetry import capture_client_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,32 +33,6 @@ setup_config()
|
||||
ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
||||
|
||||
|
||||
def _maybe_alias_anon_to_email(user_email):
|
||||
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
||||
|
||||
Idempotent via telemetry.aliased_pairs: only writes markers when
|
||||
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
||||
Best-effort: never raises.
|
||||
"""
|
||||
if client_telemetry.posthog is None:
|
||||
return
|
||||
if not user_email or "@" not in user_email:
|
||||
return
|
||||
try:
|
||||
anon_ids = read_anon_ids()
|
||||
seen = set()
|
||||
for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")):
|
||||
if not anon_id or anon_id == user_email or anon_id in seen:
|
||||
continue
|
||||
seen.add(anon_id)
|
||||
if is_aliased(anon_id, user_email):
|
||||
continue
|
||||
if client_telemetry.capture_identify(anon_id, user_email):
|
||||
mark_aliased(anon_id, user_email)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
|
||||
|
||||
|
||||
class MemoryClient:
|
||||
"""Client for interacting with the Mem0 API.
|
||||
|
||||
@@ -134,7 +108,6 @@ class MemoryClient:
|
||||
user_email=self.user_email,
|
||||
)
|
||||
|
||||
_maybe_alias_anon_to_email(self.user_email)
|
||||
capture_client_event("client.init", self, {"sync_type": "sync"})
|
||||
|
||||
def _validate_api_key(self):
|
||||
@@ -1012,7 +985,6 @@ class AsyncMemoryClient:
|
||||
user_email=self.user_email,
|
||||
)
|
||||
|
||||
_maybe_alias_anon_to_email(self.user_email)
|
||||
capture_client_event("client.init", self, {"sync_type": "async"})
|
||||
|
||||
def _validate_api_key(self):
|
||||
|
||||
+2
-16
@@ -398,7 +398,6 @@ class Project(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -408,9 +407,6 @@ class Project(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -427,12 +423,11 @@ class Project(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"multilingual, decay"
|
||||
"multilingual"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -441,7 +436,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
}
|
||||
)
|
||||
response = self._client.patch(
|
||||
@@ -457,7 +451,6 @@ class Project(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"sync_type": "sync",
|
||||
},
|
||||
)
|
||||
@@ -722,7 +715,6 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: Optional[List[str]] = None,
|
||||
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
|
||||
multilingual: Optional[bool] = None,
|
||||
decay: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update project settings.
|
||||
@@ -732,9 +724,6 @@ class AsyncProject(BaseProject):
|
||||
custom_categories: New categories for the project
|
||||
retrieval_criteria: New retrieval criteria for the project
|
||||
multilingual: Whether to use the input language for memory storage and retrieval
|
||||
decay: Toggle Memory Decay for this project. When True, search-time
|
||||
ranking boosts recently-used memories and gently dampens stale ones; when
|
||||
False, ranking is restored to the pre-decay behaviour. Off by default.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the API response.
|
||||
@@ -751,12 +740,11 @@ class AsyncProject(BaseProject):
|
||||
and custom_categories is None
|
||||
and retrieval_criteria is None
|
||||
and multilingual is None
|
||||
and decay is None
|
||||
):
|
||||
raise ValueError(
|
||||
"At least one parameter must be provided for update: "
|
||||
"custom_instructions, custom_categories, retrieval_criteria, "
|
||||
"multilingual, decay"
|
||||
"multilingual"
|
||||
)
|
||||
|
||||
payload = self._prepare_params(
|
||||
@@ -765,7 +753,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
}
|
||||
)
|
||||
response = await self._client.patch(
|
||||
@@ -781,7 +768,6 @@ class AsyncProject(BaseProject):
|
||||
"custom_categories": custom_categories,
|
||||
"retrieval_criteria": retrieval_criteria,
|
||||
"multilingual": multilingual,
|
||||
"decay": decay,
|
||||
"sync_type": "async",
|
||||
},
|
||||
)
|
||||
|
||||
+15
-102
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from hashlib import sha256
|
||||
|
||||
# Set up the directory path
|
||||
VECTOR_ID = str(uuid.uuid4())
|
||||
@@ -10,113 +8,28 @@ home_dir = os.path.expanduser("~")
|
||||
mem0_dir = os.environ.get("MEM0_DIR") or os.path.join(home_dir, ".mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _config_path():
|
||||
return os.path.join(mem0_dir, "config.json")
|
||||
|
||||
|
||||
def _load_config():
|
||||
"""Load ~/.mem0/config.json, returning {} on missing/malformed file."""
|
||||
path = _config_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to load mem0 config %s: %s", path, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_config(config):
|
||||
"""Best-effort write of ~/.mem0/config.json. Never raises."""
|
||||
path = _config_path()
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to write mem0 config %s: %s", path, e)
|
||||
|
||||
|
||||
def setup_config():
|
||||
"""Ensure ~/.mem0/config.json exists with a top-level user_id.
|
||||
|
||||
Idempotent: backfills user_id for users whose config was written by the
|
||||
CLI (which writes telemetry.anonymous_id but no top-level user_id).
|
||||
Without this, OSS Python telemetry is silently dropped because
|
||||
get_user_id() returns None when user_id is missing.
|
||||
"""
|
||||
config = _load_config()
|
||||
if config.get("user_id"):
|
||||
return
|
||||
config["user_id"] = str(uuid.uuid4())
|
||||
_write_config(config)
|
||||
config_path = os.path.join(mem0_dir, "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
user_id = str(uuid.uuid4())
|
||||
config = {"user_id": user_id}
|
||||
with open(config_path, "w") as config_file:
|
||||
json.dump(config, config_file, indent=4)
|
||||
|
||||
|
||||
def get_user_id():
|
||||
config = _load_config()
|
||||
if not config:
|
||||
config_path = os.path.join(mem0_dir, "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
return "anonymous_user"
|
||||
return config.get("user_id")
|
||||
|
||||
|
||||
def read_anon_ids():
|
||||
"""Return anon IDs and alias markers from ~/.mem0/config.json.
|
||||
|
||||
Returns a dict with keys "oss", "cli", "aliased_pairs" (IDs may be
|
||||
None). OSS Python writes top-level "user_id"; the CLI writes
|
||||
"telemetry.anonymous_id". They may coexist depending on which surface ran
|
||||
first.
|
||||
"""
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
return {
|
||||
"oss": config.get("user_id"),
|
||||
"cli": telemetry.get("anonymous_id"),
|
||||
"aliased_pairs": aliased_pairs if isinstance(aliased_pairs, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _alias_pair_marker(anon_id, email):
|
||||
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def is_aliased(anon_id, email):
|
||||
"""Return whether anon_id -> email has already been identified."""
|
||||
if not anon_id or not email:
|
||||
return False
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
if not isinstance(aliased_pairs, list):
|
||||
return False
|
||||
return _alias_pair_marker(anon_id, email) in aliased_pairs
|
||||
|
||||
|
||||
def mark_aliased(anon_id, email):
|
||||
"""Persist an anon_id -> email alias marker so $identify fires once per pair.
|
||||
|
||||
The marker is hashed to avoid storing platform emails in the local config.
|
||||
"""
|
||||
if not anon_id or not email:
|
||||
return
|
||||
config = _load_config()
|
||||
telemetry = config.get("telemetry")
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
aliased_pairs = telemetry.get("aliased_pairs")
|
||||
if not isinstance(aliased_pairs, list):
|
||||
aliased_pairs = []
|
||||
marker = _alias_pair_marker(anon_id, email)
|
||||
if marker not in aliased_pairs:
|
||||
aliased_pairs.append(marker)
|
||||
telemetry["aliased_pairs"] = aliased_pairs
|
||||
config["telemetry"] = telemetry
|
||||
_write_config(config)
|
||||
try:
|
||||
with open(config_path, "r") as config_file:
|
||||
config = json.load(config_file)
|
||||
user_id = config.get("user_id")
|
||||
return user_id
|
||||
except Exception:
|
||||
return "anonymous_user"
|
||||
|
||||
|
||||
def get_or_create_user_id(vector_store=None):
|
||||
|
||||
@@ -48,8 +48,7 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S
|
||||
|
||||
# Events that bypass sampling and always fire. Keep this set in sync with the
|
||||
# event names passed to capture_event() in mem0/memory/main.py.
|
||||
# $identify is included so PostHog person-merging is never lost to sampling.
|
||||
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"})
|
||||
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory"})
|
||||
|
||||
|
||||
def _sampling_before_send(msg):
|
||||
@@ -113,23 +112,6 @@ class AnonymousTelemetry:
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
|
||||
|
||||
def capture_identify(self, anon_id, email):
|
||||
"""Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
|
||||
if self.posthog is None:
|
||||
return False
|
||||
if not anon_id or not email or anon_id == email:
|
||||
return False
|
||||
try:
|
||||
self.posthog.capture(
|
||||
distinct_id=email,
|
||||
event="$identify",
|
||||
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.debug("Failed to capture $identify for %r: %s", email, e)
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self.posthog is not None:
|
||||
self.posthog.shutdown()
|
||||
|
||||
@@ -58,35 +58,24 @@ class LLMReranker(BaseReranker):
|
||||
# Initialize LLM using the factory
|
||||
self.llm = LlmFactory.create(llm_provider, llm_config)
|
||||
|
||||
# Honor custom scoring_prompt from config if provided
|
||||
custom_prompt = getattr(self.config, 'scoring_prompt', None)
|
||||
if custom_prompt:
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"LLMRerankerConfig.scoring_prompt is deprecated and will be removed in a future version. "
|
||||
"The prompt is now used as the system message.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._system_prompt = custom_prompt
|
||||
else:
|
||||
self._system_prompt = self._SYSTEM_PROMPT
|
||||
# Default scoring prompt
|
||||
self.scoring_prompt = getattr(self.config, 'scoring_prompt', None) or self._get_default_prompt()
|
||||
|
||||
def _get_default_prompt(self) -> str:
|
||||
"""Get the default scoring prompt template."""
|
||||
return """You are a relevance scoring assistant. Given a query and a document, you need to score how relevant the document is to the query.
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a relevance scoring assistant. "
|
||||
"Given a query and a document, score how relevant the document is to the query.\n\n"
|
||||
"Score the relevance on a scale from 0.0 to 1.0, where:\n"
|
||||
"- 1.0 = Perfectly relevant and directly answers the query\n"
|
||||
"- 0.8-0.9 = Highly relevant with good information\n"
|
||||
"- 0.6-0.7 = Moderately relevant with some useful information\n"
|
||||
"- 0.4-0.5 = Slightly relevant with limited useful information\n"
|
||||
"- 0.0-0.3 = Not relevant or no useful information\n\n"
|
||||
"Respond with only a single numerical score between 0.0 and 1.0. "
|
||||
"Do not include any explanation or additional text."
|
||||
)
|
||||
Score the relevance on a scale from 0.0 to 1.0, where:
|
||||
- 1.0 = Perfectly relevant and directly answers the query
|
||||
- 0.8-0.9 = Highly relevant with good information
|
||||
- 0.6-0.7 = Moderately relevant with some useful information
|
||||
- 0.4-0.5 = Slightly relevant with limited useful information
|
||||
- 0.0-0.3 = Not relevant or no useful information
|
||||
|
||||
# Maximum character length for query and document inputs to prevent prompt flooding.
|
||||
_MAX_INPUT_LEN = 4000
|
||||
Query: "{query}"
|
||||
Document: "{document}"
|
||||
|
||||
Provide only a single numerical score between 0.0 and 1.0. Do not include any explanation or additional text."""
|
||||
|
||||
def _extract_score(self, response_text: str) -> float:
|
||||
"""Extract numerical score from LLM response."""
|
||||
@@ -130,17 +119,12 @@ class LLMReranker(BaseReranker):
|
||||
doc_text = str(doc)
|
||||
|
||||
try:
|
||||
# Truncate inputs to prevent prompt flooding, then send as separate
|
||||
# system/user messages so instructions cannot be overridden by user data.
|
||||
safe_query = query[: self._MAX_INPUT_LEN]
|
||||
safe_doc = doc_text[: self._MAX_INPUT_LEN]
|
||||
user_message = f"Query: {safe_query}\n\nDocument: {safe_doc}"
|
||||
|
||||
# Generate scoring prompt
|
||||
prompt = self.scoring_prompt.format(query=query, document=doc_text)
|
||||
|
||||
# Get LLM response
|
||||
response = self.llm.generate_response(
|
||||
messages=[
|
||||
{"role": "system", "content": self._system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
# Extract score from response
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -26,17 +25,6 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -84,7 +72,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.database = database
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.collection_name = collection_name
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.use_azure_credential = use_azure_credential
|
||||
self.ssl_ca = ssl_ca
|
||||
@@ -186,7 +174,7 @@ class AzureMySQL(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
table_name = name or self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -20,17 +19,6 @@ from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]{0,127}$')
|
||||
|
||||
|
||||
def _validate_identifier(name: str, label: str = "identifier") -> str:
|
||||
if not _SAFE_IDENTIFIER_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid {label} '{name}': only letters, digits, and underscores are allowed, "
|
||||
"must start with a letter or underscore, and be at most 128 characters."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
@@ -71,8 +59,8 @@ class CassandraDB(VectorStoreBase):
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.keyspace = _validate_identifier(keyspace, "keyspace")
|
||||
self.collection_name = _validate_identifier(collection_name, "collection_name")
|
||||
self.keyspace = keyspace
|
||||
self.collection_name = collection_name
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.secure_connect_bundle = secure_connect_bundle
|
||||
self.protocol_version = protocol_version
|
||||
@@ -168,7 +156,7 @@ class CassandraDB(VectorStoreBase):
|
||||
vector_size (int, optional): Vector dimension (uses self.embedding_model_dims if not provided)
|
||||
distance (str): Distance metric (cosine, euclidean, dot_product)
|
||||
"""
|
||||
table_name = _validate_identifier(name, "table_name") if name else self.collection_name
|
||||
table_name = name or self.collection_name
|
||||
dims = vector_size or self.embedding_model_dims
|
||||
|
||||
try:
|
||||
@@ -387,10 +375,12 @@ class CassandraDB(VectorStoreBase):
|
||||
List[str]: List of collection names
|
||||
"""
|
||||
try:
|
||||
prepared = self.session.prepare(
|
||||
"SELECT table_name FROM system_schema.tables WHERE keyspace_name = ?"
|
||||
)
|
||||
rows = self.session.execute(prepared, (self.keyspace,))
|
||||
query = f"""
|
||||
SELECT table_name
|
||||
FROM system_schema.tables
|
||||
WHERE keyspace_name = '{self.keyspace}'
|
||||
"""
|
||||
rows = self.session.execute(query)
|
||||
return [row.table_name for row in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}")
|
||||
|
||||
@@ -7,7 +7,6 @@ from pydantic import BaseModel
|
||||
|
||||
# Try to import psycopg (psycopg3) first, then fall back to psycopg2
|
||||
try:
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Json
|
||||
from psycopg_pool import ConnectionPool
|
||||
PSYCOPG_VERSION = 3
|
||||
@@ -15,7 +14,6 @@ try:
|
||||
logger.info("Using psycopg (psycopg3) with ConnectionPool for PostgreSQL connections")
|
||||
except ImportError:
|
||||
try:
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extras import Json, execute_values
|
||||
from psycopg2.pool import ThreadedConnectionPool as ConnectionPool
|
||||
PSYCOPG_VERSION = 2
|
||||
@@ -146,10 +144,6 @@ class PGVector(VectorStoreBase):
|
||||
cur.close()
|
||||
self.connection_pool.putconn(conn)
|
||||
|
||||
def _col(self) -> "sql.Identifier":
|
||||
"""Return a safely-quoted SQL identifier for the collection table."""
|
||||
return sql.Identifier(self.collection_name)
|
||||
|
||||
def create_col(self) -> None:
|
||||
"""
|
||||
Create a new collection (table in PostgreSQL).
|
||||
@@ -158,45 +152,39 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
CREATE TABLE IF NOT EXISTS {} (
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.collection_name} (
|
||||
id UUID PRIMARY KEY,
|
||||
vector vector({}),
|
||||
vector vector({self.embedding_model_dims}),
|
||||
payload JSONB
|
||||
);
|
||||
""").format(self._col(), sql.Literal(self.embedding_model_dims))
|
||||
"""
|
||||
)
|
||||
if self.use_diskann and self.embedding_model_dims < 2000:
|
||||
cur.execute("SELECT * FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
if cur.fetchone():
|
||||
# Create DiskANN index if extension is installed for faster search
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_diskann_idx
|
||||
ON {self.collection_name}
|
||||
USING diskann (vector);
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_diskann_idx"),
|
||||
self._col(),
|
||||
)
|
||||
"""
|
||||
)
|
||||
elif self.use_hnsw:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_hnsw_idx
|
||||
ON {self.collection_name}
|
||||
USING hnsw (vector vector_cosine_ops)
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_hnsw_idx"),
|
||||
self._col(),
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
CREATE INDEX IF NOT EXISTS {} ON {}
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS {self.collection_name}_text_lemmatized_idx
|
||||
ON {self.collection_name}
|
||||
USING gin(to_tsvector('simple', payload->>'text_lemmatized'));
|
||||
""").format(
|
||||
sql.Identifier(f"{self.collection_name}_text_lemmatized_idx"),
|
||||
self._col(),
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def insert(self, vectors: list[list[float]], payloads=None, ids=None) -> None:
|
||||
@@ -207,14 +195,14 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.executemany(
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES (%s, %s, %s)").format(self._col()),
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (%s, %s, %s)",
|
||||
data,
|
||||
)
|
||||
else:
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
execute_values(
|
||||
cur,
|
||||
sql.SQL("INSERT INTO {} (id, vector, payload) VALUES %s").format(self._col()),
|
||||
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES %s",
|
||||
data,
|
||||
)
|
||||
|
||||
@@ -245,17 +233,17 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
f"""
|
||||
SELECT id, vector <=> %s::vector AS distance, payload
|
||||
FROM {}
|
||||
{}
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
ORDER BY distance
|
||||
LIMIT %s
|
||||
""").format(self._col(), filter_clause),
|
||||
""",
|
||||
(vectors, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -282,19 +270,21 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = sql.SQL("AND " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
filter_clause = ""
|
||||
if filter_conditions:
|
||||
filter_clause = "AND " + " AND ".join(filter_conditions)
|
||||
|
||||
try:
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
f"""
|
||||
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', %s)) AS score, payload
|
||||
FROM {}
|
||||
FROM {self.collection_name}
|
||||
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', %s)
|
||||
{}
|
||||
{filter_clause}
|
||||
ORDER BY score DESC
|
||||
LIMIT %s
|
||||
""").format(self._col(), filter_clause),
|
||||
""",
|
||||
(query, query, *filter_params, top_k),
|
||||
)
|
||||
|
||||
@@ -312,7 +302,7 @@ class PGVector(VectorStoreBase):
|
||||
vector_id (str): ID of the vector to delete.
|
||||
"""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(sql.SQL("DELETE FROM {} WHERE id = %s").format(self._col()), (vector_id,))
|
||||
cur.execute(f"DELETE FROM {self.collection_name} WHERE id = %s", (vector_id,))
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -331,7 +321,7 @@ class PGVector(VectorStoreBase):
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
if vector:
|
||||
cur.execute(
|
||||
sql.SQL("UPDATE {} SET vector = %s WHERE id = %s").format(self._col()),
|
||||
f"UPDATE {self.collection_name} SET vector = %s WHERE id = %s",
|
||||
(vector, vector_id),
|
||||
)
|
||||
if payload:
|
||||
@@ -339,13 +329,13 @@ class PGVector(VectorStoreBase):
|
||||
if PSYCOPG_VERSION == 3:
|
||||
# psycopg3 uses psycopg.types.json.Json
|
||||
cur.execute(
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
else:
|
||||
# psycopg2 uses psycopg2.extras.Json
|
||||
cur.execute(
|
||||
sql.SQL("UPDATE {} SET payload = %s WHERE id = %s").format(self._col()),
|
||||
f"UPDATE {self.collection_name} SET payload = %s WHERE id = %s",
|
||||
(Json(payload), vector_id),
|
||||
)
|
||||
|
||||
@@ -362,7 +352,7 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()),
|
||||
f"SELECT id, vector, payload FROM {self.collection_name} WHERE id = %s",
|
||||
(vector_id,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -384,7 +374,7 @@ class PGVector(VectorStoreBase):
|
||||
def delete_col(self) -> None:
|
||||
"""Delete a collection."""
|
||||
with self._get_cursor(commit=True) as cur:
|
||||
cur.execute(sql.SQL("DROP TABLE IF EXISTS {}").format(self._col()))
|
||||
cur.execute(f"DROP TABLE IF EXISTS {self.collection_name}")
|
||||
|
||||
def col_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -395,14 +385,14 @@ class PGVector(VectorStoreBase):
|
||||
"""
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
f"""
|
||||
SELECT
|
||||
table_name,
|
||||
(SELECT COUNT(*) FROM {}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size({}::regclass))) as total_size
|
||||
(SELECT COUNT(*) FROM {self.collection_name}) as row_count,
|
||||
(SELECT pg_size_pretty(pg_total_relation_size('{self.collection_name}'))) as total_size
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""").format(self._col(), sql.Literal(self.collection_name)),
|
||||
""",
|
||||
(self.collection_name,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
@@ -431,18 +421,17 @@ class PGVector(VectorStoreBase):
|
||||
filter_conditions.append("payload->>%s = %s")
|
||||
filter_params.extend([k, str(v)])
|
||||
|
||||
filter_clause = sql.SQL("WHERE " + " AND ".join(filter_conditions)) if filter_conditions else sql.SQL("")
|
||||
filter_clause = "WHERE " + " AND ".join(filter_conditions) if filter_conditions else ""
|
||||
|
||||
query = f"""
|
||||
SELECT id, vector, payload
|
||||
FROM {self.collection_name}
|
||||
{filter_clause}
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
with self._get_cursor() as cur:
|
||||
cur.execute(
|
||||
sql.SQL("""
|
||||
SELECT id, vector, payload
|
||||
FROM {}
|
||||
{}
|
||||
LIMIT %s
|
||||
""").format(self._col(), filter_clause),
|
||||
(*filter_params, top_k),
|
||||
)
|
||||
cur.execute(query, (*filter_params, top_k))
|
||||
results = cur.fetchall()
|
||||
return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]]
|
||||
|
||||
|
||||
+15
-71
@@ -2,9 +2,7 @@
|
||||
|
||||
Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai).
|
||||
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
|
||||
|
||||
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
|
||||
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant. Enable `autoRecall` and `autoCapture` in config to run this automatically, or use agent tools for explicit control.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -12,24 +10,12 @@ Check your OpenClaw version:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
# OpenClaw 2026.4.25 (aa36ee6)
|
||||
# OpenClaw 2026.4.15 (041266a)
|
||||
```
|
||||
|
||||
| OpenClaw Version | Plugin Support |
|
||||
|------------------|----------------|
|
||||
| `>= 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.
|
||||
| `>= 2026.4.15` | Fully supported |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -64,19 +50,7 @@ Humans should follow the Quick Start below.
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"apiKey": "${MEM0_API_KEY}",
|
||||
"userId": "alice",
|
||||
"skills": {
|
||||
"triage": { "enabled": true },
|
||||
"recall": {
|
||||
"enabled": true,
|
||||
"tokenBudget": 1500,
|
||||
"rerank": true,
|
||||
"keywordSearch": true,
|
||||
"identityAlwaysInclude": true
|
||||
},
|
||||
"dream": { "enabled": true },
|
||||
"domain": "companion"
|
||||
}
|
||||
"userId": "alice"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,24 +182,11 @@ All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open
|
||||
<img src="https://raw.githubusercontent.com/mem0ai/mem0/main/docs/images/openclaw-architecture.png" alt="Architecture" width="800" />
|
||||
</p>
|
||||
|
||||
### Skills Mode (Default)
|
||||
**Auto-Recall** (`autoRecall: true`) — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
|
||||
Enabled automatically during `openclaw mem0 init`. The agent controls memory through three skills:
|
||||
**Auto-Capture** (`autoCapture: true`) — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
|
||||
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
|
||||
- **Dream** — Periodic memory consolidation: merges duplicates, resolves conflicts, and prunes stale entries.
|
||||
|
||||
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
|
||||
### Auto-Recall & Auto-Capture
|
||||
|
||||
When skills mode is not configured, the plugin uses `autoRecall` and `autoCapture` (both enabled by default):
|
||||
|
||||
- **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
|
||||
- **Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
|
||||
|
||||
Set `autoRecall: false` or `autoCapture: false` to disable individually. The agent can also use memory tools (`memory_add`, `memory_search`, etc.) explicitly regardless of these settings.
|
||||
Both are opt-in. Once enabled, they run silently — no prompting, no manual calls required. Without them, the agent can still use memory tools (`memory_add`, `memory_search`, etc.) explicitly.
|
||||
|
||||
### Memory Scopes
|
||||
|
||||
@@ -299,25 +260,10 @@ openclaw mem0 help --json # discover all comma
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `userId` | `string` | OS username | User identifier. All memories scoped to this value. |
|
||||
| `autoRecall` | `boolean` | `true` | Inject relevant memories before each turn. Ignored when `skills` is set. |
|
||||
| `autoCapture` | `boolean` | `true` | Extract and store facts after each turn. Ignored when `skills` is set. |
|
||||
| `autoRecall` | `boolean` | `false` | Inject relevant memories before each turn |
|
||||
| `autoCapture` | `boolean` | `false` | Extract and store facts after each turn |
|
||||
| `topK` | `number` | `5` | Max memories returned per recall |
|
||||
| `searchThreshold` | `number` | `0.1` | Minimum similarity score (0-1) |
|
||||
|
||||
### Skills Mode (Recommended)
|
||||
|
||||
Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` are also `true` by default and work alongside skills mode.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | ---- | ------- | ----------- |
|
||||
| `skills.triage.enabled` | `boolean` | `true` | Enable fact extraction from conversations |
|
||||
| `skills.recall.enabled` | `boolean` | `true` | Enable memory recall before each turn |
|
||||
| `skills.recall.tokenBudget` | `number` | `1500` | Max tokens for injected memories |
|
||||
| `skills.recall.rerank` | `boolean` | `true` | Rerank search results for relevance |
|
||||
| `skills.recall.keywordSearch` | `boolean` | `true` | Augment with keyword-based search |
|
||||
| `skills.recall.identityAlwaysInclude` | `boolean` | `true` | Always include identity memories |
|
||||
| `skills.dream.enabled` | `boolean` | `true` | Enable periodic memory consolidation |
|
||||
| `skills.domain` | `string` | `"companion"` | Domain overlay for triage rules |
|
||||
| `searchThreshold` | `number` | `0.3` | Minimum similarity score (0-1) |
|
||||
|
||||
### Platform Mode
|
||||
|
||||
@@ -360,15 +306,13 @@ To avoid plaintext credentials:
|
||||
- Use env var references: `"apiKey": "${MEM0_API_KEY}"`
|
||||
- Use SecretRef: `"apiKey": {"source": "env", "provider": "default", "id": "MEM0_API_KEY"}`
|
||||
|
||||
### Memory Processing
|
||||
### Auto-Capture & Auto-Recall
|
||||
|
||||
In **skills mode** (default after `openclaw mem0 init`), the agent uses structured protocols (triage, recall, dream) to decide what to store and recall. The built-in `session-memory` hook is disabled to avoid conflicts.
|
||||
Both are **disabled by default** (`false`). When enabled:
|
||||
- `autoCapture`: sends conversation content to your configured backend (cloud or local) after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into agent context
|
||||
|
||||
Without skills, `autoCapture` and `autoRecall` are both enabled by default:
|
||||
- `autoCapture`: sends conversation content to your configured backend after each agent turn
|
||||
- `autoRecall`: queries your memory store before each agent turn and injects results into context
|
||||
|
||||
In platform mode, conversation content is sent to `api.mem0.ai` for processing. Do not use with sensitive data you do not want stored on Mem0 cloud.
|
||||
Do not enable `autoCapture` in platform mode if your conversations contain sensitive data you do not want stored on Mem0 cloud.
|
||||
|
||||
### Persistence Locations
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
readPluginAuth,
|
||||
writePluginAuth,
|
||||
writePluginConfigField,
|
||||
enableSkillsConfig,
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
} from "./config-file.ts";
|
||||
import { jsonOut, jsonErr, redactSecrets } from "./json-helpers.ts";
|
||||
@@ -51,7 +50,6 @@ import {
|
||||
LLM_PROVIDERS, EMBEDDER_PROVIDERS, VECTOR_PROVIDERS,
|
||||
buildOssLlmConfig, buildOssEmbedderConfig, buildOssVectorConfig,
|
||||
validateOssFlags, checkQdrantConnectivity, checkOllamaConnectivity, checkPgConnectivity,
|
||||
collectionNameForDims,
|
||||
} from "./oss-wizard.ts";
|
||||
|
||||
// ============================================================================
|
||||
@@ -215,11 +213,10 @@ function saveLoginConfig(
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey, userId, mode: "platform", ...(userEmail && { userEmail }) });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: platform (skills enabled)`);
|
||||
console.log(` Mode: platform`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -229,11 +226,10 @@ function saveOssConfig(userIdFlag?: string, silent?: boolean): void {
|
||||
const userId = resolveUserId(userIdFlag, existingAuth.userId);
|
||||
|
||||
writePluginAuth({ apiKey: "", userId, mode: "open-source" });
|
||||
enableSkillsConfig(userId);
|
||||
|
||||
if (!silent) {
|
||||
console.log(` Configuration saved to ${OPENCLAW_CONFIG_FILE}`);
|
||||
console.log(` Mode: open-source (skills enabled)`);
|
||||
console.log(` Mode: open-source`);
|
||||
console.log(` User ID: ${userId}`);
|
||||
}
|
||||
}
|
||||
@@ -354,17 +350,6 @@ async function runOssWizardInteractive(
|
||||
}
|
||||
|
||||
const vecCfg = buildOssVectorConfig(vecDef.id, vecInput as any);
|
||||
|
||||
// Warn if switching embedder dimensions — old collection will have wrong vector size
|
||||
const existingVecCfg = existingAuth as any;
|
||||
const oldDims = existingVecCfg?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
if (oldDims && dims && oldDims !== dims) {
|
||||
console.log(`\n ⚠ Dimension change detected: ${oldDims} → ${dims}`);
|
||||
console.log(` Old collection had ${oldDims}-dim vectors. New embedder produces ${dims}-dim vectors.`);
|
||||
console.log(` A new collection "${collectionNameForDims(dims)}" will be created.`);
|
||||
console.log(` Old memories in the previous collection will NOT be accessible with the new embedder.\n`);
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
|
||||
// === Step 4: User ID ===
|
||||
@@ -385,8 +370,6 @@ async function runOssWizardInteractive(
|
||||
console.log(` LLM: ${llmDef.id} (${llmCfg.config.model})`);
|
||||
console.log(` Embedder: ${embDef.id} (${embCfg.config.model})`);
|
||||
console.log(` Vector: ${vecDef.id} (${vecDef.id === "qdrant" ? vecCfg.config.url : vecCfg.config.host})`);
|
||||
console.log(` Dims: ${dims ?? "unknown"}`);
|
||||
console.log(` Collection:${dims ? " " + collectionNameForDims(dims) : " (default)"}`);
|
||||
console.log(` User ID: ${userIdValue}`);
|
||||
console.log("");
|
||||
console.log(" Run: openclaw gateway restart");
|
||||
@@ -556,15 +539,6 @@ export function registerCliCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on dimension change
|
||||
const prevAuth = readPluginAuth() as any;
|
||||
const prevDims = prevAuth?.oss?.vectorStore?.config?.dimension as number | undefined;
|
||||
const newDims = dims;
|
||||
let dimWarning: string | undefined;
|
||||
if (prevDims && newDims && prevDims !== newDims) {
|
||||
dimWarning = `Dimension change: ${prevDims} → ${newDims}. New collection "${collectionNameForDims(newDims)}" will be used. Old memories not accessible with new embedder.`;
|
||||
}
|
||||
|
||||
writePluginConfigField(["oss", "llm"], llmCfg);
|
||||
writePluginConfigField(["oss", "embedder"], { provider: embCfg.provider, config: embCfg.config });
|
||||
writePluginConfigField(["oss", "vectorStore"], vecCfg);
|
||||
@@ -576,10 +550,9 @@ export function registerCliCommands(
|
||||
mode: "open-source",
|
||||
config: {
|
||||
llm: { provider: llmCfg.provider, model: llmCfg.config.model },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model, dims: newDims },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }), collectionName: newDims ? collectionNameForDims(newDims) : undefined },
|
||||
embedder: { provider: embCfg.provider, model: embCfg.config.model },
|
||||
vectorStore: { provider: vecCfg.provider, ...(vecId === "qdrant" ? { url: vecCfg.config.url } : { host: vecCfg.config.host }) },
|
||||
},
|
||||
...(dimWarning && { warning: dimWarning }),
|
||||
userId: resolveUserId(opts.userId, existingAuth.userId),
|
||||
message: "Open-source mode configured. Restart the gateway: openclaw gateway restart",
|
||||
};
|
||||
@@ -916,7 +889,7 @@ export function registerCliCommands(
|
||||
runId?: string,
|
||||
): SearchOptions => {
|
||||
const base = buildSearchOptions(userIdOverride, lim, runId);
|
||||
base.threshold = 0.1;
|
||||
base.threshold = 0.3;
|
||||
return base;
|
||||
};
|
||||
|
||||
|
||||
+78
-78
@@ -19,6 +19,7 @@ export const OPENCLAW_CONFIG_FILE = join(OPENCLAW_CONFIG_DIR, "openclaw.json");
|
||||
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
|
||||
|
||||
const PLUGIN_ID = "openclaw-mem0";
|
||||
const NPM_PACKAGE = "@mem0/openclaw-mem0";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -75,44 +76,11 @@ function readFullConfig(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the full ~/.openclaw/openclaw.json.
|
||||
*
|
||||
* Re-reads the file immediately before writing and deep-merges the
|
||||
* `plugins` section so that fields written by other processes (e.g.
|
||||
* OpenClaw gateway adding `installs`, `slots`) are not clobbered.
|
||||
*/
|
||||
/** Write the full ~/.openclaw/openclaw.json (preserves all non-plugin config) */
|
||||
function writeFullConfig(config: Record<string, unknown>): void {
|
||||
if (!exists(OPENCLAW_CONFIG_DIR)) {
|
||||
mkdirp(OPENCLAW_CONFIG_DIR, 0o700);
|
||||
}
|
||||
|
||||
if (exists(OPENCLAW_CONFIG_FILE)) {
|
||||
try {
|
||||
const diskText = readText(OPENCLAW_CONFIG_FILE);
|
||||
if (diskText.trim()) {
|
||||
const disk = JSON.parse(diskText) as Record<string, unknown>;
|
||||
const diskPlugins = disk.plugins as Record<string, unknown> | undefined;
|
||||
const ourPlugins = config.plugins as Record<string, unknown> | undefined;
|
||||
if (diskPlugins && ourPlugins) {
|
||||
const OPENCLAW_MANAGED = ["installs", "slots"];
|
||||
for (const key of OPENCLAW_MANAGED) {
|
||||
if (key in diskPlugins) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(diskPlugins)) {
|
||||
if (!(key in ourPlugins)) {
|
||||
ourPlugins[key] = diskPlugins[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// disk unreadable — write our version as-is
|
||||
}
|
||||
}
|
||||
|
||||
writeText(
|
||||
OPENCLAW_CONFIG_FILE,
|
||||
JSON.stringify(config, null, 2),
|
||||
@@ -154,6 +122,82 @@ export function writePluginAuth(auth: PluginAuthConfig): void {
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the plugin has a valid install record and is in plugins.allow.
|
||||
*
|
||||
* OpenClaw's `plugins update` command requires a `plugins.installs.<id>`
|
||||
* record with `source: "npm"` and `spec` to know how to update. Without
|
||||
* this, `openclaw plugins update` prints "No install record" and skips.
|
||||
*
|
||||
* Similarly, if `plugins.allow` exists as an array, the plugin ID must
|
||||
* be in it or OpenClaw treats the plugin as untrusted.
|
||||
*
|
||||
* This is safe to call multiple times — it only writes missing fields.
|
||||
*/
|
||||
export function ensureInstallRecord(): void {
|
||||
try {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
const entry = full?.plugins?.entries?.[PLUGIN_ID];
|
||||
const record = full?.plugins?.installs?.[PLUGIN_ID];
|
||||
const allow = full?.plugins?.allow;
|
||||
const specPinned = record?.spec && /\d+\.\d+\.\d+/.test(record.spec);
|
||||
if (
|
||||
entry?.enabled === true &&
|
||||
record?.source &&
|
||||
record?.spec &&
|
||||
!specPinned &&
|
||||
Array.isArray(allow) &&
|
||||
allow.includes(PLUGIN_ID)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensurePluginStructure(full);
|
||||
|
||||
let changed = false;
|
||||
|
||||
// Ensure install record exists for `openclaw plugins update` support
|
||||
if (!full.plugins.installs) full.plugins.installs = {};
|
||||
if (!full.plugins.installs[PLUGIN_ID]) {
|
||||
full.plugins.installs[PLUGIN_ID] = {
|
||||
source: "npm",
|
||||
spec: `${NPM_PACKAGE}@latest`,
|
||||
resolvedName: NPM_PACKAGE,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
changed = true;
|
||||
} else {
|
||||
const record = full.plugins.installs[PLUGIN_ID];
|
||||
if (!record.source) {
|
||||
record.source = "npm";
|
||||
changed = true;
|
||||
}
|
||||
if (!record.spec || /\d+\.\d+\.\d+/.test(record.spec)) {
|
||||
record.spec = record.source === "clawhub"
|
||||
? `clawhub:${NPM_PACKAGE}`
|
||||
: `${NPM_PACKAGE}@latest`;
|
||||
changed = true;
|
||||
}
|
||||
if (!record.resolvedName) {
|
||||
record.resolvedName = NPM_PACKAGE;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(full.plugins.allow)) {
|
||||
full.plugins.allow = [PLUGIN_ID];
|
||||
changed = true;
|
||||
} else if (!full.plugins.allow.includes(PLUGIN_ID)) {
|
||||
full.plugins.allow.push(PLUGIN_ID);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) writeFullConfig(full);
|
||||
} catch {
|
||||
// Best-effort — don't break plugin loading if config is unreadable
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the nested plugin entry structure exists in the config object. */
|
||||
function ensurePluginStructure(full: any): void {
|
||||
@@ -187,50 +231,6 @@ export function writePluginConfigField(
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default skills configuration — matches configure.py output.
|
||||
* Enables triage, recall (with reranking), and dream consolidation.
|
||||
*/
|
||||
const DEFAULT_SKILLS_CONFIG = {
|
||||
triage: { enabled: true },
|
||||
recall: {
|
||||
enabled: true,
|
||||
tokenBudget: 1500,
|
||||
rerank: true,
|
||||
keywordSearch: true,
|
||||
identityAlwaysInclude: true,
|
||||
},
|
||||
dream: { enabled: true },
|
||||
domain: "companion",
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable skills-mode config after onboarding.
|
||||
*
|
||||
* Sets skills config on the plugin entry, tools.profile = "full",
|
||||
* and disables the built-in session-memory hook to avoid conflicts.
|
||||
* Preserves any existing skills config if already set.
|
||||
*/
|
||||
export function enableSkillsConfig(userId: string): void {
|
||||
const full = readFullConfig() as any;
|
||||
ensurePluginStructure(full);
|
||||
|
||||
const cfg = full.plugins.entries[PLUGIN_ID].config;
|
||||
if (!cfg.skills) {
|
||||
cfg.skills = { ...DEFAULT_SKILLS_CONFIG };
|
||||
}
|
||||
|
||||
if (!full.tools) full.tools = {};
|
||||
full.tools.profile = "full";
|
||||
|
||||
if (!full.hooks) full.hooks = {};
|
||||
if (!full.hooks.internal) full.hooks.internal = {};
|
||||
if (!full.hooks.internal.entries) full.hooks.internal.entries = {};
|
||||
full.hooks.internal.entries["session-memory"] = { enabled: false };
|
||||
|
||||
writeFullConfig(full);
|
||||
}
|
||||
|
||||
/** Get the configured base URL from openclaw.json or default */
|
||||
export function getBaseUrl(): string {
|
||||
const auth = readPluginAuth();
|
||||
|
||||
@@ -56,15 +56,8 @@ export const KNOWN_EMBEDDER_DIMS: Record<string, number> = {
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
"nomic-embed-text": 768,
|
||||
"mxbai-embed-large": 1024,
|
||||
"all-minilm": 384,
|
||||
"snowflake-arctic-embed": 1024,
|
||||
};
|
||||
|
||||
export function collectionNameForDims(dims: number): string {
|
||||
return `mem0_${dims}d`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config builders
|
||||
// ============================================================================
|
||||
@@ -112,8 +105,7 @@ export function buildOssEmbedderConfig(
|
||||
config.url = input.url || def.defaultUrl;
|
||||
}
|
||||
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? def.defaultDims;
|
||||
if (dims) config.embeddingDims = dims;
|
||||
const dims = KNOWN_EMBEDDER_DIMS[model] ?? undefined;
|
||||
return { provider: providerId, config, dims };
|
||||
}
|
||||
|
||||
@@ -146,11 +138,7 @@ export function buildOssVectorConfig(
|
||||
config.dbname = input.dbname || "postgres";
|
||||
}
|
||||
|
||||
if (input.dims) {
|
||||
config.dimension = input.dims;
|
||||
config.embeddingModelDims = input.dims;
|
||||
config.collectionName = collectionNameForDims(input.dims);
|
||||
}
|
||||
if (input.dims) config.dimension = input.dims;
|
||||
return { provider: providerId, config };
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -231,8 +231,8 @@ export const mem0ConfigSchema = {
|
||||
return "default";
|
||||
}
|
||||
})(),
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
autoCapture: cfg.autoCapture === true,
|
||||
autoRecall: cfg.autoRecall === true,
|
||||
// v3.0.0: customPrompt renamed to customInstructions (backwards-compat: accept either)
|
||||
customInstructions:
|
||||
typeof cfg.customInstructions === "string"
|
||||
@@ -247,7 +247,7 @@ export const mem0ConfigSchema = {
|
||||
? (cfg.customCategories as Record<string, string>)
|
||||
: DEFAULT_CUSTOM_CATEGORIES,
|
||||
searchThreshold:
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.1,
|
||||
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
|
||||
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
|
||||
needsSetup,
|
||||
oss: ossConfig,
|
||||
|
||||
@@ -561,52 +561,3 @@ What is the deployment plan?`,
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-recall threshold filtering
|
||||
// The recall hook in index.ts filters search results using cfg.searchThreshold.
|
||||
// These tests verify the threshold is honored and no hardcoded floor overrides it.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("auto-recall threshold respects cfg.searchThreshold", () => {
|
||||
const typicalV3Results = [
|
||||
{ id: "1", score: 0.553, memory: "User prefers dark mode" },
|
||||
{ id: "2", score: 0.496, memory: "User works on mem0 project" },
|
||||
{ id: "3", score: 0.471, memory: "User likes TypeScript" },
|
||||
{ id: "4", score: 0.45, memory: "User's timezone is PST" },
|
||||
{ id: "5", score: 0.42, memory: "User uses VS Code" },
|
||||
{ id: "6", score: 0.35, memory: "User mentioned family trip" },
|
||||
];
|
||||
|
||||
function applyThresholdFilter(
|
||||
results: typeof typicalV3Results,
|
||||
searchThreshold: number,
|
||||
) {
|
||||
return results.filter((r) => (r.score ?? 0) >= searchThreshold);
|
||||
}
|
||||
|
||||
it("default 0.5 threshold returns results scoring >= 0.5", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.5);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("threshold 0.4 returns results scoring >= 0.4", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.4);
|
||||
expect(filtered).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("threshold 0.3 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.3);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("threshold 0.6 correctly filters everything below", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0.6);
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("threshold 0 returns all results", () => {
|
||||
const filtered = applyThresholdFilter(typicalV3Results, 0);
|
||||
expect(filtered).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-55
@@ -54,12 +54,15 @@ import {
|
||||
import { PlatformBackend } from "./backend/platform.ts";
|
||||
import type { Backend } from "./backend/base.ts";
|
||||
import { registerCliCommands } from "./cli/commands.ts";
|
||||
import { readPluginAuth } from "./cli/config-file.ts";
|
||||
import { readPluginAuth, ensureInstallRecord } from "./cli/config-file.ts";
|
||||
import { registerAllTools } from "./tools/index.ts";
|
||||
import type { ToolDeps } from "./tools/index.ts";
|
||||
import { captureEvent } from "./telemetry.ts";
|
||||
import { bootstrapTelemetryFlag } from "./fs-safe.ts";
|
||||
|
||||
bootstrapTelemetryFlag();
|
||||
ensureInstallRecord();
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports (for tests and external consumers)
|
||||
// ============================================================================
|
||||
@@ -97,8 +100,6 @@ const memoryPlugin = definePluginEntry({
|
||||
description: "Mem0 memory backend — Mem0 platform or self-hosted open-source",
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
bootstrapTelemetryFlag();
|
||||
|
||||
// Read auth from openclaw.json plugin config (picks up post-startup login).
|
||||
// This is the single source of truth — set via `openclaw mem0 login`.
|
||||
const pluginAuth = readPluginAuth();
|
||||
@@ -206,57 +207,8 @@ const memoryPlugin = definePluginEntry({
|
||||
},
|
||||
effectiveUserId: _effectiveUserId,
|
||||
}),
|
||||
runtime: {
|
||||
async getMemorySearchManager(_params: any) {
|
||||
try {
|
||||
const userId = _effectiveUserId();
|
||||
let memoryCount = 0;
|
||||
try {
|
||||
const memories = await provider.getAll({
|
||||
user_id: userId,
|
||||
page_size: 1,
|
||||
source: "OPENCLAW",
|
||||
});
|
||||
memoryCount = Array.isArray(memories) ? memories.length : 0;
|
||||
} catch {
|
||||
// Non-fatal: status still works without count
|
||||
}
|
||||
return {
|
||||
manager: {
|
||||
status() {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
files: 0,
|
||||
chunks: memoryCount,
|
||||
dirty: false,
|
||||
workspaceDir: pluginStateDir ?? "",
|
||||
userId,
|
||||
};
|
||||
},
|
||||
async probeEmbeddingAvailability() {
|
||||
return { ok: true };
|
||||
},
|
||||
async close() {},
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
manager: null,
|
||||
error: `mem0 ${cfg.mode} backend unavailable: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
resolveMemoryBackendConfig(_params: any) {
|
||||
return {
|
||||
backend: cfg.mode,
|
||||
baseUrl: cfg.baseUrl ?? "https://api.mem0.ai",
|
||||
userId: cfg.userId,
|
||||
};
|
||||
},
|
||||
async closeAllMemorySearchManagers() {},
|
||||
},
|
||||
});
|
||||
api.logger.debug("openclaw-mem0: memory capability + runtime registered");
|
||||
api.logger.debug("openclaw-mem0: publicArtifacts capability registered");
|
||||
}
|
||||
|
||||
// Helper: build add options
|
||||
@@ -729,8 +681,12 @@ function registerHooks(
|
||||
),
|
||||
);
|
||||
|
||||
// Client-side threshold filter for auto-recall — use a stricter
|
||||
// threshold (0.6) than explicit tool searches (0.5) to avoid
|
||||
// injecting irrelevant memories into agent context
|
||||
const recallThreshold = Math.max(cfg.searchThreshold, 0.6);
|
||||
longTermResults = longTermResults.filter(
|
||||
(r) => (r.score ?? 0) >= cfg.searchThreshold,
|
||||
(r) => (r.score ?? 0) >= recallThreshold,
|
||||
);
|
||||
|
||||
// Dynamic thresholding: drop memories scoring less than 50% of
|
||||
@@ -753,7 +709,7 @@ function registerHooks(
|
||||
undefined,
|
||||
recallSessionKey,
|
||||
);
|
||||
broadOpts.threshold = cfg.searchThreshold;
|
||||
broadOpts.threshold = 0.5;
|
||||
const broadResults = await provider.search(
|
||||
"recent decisions, preferences, active projects, and configuration",
|
||||
broadOpts,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "openclaw-mem0",
|
||||
"name": "Memory (Mem0)",
|
||||
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.10",
|
||||
"kind": "memory",
|
||||
"skills": ["skills"],
|
||||
"commandAliases": [
|
||||
@@ -17,17 +17,9 @@
|
||||
"memory_update", "memory_delete", "memory_event_list", "memory_event_status"
|
||||
]
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "mem0",
|
||||
"envVars": ["MEM0_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "openclaw-mem0-oss",
|
||||
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
}
|
||||
]
|
||||
"providerAuthEnvVars": {
|
||||
"mem0": ["MEM0_API_KEY"],
|
||||
"openclaw-mem0-oss": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
@@ -179,13 +171,13 @@
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, extracts durable facts after each agent turn. Enabled by default. Ignored in skills mode."
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, extracts durable facts after each agent turn. Disabled by default."
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, injects relevant memories before each agent turn. Enabled by default. Ignored in skills mode."
|
||||
"default": false,
|
||||
"description": "Opt-in. When true, injects relevant memories before each agent turn. Disabled by default."
|
||||
},
|
||||
"customInstructions": {
|
||||
"type": "string"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user