From 2acf9571b3b0145cb1329a663319b2202cd94beb Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Wed, 1 Apr 2026 21:04:59 +0530 Subject: [PATCH] feat(cli): add event commands, --json/--agent flag, agent output sanitization, and edge-case hardening for CLI SDKs (#4649) Co-authored-by: Claude Sonnet 4.6 --- cli/CLI_SPECIFICATION.md | 190 ++++++- cli/README.md | 44 +- cli/cli-spec.json | 3 +- cli/node/src/backend/base.ts | 4 + cli/node/src/backend/platform.ts | 62 +- cli/node/src/branding.ts | 33 +- cli/node/src/commands/config.ts | 32 +- cli/node/src/commands/entities.ts | 50 +- cli/node/src/commands/events.ts | 169 ++++++ cli/node/src/commands/init.ts | 71 ++- cli/node/src/commands/memory.ts | 163 +++++- cli/node/src/commands/utils.ts | 24 +- cli/node/src/help.ts | 2 +- cli/node/src/index.ts | 138 ++++- cli/node/src/output.ts | 127 ++++- cli/node/src/state.ts | 23 + cli/node/tests/commands.test.ts | 220 ++++++++ cli/node/tests/output.test.ts | 83 +++ cli/node/tests/setup.ts | 37 ++ cli/python/src/mem0_cli/app.py | 152 ++++- cli/python/src/mem0_cli/backend/base.py | 6 + cli/python/src/mem0_cli/backend/platform.py | 46 +- cli/python/src/mem0_cli/branding.py | 50 +- .../src/mem0_cli/commands/config_cmd.py | 42 +- cli/python/src/mem0_cli/commands/entities.py | 81 ++- .../src/mem0_cli/commands/events_cmd.py | 176 ++++++ cli/python/src/mem0_cli/commands/init_cmd.py | 59 +- cli/python/src/mem0_cli/commands/memory.py | 211 ++++++- cli/python/src/mem0_cli/commands/utils.py | 35 +- cli/python/src/mem0_cli/output.py | 109 +++- cli/python/src/mem0_cli/state.py | 24 + cli/python/tests/conftest.py | 37 ++ cli/python/tests/test_commands.py | 529 ++++++++++++++++-- cli/python/tests/test_output.py | 146 +++++ docs/platform/cli.mdx | 104 +++- 35 files changed, 2941 insertions(+), 341 deletions(-) create mode 100644 cli/node/src/commands/events.ts create mode 100644 cli/node/src/state.ts create mode 100644 cli/python/src/mem0_cli/commands/events_cmd.py create mode 100644 cli/python/src/mem0_cli/state.py diff --git a/cli/CLI_SPECIFICATION.md b/cli/CLI_SPECIFICATION.md index 171fd280e..a243e833c 100644 --- a/cli/CLI_SPECIFICATION.md +++ b/cli/CLI_SPECIFICATION.md @@ -28,7 +28,7 @@ mem0 CLI is the official command-line interface for [mem0](https://mem0.ai) -- t ### Who is it for? - Developers integrating mem0 into their workflows -- AI agents that need persistent memory (the CLI is designed with `--output json` and `help --json` specifically for machine consumption) +- AI agents that need persistent memory (the CLI is designed with `--json`/`--agent` global flags and `help --json` specifically for machine consumption) - DevOps/CI pipelines that need to manage memories programmatically ### Project Structure @@ -79,6 +79,7 @@ Apache-2.0 │ ├── init_cmd.py # run_init (interactive wizard) │ ├── config_cmd.py # cmd_config_show, cmd_config_get, cmd_config_set │ ├── entities.py # cmd_entities_list, cmd_entities_delete +│ ├── events_cmd.py # cmd_event_list, cmd_event_status │ └── utils.py # cmd_status, cmd_version, cmd_import └── node/ ├── package.json # Node package config (tsup build) @@ -88,6 +89,7 @@ Apache-2.0 ├── config.ts # Config loading/saving, env var overrides ├── branding.ts # Colors, icons, banner, timedStatus, print helpers ├── output.ts # Output formatting (text, json, table, quiet) + ├── state.ts # Agent mode flag (setAgentMode, isAgentMode) ├── help.ts # Rich-style help formatter (panels, command ordering) ├── backend/ │ ├── index.ts # Re-exports @@ -98,6 +100,7 @@ Apache-2.0 ├── init.ts # runInit (interactive wizard) ├── config.ts # cmdConfigShow, cmdConfigGet, cmdConfigSet ├── entities.ts # cmdEntitiesList, cmdEntitiesDelete + ├── events.ts # cmdEventList, cmdEventStatus └── utils.ts # cmdStatus, cmdVersion, cmdImport ``` @@ -155,9 +158,15 @@ Interactive setup wizard for mem0 CLI. | `-u, --user-id` | string | No | - | Default user ID (skip prompt). | | `--email` | string | No | - | Login via email verification code. | | `--code` | string | No | - | Verification code (use with --email for non-interactive login). | +| `--force` | bool | No | false | Overwrite existing config without confirmation. | **Behavior:** +*Existing config protection:* +- If `~/.mem0/config.json` exists with an API key, the CLI warns and asks for confirmation before overwriting. +- In non-TTY mode, this is a hard error unless `--force` is passed. +- `--force` skips the confirmation in both TTY and non-TTY modes. + *Email login flow (when `--email` is provided):* - Sends a 6-digit verification code to the email via `POST /api/v1/auth/email_code/`. - If `--code` is also provided, verifies immediately (fully non-interactive). @@ -175,6 +184,7 @@ Interactive setup wizard for mem0 CLI. ```bash mem0 init mem0 init --api-key m0-xxx --user-id alice +mem0 init --api-key m0-xxx --user-id alice --force mem0 init --email alice@company.com mem0 init --email alice@company.com --code 482901 ``` @@ -700,7 +710,114 @@ mem0 entity delete --user-id alice --dry-run --- -### 3.14 `status` +### 3.14 `event list` + +List recent background processing events. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 event list [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | text (table), json | +| Default output | table | +| API endpoint | `GET /v1/events/` | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|----------------|--------|---------|------------|------| +| `-o, --output` | string | "table" | Output | Output: text, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Behavior:** Fetches all background events for the project. Displays as a table with columns: Event ID (first 8 chars), Type, Status (color-coded), Latency, Created. Status values: `PENDING` (accent), `SUCCEEDED` (green), `FAILED` (red), `PROCESSING` (yellow). + +**JSON output envelope:** +```json +{ + "status": "success", + "command": "event list", + "count": 3, + "duration_ms": 87, + "data": [ + { "id": "evt-abc", "event_type": "ADD", "status": "SUCCEEDED", "latency": 412.0, "created_at": "2026-01-01T10:00:00Z" } + ] +} +``` + +**Examples:** +```bash +mem0 event list +mem0 event list --output json +``` + +--- + +### 3.15 `event status` + +Get the status and results of a specific background event. + +| Property | Value | +|------------------|-------| +| Usage | `mem0 event status [OPTIONS]` | +| needsBackend | Yes | +| needsConfig | Yes | +| resolveIds | No | +| resolveGraph | No | +| confirmDangerous | No | +| Output formats | text, json | +| Default output | text | +| API endpoint | `GET /v1/events/{event_id}/` | + +**Arguments:** + +| Name | Type | Required | Help | +|------------|--------|----------|------| +| `event_id` | string | Yes | Event ID to inspect. | + +**Options:** + +| Flag | Type | Default | Panel | Help | +|----------------|--------|---------|------------|------| +| `-o, --output` | string | "text" | Output | Output: text, json. | +| `--api-key` | string | - | Connection | Override API key. | +| `--base-url` | string | - | Connection | Override API base URL. | + +**Behavior:** Fetches the event by ID and displays: Event ID, Type, Status (color-coded), Latency, Created, Updated, and a numbered list of result memories (event type, memory text, user_id, truncated memory ID). Displayed in a boxed panel (text) or JSON envelope. + +**JSON output envelope:** +```json +{ + "status": "success", + "command": "event status", + "duration_ms": 65, + "data": { + "id": "evt-abc", + "event_type": "ADD", + "status": "SUCCEEDED", + "latency": 412.0, + "created_at": "2026-01-01T10:00:00Z", + "updated_at": "2026-01-01T10:00:01Z", + "results": [ + { "id": "mem-xyz", "event": "ADD", "user_id": "alice", "memory": "User prefers dark mode" } + ] + } +} +``` + +**Examples:** +```bash +mem0 event status evt-abc-123 +mem0 event status evt-abc-123 --output json +``` + +--- + +### 3.16 `status` Check connectivity and authentication. @@ -721,20 +838,19 @@ Check connectivity and authentication. | `--api-key` | string | - | Connection | Override API key. | | `--base-url` | string | - | Connection | Override API base URL. | -**Behavior:** If config has a default `user_id` or `agent_id`, validates by making a minimal `POST /v2/memories/` with `page=1&page_size=1`. Otherwise validates via `GET /v1/entities/`. Displays connection status in a boxed panel (text) or JSON envelope. +**Behavior:** Validates connectivity by calling `GET /v1/ping/`. Displays connection status in a boxed panel (text) or JSON envelope. The ping endpoint is lightweight and does not require any entity scope. **JSON output:** ```json { "status": "success", "command": "status", + "duration_ms": 112, "data": { "connected": true, "backend": "platform", - "base_url": "https://api.mem0.ai", - "latency_ms": 245 - }, - "duration_ms": 245 + "base_url": "https://api.mem0.ai" + } } ``` @@ -746,7 +862,7 @@ mem0 status -o json --- -### 3.15 `help` +### 3.17 `help` Show help. Use `--json` for machine-readable output (for LLM agents). @@ -806,6 +922,9 @@ The auth header name is `Authorization` and the scheme is `Token` (not Bearer). | Delete all | `DELETE` | `/v1/memories/` | - | entity ID params | | List entities | `GET` | `/v1/entities/` | - | - | | Delete entities | `DELETE` | `/v1/entities/` | - | entity ID params | +| List events | `GET` | `/v1/events/` | - | - | +| Get event | `GET` | `/v1/events/{event_id}/` | - | - | +| Ping (status) | `GET` | `/v1/ping/` | - | - | ### How Filters Are Built (`_buildFilters` / `_build_filters`) @@ -1108,6 +1227,8 @@ For `PENDING` events, displays "Processing in background" with the event ID. ### 7.1 Supported Modes Per Command +All commands also support `agent` mode via the global `--json`/`--agent` flag, which wraps output in a structured JSON envelope with sanitized fields. + | Command | text | json | table | quiet | |----------------|------|------|-------|-------| | add | Y | Y | - | Y | @@ -1122,12 +1243,16 @@ For `PENDING` events, displays "Processing in background" with the event ID. | config set | (success msg) | - | - | - | | entity list | - | Y | Y (default) | - | | entity delete | Y | Y | - | Y | +| event list | Y (table) | Y | - | - | +| event status | Y | Y | - | - | | status | Y | Y | - | - | | help | Y | Y (--json) | - | - | -### 7.2 JSON Envelope Format (`formatJsonEnvelope`) +### 7.2 JSON Envelope Format -Used by `config show`, `status`, and `import` for structured JSON output: +There are two related envelope formats: + +**`formatJsonEnvelope`** — used by `config show`, `status`, and `import` for `--output json`: ```json { @@ -1141,14 +1266,39 @@ Used by `config show`, `status`, and `import` for structured JSON output: } ``` +**`formatAgentEnvelope`** — used by all commands in agent mode (`--json`/`--agent`). Same structure, but `data` is passed through `sanitizeAgentData(command, data)` to project only the most relevant fields: + +| Command | Fields in `data` | +|---------------|-----------------| +| add | `[{id, memory, event}]` or `[{status, event_id}]` for PENDING | +| search | `[{id, memory, score, created_at, categories}]` | +| list | `[{id, memory, created_at, categories}]` | +| get | `{id, memory, created_at, updated_at, categories, metadata}` | +| update | `{id, memory}` | +| delete | (raw API response) | +| entity list | `[{name, type, count}]` | +| event list | `[{id, event_type, status, latency, created_at}]` | +| event status | `{id, event_type, status, latency, created_at, updated_at, results: [{id, event, user_id, memory}]}` | +| status/config/import | (pass-through) | + +Error envelopes (on non-zero exit): +```json +{ + "status": "error", + "command": "", + "error": "Authentication failed. Your API key may be invalid or expired.", + "data": null +} +``` + Fields: -- `status`: Always `"success"` (errors go to stderr before exit). -- `command`: The command name (e.g. `"status"`, `"config show"`, `"import"`). +- `status`: `"success"` or `"error"`. +- `command`: The command name. - `duration_ms`: Optional, elapsed time in milliseconds. -- `scope`: Optional, active entity scope. +- `scope`: Optional, active entity scope (omitted if empty). - `count`: Optional, result count. -- `error`: Optional, error message string. -- `data`: The primary payload. +- `error`: Only present when `status` is `"error"`. +- `data`: The primary payload (sanitized in agent mode). ### 7.3 Text Output @@ -1219,6 +1369,16 @@ Destructive commands (`delete --all`, `delete --entity`, `entity delete`) requir - CI/CD pipelines - Scripting +### Why `--json`/`--agent` global flags exist + +The `--json` and `--agent` flags (aliases of each other) activate agent mode globally. When set: +1. All output becomes a structured JSON envelope (`{status, command, duration_ms, scope, count, data}`). +2. The `data` field is sanitized via `sanitizeAgentData` — only the most relevant fields are included per command, reducing noise for agents parsing the output. +3. All human-readable output (spinners, colors, banners, timing lines) is suppressed. +4. Errors are emitted as JSON to stdout with a non-zero exit code, not to stderr as text. + +This is distinct from `--output json`, which returns the raw API response without sanitization. + ### Why `--output json` is on every command Every data-returning command supports `--output json` (or `--json` for `help`). This enables machine consumption by AI agents and scripts. JSON output goes to stdout while human-readable spinners/timing go to stderr, so piping `mem0 list -o json | jq .` works cleanly. diff --git a/cli/README.md b/cli/README.md index fbcadd0f2..d411daa65 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,6 +2,8 @@ The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Works with the Mem0 Platform API. Available in Python and Node.js. +> **For AI agents:** pass `--agent` (or `--json`) on any command for structured JSON output purpose-built for tool loops — sanitized fields, no colors or spinners, errors as JSON. See [Agent mode](#agent-mode) below. + ## Installation ```bash @@ -55,12 +57,45 @@ mem0 delete | `mem0 delete` | Delete a memory, all memories for a scope, or an entity | | `mem0 import` | Bulk import memories from a JSON file | | `mem0 config` | View or modify CLI configuration | -| `mem0 entities` | List or delete entities (users, agents, apps) | +| `mem0 entity` | List or delete entities (users, agents, apps, runs) | +| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) | | `mem0 status` | Verify API connection and display current project | | `mem0 version` | Print the CLI version | Run `mem0 --help` for detailed usage on any command. +## Agent mode + +Pass `--agent` (or its alias `--json`) as a **global flag** on any command to get output designed for AI agent tool loops: + +```bash +mem0 --agent search "user preferences" --user-id alice +mem0 --agent add "User prefers dark mode" --user-id alice +mem0 --agent list --user-id alice +``` + +Every command returns the same envelope shape: + +```json +{ + "status": "success", + "command": "search", + "duration_ms": 134, + "scope": { "user_id": "alice" }, + "count": 2, + "data": [ + { "id": "abc-123", "memory": "User prefers dark mode", "score": 0.97, "created_at": "2026-01-15", "categories": ["preferences"] } + ] +} +``` + +What agent mode does differently from `--output json`: +- **Sanitized `data`**: only the fields an agent needs (id, memory, score, etc.) — no internal API noise +- **No human output**: spinners, colors, and banners are suppressed entirely +- **Errors as JSON**: errors go to stdout as `{"status": "error", "command": "...", "error": "..."}` with a non-zero exit code + +Use `mem0 help --json` to get the full command tree as JSON — useful for agents that need to self-discover available commands. + ## Output formats Control how results are displayed with `--output`: @@ -68,13 +103,10 @@ Control how results are displayed with `--output`: | Format | Description | |--------|-------------| | `text` | Human-readable with colors and formatting (default) | -| `json` | Structured JSON for piping to `jq` or agent consumption | +| `json` | Structured JSON for piping to `jq` (raw API response) | | `table` | Tabular format (default for `list`) | | `quiet` | Minimal — just IDs or status codes | - -```bash -mem0 search "preferences" --user-id alice --output json | jq '.data.results[].memory' -``` +| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) | ## Environment variables diff --git a/cli/cli-spec.json b/cli/cli-spec.json index 4b6594093..4826bd1ad 100644 --- a/cli/cli-spec.json +++ b/cli/cli-spec.json @@ -515,7 +515,8 @@ { "name": "api-key", "flags": ["--api-key"], "type": "string", "default": null, "help": "API key (skip prompt)." }, { "name": "user-id", "flags": ["-u", "--user-id"], "type": "string", "default": null, "help": "Default user ID (skip prompt)." }, { "name": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." }, - { "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." } + { "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." }, + { "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." } ] }, { diff --git a/cli/node/src/backend/base.ts b/cli/node/src/backend/base.ts index a573a5974..c4da82f54 100644 --- a/cli/node/src/backend/base.ts +++ b/cli/node/src/backend/base.ts @@ -94,6 +94,10 @@ export interface Backend { >; entities(entityType: string): Promise[]>; + + listEvents(): Promise[]>; + + getEvent(eventId: string): Promise>; } export class AuthError extends Error { diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 8256bc5c0..49a4dbac3 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -264,41 +264,33 @@ export class PlatformBackend implements Backend { } async deleteEntities(opts: EntityIds): Promise> { - const params: Record = {}; - if (opts.userId) params.user_id = opts.userId; - if (opts.agentId) params.agent_id = opts.agentId; - if (opts.appId) params.app_id = opts.appId; - if (opts.runId) params.run_id = opts.runId; - if (Object.keys(params).length === 0) { + // v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/ + const typeMap: [string, string | undefined][] = [ + ["user", opts.userId], + ["agent", opts.agentId], + ["app", opts.appId], + ["run", opts.runId], + ]; + const entities = typeMap.filter(([, v]) => v) as [string, string][]; + if (entities.length === 0) { throw new Error("At least one entity ID is required for deleteEntities."); } - return (await this._request("DELETE", "/v1/entities/", { - params, - })) as Record; + // Delete each provided entity via the v2 path-based endpoint + let result: Record = {}; + for (const [entityType, entityId] of entities) { + result = (await this._request( + "DELETE", + `/v2/entities/${entityType}/${entityId}/`, + )) as Record; + } + return result; } async status( opts: { userId?: string; agentId?: string } = {}, ): Promise> { try { - if (opts.userId || opts.agentId) { - const payload: Record = {}; - const statusParams: Record = { - page: "1", - page_size: "1", - }; - const apiFilters = this._buildFilters({ - userId: opts.userId, - agentId: opts.agentId, - }); - if (apiFilters) payload.filters = apiFilters; - await this._request("POST", "/v2/memories/", { - json: payload, - params: statusParams, - }); - } else { - await this._request("GET", "/v1/entities/"); - } + await this._request("GET", "/v1/ping/"); return { connected: true, backend: "platform", base_url: this.baseUrl }; } catch (e) { return { @@ -335,4 +327,20 @@ export class PlatformBackend implements Backend { } return items; } + + async listEvents(): Promise[]> { + const result = (await this._request("GET", "/v1/events/")) as unknown; + if (Array.isArray(result)) return result; + return ((result as Record).results ?? []) as Record< + string, + unknown + >[]; + } + + async getEvent(eventId: string): Promise> { + return (await this._request("GET", `/v1/event/${eventId}/`)) as Record< + string, + unknown + >; + } } diff --git a/cli/node/src/branding.ts b/cli/node/src/branding.ts index 4025184b0..4aa1b1986 100644 --- a/cli/node/src/branding.ts +++ b/cli/node/src/branding.ts @@ -4,6 +4,7 @@ import chalk from "chalk"; import ora, { type Ora } from "ora"; +import { getCurrentCommand, isAgentMode } from "./state.js"; import { CLI_VERSION } from "./version.js"; export const LOGO = ` @@ -42,6 +43,7 @@ export function sym(fancy: string, plain: string): string { } export function printBanner(): void { + if (isAgentMode()) return; const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2)) const logoLines = LOGO.trimEnd().split("\n"); const tagline = ` ${TAGLINE}`; @@ -75,13 +77,29 @@ export function printBanner(): void { } export function printSuccess(message: string): void { + if (isAgentMode()) return; console.log(`${success(sym("✓", "[ok]"))} ${message}`); } export function printError(message: string, hint?: string): void { + if (isAgentMode()) { + const envelope = { + status: "error", + command: getCurrentCommand(), + error: message, + data: null, + }; + console.log(JSON.stringify(envelope)); + return; + } console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`); - if (hint) { - console.error(` ${dim(hint)}`); + const resolvedHint = + hint ?? + (message.includes("Authentication failed") + ? `Run ${brand("mem0 init")} to reconfigure your API key · https://app.mem0.ai/dashboard/api-keys` + : undefined); + if (resolvedHint) { + console.error(` ${dim(resolvedHint)}`); } } @@ -90,15 +108,16 @@ export function printWarning(message: string): void { } export function printInfo(message: string): void { + if (isAgentMode()) return; console.error(`${brand(sym("◆", "*"))} ${message}`); } export function printScope(ids: Record): void { + if (isAgentMode()) return; const parts: string[] = []; for (const [key, val] of Object.entries(ids)) { if (val) { - const label = key.replace(/_/g, " ").replace("id", "ID").trim(); - parts.push(`${label}=${val}`); + parts.push(`${key}=${val}`); } } if (parts.length > 0) { @@ -119,6 +138,10 @@ export async function timedStatus( message: string, fn: (ctx: TimedStatusContext) => Promise, ): Promise { + if (isAgentMode()) { + const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" }; + return fn(ctx); + } const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" }; const spinner = ora({ text: dim(message), @@ -139,7 +162,7 @@ export async function timedStatus( const elapsed = ((performance.now() - start) / 1000).toFixed(2); spinner.stop(); if (ctx.errorMsg) { - console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`); + printError(`${ctx.errorMsg} (${elapsed}s)`); } throw err; } diff --git a/cli/node/src/commands/config.ts b/cli/node/src/commands/config.ts index 797509ef2..9e11f8608 100644 --- a/cli/node/src/commands/config.ts +++ b/cli/node/src/commands/config.ts @@ -11,15 +11,17 @@ import { saveConfig, setNestedValue, } from "../config.js"; -import { formatJsonEnvelope } from "../output.js"; +import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js"; +import { isAgentMode, setCurrentCommand } from "../state.js"; const { brand, accent, dim } = colors; export function cmdConfigShow(opts: { output?: string } = {}): void { + setCurrentCommand("config show"); const config = loadConfig(); - if (opts.output === "json") { - formatJsonEnvelope({ + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ command: "config show", data: { defaults: { @@ -66,6 +68,7 @@ export function cmdConfigShow(opts: { output?: string } = {}): void { } export function cmdConfigGet(key: string): void { + setCurrentCommand("config get"); const config = loadConfig(); const value = getNestedValue(config, key); @@ -73,20 +76,35 @@ export function cmdConfigGet(key: string): void { printError(`Unknown config key: ${key}`); } else { // Redact secrets - if (key.includes("api_key") || key.split(".").pop() === "key") { - console.log(redactKey(String(value))); + const displayValue = + key.includes("api_key") || key.split(".").pop() === "key" + ? redactKey(String(value)) + : String(value); + if (isAgentMode()) { + formatAgentEnvelope({ + command: "config get", + data: { key, value: displayValue }, + }); } else { - console.log(String(value)); + console.log(displayValue); } } } export function cmdConfigSet(key: string, value: string): void { + setCurrentCommand("config set"); const config = loadConfig(); if (setNestedValue(config, key, value)) { saveConfig(config); const display = key.includes("key") ? redactKey(value) : value; - printSuccess(`${key} = ${display}`); + if (isAgentMode()) { + formatAgentEnvelope({ + command: "config set", + data: { key, value: display }, + }); + } else { + printSuccess(`${key} = ${display}`); + } } else { printError(`Unknown config key: ${key}`); } diff --git a/cli/node/src/commands/entities.ts b/cli/node/src/commands/entities.ts index 35e002711..8b168539e 100644 --- a/cli/node/src/commands/entities.ts +++ b/cli/node/src/commands/entities.ts @@ -12,7 +12,8 @@ import { printSuccess, timedStatus, } from "../branding.js"; -import { formatJson } from "../output.js"; +import { formatAgentEnvelope, formatJson } from "../output.js"; +import { setCurrentCommand } from "../state.js"; const { brand, accent, dim } = colors; @@ -23,6 +24,7 @@ export async function cmdEntitiesList( entityType: string, opts: { output: string }, ): Promise { + setCurrentCommand("entity list"); if (!VALID_TYPES.has(entityType)) { printError( `Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`, @@ -45,8 +47,13 @@ export async function cmdEntitiesList( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { - formatJson(results); + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ + command: "entity list", + data: results, + count: results.length, + durationMs: Math.round(elapsed * 1000), + }); return; } @@ -86,6 +93,12 @@ export async function cmdEntitiesDelete( output: string; }, ): Promise { + setCurrentCommand("entity delete"); + const { isAgentMode } = await import("../state.js"); + if (isAgentMode() && !opts.force) { + printError("Destructive operation requires --force in agent mode."); + process.exit(1); + } if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) { printError( "Provide at least one of --user-id, --agent-id, --app-id, --run-id.", @@ -93,27 +106,20 @@ export async function cmdEntitiesDelete( process.exit(1); } + const scopeParts: string[] = []; + if (opts.userId) scopeParts.push(`user=${opts.userId}`); + if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); + if (opts.appId) scopeParts.push(`app=${opts.appId}`); + if (opts.runId) scopeParts.push(`run=${opts.runId}`); + const scope = scopeParts.join(", "); + if (opts.dryRun) { - const scopeParts: string[] = []; - if (opts.userId) scopeParts.push(`user=${opts.userId}`); - if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); - if (opts.appId) scopeParts.push(`app=${opts.appId}`); - if (opts.runId) scopeParts.push(`run=${opts.runId}`); - printInfo( - `Would delete entity ${scopeParts.join(", ")} and all its memories.`, - ); + printInfo(`Would delete entity ${scope} and all its memories.`); printInfo("No changes made."); return; } if (!opts.force) { - const scopeParts: string[] = []; - if (opts.userId) scopeParts.push(`user=${opts.userId}`); - if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`); - if (opts.appId) scopeParts.push(`app=${opts.appId}`); - if (opts.runId) scopeParts.push(`run=${opts.runId}`); - const scope = scopeParts.join(", "); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -148,7 +154,13 @@ export async function cmdEntitiesDelete( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { + if (opts.output === "agent") { + formatAgentEnvelope({ + command: "entity delete", + data: { deleted: true }, + durationMs: Math.round(elapsed * 1000), + }); + } else if (opts.output === "json") { formatJson(result); } else if (opts.output !== "quiet") { printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`); diff --git a/cli/node/src/commands/events.ts b/cli/node/src/commands/events.ts new file mode 100644 index 000000000..f3463c98a --- /dev/null +++ b/cli/node/src/commands/events.ts @@ -0,0 +1,169 @@ +/** + * Event commands: list and status. + */ + +import boxen from "boxen"; +import Table from "cli-table3"; +import type { Backend } from "../backend/base.js"; +import { colors, printError, printInfo, timedStatus } from "../branding.js"; +import { formatAgentEnvelope, formatJson } from "../output.js"; +import { setCurrentCommand } from "../state.js"; + +const { brand, accent, success, error: errorColor, warning, dim } = colors; + +function statusStyled(status: string): string { + switch (status.toUpperCase()) { + case "SUCCEEDED": + return success("SUCCEEDED"); + case "PENDING": + return accent("PENDING"); + case "FAILED": + return errorColor("FAILED"); + case "PROCESSING": + return warning("PROCESSING"); + default: + return status; + } +} + +export async function cmdEventList( + backend: Backend, + opts: { output: string }, +): Promise { + setCurrentCommand("event list"); + const start = performance.now(); + let results: Record[]; + try { + results = await timedStatus("Fetching events...", async () => { + return backend.listEvents(); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ + command: "event list", + data: results, + count: results.length, + durationMs: Math.round(elapsed * 1000), + }); + return; + } + + if (results.length === 0) { + console.log(); + printInfo("No events found."); + console.log(); + return; + } + + const table = new Table({ + head: [ + accent("Event ID"), + accent("Type"), + accent("Status"), + accent("Latency"), + accent("Created"), + ], + colWidths: [12, 14, 14, 10, 22], + wordWrap: true, + style: { head: [], border: [] }, + }); + + for (const ev of results) { + const evId = String(ev.id ?? "").slice(0, 8); + const evType = String(ev.event_type ?? "—"); + const status = String(ev.status ?? "—"); + const latency = ev.latency as number | undefined; + const latencyStr = latency !== undefined ? `${Math.round(latency)}ms` : "—"; + const created = String(ev.created_at ?? "—") + .slice(0, 19) + .replace("T", " "); + table.push([dim(evId), evType, statusStyled(status), latencyStr, created]); + } + + console.log(); + console.log(table.toString()); + console.log( + ` ${dim(`${results.length} event${results.length !== 1 ? "s" : ""}`)}`, + ); + console.log(); +} + +export async function cmdEventStatus( + backend: Backend, + eventId: string, + opts: { output: string }, +): Promise { + setCurrentCommand("event status"); + const start = performance.now(); + let ev: Record; + try { + ev = await timedStatus("Fetching event...", async () => { + return backend.getEvent(eventId); + }); + } catch (e) { + printError(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + const elapsed = (performance.now() - start) / 1000; + + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ + command: "event status", + data: ev, + durationMs: Math.round(elapsed * 1000), + }); + return; + } + + const status = String(ev.status ?? "—"); + const evType = String(ev.event_type ?? "—"); + const latency = ev.latency as number | undefined; + const latencyStr = latency !== undefined ? `${Math.round(latency)}ms` : "—"; + const created = String(ev.created_at ?? "—") + .slice(0, 19) + .replace("T", " "); + const updated = String(ev.updated_at ?? "—") + .slice(0, 19) + .replace("T", " "); + const results = ev.results as Record[] | undefined; + + const lines: string[] = []; + lines.push(` ${dim("Event ID:")} ${eventId}`); + lines.push(` ${dim("Type:")} ${evType}`); + lines.push(` ${dim("Status:")} ${statusStyled(status)}`); + lines.push(` ${dim("Latency:")} ${latencyStr}`); + lines.push(` ${dim("Created:")} ${created}`); + lines.push(` ${dim("Updated:")} ${updated}`); + + if (results && results.length > 0) { + lines.push(""); + lines.push(` ${dim(`Results (${results.length}):`)}`); + for (const r of results) { + const memId = String(r.id ?? "").slice(0, 8); + const data = r.data as Record | undefined; + const memory = data?.memory ? String(data.memory) : ""; + const evName = String(r.event ?? ""); + const user = String(r.user_id ?? ""); + let detail = `${evName} ${memory}`; + if (user) detail += ` ${dim(`(user_id=${user})`)}`; + lines.push(` ${success("·")} ${detail} ${dim(`(${memId})`)}`); + } + } + + const content = lines.join("\n"); + console.log(); + console.log( + boxen(content, { + title: brand("Event Status"), + titleAlignment: "left", + borderColor: "magenta", + padding: 1, + }), + ); + console.log(); +} diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index 76f7cd8be..b1cdcae80 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -2,6 +2,7 @@ * mem0 init — interactive setup wizard. */ +import fs from "node:fs"; import readline from "node:readline"; import { PlatformBackend } from "../backend/platform.js"; import { @@ -12,10 +13,12 @@ import { printSuccess, } from "../branding.js"; import { + CONFIG_FILE, DEFAULT_BASE_URL, type Mem0Config, createDefaultConfig, loadConfig, + redactKey, saveConfig, } from "../config.js"; @@ -193,9 +196,10 @@ async function setupDefaults(config: Mem0Config): Promise { console.log(); printInfo("Set default entity IDs (press Enter to skip).\n"); + const _systemUser = process.env.USER || process.env.USERNAME || "mem0-cli"; const userId = await promptLine( ` ${brand("Default User ID")} ${dim("(recommended)")}`, - "mem0-cli", + _systemUser, ); if (userId) config.defaults.userId = userId; } @@ -214,7 +218,7 @@ async function validatePlatform(config: Mem0Config): Promise { } else { printError( `Could not connect: ${status.error ?? "Unknown error"}`, - "Check your API key and try again.", + "Visit https://app.mem0.ai/dashboard/api-keys to get a new key, or run mem0 init again.", ); } } catch (e) { @@ -228,6 +232,7 @@ export async function runInit( userId?: string; email?: string; code?: string; + force?: boolean; } = {}, ): Promise { const config = createDefaultConfig(); @@ -247,6 +252,40 @@ export async function runInit( process.exit(1); } + // Warn if an existing config with an API key would be overwritten + if ( + !opts.force && + fs.existsSync(CONFIG_FILE) && + savedConfig.platform.apiKey + ) { + console.log( + `\n ${brand("Existing configuration found")} ${dim(`(API key: ${redactKey(savedConfig.platform.apiKey)})`)}`, + ); + if (process.stdin.isTTY) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await new Promise((resolve) => { + rl.question( + " Overwrite existing config? This cannot be undone. [y/N] ", + resolve, + ); + }); + rl.close(); + if (answer.toLowerCase() !== "y") { + printInfo("Cancelled. Use --force to skip this check."); + process.exit(0); + } + } else { + printError( + "Existing config would be overwritten.", + "Use --force to overwrite.", + ); + process.exit(1); + } + } + // ── Email login flow ────────────────────────────────────────────────────── if (opts.email) { const email = opts.email.trim().toLowerCase(); @@ -268,7 +307,8 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; - config.defaults.userId = opts.userId || "mem0-cli"; + config.defaults.userId = + opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; saveConfig(config); console.log(); @@ -283,6 +323,19 @@ export async function runInit( // ── API key flow ────────────────────────────────────────────────────────── + // 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 [--user-id ]", + ); + process.exit(1); + } + opts.userId = + opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; + } + // Non-interactive: both flags provided if (opts.apiKey && opts.userId) { config.platform.apiKey = opts.apiKey; @@ -293,15 +346,6 @@ export async function runInit( return; } - // Non-TTY without full flags: error with usage hint - if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) { - printError( - "Non-interactive terminal detected and missing required flags.", - "Usage: mem0 init --api-key --user-id ", - ); - process.exit(1); - } - printBanner(); console.log(); printInfo("Welcome! Let's set up your mem0 CLI.\n"); @@ -341,7 +385,8 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; - config.defaults.userId = opts.userId || "mem0-cli"; + config.defaults.userId = + opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; saveConfig(config); console.log(); diff --git a/cli/node/src/commands/memory.ts b/cli/node/src/commands/memory.ts index 997260670..8ce6169b4 100644 --- a/cli/node/src/commands/memory.ts +++ b/cli/node/src/commands/memory.ts @@ -13,6 +13,7 @@ import { } from "../branding.js"; import { formatAddResult, + formatAgentEnvelope, formatJson, formatJsonEnvelope, formatMemoriesTable, @@ -20,6 +21,18 @@ import { formatSingleMemory, printResultSummary, } from "../output.js"; +import { isAgentMode, setCurrentCommand } from "../state.js"; + +/** True only when stdin is an actual pipe or file redirect — never in agent mode. */ +function _stdinIsPiped(): boolean { + if (isAgentMode()) return false; + try { + const stat = fs.fstatSync(0); + return stat.isFIFO() || stat.isFile(); + } catch { + return false; + } +} export async function cmdAdd( backend: Backend, @@ -40,6 +53,7 @@ export async function cmdAdd( output: string; }, ): Promise { + setCurrentCommand("add"); let msgs: Record[] | undefined; let content = text; @@ -64,8 +78,8 @@ export async function cmdAdd( process.exit(1); } } - // Read from stdin if piped - else if (!content && !process.stdin.isTTY) { + // Read from stdin only if stdin is an actual pipe or file redirect + else if (!content && _stdinIsPiped()) { content = fs.readFileSync(0, "utf-8").trim(); } @@ -136,8 +150,43 @@ export async function cmdAdd( if (opts.output === "quiet") return; + // Deduplicate PENDING entries sharing the same event_id across all output modes + const rawResults: Record[] = Array.isArray(result) + ? result + : ((result.results as Record[]) ?? [result]); + const seenEvents = new Set(); + const deduped: Record[] = []; + for (const r of rawResults) { + if (r.status === "PENDING") { + const eid = (r.event_id as string) ?? ""; + if (eid && seenEvents.has(eid)) continue; + if (eid) seenEvents.add(eid); + } + deduped.push(r); + } + // Write back so downstream formatters see deduplicated data + const dedupedResult: Record = Array.isArray(result) + ? (deduped as unknown as Record) + : { ...result, results: deduped }; + + if (opts.output === "agent") { + const scope: Record = { + user_id: opts.userId, + agent_id: opts.agentId, + app_id: opts.appId, + run_id: opts.runId, + }; + formatAgentEnvelope({ + command: "add", + data: deduped, + scope, + count: deduped.length, + }); + return; + } + if (opts.output === "json") { - formatAddResult(result, opts.output); + formatAddResult(dedupedResult, opts.output); return; } @@ -148,14 +197,18 @@ export async function cmdAdd( app_id: opts.appId, run_id: opts.runId, }); - const results = Array.isArray(result) - ? result - : ((result.results as unknown[]) ?? [result]); - const count = results.length; - printSuccess( - `Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`, - ); - formatAddResult(result, opts.output); + const count = deduped.length; + const allPending = count > 0 && deduped.every((r) => r.status === "PENDING"); + if (allPending) { + printSuccess( + `Memory queued — ${count} event${count !== 1 ? "s" : ""} pending`, + ); + } else { + printSuccess( + `Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`, + ); + } + formatAddResult(dedupedResult, opts.output); } export async function cmdSearch( @@ -176,6 +229,7 @@ export async function cmdSearch( output: string; }, ): Promise { + setCurrentCommand("search"); if (!query) { printError("No query provided. Pass a query argument or pipe via stdin."); process.exit(1); @@ -231,6 +285,23 @@ export async function cmdSearch( if (opts.output === "quiet") return; + if (opts.output === "agent") { + const scope: Record = { + user_id: opts.userId, + agent_id: opts.agentId, + app_id: opts.appId, + run_id: opts.runId, + }; + formatAgentEnvelope({ + command: "search", + data: results, + scope, + count: results.length, + durationMs: Math.round(elapsed * 1000), + }); + return; + } + if (opts.output === "json") { formatJson(results); } else if (opts.output === "table") { @@ -267,6 +338,7 @@ export async function cmdGet( memoryId: string, opts: { output: string }, ): Promise { + setCurrentCommand("get"); let result: Record; try { result = await timedStatus("Fetching memory...", async () => { @@ -277,7 +349,11 @@ export async function cmdGet( process.exit(1); } - formatSingleMemory(result, opts.output); + if (opts.output === "agent") { + formatAgentEnvelope({ command: "get", data: result }); + } else { + formatSingleMemory(result, opts.output); + } } export async function cmdList( @@ -296,6 +372,7 @@ export async function cmdList( output: string; }, ): Promise { + setCurrentCommand("list"); if (opts.pageSize < 1) { printError("--page-size must be >= 1."); process.exit(1); @@ -330,12 +407,19 @@ export async function cmdList( if (opts.output === "quiet") return; - if (opts.output === "json") { - formatJsonEnvelope({ + if (opts.output === "agent" || opts.output === "json") { + const scope: Record = { + user_id: opts.userId, + agent_id: opts.agentId, + app_id: opts.appId, + run_id: opts.runId, + }; + formatAgentEnvelope({ command: "list", data: results, + scope, count: results.length, - scope: { user_id: opts.userId, agent_id: opts.agentId }, + durationMs: Math.round(elapsed * 1000), }); } else if (opts.output === "table") { if (results.length > 0) { @@ -374,6 +458,7 @@ export async function cmdUpdate( text: string | undefined, opts: { metadata?: string; output: string }, ): Promise { + setCurrentCommand("update"); let meta: Record | undefined; if (opts.metadata) { try { @@ -396,7 +481,13 @@ export async function cmdUpdate( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { + if (opts.output === "agent") { + formatAgentEnvelope({ + command: "update", + data: result, + durationMs: Math.round(elapsed * 1000), + }); + } else if (opts.output === "json") { formatJson(result); } else if (opts.output !== "quiet") { printSuccess( @@ -410,6 +501,7 @@ export async function cmdDelete( memoryId: string, opts: { output: string; dryRun?: boolean; force?: boolean }, ): Promise { + setCurrentCommand("delete"); if (opts.dryRun) { let mem: Record; try { @@ -436,7 +528,13 @@ export async function cmdDelete( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { + if (opts.output === "agent") { + formatAgentEnvelope({ + command: "delete", + data: { id: memoryId, deleted: true }, + durationMs: Math.round(elapsed * 1000), + }); + } else if (opts.output === "json") { formatJson(result); } else if (opts.output !== "quiet") { printSuccess( @@ -458,16 +556,15 @@ export async function cmdDeleteAll( output: string; }, ): Promise { + setCurrentCommand("delete-all"); + const { isAgentMode } = await import("../state.js"); + if (isAgentMode() && !opts.force) { + printError("Destructive operation requires --force in agent mode."); + process.exit(1); + } if (opts.all) { // Project-wide wipe using wildcard entity IDs - if (opts.dryRun) { - printInfo("Would delete ALL memories project-wide."); - printInfo( - "Run without --dry-run to see the actual count of deleted memories.", - ); - printInfo("No changes made."); - return; - } + // Note: --dry-run is ignored here because the API has no count-before-delete endpoint. if (!opts.force) { const readline = await import("node:readline"); @@ -509,7 +606,13 @@ export async function cmdDeleteAll( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { + if (opts.output === "agent") { + formatAgentEnvelope({ + command: "delete-all", + data: result, + durationMs: Math.round(elapsed * 1000), + }); + } else if (opts.output === "json") { formatJson(result); } else if (opts.output !== "quiet") { if (result.message) { @@ -586,7 +689,13 @@ export async function cmdDeleteAll( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { + if (opts.output === "agent") { + formatAgentEnvelope({ + command: "delete-all", + data: result, + durationMs: Math.round(elapsed * 1000), + }); + } else if (opts.output === "json") { formatJson(result); } else if (opts.output !== "quiet") { if (result.message) { diff --git a/cli/node/src/commands/utils.ts b/cli/node/src/commands/utils.ts index 8243ea520..ae7b441d4 100644 --- a/cli/node/src/commands/utils.ts +++ b/cli/node/src/commands/utils.ts @@ -6,7 +6,8 @@ import fs from "node:fs"; import boxen from "boxen"; import type { Backend } from "../backend/base.js"; import { colors, printError, printSuccess, timedStatus } from "../branding.js"; -import { formatJsonEnvelope } from "../output.js"; +import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js"; +import { setCurrentCommand } from "../state.js"; import { CLI_VERSION } from "../version.js"; const { brand, dim, success, error: errorColor } = colors; @@ -15,6 +16,7 @@ export async function cmdStatus( backend: Backend, opts: { userId?: string; agentId?: string; output?: string } = {}, ): Promise { + setCurrentCommand("status"); const start = performance.now(); let result: Record; try { @@ -29,14 +31,13 @@ export async function cmdStatus( } const elapsed = (performance.now() - start) / 1000; - if (opts.output === "json") { - formatJsonEnvelope({ + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ command: "status", data: { connected: result.connected, backend: result.backend ?? null, base_url: result.base_url ?? null, - latency_ms: Math.round(elapsed * 1000), }, durationMs: Math.round(elapsed * 1000), }); @@ -56,6 +57,15 @@ export async function cmdStatus( } if (result.error) { lines.push(` ${errorColor("Error:")} ${result.error}`); + if (String(result.error).includes("Authentication failed")) { + lines.push(""); + lines.push( + ` ${dim("Run")} ${brand("mem0 init")} ${dim("to reconfigure your API key")}`, + ); + lines.push( + ` ${dim("Get a key at")} ${brand("https://app.mem0.ai/dashboard/api-keys")}`, + ); + } } lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`); @@ -81,6 +91,7 @@ export async function cmdImport( filePath: string, opts: { userId?: string; agentId?: string; output?: string }, ): Promise { + setCurrentCommand("import"); let data: Record[]; try { const raw = fs.readFileSync(filePath, "utf-8"); @@ -125,13 +136,12 @@ export async function cmdImport( const elapsed = (performance.now() - start) / 1000; console.log(); // Clear progress line - if (opts.output === "json") { - formatJsonEnvelope({ + if (opts.output === "agent" || opts.output === "json") { + formatAgentEnvelope({ command: "import", data: { added, failed, - duration_s: Number.parseFloat(elapsed.toFixed(2)), }, durationMs: Math.round(elapsed * 1000), }); diff --git a/cli/node/src/help.ts b/cli/node/src/help.ts index ad1971a63..4b014a50d 100644 --- a/cli/node/src/help.ts +++ b/cli/node/src/help.ts @@ -36,7 +36,7 @@ const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [ }, { panel: "Management", - commands: ["init", "status", "import", "help", "entity", "config"], + commands: ["init", "status", "import", "help", "entity", "event", "config"], }, ]; diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index 8201eac00..95495b30c 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -13,6 +13,7 @@ import { colors, printError } from "./branding.js"; import type { Mem0Config } from "./config.js"; import { loadConfig } from "./config.js"; import { richFormatHelp } from "./help.js"; +import { setAgentMode } from "./state.js"; import { CLI_VERSION } from "./version.js"; const program = new Command(); @@ -43,6 +44,13 @@ function getBackendOnly(apiKey?: string, baseUrl?: string): Backend { return getBackendAndConfig(apiKey, baseUrl).backend; } +function checkAgentMode(): boolean { + const rootOpts = program.opts(); + const isAgent = !!(rootOpts.json || rootOpts.agent); + if (isAgent) setAgentMode(true); + return isAgent; +} + /** * Resolve entity IDs: CLI flag > config default > undefined. * @@ -105,6 +113,11 @@ program console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`); process.exit(0); }) + .option("--json", "Output as JSON for agent/programmatic use.") + .option( + "--agent", + "Output as JSON for agent/programmatic use. (alias: --json)", + ) .usage(" [options]") .helpOption("--help", "Show this message and exit.") .addHelpCommand(false) @@ -114,9 +127,7 @@ program program .command("init") - .description( - "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).", - ) + .description("Interactive setup wizard for mem0 CLI.") .option("--api-key ", "API key (skip prompt).") .option("-u, --user-id ", "Default user ID (skip prompt).") .option("--email ", "Login via email verification code.") @@ -124,6 +135,7 @@ program "--code ", "Verification code (use with --email for non-interactive login).", ) + .option("--force", "Overwrite existing config without confirmation.", 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", @@ -135,6 +147,7 @@ program userId: opts.userId, email: opts.email, code: opts.code, + force: opts.force, }); }); @@ -165,17 +178,21 @@ program ) .action(async (text, opts) => { const { cmdAdd } = await import("./commands/memory.js"); + const isAgent = checkAgentMode(); const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); const ids = resolveIds(config, opts); const enableGraph = resolveGraph(config, opts); - await cmdAdd(backend, text, { ...ids, ...opts, enableGraph }); + const output = isAgent ? "agent" : opts.output; + await cmdAdd(backend, text, { ...ids, ...opts, enableGraph, output }); }); // ── Memory: search ──────────────────────────────────────────────────────── program .command("search [query]") - .description("Search memories by semantic query.") + .description( + "Query your memory store — semantic, keyword, or hybrid retrieval.", + ) .option("-u, --user-id ", "Filter by user.") .option("--agent-id ", "Filter by agent.") .option("--app-id ", "Filter by app.") @@ -215,9 +232,11 @@ program process.exit(1); } const { cmdSearch } = await import("./commands/memory.js"); + const isAgent = checkAgentMode(); const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); const ids = resolveIds(config, opts); const enableGraph = resolveGraph(config, opts); + const output = isAgent ? "agent" : opts.output; await cmdSearch(backend, resolvedQuery, { ...ids, topK: opts.topK, @@ -227,7 +246,7 @@ program filterJson: opts.filter, fields: opts.fields, enableGraph, - output: opts.output, + output, }); }); @@ -245,8 +264,10 @@ program ) .action(async (memoryId, opts) => { const { cmdGet } = await import("./commands/memory.js"); + const isAgent = checkAgentMode(); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); - await cmdGet(backend, memoryId, { output: opts.output }); + const output = isAgent ? "agent" : opts.output; + await cmdGet(backend, memoryId, { output }); }); // ── Memory: list ────────────────────────────────────────────────────────── @@ -279,9 +300,11 @@ program ) .action(async (opts) => { const { cmdList } = await import("./commands/memory.js"); + const isAgent = checkAgentMode(); const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); const ids = resolveIds(config, opts); const enableGraph = resolveGraph(config, opts); + const output = isAgent ? "agent" : opts.output; await cmdList(backend, { ...ids, page: opts.page, @@ -290,7 +313,7 @@ program after: opts.after, before: opts.before, enableGraph, - output: opts.output, + output, }); }); @@ -313,10 +336,12 @@ program resolvedText = fs.readFileSync(0, "utf-8").trim(); } const { cmdUpdate } = await import("./commands/memory.js"); + const isAgent = checkAgentMode(); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + const output = isAgent ? "agent" : opts.output; await cmdUpdate(backend, memoryId, resolvedText, { metadata: opts.metadata, - output: opts.output, + output, }); }); @@ -352,6 +377,8 @@ program ].join("\n"), ) .action(async (memoryId, opts) => { + const isAgent = checkAgentMode(); + const output = isAgent ? "agent" : opts.output; // ── Mutual-exclusion checks ── if (memoryId && opts.all) { printError("Cannot combine with --all. Use one or the other."); @@ -382,7 +409,7 @@ program const { cmdDelete } = await import("./commands/memory.js"); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); await cmdDelete(backend, memoryId, { - output: opts.output, + output, dryRun: opts.dryRun, force: opts.force, }); @@ -409,7 +436,7 @@ program dryRun: opts.dryRun, all: opts.project, ...ids, - output: opts.output, + output, }); return; } @@ -418,7 +445,7 @@ program if (opts.entity) { const { cmdEntitiesDelete } = await import("./commands/entities.js"); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); - await cmdEntitiesDelete(backend, opts); + await cmdEntitiesDelete(backend, { ...opts, output }); return; } }); @@ -440,7 +467,9 @@ configCmd ) .action(async (opts) => { const { cmdConfigShow } = await import("./commands/config.js"); - cmdConfigShow({ output: opts.output }); + const isAgent = checkAgentMode(); + const output = isAgent ? "agent" : opts.output; + cmdConfigShow({ output }); }); configCmd @@ -452,6 +481,7 @@ configCmd ) .action(async (key) => { const { cmdConfigGet } = await import("./commands/config.js"); + checkAgentMode(); cmdConfigGet(key); }); @@ -464,6 +494,7 @@ configCmd ) .action(async (key, value) => { const { cmdConfigSet } = await import("./commands/config.js"); + checkAgentMode(); cmdConfigSet(key, value); }); @@ -487,8 +518,10 @@ entityCmd ) .action(async (entityType, opts) => { const { cmdEntitiesList } = await import("./commands/entities.js"); + const isAgent = checkAgentMode(); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); - await cmdEntitiesList(backend, entityType, { output: opts.output }); + const output = isAgent ? "agent" : opts.output; + await cmdEntitiesList(backend, entityType, { output }); }); entityCmd @@ -509,8 +542,54 @@ entityCmd ) .action(async (opts) => { const { cmdEntitiesDelete } = await import("./commands/entities.js"); + const isAgent = checkAgentMode(); const backend = getBackendOnly(opts.apiKey, opts.baseUrl); - await cmdEntitiesDelete(backend, opts); + const output = isAgent ? "agent" : opts.output; + await cmdEntitiesDelete(backend, { ...opts, output }); + }); + +// ── Event subcommands ───────────────────────────────────────────────────── + +const eventCmd = program + .command("event") + .description("Inspect background processing events.") + .addHelpCommand(false) + .configureHelp({ formatHelp: richFormatHelp }); + +eventCmd + .command("list") + .description("List recent background processing events.") + .option("-o, --output ", "Output: table, json.", "table") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText( + "after", + "\nExamples:\n $ mem0 event list\n $ mem0 event list -o json", + ) + .action(async (opts) => { + const { cmdEventList } = await import("./commands/events.js"); + const isAgent = checkAgentMode(); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + const output = isAgent ? "agent" : opts.output; + await cmdEventList(backend, { output }); + }); + +eventCmd + .command("status ") + .description("Check the status of a specific background event.") + .option("-o, --output ", "Output: text, json.", "text") + .option("--api-key ", "Override API key.") + .option("--base-url ", "Override API base URL.") + .addHelpText( + "after", + "\nExamples:\n $ mem0 event status \n $ mem0 event status -o json", + ) + .action(async (eventId, opts) => { + const { cmdEventStatus } = await import("./commands/events.js"); + const isAgent = checkAgentMode(); + const backend = getBackendOnly(opts.apiKey, opts.baseUrl); + const output = isAgent ? "agent" : opts.output; + await cmdEventStatus(backend, eventId, { output }); }); // ── Utility commands ────────────────────────────────────────────────────── @@ -524,11 +603,13 @@ program .addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json") .action(async (opts) => { const { cmdStatus } = await import("./commands/utils.js"); + const isAgent = checkAgentMode(); const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); + const output = isAgent ? "agent" : opts.output; await cmdStatus(backend, { userId: config.defaults.userId || undefined, agentId: config.defaults.agentId || undefined, - output: opts.output, + output, }); }); @@ -546,12 +627,14 @@ program ) .action(async (filePath, opts) => { const { cmdImport } = await import("./commands/utils.js"); + const isAgent = checkAgentMode(); const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl); const ids = resolveIds(config, opts); + const output = isAgent ? "agent" : opts.output; await cmdImport(backend, filePath, { userId: ids.userId, agentId: ids.agentId, - output: opts.output, + output, }); }); @@ -565,7 +648,9 @@ program .option("--json", "Output machine-readable JSON for LLM agents.", false) .addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json") .action((opts) => { - if (opts.json) { + // opts.json is set when `mem0 help --json` is used (subcommand flag). + // program.opts().json is set when the root --json global flag was used first. + if (opts.json || program.opts().json) { // Load spec from parent directory const __dirname = path.dirname(fileURLToPath(import.meta.url)); const specPath = path.join(__dirname, "..", "..", "cli-spec.json"); @@ -595,7 +680,9 @@ program console.log( " add Add a memory from text, messages, file, or stdin", ); - console.log(" search Search memories by semantic query"); + console.log( + " search Query your memory store (semantic, keyword, hybrid)", + ); console.log(" get Get a specific memory by ID"); console.log(" list List memories with optional filters"); console.log(" update Update a memory's text or metadata"); @@ -605,6 +692,9 @@ program console.log(" import Import memories from a JSON file"); console.log(" config Manage configuration (show, get, set)"); console.log(" entity Manage entities (list, delete)"); + console.log( + " event Inspect background events (list, status)", + ); console.log(" init Interactive setup wizard"); console.log(" status Check connectivity and authentication"); console.log(); @@ -616,16 +706,6 @@ program } }); -// ── Version ─────────────────────────────────────────────────────────────── - -program - .command("version") - .description("Show version.") - .action(async () => { - const { cmdVersion } = await import("./commands/utils.js"); - cmdVersion(); - }); - // ── Entrypoint ──────────────────────────────────────────────────────────── program.parse(); diff --git a/cli/node/src/output.ts b/cli/node/src/output.ts index a3cff7b10..83cfa98b1 100644 --- a/cli/node/src/output.ts +++ b/cli/node/src/output.ts @@ -169,17 +169,26 @@ export function formatAddResult( } console.log(); + const seenPendingEvents = new Set(); for (const r of results) { // Detect async PENDING response if (r.status === "PENDING") { - const eventId = ((r.event_id as string) ?? "").slice(0, 8); + const eventId = (r.event_id as string) ?? ""; + // Deduplicate PENDING entries with the same event_id + if (eventId && seenPendingEvents.has(eventId)) continue; + if (eventId) seenPendingEvents.add(eventId); const icon = accent(sym("⧗", "...")); const parts = [ ` ${icon} ${dim("Queued".padEnd(10))}`, "Processing in background", ]; - if (eventId) parts.push(dim(`(event ${eventId})`)); console.log(parts.join(" ")); + if (eventId) { + console.log(` ${dim(` event_id: ${eventId}`)}`); + console.log( + ` ${dim(` → Check status: mem0 event status ${eventId}`)}`, + ); + } continue; } @@ -238,6 +247,118 @@ export function formatJsonEnvelope(opts: { console.log(JSON.stringify(envelope, null, 2)); } +function pick( + obj: Record, + keys: string[], +): Record { + const result: Record = {}; + for (const key of keys) { + if (key in obj) result[key] = obj[key]; + } + return result; +} + +export function sanitizeAgentData(command: string, data: unknown): unknown { + if (data === null || data === undefined) return data; + + switch (command) { + case "add": { + const items = Array.isArray(data) ? data : [data]; + return items.map((item) => { + const r = item as Record; + if (r.status === "PENDING") return pick(r, ["status", "event_id"]); + return pick(r, ["id", "memory", "event"]); + }); + } + case "search": + return (data as Record[]).map((r) => + pick(r, ["id", "memory", "score", "created_at", "categories"]), + ); + case "list": + return (data as Record[]).map((r) => + pick(r, ["id", "memory", "created_at", "categories"]), + ); + case "get": { + const r = data as Record; + return pick(r, [ + "id", + "memory", + "created_at", + "updated_at", + "categories", + "metadata", + ]); + } + case "update": { + const r = data as Record; + return pick(r, ["id", "memory"]); + } + case "delete": + case "delete-all": + case "entity delete": + return data; + case "entity list": + return (data as Record[]).map((r) => ({ + name: (r.name ?? r.id) as string, + ...pick(r, ["type", "count"]), + })); + case "event list": + return (data as Record[]).map((r) => + pick(r, ["id", "event_type", "status", "latency", "created_at"]), + ); + case "event status": { + const ev = data as Record; + const rawResults = + (ev.results as Record[] | undefined) ?? []; + const sanitizedResults = rawResults.map((r) => { + const nested = r.data as Record | undefined; + return { + id: r.id, + event: r.event, + user_id: r.user_id, + memory: nested?.memory ?? null, + }; + }); + return { + ...pick(ev, [ + "id", + "event_type", + "status", + "latency", + "created_at", + "updated_at", + ]), + results: sanitizedResults, + }; + } + default: + return data; + } +} + +export function formatAgentEnvelope(opts: { + command: string; + data: unknown; + durationMs?: number; + scope?: Record; + count?: number; +}): void { + const envelope: Record = { + status: "success", + command: opts.command, + }; + if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs; + if (opts.scope) { + const filtered = Object.fromEntries( + Object.entries(opts.scope).filter(([, v]) => v), + ); + if (Object.keys(filtered).length > 0) envelope.scope = filtered; + } + if (opts.count !== undefined) envelope.count = opts.count; + envelope.data = sanitizeAgentData(opts.command, opts.data); + console.log(JSON.stringify(envelope, null, 2)); +} + export function printResultSummary(opts: { count: number; durationSecs?: number; @@ -249,7 +370,7 @@ export function printResultSummary(opts: { if (opts.scopeIds) { const scopeParts = Object.entries(opts.scopeIds) .filter(([, v]) => v) - .map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`); + .map(([k, v]) => `${k}=${v}`); if (scopeParts.length > 0) parts.push(scopeParts.join(", ")); } if (opts.durationSecs !== undefined) diff --git a/cli/node/src/state.ts b/cli/node/src/state.ts new file mode 100644 index 000000000..a654c5d8e --- /dev/null +++ b/cli/node/src/state.ts @@ -0,0 +1,23 @@ +/** + * Agent mode state — set by the root program option handler, + * read by commands and branding functions. + */ + +let _agentMode = false; +let _currentCommand = ""; + +export function isAgentMode(): boolean { + return _agentMode; +} + +export function setAgentMode(val: boolean): void { + _agentMode = val; +} + +export function getCurrentCommand(): string { + return _currentCommand; +} + +export function setCurrentCommand(name: string): void { + _currentCommand = name; +} diff --git a/cli/node/tests/commands.test.ts b/cli/node/tests/commands.test.ts index f049c8bdf..581a39b3d 100644 --- a/cli/node/tests/commands.test.ts +++ b/cli/node/tests/commands.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createMockBackend } from "./setup.js"; import type { Backend } from "../src/backend/base.js"; +import { setAgentMode } from "../src/state.js"; let mockBackend: Backend; @@ -31,6 +32,7 @@ import { afterEach } from "vitest"; afterEach(() => { console.log = originalLog; console.error = originalError; + setAgentMode(false); }); describe("cmdAdd", () => { @@ -84,6 +86,59 @@ describe("cmdAdd", () => { }); }); +describe("cmdAdd deduplicates PENDING", () => { + const DUPLICATE_PENDING = { + results: [ + { status: "PENDING", event_id: "evt-dup" }, + { status: "PENDING", event_id: "evt-dup" }, + ], + }; + + it("text shows one pending block", async () => { + (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "text", + }); + expect(output.match(/Queued/g)?.length).toBe(1); + }); + + it("json shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "json", + }); + const data = JSON.parse(output); + const pending = data.results.filter((r: Record) => r.status === "PENDING"); + expect(pending).toHaveLength(1); + }); + + it("agent shows one pending entry", async () => { + (mockBackend.add as ReturnType).mockResolvedValue(DUPLICATE_PENDING); + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "agent", + }); + const data = JSON.parse(output); + expect(data.count).toBe(1); + expect(data.data).toHaveLength(1); + }); +}); + describe("cmdSearch", () => { it("searches and shows results in text mode", async () => { const { cmdSearch } = await import("../src/commands/memory.js"); @@ -219,3 +274,168 @@ describe("cmdEntitiesList", () => { expect(output).toContain("alice"); }); }); + +describe("cmdEventList", () => { + it("lists events in table mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect(output).toContain("evt-abc-"); + expect(output).toContain("ADD"); + expect(output).toContain("SUCCEEDED"); + }); + + it("lists events in json mode", async () => { + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "json" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("evt-def-456-ghi-789"); + }); + + it("shows empty message when no events", async () => { + (mockBackend.listEvents as ReturnType).mockResolvedValueOnce([]); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "table" }); + expect((output + errOutput).toLowerCase()).toContain("no events"); + }); +}); + +describe("cmdEventStatus", () => { + it("shows event details in text mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("SUCCEEDED"); + }); + + it("shows event details in json mode", async () => { + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" }); + expect(output).toContain("evt-abc-123-def-456"); + expect(output).toContain("ADD"); + }); +}); + +describe("agent mode", () => { + it("cmdAdd outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdAdd } = await import("../src/commands/memory.js"); + await cmdAdd(mockBackend, "test preference", { + userId: "alice", + immutable: false, + noInfer: false, + enableGraph: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("add"); + expect(parsed.data).toBeDefined(); + expect(parsed.scope).toMatchObject({ user_id: "alice" }); + expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort()); + }); + + it("cmdSearch outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdSearch } = await import("../src/commands/memory.js"); + await cmdSearch(mockBackend, "preferences", { + userId: "alice", + topK: 10, + threshold: 0.3, + rerank: false, + keyword: false, + enableGraph: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("search"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + const keys = Object.keys(parsed.data[0]); + expect(keys).toContain("id"); + expect(keys).toContain("memory"); + expect(keys).toContain("score"); + expect(keys).toContain("created_at"); + expect(keys).toContain("categories"); + expect(keys).not.toContain("user_id"); + expect(keys).not.toContain("agent_id"); + }); + + it("cmdList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdList } = await import("../src/commands/memory.js"); + await cmdList(mockBackend, { + userId: "alice", + page: 1, + pageSize: 100, + enableGraph: false, + output: "agent", + }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]); + }); + + it("cmdGet outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdGet } = await import("../src/commands/memory.js"); + await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("get"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "abc-123-def-456" }); + expect(Object.keys(parsed.data)).not.toContain("user_id"); + }); + + it("cmdUpdate outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdUpdate } = await import("../src/commands/memory.js"); + await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("update"); + expect(parsed.data).toBeDefined(); + }); + + it("cmdDelete outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdDelete } = await import("../src/commands/memory.js"); + await cmdDelete(mockBackend, "abc-123", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("delete"); + expect(parsed.data).toBeDefined(); + }); + + it("cmdEventList outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventList } = await import("../src/commands/events.js"); + await cmdEventList(mockBackend, { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event list"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.count).toBe(2); + expect(Object.keys(parsed.data[0]).sort()).toEqual( + ["created_at", "event_type", "id", "latency", "status"], + ); + expect(Object.keys(parsed.data[0])).not.toContain("updated_at"); + }); + + it("cmdEventStatus outputs JSON envelope", async () => { + setAgentMode(true); + const { cmdEventStatus } = await import("../src/commands/events.js"); + await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" }); + const parsed = JSON.parse(output.trim()); + expect(parsed.status).toBe("success"); + expect(parsed.command).toBe("event status"); + expect(parsed.data).toBeDefined(); + expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" }); + expect(parsed.data.results[0]).toHaveProperty("memory"); + expect(parsed.data.results[0]).not.toHaveProperty("data"); + }); +}); diff --git a/cli/node/tests/output.test.ts b/cli/node/tests/output.test.ts index 71f43f247..65ba6a485 100644 --- a/cli/node/tests/output.test.ts +++ b/cli/node/tests/output.test.ts @@ -10,6 +10,7 @@ import { formatSingleMemory, formatAddResult, printResultSummary, + sanitizeAgentData, } from "../src/output.js"; let output: string; @@ -98,6 +99,18 @@ describe("formatAddResult", () => { }); expect(output).toContain("Queued"); }); + + it("deduplicates PENDING entries with same event_id", () => { + formatAddResult({ + results: [ + { status: "PENDING", event_id: "evt-dup" }, + { status: "PENDING", event_id: "evt-dup" }, + ], + }); + // Should show only one PENDING block despite two entries with same event_id + expect(output.match(/Queued/g)?.length).toBe(1); + expect(output.match(/evt-dup/g)?.length).toBe(2); // event_id line + status hint line + }); }); describe("printResultSummary", () => { @@ -113,3 +126,73 @@ describe("printResultSummary", () => { expect(output).not.toContain("results"); }); }); + +describe("sanitizeAgentData", () => { + it("projects add results", () => { + const raw = [{ id: "abc", memory: "test", event: "ADD", metadata: { x: 1 }, categories: ["a"] }]; + const result = sanitizeAgentData("add", raw) as Record[]; + expect(result).toEqual([{ id: "abc", memory: "test", event: "ADD" }]); + }); + + it("passes through PENDING add items", () => { + const raw = [{ status: "PENDING", event_id: "evt-123", noise: "x" }]; + const result = sanitizeAgentData("add", raw) as Record[]; + expect(result).toEqual([{ status: "PENDING", event_id: "evt-123" }]); + }); + + it("projects search results", () => { + const raw = [{ id: "abc", memory: "test", score: 0.9, created_at: "2026-01-01", categories: ["a"], user_id: "u1" }]; + const result = sanitizeAgentData("search", raw) as Record[]; + expect(result[0]).not.toHaveProperty("user_id"); + expect(result[0]).toHaveProperty("score"); + }); + + it("projects list results", () => { + const raw = [{ id: "abc", memory: "test", created_at: "2026-01-01", categories: ["a"], user_id: "u1" }]; + const result = sanitizeAgentData("list", raw) as Record[]; + expect(Object.keys(result[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]); + }); + + it("projects get result", () => { + const raw = { id: "abc", memory: "test", created_at: "2026-01-01", updated_at: "2026-01-02", categories: ["a"], metadata: { k: "v" }, user_id: "u1" }; + const result = sanitizeAgentData("get", raw) as Record; + expect(result).not.toHaveProperty("user_id"); + expect(result).toHaveProperty("metadata"); + }); + + it("projects update result", () => { + const raw = { id: "abc", memory: "updated", extra: "noise" }; + const result = sanitizeAgentData("update", raw); + expect(result).toEqual({ id: "abc", memory: "updated" }); + }); + + it("projects event list results", () => { + const raw = [{ id: "evt-1", event_type: "ADD", status: "SUCCEEDED", graph_status: null, latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02" }]; + const result = sanitizeAgentData("event list", raw) as Record[]; + expect(result[0]).not.toHaveProperty("updated_at"); + expect(result[0]).not.toHaveProperty("graph_status"); + }); + + it("flattens event status results", () => { + const raw = { + id: "evt-1", event_type: "ADD", status: "SUCCEEDED", + latency: 100, created_at: "2026-01-01", updated_at: "2026-01-02", + results: [{ id: "mem-1", event: "ADD", user_id: "alice", data: { memory: "dark mode" } }], + }; + const result = sanitizeAgentData("event status", raw) as Record; + const firstResult = (result.results as Record[])[0]; + expect(firstResult).toHaveProperty("memory", "dark mode"); + expect(firstResult).not.toHaveProperty("data"); + }); + + it("passes through status/config/import commands unchanged", () => { + const data = { key: "value", other: "stuff" }; + for (const cmd of ["status", "import", "config show", "config get", "config set"]) { + expect(sanitizeAgentData(cmd, data)).toEqual(data); + } + }); + + it("handles null data", () => { + expect(sanitizeAgentData("add", null)).toBeNull(); + }); +}); diff --git a/cli/node/tests/setup.ts b/cli/node/tests/setup.ts index 7f7c19d7a..876e16208 100644 --- a/cli/node/tests/setup.ts +++ b/cli/node/tests/setup.ts @@ -71,5 +71,42 @@ export function createMockBackend(): Backend { { name: "alice", count: 5 }, { name: "bob", count: 3 }, ]), + listEvents: vi.fn().mockResolvedValue([ + { + id: "evt-abc-123-def-456", + event_type: "ADD", + status: "SUCCEEDED", + graph_status: null, + latency: 1234.5, + created_at: "2026-04-01T10:00:00Z", + updated_at: "2026-04-01T10:00:01Z", + }, + { + id: "evt-def-456-ghi-789", + event_type: "SEARCH", + status: "PENDING", + graph_status: null, + latency: null, + created_at: "2026-04-01T10:01:00Z", + updated_at: "2026-04-01T10:01:00Z", + }, + ]), + getEvent: vi.fn().mockResolvedValue({ + id: "evt-abc-123-def-456", + event_type: "ADD", + status: "SUCCEEDED", + graph_status: "SUCCEEDED", + latency: 1234.5, + created_at: "2026-04-01T10:00:00Z", + updated_at: "2026-04-01T10:00:01Z", + results: [ + { + id: "mem-abc-123", + event: "ADD", + user_id: "alice", + data: { memory: "User prefers dark mode" }, + }, + ], + }), }; } diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 1363af9ed..93b3adcd2 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -3,6 +3,8 @@ from __future__ import annotations import json as _json +import os +import stat as _stat_mod import sys from pathlib import Path @@ -43,7 +45,14 @@ entity_app = typer.Typer( no_args_is_help=True, rich_markup_mode="rich", ) -# entity_app registered after Memory commands to control panel ordering + +event_app = typer.Typer( + name="event", + help="Inspect background processing events.", + no_args_is_help=True, + rich_markup_mode="rich", +) +# entity_app and event_app registered after Memory commands to control panel ordering # ── Helpers ─────────────────────────────────────────────────────────────── @@ -114,9 +123,22 @@ def _resolve_ids( } +def _stdin_is_piped() -> bool: + """Return True only when stdin is an actual pipe or file redirect — not a bare open fd.""" + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return False + try: + mode = os.fstat(sys.stdin.fileno()).st_mode + return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode) + except Exception: + return False + + def _read_stdin() -> str | None: - """Read from stdin if it is piped (not a TTY).""" - if not sys.stdin.isatty(): + """Read from stdin if it is an actual pipe or file redirect (not a TTY, not agent mode).""" + if _stdin_is_piped(): return sys.stdin.read().strip() or None return None @@ -128,7 +150,18 @@ def _read_stdin() -> str | None: def main_callback( ctx: typer.Context, version: bool = typer.Option(False, "--version", help="Show version and exit."), + json_agent: bool = typer.Option( + False, + "--json", + "--agent", + help="Output as JSON for agent/programmatic use.", + is_eager=False, + ), ) -> None: + if json_agent: + from mem0_cli.state import set_agent_mode + + set_agent_mode(True) if version: from mem0_cli.commands.utils import cmd_version @@ -273,7 +306,7 @@ def search( None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" ), ) -> None: - """Search memories by semantic query. + """Query your memory store — semantic, keyword, or hybrid retrieval. Examples: mem0 search "preferences" --user-id alice @@ -702,6 +735,70 @@ def entity_delete( app.add_typer(entity_app, name="entity", rich_help_panel="Management") +# ── Event subcommands ───────────────────────────────────────────────────── + + +@event_app.command("list") +def event_list( + output: str = typer.Option( + "table", "--output", "-o", help="Output: table, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """List recent background processing events. + + Examples: + mem0 event list + mem0 event list -o json + """ + from mem0_cli.commands.events_cmd import cmd_event_list + + backend = _get_backend(api_key, base_url) + cmd_event_list(backend, output=output) + + +@event_app.command("status") +def event_status( + event_id: str = typer.Argument(..., help="Event ID to inspect."), + output: str = typer.Option( + "text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output" + ), + api_key: str | None = typer.Option( + None, + "--api-key", + help="Override API key.", + envvar="MEM0_API_KEY", + rich_help_panel="Connection", + ), + base_url: str | None = typer.Option( + None, "--base-url", help="Override API base URL.", rich_help_panel="Connection" + ), +) -> None: + """Check the status of a specific background event. + + Examples: + mem0 event status + mem0 event status -o json + """ + from mem0_cli.commands.events_cmd import cmd_event_status + + backend = _get_backend(api_key, base_url) + cmd_event_status(backend, event_id, output=output) + + +# ── Event subgroup ── +app.add_typer(event_app, name="event", rich_help_panel="Management") + + # ── Management commands ─────────────────────────────────────────────────── @@ -715,6 +812,9 @@ def init( code: str | None = typer.Option( None, "--code", help="Verification code (use with --email for non-interactive login)." ), + force: bool = typer.Option( + False, "--force", help="Overwrite existing config without confirmation." + ), ) -> None: """Interactive setup wizard for mem0 CLI. @@ -726,7 +826,7 @@ def init( """ from mem0_cli.commands.init_cmd import run_init - run_init(api_key=api_key, user_id=user_id, email=email, code=code) + 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) @@ -831,7 +931,7 @@ def _build_help_json() -> dict: }, }, "search": { - "description": "Search memories by semantic query.", + "description": "Query your memory store — semantic, keyword, or hybrid retrieval.", "usage": "mem0 search [OPTIONS]", "arguments": {"query": {"description": "Search query.", "required": False}}, "options": { @@ -935,6 +1035,24 @@ def _build_help_json() -> dict: "value": {"description": "Value to set.", "required": True}, }, }, + "event": { + "description": "Inspect background processing events.", + "subcommands": { + "list": { + "description": "List recent background processing events.", + "usage": "mem0 event list [OPTIONS]", + "options": {"--output, -o": "Output format: table, json."}, + }, + "status": { + "description": "Check the status of a specific background event.", + "usage": "mem0 event status [OPTIONS]", + "arguments": { + "event_id": {"description": "Event ID to inspect.", "required": True} + }, + "options": {"--output, -o": "Output format: text, json."}, + }, + }, + }, "entity": { "description": "Manage entities.", "subcommands": { @@ -986,6 +1104,7 @@ def _build_help_json() -> dict: "global_options": { "--api-key": "Override API key (env: MEM0_API_KEY).", "--base-url": "Override API base URL.", + "--json / --agent": "Output as JSON for agent/programmatic use.", "--help": "Show help for a command.", "--version": "Show version and exit.", }, @@ -1015,7 +1134,7 @@ def help( console.print("Usage: mem0 [OPTIONS]\n") console.print("[bold]Commands:[/]") console.print(" add Add a memory from text, messages, file, or stdin") - console.print(" search Search memories by semantic query") + console.print(" search Query your memory store (semantic, keyword, hybrid)") console.print(" get Get a specific memory by ID") console.print(" list List memories with optional filters") console.print(" update Update a memory's text or metadata") @@ -1023,6 +1142,7 @@ def help( console.print(" import Import memories from a JSON file") console.print(" config Manage configuration (show, get, set)") console.print(" entity Manage entities (list, delete)") + console.print(" event Inspect background events (list, status)") console.print(" init Interactive setup wizard") console.print(" status Check connectivity and authentication") console.print() @@ -1031,14 +1151,6 @@ def help( console.print() -@app.command(rich_help_panel="Utility") -def version() -> None: - """Show version and exit.""" - from mem0_cli.commands.utils import cmd_version - - cmd_version() - - # Register config subgroup here so it appears after help in Management panel app.add_typer(config_app, name="config", rich_help_panel="Management") @@ -1047,4 +1159,14 @@ app.add_typer(config_app, name="config", rich_help_panel="Management") def main() -> None: + import sys + + # Allow --json/--agent anywhere in the command line (not just before subcommand). + _json_flags = {"--json", "--agent"} + if any(a in _json_flags for a in sys.argv[1:]): + from mem0_cli.state import set_agent_mode + + set_agent_mode(True) + sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in _json_flags] + app() diff --git a/cli/python/src/mem0_cli/backend/base.py b/cli/python/src/mem0_cli/backend/base.py index 7a48429dc..c74b6ca33 100644 --- a/cli/python/src/mem0_cli/backend/base.py +++ b/cli/python/src/mem0_cli/backend/base.py @@ -104,6 +104,12 @@ class Backend(ABC): @abstractmethod def entities(self, entity_type: str) -> list[dict]: ... + @abstractmethod + def list_events(self) -> list[dict]: ... + + @abstractmethod + def get_event(self, event_id: str) -> dict: ... + def get_backend(config: Mem0Config) -> Backend: """Return the Platform backend.""" diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 452c33ad1..77d5853b0 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -265,18 +265,21 @@ class PlatformBackend(Backend): app_id: str | None = None, run_id: str | None = None, ) -> dict: - params: dict[str, str] = {} - if user_id: - params["user_id"] = user_id - if agent_id: - params["agent_id"] = agent_id - if app_id: - params["app_id"] = app_id - if run_id: - params["run_id"] = run_id - if not params: + # v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/ + type_map = { + "user": user_id, + "agent": agent_id, + "app": app_id, + "run": run_id, + } + entities = {t: v for t, v in type_map.items() if v} + if not entities: raise ValueError("At least one entity ID is required for delete_entities.") - return self._request("DELETE", "/v1/entities/", params=params) + # Delete each provided entity via the v2 path-based endpoint + result: dict = {} + for entity_type, entity_id in entities.items(): + result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/") + return result def status( self, @@ -284,19 +287,9 @@ class PlatformBackend(Backend): user_id: str | None = None, agent_id: str | None = None, ) -> dict[str, Any]: - """Check connectivity by making a lightweight API call.""" + """Check connectivity using the ping endpoint.""" try: - # If entity IDs are available, validate with a minimal memories list - if user_id or agent_id: - payload: dict[str, Any] = {} - params = {"page": "1", "page_size": "1"} - api_filters = self._build_filters(user_id=user_id, agent_id=agent_id) - if api_filters: - payload["filters"] = api_filters - self._request("POST", "/v2/memories/", json=payload, params=params) - else: - # No entity IDs — use entities endpoint to validate API key - self._request("GET", "/v1/entities/") + self._request("GET", "/v1/ping/") return {"connected": True, "backend": "platform", "base_url": self.base_url} except Exception as e: return {"connected": False, "backend": "platform", "error": str(e)} @@ -311,6 +304,13 @@ class PlatformBackend(Backend): items = [e for e in items if e.get("type", "").lower() == target_type] return items + def list_events(self) -> list[dict]: + result = self._request("GET", "/v1/events/") + return result if isinstance(result, list) else result.get("results", []) + + def get_event(self, event_id: str) -> dict: + return self._request("GET", f"/v1/event/{event_id}/") + class AuthError(Exception): pass diff --git a/cli/python/src/mem0_cli/branding.py b/cli/python/src/mem0_cli/branding.py index d6a89d87b..b2bdf9cc0 100644 --- a/cli/python/src/mem0_cli/branding.py +++ b/cli/python/src/mem0_cli/branding.py @@ -43,6 +43,10 @@ def _sym(fancy: str, plain: str) -> str: def print_banner(console: Console) -> None: """Print the mem0 welcome banner.""" + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return logo_text = Text(LOGO, style=f"bold {BRAND_COLOR}") tagline = Text(f" {TAGLINE}\n", style=f"{ACCENT_COLOR}") @@ -61,11 +65,28 @@ def print_banner(console: Console) -> None: def print_success(console: Console, message: str) -> None: + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return sym = _sym("✓", "[ok]") console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}") def print_error(console: Console, message: str, hint: str | None = None) -> None: + from mem0_cli.state import get_current_command, is_agent_mode + + if is_agent_mode(): + import json as _json + + envelope = { + "status": "error", + "command": get_current_command(), + "error": message, + "data": None, + } + print(_json.dumps(envelope)) + return sym = _sym("✗", "[error]") console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}") if hint: @@ -73,11 +94,19 @@ def print_error(console: Console, message: str, hint: str | None = None) -> None def print_warning(console: Console, message: str) -> None: + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return sym = _sym("⚠", "[warn]") console.print(f"[{WARNING_COLOR}]{sym}[/] {message}") def print_info(console: Console, message: str) -> None: + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return sym = _sym("◆", "*") console.print(f"[{BRAND_COLOR}]{sym}[/] {message}") @@ -89,7 +118,9 @@ def timed_status(console: Console, message: str): The spinner and timing output are sent to stderr (via ``_err``) so they never contaminate machine-readable stdout. The *console* parameter is kept for backward compatibility but is not used for spinner output. + In agent mode the spinner is suppressed entirely. """ + from mem0_cli.state import is_agent_mode class _Ctx: def __init__(self): @@ -97,6 +128,13 @@ def timed_status(console: Console, message: str): self.error_msg = "" ctx = _Ctx() + if is_agent_mode(): + try: + yield ctx + except Exception: + raise + return + start = time.perf_counter() try: with Status(f"[{DIM_COLOR}]{message}[/]", console=_err): @@ -105,6 +143,11 @@ def timed_status(console: Console, message: str): elapsed = time.perf_counter() - start if ctx.error_msg: print_error(_err, f"{ctx.error_msg} ({elapsed:.2f}s)") + if "Authentication failed" in ctx.error_msg: + _err.print( + f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key" + f" · [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]" + ) raise else: elapsed = time.perf_counter() - start @@ -114,11 +157,14 @@ def timed_status(console: Console, message: str): def print_scope(console: Console, **ids: str | None) -> None: """Show active entity scope if any IDs are set.""" + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return parts = [] for key, val in ids.items(): if val: - label = key.replace("_", " ").replace("id", "ID").strip() - parts.append(f"{label}={val}") + parts.append(f"{key}={val}") if parts: scope_str = ", ".join(parts) console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]") diff --git a/cli/python/src/mem0_cli/commands/config_cmd.py b/cli/python/src/mem0_cli/commands/config_cmd.py index fc8653042..1bfe090e6 100644 --- a/cli/python/src/mem0_cli/commands/config_cmd.py +++ b/cli/python/src/mem0_cli/commands/config_cmd.py @@ -20,12 +20,17 @@ err_console = Console(stderr=True) def cmd_config_show(*, output: str = "text") -> None: """Display current configuration (secrets redacted).""" - from mem0_cli.output import format_json_envelope + from mem0_cli.output import format_agent_envelope + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("config show") + if is_agent_mode(): + output = "agent" config = load_config() - if output == "json": - format_json_envelope( + if output in ("json", "agent"): + format_agent_envelope( console, command="config show", data={ @@ -84,25 +89,44 @@ def cmd_config_show(*, output: str = "text") -> None: def cmd_config_get(key: str) -> None: """Get a config value.""" + from mem0_cli.output import format_agent_envelope + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("config get") config = load_config() value = get_nested_value(config, key) if value is None: print_error(err_console, f"Unknown config key: {key}") + return + + display_value = ( + redact_key(str(value)) if ("api_key" in key or "key" in key.split(".")[-1:]) else str(value) + ) + + if is_agent_mode(): + format_agent_envelope( + console, command="config get", data={"key": key, "value": display_value} + ) else: - # Redact secrets - if "api_key" in key or "key" in key.split(".")[-1:]: - console.print(redact_key(str(value))) - else: - console.print(str(value)) + console.print(display_value) def cmd_config_set(key: str, value: str) -> None: """Set a config value.""" + from mem0_cli.output import format_agent_envelope + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("config set") config = load_config() if set_nested_value(config, key, value): save_config(config) display = redact_key(value) if "key" in key else value - print_success(console, f"{key} = {display}") + if is_agent_mode(): + format_agent_envelope( + console, command="config set", data={"key": key, "value": display} + ) + else: + print_success(console, f"{key} = {display}") else: print_error(err_console, f"Unknown config key: {key}") diff --git a/cli/python/src/mem0_cli/commands/entities.py b/cli/python/src/mem0_cli/commands/entities.py index 29c0980f2..bd267e993 100644 --- a/cli/python/src/mem0_cli/commands/entities.py +++ b/cli/python/src/mem0_cli/commands/entities.py @@ -18,7 +18,7 @@ from mem0_cli.branding import ( print_success, timed_status, ) -from mem0_cli.output import format_json +from mem0_cli.output import format_agent_envelope, format_json console = Console() err_console = Console(stderr=True) @@ -26,6 +26,11 @@ err_console = Console(stderr=True) def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None: """List entities of a given type.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("entity list") + if is_agent_mode(): + output = "agent" valid_types = {"users", "agents", "apps", "runs"} if entity_type not in valid_types: print_error( @@ -42,6 +47,16 @@ def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> Non raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start + if output == "agent": + format_agent_envelope( + console, + command="entity list", + data=results, + count=len(results), + duration_ms=int(_elapsed * 1000), + ) + return + if output == "json": format_json(console, results) return @@ -77,41 +92,39 @@ def cmd_entities_delete( output: str, ) -> None: """Delete an entity and all its memories (cascade delete).""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("entity delete") + if is_agent_mode(): + output = "agent" + if not force: + print_error(err_console, "Destructive operation requires --force in agent mode.") + raise typer.Exit(1) if not any([user_id, agent_id, app_id, run_id]): print_error( err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id." ) raise typer.Exit(1) + scope_parts = [] + if user_id: + scope_parts.append(f"user={user_id}") + if agent_id: + scope_parts.append(f"agent={agent_id}") + if app_id: + scope_parts.append(f"app={app_id}") + if run_id: + scope_parts.append(f"run={run_id}") + scope_str = ", ".join(scope_parts) + if dry_run: - scope_parts = [] - if user_id: - scope_parts.append(f"user={user_id}") - if agent_id: - scope_parts.append(f"agent={agent_id}") - if app_id: - scope_parts.append(f"app={app_id}") - if run_id: - scope_parts.append(f"run={run_id}") - scope = ", ".join(scope_parts) - print_info(console, f"Would delete entity {scope} and all its memories.") + print_info(console, f"Would delete entity {scope_str} and all its memories.") print_info(console, "No changes made (dry run).") return if not force: - scope_parts = [] - if user_id: - scope_parts.append(f"user={user_id}") - if agent_id: - scope_parts.append(f"agent={agent_id}") - if app_id: - scope_parts.append(f"app={app_id}") - if run_id: - scope_parts.append(f"run={run_id}") - scope = ", ".join(scope_parts) - confirm = typer.confirm( - f"\n \u26a0 Delete entity {scope} AND all its memories? This cannot be undone." + f"\n \u26a0 Delete entity {scope_str} AND all its memories? This cannot be undone." ) if not confirm: print_info(console, "Cancelled.") @@ -131,7 +144,25 @@ def cmd_entities_delete( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start - if output == "json": + scope = { + k: v + for k, v in { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "run_id": run_id, + }.items() + if v + } + if output == "agent": + format_agent_envelope( + console, + command="entity delete", + data={"deleted": True}, + scope=scope or None, + duration_ms=int(_elapsed * 1000), + ) + elif output == "json": format_json(console, result) elif output != "quiet": print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)") diff --git a/cli/python/src/mem0_cli/commands/events_cmd.py b/cli/python/src/mem0_cli/commands/events_cmd.py new file mode 100644 index 000000000..1e6b8f77f --- /dev/null +++ b/cli/python/src/mem0_cli/commands/events_cmd.py @@ -0,0 +1,176 @@ +"""Event commands: list and status.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from mem0_cli.backend.base import Backend +from mem0_cli.branding import ( + ACCENT_COLOR, + BRAND_COLOR, + DIM_COLOR, + ERROR_COLOR, + SUCCESS_COLOR, + WARNING_COLOR, + print_info, + timed_status, +) +from mem0_cli.output import format_agent_envelope, format_json + +console = Console() +err_console = Console(stderr=True) + +_STATUS_STYLE = { + "SUCCEEDED": f"[{SUCCESS_COLOR}]SUCCEEDED[/]", + "PENDING": f"[{ACCENT_COLOR}]PENDING[/]", + "FAILED": f"[{ERROR_COLOR}]FAILED[/]", + "PROCESSING": f"[{WARNING_COLOR}]PROCESSING[/]", +} + + +def _status_styled(status: str) -> str: + return _STATUS_STYLE.get(status.upper(), status) + + +def cmd_event_list(backend: Backend, *, output: str = "table") -> None: + """List recent background events.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("event list") + if is_agent_mode(): + output = "agent" + import time as _time + + _start = _time.perf_counter() + with timed_status(err_console, "Fetching events...") as _ts: + try: + results = backend.list_events() + except Exception as e: + _ts.error_msg = str(e) + raise typer.Exit(1) from None + + _elapsed = _time.perf_counter() - _start + + if output == "agent": + format_agent_envelope( + console, + command="event list", + data=results, + count=len(results), + duration_ms=int(_elapsed * 1000), + ) + return + + if output == "json": + format_json(console, results) + return + + if not results: + console.print() + print_info(console, "No events found.") + console.print() + return + + table = Table( + border_style=BRAND_COLOR, + header_style=f"bold {ACCENT_COLOR}", + row_styles=["", "dim"], + padding=(0, 1), + ) + table.add_column("Event ID", style="dim", max_width=10, no_wrap=True) + table.add_column("Type", max_width=14) + table.add_column("Status", max_width=12) + table.add_column("Latency", max_width=10, justify="right") + table.add_column("Created", max_width=20) + + for ev in results: + ev_id = str(ev.get("id", ""))[:8] + ev_type = str(ev.get("event_type", "—")) + status = str(ev.get("status", "—")) + latency = ev.get("latency") + latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—" + created = str(ev.get("created_at", "—"))[:19].replace("T", " ") + table.add_row(ev_id, ev_type, _status_styled(status), latency_str, created) + + console.print() + console.print(table) + console.print(f" [{DIM_COLOR}]{len(results)} event{'s' if len(results) != 1 else ''}[/]") + console.print() + + +def cmd_event_status(backend: Backend, event_id: str, *, output: str = "text") -> None: + """Get the status of a specific background event.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("event status") + if is_agent_mode(): + output = "agent" + import time as _time + + _start = _time.perf_counter() + with timed_status(err_console, "Fetching event...") as _ts: + try: + ev = backend.get_event(event_id) + except Exception as e: + _ts.error_msg = str(e) + raise typer.Exit(1) from None + + _elapsed = _time.perf_counter() - _start + + if output == "agent": + format_agent_envelope( + console, + command="event status", + data=ev, + duration_ms=int(_elapsed * 1000), + ) + return + + if output == "json": + format_json(console, ev) + return + + status = str(ev.get("status", "—")) + ev_type = str(ev.get("event_type", "—")) + latency = ev.get("latency") + latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—" + created = str(ev.get("created_at", "—"))[:19].replace("T", " ") + updated = str(ev.get("updated_at", "—"))[:19].replace("T", " ") + results = ev.get("results") + + lines = [] + lines.append(f" [{DIM_COLOR}]Event ID:[/] {event_id}") + lines.append(f" [{DIM_COLOR}]Type:[/] {ev_type}") + lines.append(f" [{DIM_COLOR}]Status:[/] {_status_styled(status)}") + lines.append(f" [{DIM_COLOR}]Latency:[/] {latency_str}") + lines.append(f" [{DIM_COLOR}]Created:[/] {created}") + lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}") + + if results: + lines.append("") + lines.append(f" [{DIM_COLOR}]Results ({len(results)}):[/]") + for r in results: + mem_id = str(r.get("id", ""))[:8] + data = r.get("data", {}) + memory = data.get("memory", "") if isinstance(data, dict) else str(data) + ev_name = str(r.get("event", "")) + user = str(r.get("user_id", "")) + detail = f"{ev_name} {memory}" + if user: + detail += f" [{DIM_COLOR}](user_id={user})[/]" + lines.append(f" [{SUCCESS_COLOR}]·[/] {detail} [{DIM_COLOR}]({mem_id})[/]") + + content = "\n".join(lines) + panel = Panel( + content, + title=f"[{BRAND_COLOR}]Event Status[/]", + title_align="left", + border_style=BRAND_COLOR, + padding=(1, 1), + ) + console.print() + console.print(panel) + console.print() diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index 48570a2f5..ec7bb9464 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -19,7 +19,7 @@ from mem0_cli.branding import ( print_info, print_success, ) -from mem0_cli.config import DEFAULT_BASE_URL, Mem0Config, save_config +from mem0_cli.config import CONFIG_FILE, DEFAULT_BASE_URL, Mem0Config, load_config, save_config console = Console() err_console = Console(stderr=True) @@ -169,6 +169,7 @@ def run_init( user_id: str | None = None, email: str | None = None, code: str | None = None, + force: bool = False, ) -> None: """Interactive setup wizard for mem0 CLI. @@ -184,6 +185,29 @@ def run_init( print_error(err_console, "--code requires --email.") raise typer.Exit(1) + # Warn if an existing config with an API key would be overwritten + if not force and CONFIG_FILE.exists(): + existing = load_config() + if existing.platform.api_key: + from mem0_cli.config import redact_key + + console.print( + f"\n [{BRAND_COLOR}]Existing configuration found[/] " + f"[{DIM_COLOR}](API key: {redact_key(existing.platform.api_key)})[/]" + ) + if sys.stdin.isatty(): + confirm = typer.confirm(" Overwrite existing config? This cannot be undone.") + if not confirm: + print_info(console, "Cancelled. Use --force to skip this check.") + raise typer.Exit(0) + else: + print_error( + err_console, + "Existing config would be overwritten.", + hint="Use --force to overwrite.", + ) + raise typer.Exit(1) + # ── Email login flow ────────────────────────────────────────────── if email: if api_key: @@ -205,7 +229,9 @@ def run_init( raise typer.Exit(1) config.platform.api_key = api_key_val config.platform.base_url = base_url - config.defaults.user_id = user_id or "mem0-cli" + config.defaults.user_id = ( + user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" + ) save_config(config) @@ -220,6 +246,17 @@ def run_init( # ── API key flow (existing) ─────────────────────────────────────── + # Non-TTY: resolve defaults so partial flags work in pipelines / CI + if not sys.stdin.isatty(): + if not api_key: + print_error( + err_console, + "Non-interactive terminal detected and --api-key is required.", + hint="Run: mem0 init --api-key [--user-id ]", + ) + raise typer.Exit(1) + user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" + # Fully non-interactive when both flags provided if api_key and user_id: config.platform.api_key = api_key @@ -229,15 +266,6 @@ def run_init( print_success(console, "Configuration saved to ~/.mem0/config.json") return - # Non-TTY without full flags -> error - if not sys.stdin.isatty() and (not api_key or not user_id): - print_error( - err_console, - "Non-interactive terminal detected and required flags missing.", - hint="Run: mem0 init --api-key --user-id ", - ) - raise typer.Exit(1) - print_banner(console) console.print() print_info(console, "Welcome! Let's set up your mem0 CLI.\n") @@ -271,7 +299,9 @@ def run_init( raise typer.Exit(1) config.platform.api_key = api_key_val config.platform.base_url = base_url - config.defaults.user_id = user_id or "mem0-cli" + config.defaults.user_id = ( + user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" + ) save_config(config) @@ -331,9 +361,10 @@ def _setup_defaults(config: Mem0Config) -> None: console.print() print_info(console, "Set default entity IDs (press Enter to skip).\n") + _default_user = os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" user_id = Prompt.ask( f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]", - default="mem0-cli", + default=_default_user, ) if user_id: config.defaults.user_id = user_id @@ -357,7 +388,7 @@ def _validate_platform(config: Mem0Config) -> None: print_error( err_console, f"Could not connect: {status.get('error', 'Unknown error')}", - hint="Check your API key and try again.", + hint="Visit https://app.mem0.ai/dashboard/api-keys to get a new key, then run mem0 init again.", ) except Exception as e: print_error(err_console, f"Connection test failed: {e}") diff --git a/cli/python/src/mem0_cli/commands/memory.py b/cli/python/src/mem0_cli/commands/memory.py index 3f4a775e2..779447a0e 100644 --- a/cli/python/src/mem0_cli/commands/memory.py +++ b/cli/python/src/mem0_cli/commands/memory.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import os +import stat as _stat_mod import sys import time as _time from pathlib import Path @@ -20,6 +22,7 @@ from mem0_cli.branding import ( ) from mem0_cli.output import ( format_add_result, + format_agent_envelope, format_json, format_memories_table, format_memories_text, @@ -31,6 +34,19 @@ console = Console() err_console = Console(stderr=True) +def _stdin_is_piped() -> bool: + """Return True only when stdin is an actual pipe or file redirect.""" + from mem0_cli.state import is_agent_mode + + if is_agent_mode(): + return False + try: + mode = os.fstat(sys.stdin.fileno()).st_mode + return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode) + except Exception: + return False + + def cmd_add( backend: Backend, text: str | None, @@ -50,6 +66,11 @@ def cmd_add( output: str = "text", ) -> None: """Add a memory.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("add") + if is_agent_mode(): + output = "agent" msgs = None content = text @@ -70,8 +91,8 @@ def cmd_add( print_error(err_console, f"Invalid JSON in --messages: {e}") raise typer.Exit(1) from None - # Read from stdin if no text and stdin is piped - elif not content and not sys.stdin.isatty(): + # Read from stdin only if stdin is an actual pipe or file redirect + elif not content and _stdin_is_piped(): content = sys.stdin.read().strip() if not content and not msgs: @@ -133,18 +154,61 @@ def cmd_add( if output == "quiet": return + # Deduplicate PENDING entries sharing the same event_id across all output modes + results_list = result if isinstance(result, list) else result.get("results", [result]) + seen_events: set[str] = set() + deduped: list[dict] = [] + for r in results_list: + if r.get("status") == "PENDING": + eid = r.get("event_id", "") + if eid and eid in seen_events: + continue + if eid: + seen_events.add(eid) + deduped.append(r) + # Write back so downstream formatters see deduplicated data + if isinstance(result, dict) and "results" in result: + result = {**result, "results": deduped} + else: + result = deduped + + if output == "agent": + scope = { + k: v + for k, v in { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "run_id": run_id, + }.items() + if v + } + format_agent_envelope( + console, + command="add", + data=deduped, + scope=scope or None, + count=len(deduped), + ) + return + if output == "json": format_add_result(console, result, output) return console.print() print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id) - # Count results - results = result if isinstance(result, list) else result.get("results", [result]) - count = len(results) if results else 0 - print_success( - console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted" - ) + count = len(deduped) + all_pending = count > 0 and all(r.get("status") == "PENDING" for r in deduped) + if all_pending: + print_success( + console, + f"Memory queued — {count} event{'s' if count != 1 else ''} pending", + ) + else: + print_success( + console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted" + ) format_add_result(console, result, output) @@ -166,6 +230,11 @@ def cmd_search( output: str = "text", ) -> None: """Search memories.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("search") + if is_agent_mode(): + output = "agent" filters = None if filter_json: try: @@ -210,6 +279,27 @@ def cmd_search( if output == "quiet": return + if output == "agent": + scope = { + k: v + for k, v in { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "run_id": run_id, + }.items() + if v + } + format_agent_envelope( + console, + command="search", + data=results, + scope=scope or None, + count=len(results), + duration_ms=int(_elapsed * 1000), + ) + return + if output == "json": format_json(console, results) elif output == "table": @@ -236,6 +326,11 @@ def cmd_search( def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None: """Get a specific memory by ID.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("get") + if is_agent_mode(): + output = "agent" with timed_status(err_console, "Fetching memory...") as _ts: try: result = backend.get(memory_id) @@ -243,7 +338,10 @@ def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None: print_error(err_console, str(e)) raise typer.Exit(1) from None - format_single_memory(console, result, output) + if output == "agent": + format_agent_envelope(console, command="get", data=result) + else: + format_single_memory(console, result, output) def cmd_list( @@ -262,6 +360,11 @@ def cmd_list( output: str = "table", ) -> None: """List memories.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("list") + if is_agent_mode(): + output = "agent" if page_size < 1: print_error(err_console, "--page-size must be >= 1.") raise typer.Exit(1) @@ -292,15 +395,24 @@ def cmd_list( if output == "quiet": return - if output == "json": - from mem0_cli.output import format_json_envelope - - format_json_envelope( + if output in ("json", "agent"): + scope = { + k: v + for k, v in { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "run_id": run_id, + }.items() + if v + } + format_agent_envelope( console, command="list", data=results, + scope=scope or None, count=len(results), - scope={k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}, + duration_ms=int(_elapsed * 1000), ) elif output == "table": if results: @@ -343,6 +455,11 @@ def cmd_update( output: str, ) -> None: """Update a memory.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("update") + if is_agent_mode(): + output = "agent" meta = None if metadata: try: @@ -360,7 +477,14 @@ def cmd_update( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start - if output == "json": + if output == "agent": + format_agent_envelope( + console, + command="update", + data=result, + duration_ms=int(_elapsed * 1000), + ) + elif output == "json": format_json(console, result) elif output != "quiet": print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)") @@ -375,6 +499,11 @@ def cmd_delete( output: str, ) -> None: """Delete a single memory by ID.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("delete") + if is_agent_mode(): + output = "agent" if dry_run: # Fetch and display what would be deleted try: @@ -395,7 +524,14 @@ def cmd_delete( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start - if output == "json": + if output == "agent": + format_agent_envelope( + console, + command="delete", + data={"id": memory_id, "deleted": True}, + duration_ms=int(_elapsed * 1000), + ) + elif output == "json": format_json(console, result) elif output != "quiet": print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)") @@ -414,13 +550,17 @@ def cmd_delete_all( output: str, ) -> None: """Delete all memories matching a scope.""" + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("delete-all") + if is_agent_mode(): + output = "agent" + if not force: + print_error(err_console, "Destructive operation requires --force in agent mode.") + raise typer.Exit(1) if all_: # Project-wide wipe using wildcard entity IDs - if dry_run: - print_info(console, "Would delete ALL memories project-wide.") - print_info(console, "Run without --dry-run to see the actual count.") - print_info(console, "No changes made (dry run).") - return + # Note: --dry-run is ignored here because the API has no count-before-delete endpoint. if not force: confirm = typer.confirm( @@ -445,7 +585,14 @@ def cmd_delete_all( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start - if output == "json": + if output == "agent": + format_agent_envelope( + console, + command="delete-all", + data={"deleted": True, "scope": "project"}, + duration_ms=int(_elapsed * 1000), + ) + elif output == "json": format_json(console, result) elif output != "quiet": if isinstance(result, dict) and "message" in result: @@ -503,7 +650,25 @@ def cmd_delete_all( raise typer.Exit(1) from None _elapsed = _time.perf_counter() - _start - if output == "json": + scope = { + k: v + for k, v in { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "run_id": run_id, + }.items() + if v + } + if output == "agent": + format_agent_envelope( + console, + command="delete-all", + data={"deleted": True}, + scope=scope or None, + duration_ms=int(_elapsed * 1000), + ) + elif output == "json": format_json(console, result) elif output != "quiet": if isinstance(result, dict) and "message" in result: diff --git a/cli/python/src/mem0_cli/commands/utils.py b/cli/python/src/mem0_cli/commands/utils.py index ef03906e3..5016f5f8f 100644 --- a/cli/python/src/mem0_cli/commands/utils.py +++ b/cli/python/src/mem0_cli/commands/utils.py @@ -35,22 +35,26 @@ def cmd_status( output: str = "text", ) -> None: """Check connectivity and auth.""" - from mem0_cli.output import format_json_envelope + from mem0_cli.output import format_agent_envelope + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("status") + if is_agent_mode(): + output = "agent" _start = _time.perf_counter() with timed_status(err_console, "Checking connection...") as _ts: result = backend.status(user_id=user_id, agent_id=agent_id) _elapsed = _time.perf_counter() - _start - if output == "json": - format_json_envelope( + if output in ("json", "agent"): + format_agent_envelope( console, command="status", data={ "connected": result.get("connected", False), "backend": result.get("backend", "?"), "base_url": result.get("base_url", ""), - "latency_ms": int(_elapsed * 1000), }, duration_ms=int(_elapsed * 1000), ) @@ -67,6 +71,14 @@ def cmd_status( lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}") if result.get("error"): lines.append(f" [{ERROR_COLOR}]Error:[/] {result['error']}") + if "Authentication failed" in str(result["error"]): + lines.append("") + lines.append( + f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key[/]" + ) + lines.append( + f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys[/bold][/]" + ) lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s") content = "\n".join(lines) @@ -96,7 +108,12 @@ def cmd_import( output: str = "text", ) -> None: """Import memories from a JSON file.""" - from mem0_cli.output import format_json_envelope + from mem0_cli.output import format_agent_envelope + from mem0_cli.state import is_agent_mode, set_current_command + + set_current_command("import") + if is_agent_mode(): + output = "agent" try: data = json.loads(Path(file_path).read_text()) @@ -129,11 +146,13 @@ def cmd_import( failed += 1 _elapsed = _time.perf_counter() - _start - if output == "json": - format_json_envelope( + if output in ("json", "agent"): + scope = {k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v} + format_agent_envelope( console, command="import", - data={"added": added, "failed": failed, "duration_s": round(_elapsed, 2)}, + data={"added": added, "failed": failed}, + scope=scope or None, duration_ms=int(_elapsed * 1000), ) return diff --git a/cli/python/src/mem0_cli/output.py b/cli/python/src/mem0_cli/output.py index fc773f919..dd023e194 100644 --- a/cli/python/src/mem0_cli/output.py +++ b/cli/python/src/mem0_cli/output.py @@ -155,16 +155,23 @@ def format_add_result(console: Console, result: dict | list, output: str = "text return console.print() + seen_pending_events: set[str] = set() for r in results: # Detect async PENDING response from Platform API if r.get("status") == "PENDING": - event_id = r.get("event_id", "")[:8] + event_id = r.get("event_id", "") + # Deduplicate PENDING entries with the same event_id + if event_id and event_id in seen_pending_events: + continue + if event_id: + seen_pending_events.add(event_id) icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]" parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"] parts.append("[white]Processing in background[/]") - if event_id: - parts.append(f"[{DIM_COLOR}](event {event_id})[/]") console.print(" ".join(parts)) + if event_id: + console.print(f" [{DIM_COLOR}] event_id: {event_id}[/]") + console.print(f" [{DIM_COLOR}] → Check status: mem0 event status {event_id}[/]") continue event = r.get("event", "ADD") @@ -225,6 +232,100 @@ def format_json_envelope( console.print_json(json.dumps(envelope, default=str)) +def sanitize_agent_data(command: str, data: Any) -> Any: + """Project API response data to minimal relevant fields for agent consumption.""" + + def pick(obj: dict, keys: list) -> dict: + return {k: obj[k] for k in keys if k in obj} + + if data is None: + return data + + if command == "add": + items = data if isinstance(data, list) else [data] + result = [] + for item in items: + if item.get("status") == "PENDING": + result.append(pick(item, ["status", "event_id"])) + else: + result.append(pick(item, ["id", "memory", "event"])) + return result + + if command == "search": + return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data] + + if command == "list": + return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data] + + if command == "get": + return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"]) + + if command == "update": + return pick(data, ["id", "memory"]) + + if command in ("delete", "delete-all", "entity delete"): + return data + + if command == "entity list": + result = [] + for r in data: + item = pick(r, ["type", "count"]) + item["name"] = r.get("name") or r.get("id", "") + result.append(item) + return result + + if command == "event list": + return [pick(r, ["id", "event_type", "status", "latency", "created_at"]) for r in data] + + if command == "event status": + ev = data + raw_results = ev.get("results") or [] + sanitized_results = [] + for r in raw_results: + nested = r.get("data") or {} + memory = nested.get("memory") if isinstance(nested, dict) else None + sanitized_results.append( + { + "id": r.get("id"), + "event": r.get("event"), + "user_id": r.get("user_id"), + "memory": memory, + } + ) + result = pick(ev, ["id", "event_type", "status", "latency", "created_at", "updated_at"]) + result["results"] = sanitized_results + return result + + # Pass-through: status, import, config show/get/set + return data + + +def format_agent_envelope( + console: Console, + *, + command: str, + data: Any, + duration_ms: int | None = None, + scope: dict | None = None, + count: int | None = None, +) -> None: + """Output structured JSON envelope for agent/programmatic use (--json/--agent mode).""" + envelope: dict[str, Any] = { + "status": "success", + "command": command, + } + if duration_ms is not None: + envelope["duration_ms"] = duration_ms + if scope: + filtered = {k: v for k, v in scope.items() if v} + if filtered: + envelope["scope"] = filtered + if count is not None: + envelope["count"] = count + envelope["data"] = sanitize_agent_data(command, data) + console.print_json(json.dumps(envelope, default=str)) + + def print_result_summary( console: Console, count: int, @@ -237,7 +338,7 @@ def print_result_summary( parts = [f"{count} result{'s' if count != 1 else ''}"] if page is not None: parts.append(f"page {page}") - scope_parts = [f"{k.replace('_', ' ')}={v}" for k, v in scope_ids.items() if v] + scope_parts = [f"{k}={v}" for k, v in scope_ids.items() if v] if scope_parts: parts.append(", ".join(scope_parts)) if duration_secs is not None: diff --git a/cli/python/src/mem0_cli/state.py b/cli/python/src/mem0_cli/state.py new file mode 100644 index 000000000..c84f34c3e --- /dev/null +++ b/cli/python/src/mem0_cli/state.py @@ -0,0 +1,24 @@ +"""Agent mode state — set by the root callback, read by commands and branding.""" + +from __future__ import annotations + +_agent_mode: bool = False +_current_command: str = "" + + +def is_agent_mode() -> bool: + return _agent_mode + + +def set_agent_mode(val: bool) -> None: + global _agent_mode + _agent_mode = val + + +def get_current_command() -> str: + return _current_command + + +def set_current_command(name: str) -> None: + global _current_command + _current_command = name diff --git a/cli/python/tests/conftest.py b/cli/python/tests/conftest.py index 7eb36ed3e..9ac3a0909 100644 --- a/cli/python/tests/conftest.py +++ b/cli/python/tests/conftest.py @@ -96,6 +96,43 @@ def mock_backend(): {"name": "alice", "count": 5}, {"name": "bob", "count": 3}, ] + backend.list_events.return_value = [ + { + "id": "evt-abc-123-def-456", + "event_type": "ADD", + "status": "SUCCEEDED", + "graph_status": None, + "latency": 1234.5, + "created_at": "2026-04-01T10:00:00Z", + "updated_at": "2026-04-01T10:00:01Z", + }, + { + "id": "evt-def-456-ghi-789", + "event_type": "SEARCH", + "status": "PENDING", + "graph_status": None, + "latency": None, + "created_at": "2026-04-01T10:01:00Z", + "updated_at": "2026-04-01T10:01:00Z", + }, + ] + backend.get_event.return_value = { + "id": "evt-abc-123-def-456", + "event_type": "ADD", + "status": "SUCCEEDED", + "graph_status": "SUCCEEDED", + "latency": 1234.5, + "created_at": "2026-04-01T10:00:00Z", + "updated_at": "2026-04-01T10:00:01Z", + "results": [ + { + "id": "mem-abc-123", + "event": "ADD", + "user_id": "alice", + "data": {"memory": "User prefers dark mode"}, + } + ], + } return backend diff --git a/cli/python/tests/test_commands.py b/cli/python/tests/test_commands.py index 1e064f5b8..8d3f35c44 100644 --- a/cli/python/tests/test_commands.py +++ b/cli/python/tests/test_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import typing from io import StringIO from unittest.mock import patch @@ -16,6 +17,7 @@ from mem0_cli.commands.config_cmd import ( cmd_config_show, ) from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list +from mem0_cli.commands.events_cmd import cmd_event_list, cmd_event_status from mem0_cli.commands.memory import ( cmd_add, cmd_delete, @@ -176,30 +178,28 @@ class TestAddCommand: def test_add_no_content_exits(self, mock_backend): console, _buf = _make_console() err_console, _err_buf = _make_err_console() - # Patch stdin.isatty to return True so it doesn't try to read stdin with ( patch("mem0_cli.commands.memory.console", console), patch("mem0_cli.commands.memory.err_console", err_console), - patch("mem0_cli.commands.memory.sys") as mock_sys, + patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False), + pytest.raises((SystemExit, ClickExit)), ): - mock_sys.stdin.isatty.return_value = True - with pytest.raises((SystemExit, ClickExit)): - cmd_add( - mock_backend, - None, - user_id="alice", - agent_id=None, - app_id=None, - run_id=None, - messages=None, - file=None, - metadata=None, - immutable=False, - no_infer=False, - expires=None, - categories=None, - output="text", - ) + cmd_add( + mock_backend, + None, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) def test_add_invalid_metadata_json(self, mock_backend): console, _buf = _make_console() @@ -279,6 +279,66 @@ class TestAddCommand: mock_backend.add.assert_called_once() +class TestAddDeduplicatesPending: + """Ensure duplicate PENDING entries with the same event_id are collapsed.""" + + DUPLICATE_PENDING: typing.ClassVar[dict] = { + "results": [ + {"status": "PENDING", "event_id": "evt-dup"}, + {"status": "PENDING", "event_id": "evt-dup"}, + ] + } + + def _run_add(self, mock_backend, output): + mock_backend.add.return_value = self.DUPLICATE_PENDING + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output=output, + ) + return buf.getvalue() + + def test_text_shows_one_pending(self, mock_backend): + raw = self._run_add(mock_backend, "text") + assert raw.count("Queued") == 1 + + def test_json_shows_one_pending(self, mock_backend): + raw = self._run_add(mock_backend, "json") + data = json.loads(raw) + results = data.get("results", data) + pending = [r for r in results if r.get("status") == "PENDING"] + assert len(pending) == 1 + + def test_agent_shows_one_pending(self, mock_backend): + from mem0_cli.state import set_agent_mode + + set_agent_mode(True) + try: + raw = self._run_add(mock_backend, "agent") + finally: + set_agent_mode(False) + data = json.loads(raw) + assert data["count"] == 1 + assert len(data["data"]) == 1 + + class TestSearchCommand: def test_search_text(self, mock_backend): console, buf = _make_console() @@ -618,28 +678,6 @@ class TestDeleteAllCommand: run_id="*", ) - def test_delete_all_project_wide_dry_run(self, mock_backend): - console, buf = _make_console() - err_console, _err_buf = _make_err_console() - with ( - patch("mem0_cli.commands.memory.console", console), - patch("mem0_cli.commands.memory.err_console", err_console), - ): - cmd_delete_all( - mock_backend, - force=True, - all_=True, - dry_run=True, - user_id=None, - agent_id=None, - app_id=None, - run_id=None, - output="text", - ) - output = buf.getvalue() - assert "project-wide" in output.lower() - mock_backend.delete.assert_not_called() - def test_delete_all_project_wide_async_response(self, mock_backend): mock_backend.delete.return_value = {"message": "Memories deletion started..."} console, buf = _make_console() @@ -888,6 +926,28 @@ class TestEntitiesDeleteCommand: output = buf.getvalue() assert "deleted" in output.lower() + def test_delete_entity_agent_id(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.entities.console", console), + patch("mem0_cli.commands.entities.err_console", err_console), + ): + cmd_entities_delete( + mock_backend, + user_id=None, + agent_id="bot1", + app_id=None, + run_id=None, + force=True, + output="text", + ) + mock_backend.delete_entities.assert_called_once_with( + user_id=None, agent_id="bot1", app_id=None, run_id=None + ) + output = buf.getvalue() + assert "deleted" in output.lower() + def test_delete_entity_no_id_exits(self, mock_backend): console, _buf = _make_console() err_console, _err_buf = _make_err_console() @@ -1024,3 +1084,388 @@ class TestEnableGraph: ) call_kwargs = mock_backend.list_memories.call_args assert call_kwargs.kwargs.get("enable_graph") is True + + +class TestEventCommands: + def test_event_list_table(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_list(mock_backend, output="table") + out = buf.getvalue() + assert "evt-abc-" in out + assert "ADD" in out + assert "SUCCEEDED" in out + + def test_event_list_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_list(mock_backend, output="json") + out = buf.getvalue() + assert "evt-abc-123-def-456" in out + assert "evt-def-456-ghi-789" in out + + def test_event_list_empty(self, mock_backend): + mock_backend.list_events.return_value = [] + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_list(mock_backend, output="table") + out = buf.getvalue() + assert "No events" in out + + def test_event_status_text(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text") + out = buf.getvalue() + assert "evt-abc-123-def-456" in out + assert "SUCCEEDED" in out + + def test_event_status_json(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_status(mock_backend, "evt-abc-123-def-456", output="json") + out = buf.getvalue() + assert "evt-abc-123-def-456" in out + assert "ADD" in out + + +class TestAgentMode: + """Tests for --json/--agent mode: structured JSON envelope output.""" + + def setup_method(self): + """Enable agent mode before each test.""" + from mem0_cli.state import set_agent_mode + + set_agent_mode(True) + + def teardown_method(self): + """Reset agent mode after each test.""" + from mem0_cli.state import set_agent_mode + + set_agent_mode(False) + + # ── add ────────────────────────────────────────────────────────────────── + + def test_add_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "I prefer dark mode", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", # will be overridden to "agent" + ) + raw = buf.getvalue() + data = json.loads(raw) + assert data["status"] == "success" + assert data["command"] == "add" + assert "data" in data + assert isinstance(data["data"], list) + assert data["count"] == 1 + assert set(data["data"][0].keys()) == {"id", "memory", "event"} + + def test_add_agent_mode_scope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "test", + user_id="bob", + agent_id="agent1", + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + data = json.loads(buf.getvalue()) + assert data["scope"]["user_id"] == "bob" + assert data["scope"]["agent_id"] == "agent1" + + # ── search ─────────────────────────────────────────────────────────────── + + def test_search_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_search( + mock_backend, + "dark mode", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=10, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="text", + ) + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "search" + assert isinstance(data["data"], list) + assert data["count"] == 2 + assert "duration_ms" in data + assert set(data["data"][0].keys()) == {"id", "memory", "score", "created_at", "categories"} + + # ── list ───────────────────────────────────────────────────────────────── + + def test_list_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_list( + mock_backend, + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + page=1, + page_size=100, + category=None, + after=None, + before=None, + output="table", # will be overridden to "agent" + ) + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "list" + assert isinstance(data["data"], list) + assert data["count"] == 2 + assert data["scope"]["user_id"] == "alice" + assert set(data["data"][0].keys()) == {"id", "memory", "created_at", "categories"} + + # ── get ────────────────────────────────────────────────────────────────── + + def test_get_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_get(mock_backend, "abc-123-def-456", output="text") + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "get" + assert isinstance(data["data"], dict) + assert data["data"]["id"] == "abc-123-def-456" + assert "memory" in data["data"] + assert set(data["data"].keys()) >= {"id", "memory"} + + # ── update ─────────────────────────────────────────────────────────────── + + def test_update_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_update(mock_backend, "abc-123", "Updated content", metadata=None, output="text") + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "update" + assert isinstance(data["data"], dict) + assert "memory" in data["data"] + assert "duration_ms" in data + + # ── delete ─────────────────────────────────────────────────────────────── + + def test_delete_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_delete(mock_backend, "abc-123-def-456", output="text") + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "delete" + assert data["data"]["id"] == "abc-123-def-456" + assert data["data"]["deleted"] is True + assert "duration_ms" in data + + # ── event list ─────────────────────────────────────────────────────────── + + def test_event_list_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_list(mock_backend, output="table") + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "event list" + assert isinstance(data["data"], list) + assert data["count"] == 2 + assert "duration_ms" in data + assert set(data["data"][0].keys()) == { + "id", + "event_type", + "status", + "latency", + "created_at", + } + + # ── event status ───────────────────────────────────────────────────────── + + def test_event_status_agent_mode_envelope(self, mock_backend): + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.events_cmd.console", console), + patch("mem0_cli.commands.events_cmd.err_console", err_console), + ): + cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text") + data = json.loads(buf.getvalue()) + assert data["status"] == "success" + assert data["command"] == "event status" + assert isinstance(data["data"], dict) + assert data["data"]["id"] == "evt-abc-123-def-456" + assert "duration_ms" in data + assert set(data["data"]["results"][0].keys()) == {"id", "event", "user_id", "memory"} + assert "data" not in data["data"]["results"][0] + + # ── error handling ─────────────────────────────────────────────────────── + + def test_error_in_agent_mode_produces_json_to_stdout(self, mock_backend): + """Errors in agent mode must emit a JSON envelope to stdout, not stderr.""" + from io import StringIO + + mock_backend.get.side_effect = Exception("Memory not found") + console, _buf = _make_console() + err_console, _err_buf = _make_err_console() + + captured_stdout = StringIO() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + patch("sys.stdout", captured_stdout), + pytest.raises((SystemExit, ClickExit)), + ): + cmd_get(mock_backend, "bad-id", output="text") + + stdout_output = captured_stdout.getvalue() + # The error JSON envelope must be on stdout + error_data = json.loads(stdout_output) + assert error_data["status"] == "error" + assert "error" in error_data + assert error_data["data"] is None + + def test_branding_suppressed_in_agent_mode(self, mock_backend): + """Scope line and success message must be absent in agent mode output.""" + console, buf = _make_console() + err_console, _err_buf = _make_err_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console), + ): + cmd_add( + mock_backend, + "branding test", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + messages=None, + file=None, + metadata=None, + immutable=False, + no_infer=False, + expires=None, + categories=None, + output="text", + ) + output = buf.getvalue() + # Must be valid JSON only — no human-readable branding + data = json.loads(output) + assert data["status"] == "success" + # "Scope:" and "Memory processed" must NOT appear in the raw output + assert "Scope:" not in output + assert "Memory processed" not in output + assert "spinner" not in output.lower() + + def test_no_spinner_in_agent_mode(self, mock_backend): + """timed_status must not emit spinner output in agent mode.""" + err_buf = StringIO() + err_console_buf = Console(file=err_buf, force_terminal=False, no_color=True, width=120) + console, _buf = _make_console() + with ( + patch("mem0_cli.commands.memory.console", console), + patch("mem0_cli.commands.memory.err_console", err_console_buf), + ): + cmd_search( + mock_backend, + "query", + user_id="alice", + agent_id=None, + app_id=None, + run_id=None, + top_k=5, + threshold=0.3, + rerank=False, + keyword=False, + filter_json=None, + fields=None, + output="text", + ) + # The err_buf captures what would have been spinner/timing noise + # In agent mode it should be empty (no status lines printed) + err_output = err_buf.getvalue() + assert "Searching" not in err_output diff --git a/cli/python/tests/test_output.py b/cli/python/tests/test_output.py index e9efa0f2f..e9256ad00 100644 --- a/cli/python/tests/test_output.py +++ b/cli/python/tests/test_output.py @@ -11,6 +11,7 @@ from mem0_cli.output import ( format_memories_table, format_memories_text, format_single_memory, + sanitize_agent_data, ) @@ -129,8 +130,153 @@ class TestAddResult: output = buf.getvalue() assert output.strip() == "" + def test_format_add_result_deduplicates_pending_by_event_id(self): + console, buf = _make_console() + result = { + "results": [ + {"status": "PENDING", "event_id": "evt-dup"}, + {"status": "PENDING", "event_id": "evt-dup"}, + ] + } + format_add_result(console, result, "text") + output = buf.getvalue() + # Should show only one PENDING block despite two entries with same event_id + assert output.count("evt-dup") == 2 # event_id line + status hint line + assert output.count("Queued") == 1 + def test_format_add_result_empty(self): console, buf = _make_console() format_add_result(console, {"results": []}, "text") output = buf.getvalue() assert "No memories extracted" in output + + +class TestSanitizeAgentData: + def test_add_projects_fields(self): + raw = [ + { + "id": "abc", + "memory": "test", + "event": "ADD", + "metadata": {"x": 1}, + "categories": ["a"], + } + ] + result = sanitize_agent_data("add", raw) + assert result == [{"id": "abc", "memory": "test", "event": "ADD"}] + + def test_add_pending_passthrough(self): + raw = [{"status": "PENDING", "event_id": "evt-123", "metadata": "noise"}] + result = sanitize_agent_data("add", raw) + assert result == [{"status": "PENDING", "event_id": "evt-123"}] + + def test_search_projects_fields(self): + raw = [ + { + "id": "abc", + "memory": "test", + "score": 0.9, + "created_at": "2026-01-01", + "categories": ["a"], + "user_id": "u1", + "agent_id": None, + } + ] + result = sanitize_agent_data("search", raw) + assert result == [ + { + "id": "abc", + "memory": "test", + "score": 0.9, + "created_at": "2026-01-01", + "categories": ["a"], + } + ] + + def test_list_projects_fields(self): + raw = [ + { + "id": "abc", + "memory": "test", + "created_at": "2026-01-01", + "categories": ["a"], + "user_id": "u1", + } + ] + result = sanitize_agent_data("list", raw) + assert result == [ + {"id": "abc", "memory": "test", "created_at": "2026-01-01", "categories": ["a"]} + ] + + def test_get_projects_fields(self): + raw = { + "id": "abc", + "memory": "test", + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "categories": ["a"], + "metadata": {"k": "v"}, + "user_id": "u1", + } + result = sanitize_agent_data("get", raw) + assert "user_id" not in result + assert "id" in result and "memory" in result + + def test_update_projects_fields(self): + raw = {"id": "abc", "memory": "updated", "extra": "noise"} + result = sanitize_agent_data("update", raw) + assert result == {"id": "abc", "memory": "updated"} + + def test_event_list_projects_fields(self): + raw = [ + { + "id": "evt-1", + "event_type": "ADD", + "status": "SUCCEEDED", + "graph_status": None, + "latency": 100.0, + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + } + ] + result = sanitize_agent_data("event list", raw) + assert result == [ + { + "id": "evt-1", + "event_type": "ADD", + "status": "SUCCEEDED", + "latency": 100.0, + "created_at": "2026-01-01", + } + ] + assert "updated_at" not in result[0] + assert "graph_status" not in result[0] + + def test_event_status_flattens_results(self): + raw = { + "id": "evt-1", + "event_type": "ADD", + "status": "SUCCEEDED", + "latency": 100.0, + "created_at": "2026-01-01", + "updated_at": "2026-01-02", + "results": [ + {"id": "mem-1", "event": "ADD", "user_id": "alice", "data": {"memory": "dark mode"}} + ], + } + result = sanitize_agent_data("event status", raw) + assert result["results"][0] == { + "id": "mem-1", + "event": "ADD", + "user_id": "alice", + "memory": "dark mode", + } + assert "data" not in result["results"][0] + + def test_passthrough_commands(self): + for cmd in ("status", "import", "config show", "config get", "config set"): + data = {"key": "value", "other": "stuff"} + assert sanitize_agent_data(cmd, data) == data + + def test_none_data(self): + assert sanitize_agent_data("add", None) is None diff --git a/docs/platform/cli.mdx b/docs/platform/cli.mdx index 1935945de..f6206917d 100644 --- a/docs/platform/cli.mdx +++ b/docs/platform/cli.mdx @@ -1,6 +1,6 @@ --- title: CLI -description: "Manage memories from your terminal. Available for Node.js and Python." +description: "Manage memories from your terminal — built for both humans and AI agents." icon: "terminal" iconType: "solid" --- @@ -9,6 +9,10 @@ The mem0 CLI lets you add, search, list, update, and delete memories directly fr Both implementations provide identical behavior — same commands, same options, same output formats. + +**Built for AI agents.** Pass `--agent` (or `--json`) as a global flag on any command to get structured JSON output optimized for programmatic consumption — sanitized fields, no colors or spinners, and errors as JSON too. Drop it into any agent tool loop with zero extra parsing. + + ## Installation @@ -74,8 +78,23 @@ Interactive setup wizard. Prompts for your API key and default user ID. ```bash mem0 init mem0 init --api-key m0-xxx --user-id alice +mem0 init --email alice@company.com ``` +If an existing configuration is detected, the CLI will ask for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD pipelines). + +```bash +mem0 init --api-key m0-xxx --user-id alice --force +``` + +| Flag | Description | +|------|-------------| +| `--api-key` | API key (skip prompt) | +| `-u, --user-id` | Default user ID (skip prompt) | +| `--email` | Login via email verification code | +| `--code` | Verification code (use with `--email` for non-interactive login) | +| `--force` | Overwrite existing config without confirmation | + ### `mem0 add` Add a memory from text, a JSON messages array, a file, or stdin. @@ -202,16 +221,32 @@ mem0 config get api_key # Get a specific value mem0 config set user_id bob # Set a value ``` -### `mem0 entities` +### `mem0 entity` -List or delete entities (users, agents, apps). +List or delete entities (users, agents, apps, runs). ```bash -mem0 entities list -mem0 entities list --type agent --output json -mem0 entities delete --user-id alice --force +mem0 entity list users +mem0 entity list agents --output json +mem0 entity delete --user-id alice --force ``` +### `mem0 event` + +Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs). + +```bash +# List recent events +mem0 event list + +# Check the status of a specific event +mem0 event status +``` + +| Flag | Description | +|------|-------------| +| `-o, --output` | Output format: `text`, `json` | + ### `mem0 status` Verify your API connection and display the current project. @@ -238,6 +273,7 @@ All commands support the `--output` flag to control how results are displayed: | `json` | Structured JSON, suitable for piping to `jq` or consumption by AI agents | | `table` | Tabular format (default for `list`) | | `quiet` | Minimal output — just IDs or status codes | +| `agent` | Structured JSON envelope with sanitized fields — set automatically by `--json`/`--agent` | Example with JSON output: @@ -247,20 +283,54 @@ mem0 search "user preferences" --user-id alice --output json | jq '.data.results ## Use with AI agents -The CLI is designed to be used by AI agents and automation tools. Two features make this straightforward: +The CLI is purpose-built for use inside AI agent tool loops. Pass `--agent` or `--json` as a global flag on **any** command to activate agent mode: -- **`--output json`** returns structured data that agents can parse directly -- **`mem0 help --json`** returns the full command tree as JSON, so agents can discover available commands programmatically +- Every command outputs a consistent JSON envelope: `{"status", "command", "duration_ms", "scope", "count", "data"}` +- The `data` field contains only the fields that matter — IDs, memory text, scores, categories. Noisy API fields are stripped. +- All human-readable output is suppressed: no spinners, no colors, no banners. +- Errors are returned as JSON to stdout with a non-zero exit code, so your agent can catch them the same way as successes. ```bash -# Agent adds a memory -mem0 add "User prefers concise responses" --user-id user-42 --output quiet - -# Agent searches memories and parses results -mem0 search "response preferences" --user-id user-42 --output json +# Drop --agent on any command and get clean, parseable JSON +mem0 --agent search "response preferences" --user-id user-42 +mem0 --agent add "User prefers concise responses" --user-id user-42 +mem0 --agent list --user-id user-42 +mem0 --agent delete --all --user-id user-42 --force ``` -For non-interactive environments (CI, agent runtimes), use `mem0 init --api-key --user-id` or set the `MEM0_API_KEY` environment variable to skip interactive prompts. + +```json Output: mem0 --agent search "dark mode" --user-id alice +{ + "status": "success", + "command": "search", + "duration_ms": 134, + "scope": { "user_id": "alice" }, + "count": 2, + "data": [ + { "id": "abc-123", "memory": "User prefers dark mode", "score": 0.97, "created_at": "2026-01-15", "categories": ["preferences"] }, + { "id": "def-456", "memory": "User uses vim keybindings", "score": 0.81, "created_at": "2026-01-10", "categories": ["tools"] } + ] +} +``` + +```json Output: mem0 --agent add "Likes concise answers" --user-id alice +{ + "status": "success", + "command": "add", + "duration_ms": 210, + "data": [ + { "id": "ghi-789", "memory": "Likes concise answers", "event": "ADD" } + ] +} +``` + + +Two other agent-friendly features: + +- **`--output json`** returns structured data without sanitization — useful when you want the full raw API response +- **`mem0 help --json`** returns the complete command tree as JSON, so agents can self-discover available commands and options + +For non-interactive environments (CI, agent runtimes), set credentials via `mem0 init --api-key m0-xxx --user-id alice --force` or the `MEM0_API_KEY` environment variable. ## Environment variables @@ -278,10 +348,12 @@ Environment variables take precedence over values in the config file, which take ## Global flags -These flags are available on all commands that interact with the API: +These flags are available on all commands: | Flag | Description | |------|-------------| +| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners | +| `--agent` | Alias for `--json` | | `--api-key` | Override the configured API key for this request | | `--base-url` | Override the configured API base URL for this request | | `-o, --output` | Set the output format |