Compare commits

..
Author SHA1 Message Date
mintlify[bot]andGitHub 3ce9a5d517 Fix broken /cli link in introduction.mdx
Generated-By: mintlify-agent
2026-03-31 00:07:47 +00:00
111 changed files with 2557 additions and 10247 deletions
+1 -2
View File
@@ -7,7 +7,6 @@ on:
jobs:
build-n-publish:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
if: startsWith(github.event.release.tag_name, 'v')
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -39,7 +38,7 @@ jobs:
# packages_dir: dist/
- name: Publish distribution 📦 to PyPI
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages_dir: dist/
-43
View File
@@ -1,43 +0,0 @@
name: Publish @mem0/cli 📦 to npm
on:
release:
types: [published]
jobs:
build-n-publish:
name: Build and publish @mem0/cli 📦 to npm
if: startsWith(github.event.release.tag_name, 'cli-node-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: cli/node
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
-100
View File
@@ -1,100 +0,0 @@
name: CLI Node CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
pull_request:
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: cli/node
run: pnpm run lint
- name: Type check
working-directory: cli/node
run: pnpm run typecheck
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Run tests
working-directory: cli/node
run: pnpm run test
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: cli/node/pnpm-lock.yaml
- name: Install dependencies
working-directory: cli/node
run: pnpm install --frozen-lockfile
- name: Build
working-directory: cli/node
run: pnpm run build
- name: Verify dist output
run: |
test -f cli/node/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
-34
View File
@@ -1,34 +0,0 @@
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
on:
release:
types: [published]
jobs:
build-n-publish:
name: Build and publish mem0-cli 📦 to PyPI
if: startsWith(github.event.release.tag_name, 'cli-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: cli/python
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Hatch
run: pip install hatch
- name: Build a binary wheel and a source tarball
run: hatch build --clean
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: cli/python/dist/
-79
View File
@@ -1,79 +0,0 @@
name: CLI Python CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
pull_request:
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Lint with ruff
working-directory: cli/python
run: ruff check .
- name: Check formatting
working-directory: cli/python
run: ruff format --check .
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
working-directory: cli/python
run: pip install -e ".[dev]"
- name: Run tests
working-directory: cli/python
run: pytest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Hatch
run: pip install hatch
- name: Build
working-directory: cli/python
run: hatch build --clean
- name: Verify dist output
run: |
ls cli/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
ls cli/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
-43
View File
@@ -1,43 +0,0 @@
name: Publish @mem0/openclaw-mem0 📦 to npm
on:
release:
types: [published]
jobs:
build-n-publish:
name: Build and publish @mem0/openclaw-mem0 📦 to npm
if: startsWith(github.event.release.tag_name, 'openclaw-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: openclaw
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to npm
run: npm publish --provenance --access public
-43
View File
@@ -1,43 +0,0 @@
name: Publish mem0ai 📦 to npm
on:
release:
types: [published]
jobs:
build-n-publish:
name: Build and publish mem0ai 📦 to npm
if: startsWith(github.event.release.tag_name, 'ts-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: mem0-ts
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: mem0-ts/pnpm-lock.yaml
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
-43
View File
@@ -1,43 +0,0 @@
name: Publish @mem0/vercel-ai-provider 📦 to npm
on:
release:
types: [published]
jobs:
build-n-publish:
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
if: startsWith(github.event.release.tag_name, 'vercel-ai-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: vercel-ai-sdk
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: vercel-ai-sdk/pnpm-lock.yaml
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: npm publish --provenance --access public
-28
View File
@@ -61,31 +61,3 @@ make test # After activating a shell with hatch shell test_XX
Make sure that all tests pass across all supported Python versions before submitting a pull request.
We look forward to your pull requests and can't wait to see your contributions!
### 🚀 Releasing
All packages are published automatically via GitHub Actions when a GitHub Release is created with the correct tag prefix.
#### Tag Prefixes
| Package | Registry | Tag Prefix | Example |
|---------|----------|------------|---------|
| `mem0ai` (Python SDK) | PyPI | `v*` | `v0.1.31` |
| `mem0-cli` (Python CLI) | PyPI | `cli-v*` | `cli-v0.2.1` |
| `mem0ai` (TypeScript SDK) | npm | `ts-v*` | `ts-v2.4.6` |
| `@mem0/cli` (Node CLI) | npm | `cli-node-v*` | `cli-node-v0.1.2` |
| `@mem0/vercel-ai-provider` | npm | `vercel-ai-v*` | `vercel-ai-v2.0.6` |
| `@mem0/openclaw-mem0` | npm | `openclaw-v*` | `openclaw-v1.0.1` |
#### How to Release
1. Bump the version in `pyproject.toml` (Python) or `package.json` (Node)
2. Create a [GitHub Release](https://github.com/mem0ai/mem0/releases/new) with the matching tag prefix
3. The correct workflow will trigger automatically — verify in the [Actions tab](https://github.com/mem0ai/mem0/actions)
#### Publishing Details
- **PyPI packages** use OIDC trusted publishing via `pypa/gh-action-pypi-publish`
- **npm packages** use OIDC trusted publishing via npm CLI (>= 11.5.1) — no tokens or secrets required
- All workflows require `permissions: id-token: write` for OIDC authentication
- First publish of a new npm package must be done manually; OIDC works for subsequent versions
+2 -2
View File
@@ -162,7 +162,7 @@ For detailed integration steps, see the [Quickstart](https://docs.mem0.ai/quicks
## 📚 Documentation & Support
- Full docs: https://docs.mem0.ai
- Community: [Discord](https://mem0.dev/DiG) · [X (formerly Twitter)](https://x.com/mem0ai)
- Community: [Discord](https://mem0.dev/DiG) · [Twitter](https://x.com/mem0ai)
- Contact: founders@mem0.ai
## Citation
@@ -180,4 +180,4 @@ We now have a paper you can cite:
## ⚖️ License
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
+15 -188
View File
@@ -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 `--json`/`--agent` global flags and `help --json` specifically for machine consumption)
- AI agents that need persistent memory (the CLI is designed with `--output json` and `help --json` specifically for machine consumption)
- DevOps/CI pipelines that need to manage memories programmatically
### Project Structure
@@ -79,7 +79,6 @@ 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)
@@ -89,7 +88,6 @@ 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
@@ -100,7 +98,6 @@ 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
```
@@ -156,25 +153,8 @@ Interactive setup wizard for mem0 CLI.
|-----------------|--------|----------|---------|------|
| `--api-key` | string | No | - | API key (skip prompt). |
| `-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).
- If `--code` is not provided, prompts for the code interactively.
- On success: receives API key, org_id, project_id. Saves to config. Creates account if email is new.
- Cannot be combined with `--api-key`.
*API key flow (existing behavior):*
- If both `--api-key` and `--user-id` are provided, runs non-interactively (no prompts).
- If running in a non-TTY without both flags, prints an error with usage hint and exits.
- Interactive mode: prints banner, prompts for API key (masked with `*`), prompts for default user ID (default: `mem0-cli`), validates connection, saves config.
@@ -184,9 +164,6 @@ 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
```
---
@@ -710,114 +687,7 @@ mem0 entity delete --user-id alice --dry-run
---
### 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 <event_id> [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`
### 3.14 `status`
Check connectivity and authentication.
@@ -838,19 +708,20 @@ Check connectivity and authentication.
| `--api-key` | string | - | Connection | Override API key. |
| `--base-url` | string | - | Connection | Override API base URL. |
**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.
**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.
**JSON output:**
```json
{
"status": "success",
"command": "status",
"duration_ms": 112,
"data": {
"connected": true,
"backend": "platform",
"base_url": "https://api.mem0.ai"
}
"base_url": "https://api.mem0.ai",
"latency_ms": 245
},
"duration_ms": 245
}
```
@@ -862,7 +733,7 @@ mem0 status -o json
---
### 3.17 `help`
### 3.15 `help`
Show help. Use `--json` for machine-readable output (for LLM agents).
@@ -922,9 +793,6 @@ 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`)
@@ -1227,8 +1095,6 @@ 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 |
@@ -1243,16 +1109,12 @@ All commands also support `agent` mode via the global `--json`/`--agent` flag, w
| 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
### 7.2 JSON Envelope Format (`formatJsonEnvelope`)
There are two related envelope formats:
**`formatJsonEnvelope`** — used by `config show`, `status`, and `import` for `--output json`:
Used by `config show`, `status`, and `import` for structured JSON output:
```json
{
@@ -1266,39 +1128,14 @@ There are two related envelope formats:
}
```
**`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": "<command_name>",
"error": "Authentication failed. Your API key may be invalid or expired.",
"data": null
}
```
Fields:
- `status`: `"success"` or `"error"`.
- `command`: The command name.
- `status`: Always `"success"` (errors go to stderr before exit).
- `command`: The command name (e.g. `"status"`, `"config show"`, `"import"`).
- `duration_ms`: Optional, elapsed time in milliseconds.
- `scope`: Optional, active entity scope (omitted if empty).
- `scope`: Optional, active entity scope.
- `count`: Optional, result count.
- `error`: Only present when `status` is `"error"`.
- `data`: The primary payload (sanitized in agent mode).
- `error`: Optional, error message string.
- `data`: The primary payload.
### 7.3 Text Output
@@ -1369,16 +1206,6 @@ 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.
+8 -46
View File
@@ -2,8 +2,6 @@
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
@@ -19,15 +17,9 @@ Both packages install a `mem0` binary with identical behavior.
## Quick start
```bash
# Interactive setup wizard
# Authenticate and save config
mem0 init
# Or login via email (get a new API key)
mem0 init --email alice@company.com
# Or authenticate with an existing API key
mem0 init --api-key m0-xxx
# Add a memory
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
@@ -48,7 +40,7 @@ mem0 delete <memory-id>
| Command | Description |
|---------|-------------|
| `mem0 init` | Setup wizard — login via email or configure API key manually |
| `mem0 init` | Interactive setup wizard — configures API key and default user ID |
| `mem0 add` | Add a memory from text, JSON messages, a file, or stdin |
| `mem0 search` | Search memories using natural language |
| `mem0 list` | List memories with optional filters and pagination |
@@ -57,45 +49,12 @@ mem0 delete <memory-id>
| `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 entity` | List or delete entities (users, agents, apps, runs) |
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
| `mem0 entities` | List or delete entities (users, agents, apps) |
| `mem0 status` | Verify API connection and display current project |
| `mem0 version` | Print the CLI version |
Run `mem0 <command> --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`:
@@ -103,10 +62,13 @@ Control how results are displayed with `--output`:
| Format | Description |
|--------|-------------|
| `text` | Human-readable with colors and formatting (default) |
| `json` | Structured JSON for piping to `jq` (raw API response) |
| `json` | Structured JSON for piping to `jq` or agent consumption |
| `table` | Tabular format (default for `list`) |
| `quiet` | Minimal — just IDs or status codes |
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
```bash
mem0 search "preferences" --user-id alice --output json | jq '.data.results[].memory'
```
## Environment variables
+5 -11
View File
@@ -10,8 +10,8 @@
"logoMini": "\u25c6 mem0",
"tagline": "The Memory Layer for AI Agents",
"colors": {
"brand": "#F1C96C",
"accent": "#F5D78E",
"brand": "#8b5cf6",
"accent": "#a78bfa",
"success": "#22c55e",
"error": "#ef4444",
"warning": "#f59e0b",
@@ -503,21 +503,15 @@
},
{
"name": "init",
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
"usage": "mem0 init [OPTIONS]",
"description": "Interactive setup wizard for mem0 CLI.",
"usage": "mem0 init",
"needsBackend": false,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"arguments": [],
"options": [
{ "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": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." }
]
"options": []
},
{
"name": "status",
+2 -7
View File
@@ -1,13 +1,13 @@
{
"name": "@mem0/cli",
"version": "0.1.2",
"version": "0.1.0",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
"mem0": "./dist/index.js"
},
"scripts": {
"build": "tsup",
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
@@ -20,11 +20,6 @@
},
"license": "Apache-2.0",
"author": "mem0.ai <founders@mem0.ai>",
"repository": {
"type": "git",
"url": "https://github.com/mem0ai/mem0",
"directory": "cli/node"
},
"keywords": ["mem0", "memory", "ai", "agents", "cli"],
"publishConfig": {
"access": "public"
+71 -84
View File
@@ -6,123 +6,110 @@ import type { Mem0Config } from "../config.js";
import { PlatformBackend } from "./platform.js";
export interface AddOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
metadata?: Record<string, unknown>;
immutable?: boolean;
infer?: boolean;
expires?: string;
categories?: string[];
enableGraph?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
metadata?: Record<string, unknown>;
immutable?: boolean;
infer?: boolean;
expires?: string;
categories?: string[];
enableGraph?: boolean;
}
export interface SearchOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
topK?: number;
threshold?: number;
rerank?: boolean;
keyword?: boolean;
filters?: Record<string, unknown>;
fields?: string[];
enableGraph?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
topK?: number;
threshold?: number;
rerank?: boolean;
keyword?: boolean;
filters?: Record<string, unknown>;
fields?: string[];
enableGraph?: boolean;
}
export interface ListOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
page?: number;
pageSize?: number;
category?: string;
after?: string;
before?: string;
enableGraph?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
page?: number;
pageSize?: number;
category?: string;
after?: string;
before?: string;
enableGraph?: boolean;
}
export interface DeleteOptions {
all?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
all?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface EntityIds {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface Backend {
add(
content?: string,
messages?: Record<string, unknown>[],
opts?: AddOptions,
): Promise<Record<string, unknown>>;
add(
content?: string,
messages?: Record<string, unknown>[],
opts?: AddOptions,
): Promise<Record<string, unknown>>;
search(
query: string,
opts?: SearchOptions,
): Promise<Record<string, unknown>[]>;
search(query: string, opts?: SearchOptions): Promise<Record<string, unknown>[]>;
get(memoryId: string): Promise<Record<string, unknown>>;
get(memoryId: string): Promise<Record<string, unknown>>;
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
listMemories(opts?: ListOptions): Promise<Record<string, unknown>[]>;
update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
delete(
memoryId?: string,
opts?: DeleteOptions,
): Promise<Record<string, unknown>>;
delete(memoryId?: string, opts?: DeleteOptions): Promise<Record<string, unknown>>;
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
status(opts?: { userId?: string; agentId?: string }): Promise<
Record<string, unknown>
>;
status(opts?: { userId?: string; agentId?: string }): Promise<Record<string, unknown>>;
entities(entityType: string): Promise<Record<string, unknown>[]>;
listEvents(): Promise<Record<string, unknown>[]>;
getEvent(eventId: string): Promise<Record<string, unknown>>;
entities(entityType: string): Promise<Record<string, unknown>[]>;
}
export class AuthError extends Error {
constructor(
message = "Authentication failed. Your API key may be invalid or expired.",
) {
super(message);
this.name = "AuthError";
}
constructor(message = "Authentication failed. Your API key may be invalid or expired.") {
super(message);
this.name = "AuthError";
}
}
export class NotFoundError extends Error {
constructor(path: string) {
super(`Resource not found: ${path}`);
this.name = "NotFoundError";
}
constructor(path: string) {
super(`Resource not found: ${path}`);
this.name = "NotFoundError";
}
}
export class APIError extends Error {
constructor(path: string, detail: string) {
super(`Bad request to ${path}: ${detail}`);
this.name = "APIError";
}
constructor(path: string, detail: string) {
super(`Bad request to ${path}: ${detail}`);
this.name = "APIError";
}
}
export function getBackend(config: Mem0Config): Backend {
return new PlatformBackend(config.platform);
return new PlatformBackend(config.platform);
}
+1 -8
View File
@@ -3,12 +3,5 @@
*/
export { getBackend } from "./base.js";
export type {
Backend,
AddOptions,
SearchOptions,
ListOptions,
DeleteOptions,
EntityIds,
} from "./base.js";
export type { Backend, AddOptions, SearchOptions, ListOptions, DeleteOptions, EntityIds } from "./base.js";
export { AuthError, NotFoundError, APIError } from "./base.js";
+266 -309
View File
@@ -4,343 +4,300 @@
import type { PlatformConfig } from "../config.js";
import {
APIError,
type AddOptions,
AuthError,
type Backend,
type DeleteOptions,
type EntityIds,
type ListOptions,
NotFoundError,
type SearchOptions,
type AddOptions,
APIError,
AuthError,
type Backend,
type DeleteOptions,
type EntityIds,
type ListOptions,
NotFoundError,
type SearchOptions,
} from "./base.js";
export class PlatformBackend implements Backend {
private baseUrl: string;
private headers: Record<string, string>;
private baseUrl: string;
private headers: Record<string, string>;
constructor(config: PlatformConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.headers = {
Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json",
};
}
constructor(config: PlatformConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.headers = {
Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json",
};
}
private async _request(
method: string,
path: string,
opts?: { json?: unknown; params?: Record<string, string> },
): Promise<unknown> {
let url = `${this.baseUrl}${path}`;
if (opts?.params) {
const qs = new URLSearchParams(opts.params).toString();
url += `?${qs}`;
}
private async _request(
method: string,
path: string,
opts?: { json?: unknown; params?: Record<string, string> },
): Promise<unknown> {
let url = `${this.baseUrl}${path}`;
if (opts?.params) {
const qs = new URLSearchParams(opts.params).toString();
url += `?${qs}`;
}
const fetchOpts: RequestInit = {
method,
headers: this.headers,
signal: AbortSignal.timeout(30_000),
};
if (opts?.json) {
fetchOpts.body = JSON.stringify(opts.json);
}
const fetchOpts: RequestInit = {
method,
headers: this.headers,
signal: AbortSignal.timeout(30_000),
};
if (opts?.json) {
fetchOpts.body = JSON.stringify(opts.json);
}
const resp = await fetch(url, fetchOpts);
const resp = await fetch(url, fetchOpts);
if (resp.status === 401) {
throw new AuthError();
}
if (resp.status === 404) {
throw new NotFoundError(path);
}
if (resp.status === 400) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail =
((body.detail ?? body.message ?? JSON.stringify(body)) as string) ??
resp.statusText;
} catch {
detail = resp.statusText;
}
throw new APIError(path, detail);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = (body.detail ?? body.message ?? resp.statusText) as string;
} catch {
/* ignore */
}
throw new Error(`HTTP ${resp.status}: ${detail}`);
}
if (resp.status === 204) {
return {};
}
return resp.json();
}
if (resp.status === 401) {
throw new AuthError();
}
if (resp.status === 404) {
throw new NotFoundError(path);
}
if (resp.status === 400) {
let detail: string;
try {
const body = await resp.json();
detail = (body as Record<string, string>).detail ?? resp.statusText;
} catch {
detail = resp.statusText;
}
throw new APIError(path, detail);
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
}
if (resp.status === 204) {
return {};
}
return resp.json();
}
async add(
content?: string,
messages?: Record<string, unknown>[],
opts: AddOptions = {},
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
async add(
content?: string,
messages?: Record<string, unknown>[],
opts: AddOptions = {},
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (messages) {
payload.messages = messages;
} else if (content) {
payload.messages = [{ role: "user", content }];
}
if (messages) {
payload.messages = messages;
} else if (content) {
payload.messages = [{ role: "user", content }];
}
if (opts.userId) payload.user_id = opts.userId;
if (opts.agentId) payload.agent_id = opts.agentId;
if (opts.appId) payload.app_id = opts.appId;
if (opts.runId) payload.run_id = opts.runId;
if (opts.metadata) payload.metadata = opts.metadata;
if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true;
if (opts.userId) payload.user_id = opts.userId;
if (opts.agentId) payload.agent_id = opts.agentId;
if (opts.appId) payload.app_id = opts.appId;
if (opts.runId) payload.run_id = opts.runId;
if (opts.metadata) payload.metadata = opts.metadata;
if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true;
return (await this._request("POST", "/v1/memories/", {
json: payload,
})) as Record<string, unknown>;
}
return (await this._request("POST", "/v1/memories/", { json: payload })) as Record<
string,
unknown
>;
}
private _buildFilters(opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
extraFilters?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
// If caller passed a pre-built filter structure, use it directly
if (
opts.extraFilters &&
("AND" in opts.extraFilters || "OR" in opts.extraFilters)
) {
return opts.extraFilters;
}
private _buildFilters(opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
extraFilters?: Record<string, unknown>;
}): Record<string, unknown> | undefined {
// If caller passed a pre-built filter structure, use it directly
if (opts.extraFilters && ("AND" in opts.extraFilters || "OR" in opts.extraFilters)) {
return opts.extraFilters;
}
const andConditions: Record<string, unknown>[] = [];
if (opts.userId) andConditions.push({ user_id: opts.userId });
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
if (opts.appId) andConditions.push({ app_id: opts.appId });
if (opts.runId) andConditions.push({ run_id: opts.runId });
const andConditions: Record<string, unknown>[] = [];
if (opts.userId) andConditions.push({ user_id: opts.userId });
if (opts.agentId) andConditions.push({ agent_id: opts.agentId });
if (opts.appId) andConditions.push({ app_id: opts.appId });
if (opts.runId) andConditions.push({ run_id: opts.runId });
if (opts.extraFilters) {
for (const [k, v] of Object.entries(opts.extraFilters)) {
andConditions.push({ [k]: v });
}
}
if (opts.extraFilters) {
for (const [k, v] of Object.entries(opts.extraFilters)) {
andConditions.push({ [k]: v });
}
}
if (andConditions.length === 1) return andConditions[0];
if (andConditions.length > 1) return { AND: andConditions };
return undefined;
}
if (andConditions.length === 1) return andConditions[0];
if (andConditions.length > 1) return { AND: andConditions };
return undefined;
}
async search(
query: string,
opts: SearchOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {
query,
top_k: opts.topK ?? 10,
threshold: opts.threshold ?? 0.3,
};
async search(query: string, opts: SearchOptions = {}): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {
query,
top_k: opts.topK ?? 10,
threshold: opts.threshold ?? 0.3,
};
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: opts.filters,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true;
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: opts.filters,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/search/", {
json: payload,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
const result = (await this._request("POST", "/v2/memories/search/", {
json: payload,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
string,
unknown
>;
}
async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<string, unknown>;
}
async listMemories(
opts: ListOptions = {},
): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {};
const params: Record<string, string> = {
page: String(opts.page ?? 1),
page_size: String(opts.pageSize ?? 100),
};
async listMemories(opts: ListOptions = {}): Promise<Record<string, unknown>[]> {
const payload: Record<string, unknown> = {};
const params: Record<string, string> = {
page: String(opts.page ?? 1),
page_size: String(opts.pageSize ?? 100),
};
const extra: Record<string, unknown> = {};
if (opts.category) {
extra.categories = { contains: opts.category };
}
if (opts.after) {
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
gte: opts.after,
};
}
if (opts.before) {
extra.created_at = {
...(extra.created_at as Record<string, unknown> | undefined),
lte: opts.before,
};
}
const extra: Record<string, unknown> = {};
if (opts.category) {
extra.categories = { contains: opts.category };
}
if (opts.after) {
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), gte: opts.after };
}
if (opts.before) {
extra.created_at = { ...(extra.created_at as Record<string, unknown> | undefined), lte: opts.before };
}
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true;
const apiFilters = this._buildFilters({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true;
const result = (await this._request("POST", "/v2/memories/", {
json: payload,
params,
})) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
const result = (await this._request("POST", "/v2/memories/", { json: payload, params })) as unknown;
if (Array.isArray(result)) return result;
const obj = result as Record<string, unknown>;
return (obj.results ?? obj.memories ?? []) as Record<string, unknown>[];
}
async update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (content) payload.text = content;
if (metadata) payload.metadata = metadata;
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
json: payload,
})) as Record<string, unknown>;
}
async update(
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (content) payload.text = content;
if (metadata) payload.metadata = metadata;
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
json: payload,
})) as Record<string, unknown>;
}
async delete(
memoryId?: string,
opts: DeleteOptions = {},
): Promise<Record<string, unknown>> {
if (opts.all) {
const params: Record<string, string> = {};
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;
return (await this._request("DELETE", "/v1/memories/", {
params,
})) as Record<string, unknown>;
}
if (memoryId) {
return (await this._request(
"DELETE",
`/v1/memories/${memoryId}/`,
)) as Record<string, unknown>;
}
throw new Error("Either memoryId or --all is required");
}
async delete(
memoryId?: string,
opts: DeleteOptions = {},
): Promise<Record<string, unknown>> {
if (opts.all) {
const params: Record<string, string> = {};
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;
return (await this._request("DELETE", "/v1/memories/", { params })) as Record<
string,
unknown
>;
}
if (memoryId) {
return (await this._request("DELETE", `/v1/memories/${memoryId}/`)) as Record<
string,
unknown
>;
}
throw new Error("Either memoryId or --all is required");
}
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
// 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.");
}
// Delete each provided entity via the v2 path-based endpoint
let result: Record<string, unknown> = {};
for (const [entityType, entityId] of entities) {
result = (await this._request(
"DELETE",
`/v2/entities/${entityType}/${entityId}/`,
)) as Record<string, unknown>;
}
return result;
}
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
const params: Record<string, string> = {};
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) {
throw new Error("At least one entity ID is required for deleteEntities.");
}
return (await this._request("DELETE", "/v1/entities/", { params })) as Record<
string,
unknown
>;
}
async status(
opts: { userId?: string; agentId?: string } = {},
): Promise<Record<string, unknown>> {
try {
await this._request("GET", "/v1/ping/");
return { connected: true, backend: "platform", base_url: this.baseUrl };
} catch (e) {
return {
connected: false,
backend: "platform",
error: e instanceof Error ? e.message : String(e),
};
}
}
async status(
opts: { userId?: string; agentId?: string } = {},
): Promise<Record<string, unknown>> {
try {
if (opts.userId || opts.agentId) {
const payload: Record<string, unknown> = {};
const statusParams: Record<string, string> = { 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/");
}
return { connected: true, backend: "platform", base_url: this.baseUrl };
} catch (e) {
return {
connected: false,
backend: "platform",
error: e instanceof Error ? e.message : String(e),
};
}
}
async entities(entityType: string): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/entities/")) as unknown;
let items: Record<string, unknown>[];
if (Array.isArray(result)) {
items = result;
} else {
items = ((result as Record<string, unknown>).results ?? []) as Record<
string,
unknown
>[];
}
async entities(entityType: string): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/entities/")) as unknown;
let items: Record<string, unknown>[];
if (Array.isArray(result)) {
items = result;
} else {
items = ((result as Record<string, unknown>).results ?? []) as Record<string, unknown>[];
}
const typeMap: Record<string, string> = {
users: "user",
agents: "agent",
apps: "app",
runs: "run",
};
const targetType = typeMap[entityType];
if (targetType) {
items = items.filter(
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
);
}
return items;
}
async listEvents(): Promise<Record<string, unknown>[]> {
const result = (await this._request("GET", "/v1/events/")) as unknown;
if (Array.isArray(result)) return result;
return ((result as Record<string, unknown>).results ?? []) as Record<
string,
unknown
>[];
}
async getEvent(eventId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record<
string,
unknown
>;
}
const typeMap: Record<string, string> = {
users: "user",
agents: "agent",
apps: "app",
runs: "run",
};
const targetType = typeMap[entityType];
if (targetType) {
items = items.filter(
(e) => (e.type as string | undefined)?.toLowerCase() === targetType,
);
}
return items;
}
}
+71 -98
View File
@@ -4,8 +4,10 @@
import chalk from "chalk";
import ora, { type Ora } from "ora";
import { getCurrentCommand, isAgentMode } from "./state.js";
import { CLI_VERSION } from "./version.js";
import { createRequire } from "node:module";
const _require = createRequire(import.meta.url);
const PKG_VERSION: string = _require("../package.json").version;
export const LOGO = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
@@ -19,8 +21,8 @@ export const LOGO = `
export const LOGO_MINI = "◆ mem0";
export const TAGLINE = "The Memory Layer for AI Agents";
export const BRAND_COLOR = "#F1C96C";
export const ACCENT_COLOR = "#F5D78E";
export const BRAND_COLOR = "#8b5cf6";
export const ACCENT_COLOR = "#a78bfa";
export const SUCCESS_COLOR = "#22c55e";
export const ERROR_COLOR = "#ef4444";
export const WARNING_COLOR = "#f59e0b";
@@ -38,96 +40,75 @@ const dim = chalk.hex(DIM_COLOR);
* plain-text for piped/non-TTY or NO_COLOR environments.
*/
export function sym(fancy: string, plain: string): string {
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
return fancy;
if (!process.stdout.isTTY || process.env.NO_COLOR) return plain;
return fancy;
}
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}`;
const subtitle = `Node.js SDK · v${CLI_VERSION}`;
const contentLines = ["", ...logoLines, "", tagline, ""];
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
const logoLines = LOGO.trimEnd().split("\n");
const tagline = ` ${TAGLINE}`;
const subtitle = `Node.js SDK · v${PKG_VERSION}`;
const contentLines = ["", ...logoLines, "", tagline, ""];
// Compute inner width from longest content line + padding both sides
const maxContent = Math.max(...contentLines.map((l) => l.length));
const innerWidth = maxContent + pad * 2;
const totalWidth = innerWidth + 2; // + 2 for │ borders
// Compute inner width from longest content line + padding both sides
const maxContent = Math.max(...contentLines.map((l) => l.length));
const innerWidth = maxContent + pad * 2;
const totalWidth = innerWidth + 2; // + 2 for │ borders
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
const bottomBorder = brand(
`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`,
);
const topBorder = brand(`╭${"─".repeat(totalWidth - 2)}╮`);
const subtitleFill = totalWidth - 2 - subtitle.length - 3; // 3 = "─ " before subtitle + "─" after
const bottomBorder = brand(`╰${"─".repeat(subtitleFill)} ${dim(subtitle)} ${"─"}╯`);
const body = contentLines.map((line) => {
const rightPad = innerWidth - pad - line.length;
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
});
// Re-color tagline line with accent instead of brand.bold
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
const taglineRightPad = innerWidth - pad - tagline.length;
body[taglineIdx] =
`${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
const body = contentLines.map((line) => {
const rightPad = innerWidth - pad - line.length;
return `${brand("│")}${" ".repeat(pad)}${brand.bold(line)}${" ".repeat(Math.max(rightPad, 0))}${brand("│")}`;
});
// Re-color tagline line with accent instead of brand.bold
const taglineIdx = body.length - 2; // second-to-last (before trailing empty line)
const taglineRightPad = innerWidth - pad - tagline.length;
body[taglineIdx] = `${brand("│")}${" ".repeat(pad)}${accent(tagline)}${" ".repeat(Math.max(taglineRightPad, 0))}${brand("│")}`;
console.log(topBorder);
for (const line of body) console.log(line);
console.log(bottomBorder);
console.log(topBorder);
for (const line of body) console.log(line);
console.log(bottomBorder);
}
export function printSuccess(message: string): void {
if (isAgentMode()) return;
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
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}`);
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)}`);
}
console.error(`${error(sym("✗", "[error]") + " Error:")} ${message}`);
if (hint) {
console.error(` ${dim(hint)}`);
}
}
export function printWarning(message: string): void {
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
}
export function printInfo(message: string): void {
if (isAgentMode()) return;
console.error(`${brand(sym("◆", "*"))} ${message}`);
console.log(`${brand(sym("◆", "*"))} ${message}`);
}
export function printScope(ids: Record<string, string | undefined>): void {
if (isAgentMode()) return;
const parts: string[] = [];
for (const [key, val] of Object.entries(ids)) {
if (val) {
parts.push(`${key}=${val}`);
}
}
if (parts.length > 0) {
console.error(` ${dim(`Scope: ${parts.join(", ")}`)}`);
}
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}`);
}
}
if (parts.length > 0) {
console.log(` ${dim(`Scope: ${parts.join(", ")}`)}`);
}
}
export interface TimedStatusContext {
successMsg: string;
errorMsg: string;
successMsg: string;
errorMsg: string;
}
/**
@@ -135,37 +116,29 @@ export interface TimedStatusContext {
* Equivalent to Python's timed_status context manager.
*/
export async function timedStatus<T>(
message: string,
fn: (ctx: TimedStatusContext) => Promise<T>,
message: string,
fn: (ctx: TimedStatusContext) => Promise<T>,
): Promise<T> {
if (isAgentMode()) {
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
return fn(ctx);
}
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
const spinner = ora({
text: dim(message),
color: "yellow",
stream: process.stderr,
}).start();
const start = performance.now();
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
const spinner = ora({ text: dim(message), color: "magenta", stream: process.stderr }).start();
const start = performance.now();
try {
const result = await fn(ctx);
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop();
if (ctx.successMsg) {
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
}
return result;
} catch (err) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop();
if (ctx.errorMsg) {
printError(`${ctx.errorMsg} (${elapsed}s)`);
}
throw err;
}
try {
const result = await fn(ctx);
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop();
if (ctx.successMsg) {
console.error(`${success("✓")} ${ctx.successMsg} (${elapsed}s)`);
}
return result;
} catch (err) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
spinner.stop();
if (ctx.errorMsg) {
console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`);
}
throw err;
}
}
/** Format helpers using brand colors for external use. */
+65 -86
View File
@@ -3,109 +3,88 @@
*/
import Table from "cli-table3";
import { colors, printError, printSuccess } from "../branding.js";
import { printError, printSuccess, colors } from "../branding.js";
import {
getNestedValue,
loadConfig,
redactKey,
saveConfig,
setNestedValue,
getNestedValue,
loadConfig,
redactKey,
saveConfig,
setNestedValue,
} from "../config.js";
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
import { isAgentMode, setCurrentCommand } from "../state.js";
import { formatJsonEnvelope } from "../output.js";
const { brand, accent, dim } = colors;
export function cmdConfigShow(opts: { output?: string } = {}): void {
setCurrentCommand("config show");
const config = loadConfig();
const config = loadConfig();
if (opts.output === "agent" || opts.output === "json") {
formatAgentEnvelope({
command: "config show",
data: {
defaults: {
user_id: config.defaults.userId || null,
agent_id: config.defaults.agentId || null,
app_id: config.defaults.appId || null,
run_id: config.defaults.runId || null,
enable_graph: config.defaults.enableGraph,
},
platform: {
api_key: redactKey(config.platform.apiKey),
base_url: config.platform.baseUrl,
},
},
});
return;
}
if (opts.output === "json") {
formatJsonEnvelope({
command: "config show",
data: {
defaults: {
user_id: config.defaults.userId || null,
agent_id: config.defaults.agentId || null,
app_id: config.defaults.appId || null,
run_id: config.defaults.runId || null,
enable_graph: config.defaults.enableGraph,
},
platform: {
api_key: redactKey(config.platform.apiKey),
base_url: config.platform.baseUrl,
},
},
});
return;
}
console.log();
console.log(` ${brand("◆ mem0 Configuration")}\n`);
console.log();
console.log(` ${brand("◆ mem0 Configuration")}\n`);
const table = new Table({
head: [accent("Key"), accent("Value")],
style: { head: [], border: [] },
});
const table = new Table({
head: [accent("Key"), accent("Value")],
style: { head: [], border: [] },
});
// Defaults
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
table.push([
"defaults.agent_id",
config.defaults.agentId || dim("(not set)"),
]);
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
table.push(["", ""]);
// Defaults
table.push(["defaults.user_id", config.defaults.userId || dim("(not set)")]);
table.push(["defaults.agent_id", config.defaults.agentId || dim("(not set)")]);
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
table.push(["", ""]);
// Platform
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
table.push(["platform.base_url", config.platform.baseUrl]);
// Platform
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
table.push(["platform.base_url", config.platform.baseUrl]);
console.log(table.toString());
console.log();
console.log(table.toString());
console.log();
}
export function cmdConfigGet(key: string): void {
setCurrentCommand("config get");
const config = loadConfig();
const value = getNestedValue(config, key);
const config = loadConfig();
const value = getNestedValue(config, key);
if (value === undefined) {
printError(`Unknown config key: ${key}`);
} else {
// Redact secrets
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(displayValue);
}
}
if (value === undefined) {
printError(`Unknown config key: ${key}`);
} else {
// Redact secrets
if (key.includes("api_key") || key.split(".").pop() === "key") {
console.log(redactKey(String(value)));
} else {
console.log(String(value));
}
}
}
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;
if (isAgentMode()) {
formatAgentEnvelope({
command: "config set",
data: { key, value: display },
});
} else {
printSuccess(`${key} = ${display}`);
}
} else {
printError(`Unknown config key: ${key}`);
}
const config = loadConfig();
if (setNestedValue(config, key, value)) {
saveConfig(config);
const display = key.includes("key") ? redactKey(value) : value;
printSuccess(`${key} = ${display}`);
} else {
printError(`Unknown config key: ${key}`);
}
}
+109 -138
View File
@@ -4,165 +4,136 @@
import readline from "node:readline";
import Table from "cli-table3";
import { printError, printInfo, printSuccess, timedStatus, colors } from "../branding.js";
import type { Backend } from "../backend/base.js";
import {
colors,
printError,
printInfo,
printSuccess,
timedStatus,
} from "../branding.js";
import { formatAgentEnvelope, formatJson } from "../output.js";
import { setCurrentCommand } from "../state.js";
import { formatJson } from "../output.js";
const { brand, accent, dim } = colors;
const VALID_TYPES = new Set(["users", "agents", "apps", "runs"]);
export async function cmdEntitiesList(
backend: Backend,
entityType: string,
opts: { output: string },
backend: Backend,
entityType: string,
opts: { output: string },
): Promise<void> {
setCurrentCommand("entity list");
if (!VALID_TYPES.has(entityType)) {
printError(
`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
);
process.exit(1);
}
if (!VALID_TYPES.has(entityType)) {
printError(`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`);
process.exit(1);
}
const start = performance.now();
let results: Record<string, unknown>[];
try {
results = await timedStatus(`Fetching ${entityType}...`, async () => {
return backend.entities(entityType);
});
} catch (e) {
printError(
e instanceof Error ? e.message : String(e),
"This feature may require the mem0 Platform.",
);
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
const start = performance.now();
let results: Record<string, unknown>[];
try {
results = await timedStatus(`Fetching ${entityType}...`, async () => {
return backend.entities(entityType);
});
} catch (e) {
printError(
e instanceof Error ? e.message : String(e),
"This feature may require the mem0 Platform.",
);
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
if (opts.output === "agent" || opts.output === "json") {
formatAgentEnvelope({
command: "entity list",
data: results,
count: results.length,
durationMs: Math.round(elapsed * 1000),
});
return;
}
if (opts.output === "json") {
formatJson(results);
return;
}
if (!results.length) {
printInfo(`No ${entityType} found.`);
return;
}
if (!results.length) {
printInfo(`No ${entityType} found.`);
return;
}
const table = new Table({
head: [accent("Name / ID"), accent("Created")],
style: { head: [], border: [] },
});
const table = new Table({
head: [accent("Name / ID"), accent("Created")],
style: { head: [], border: [] },
});
for (const entity of results) {
const name = String(entity.name ?? entity.id ?? "—");
const created = String(entity.created_at ?? "—").slice(0, 10);
table.push([name, created]);
}
for (const entity of results) {
const name = String(entity.name ?? entity.id ?? "—");
const created = String(entity.created_at ?? "—").slice(0, 10);
table.push([name, created]);
}
console.log();
console.log(table.toString());
console.log(
` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`,
);
console.log();
console.log();
console.log(table.toString());
console.log(` ${dim(`${results.length} ${entityType} (${elapsed.toFixed(2)}s)`)}`);
console.log();
}
export async function cmdEntitiesDelete(
backend: Backend,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
dryRun?: boolean;
force: boolean;
output: string;
},
backend: Backend,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
dryRun?: boolean;
force: boolean;
output: string;
},
): Promise<void> {
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.",
);
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.");
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("No changes made.");
return;
}
if (opts.dryRun) {
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(", ");
if (!opts.force) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
resolve,
);
});
rl.close();
if (answer.toLowerCase() !== "y") {
printInfo("Cancelled.");
process.exit(0);
}
}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(
`\n \u26a0 Delete entity ${scope} AND all its memories? This cannot be undone. [y/N] `,
resolve,
);
});
rl.close();
if (answer.toLowerCase() !== "y") {
printInfo("Cancelled.");
process.exit(0);
}
}
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Deleting entity...", async () => {
return backend.deleteEntities({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Deleting entity...", async () => {
return backend.deleteEntities({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
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)`);
}
if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
}
}
-169
View File
@@ -1,169 +0,0 @@
/**
* 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<void> {
setCurrentCommand("event list");
const start = performance.now();
let results: Record<string, unknown>[];
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<void> {
setCurrentCommand("event status");
const start = performance.now();
let ev: Record<string, unknown>;
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<string, unknown>[] | 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<string, unknown> | 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();
}
+142 -388
View File
@@ -2,427 +2,181 @@
* mem0 init — interactive setup wizard.
*/
import fs from "node:fs";
import readline from "node:readline";
import { PlatformBackend } from "../backend/platform.js";
import {
colors,
printBanner,
printError,
printInfo,
printSuccess,
printBanner,
printError,
printInfo,
printSuccess,
colors,
} from "../branding.js";
import {
CONFIG_FILE,
DEFAULT_BASE_URL,
type Mem0Config,
createDefaultConfig,
loadConfig,
redactKey,
saveConfig,
} from "../config.js";
import { type Mem0Config, createDefaultConfig, saveConfig } from "../config.js";
import { PlatformBackend } from "../backend/platform.js";
const { brand, dim } = colors;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function validateEmail(email: string): void {
if (!EMAIL_RE.test(email)) {
printError(`Invalid email address: ${JSON.stringify(email)}`);
process.exit(1);
}
}
async function emailLogin(
email: string,
code: string | undefined,
baseUrl: string,
): Promise<Record<string, unknown>> {
const url = baseUrl.replace(/\/+$/, "");
let codeValue = code;
if (!codeValue) {
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
if (resp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!resp.ok) {
let detail: string;
try {
const body = (await resp.json()) as Record<string, unknown>;
detail = (body.error ?? body.detail ?? resp.statusText) as string;
} catch {
detail = resp.statusText;
}
printError(`Failed to send code: ${detail}`);
process.exit(1);
}
printSuccess("Verification code sent! Check your email.");
if (!process.stdin.isTTY) {
printError(
"No --code provided and terminal is non-interactive.",
"Run: mem0 init --email <email> --code <code>",
);
process.exit(1);
}
console.log();
const entered = await promptLine(` ${brand("Verification Code")}`);
if (!entered) {
printError("Code is required.");
process.exit(1);
}
codeValue = entered;
}
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: codeValue.trim() }),
signal: AbortSignal.timeout(30_000),
});
if (verifyResp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!verifyResp.ok) {
let detail: string;
try {
const body = (await verifyResp.json()) as Record<string, unknown>;
detail = (body.error ?? body.detail ?? verifyResp.statusText) as string;
} catch {
detail = verifyResp.statusText;
}
printError(`Verification failed: ${detail}`);
process.exit(1);
}
return verifyResp.json() as Promise<Record<string, unknown>>;
}
function promptSecret(label: string): Promise<string> {
return new Promise((resolve, reject) => {
process.stdout.write(label);
return new Promise((resolve, reject) => {
process.stdout.write(label);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf-8");
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf-8");
const chars: string[] = [];
const chars: string[] = [];
const onData = (key: string) => {
for (const ch of key) {
if (ch === "\r" || ch === "\n") {
cleanup();
process.stdout.write("\n");
resolve(chars.join(""));
return;
}
if (ch === "\x03") {
cleanup();
reject(new Error("Interrupted"));
return;
}
if (ch === "\x7f" || ch === "\x08") {
// backspace
if (chars.length > 0) {
chars.pop();
process.stdout.write("\b \b");
}
} else if (ch === "\x15") {
// Ctrl+U — clear line
process.stdout.write("\b \b".repeat(chars.length));
chars.length = 0;
} else if (ch >= " ") {
chars.push(ch);
process.stdout.write("*");
}
}
};
const onData = (key: string) => {
for (const ch of key) {
if (ch === "\r" || ch === "\n") {
cleanup();
process.stdout.write("\n");
resolve(chars.join(""));
return;
}
if (ch === "\x03") {
cleanup();
reject(new Error("Interrupted"));
return;
}
if (ch === "\x7f" || ch === "\x08") {
// backspace
if (chars.length > 0) {
chars.pop();
process.stdout.write("\b \b");
}
} else if (ch === "\x15") {
// Ctrl+U — clear line
process.stdout.write("\b \b".repeat(chars.length));
chars.length = 0;
} else if (ch >= " ") {
chars.push(ch);
process.stdout.write("*");
}
}
};
const cleanup = () => {
process.stdin.removeListener("data", onData);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
};
const cleanup = () => {
process.stdin.removeListener("data", onData);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
};
process.stdin.on("data", onData);
});
process.stdin.on("data", onData);
});
}
function promptLine(label: string, defaultValue?: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim() || defaultValue || "");
});
});
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `;
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim() || defaultValue || "");
});
});
}
async function setupPlatform(config: Mem0Config): Promise<void> {
console.log();
console.log(
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`,
);
console.log();
console.log();
console.log(` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`);
console.log();
process.stdout.write(` ${brand("API Key")}: `);
const apiKey = await promptSecret("");
if (!apiKey) {
printError("API key is required.");
process.exit(1);
}
config.platform.apiKey = apiKey;
process.stdout.write(` ${brand("API Key")}: `);
const apiKey = await promptSecret("");
if (!apiKey) {
printError("API key is required.");
process.exit(1);
}
config.platform.apiKey = apiKey;
}
async function setupDefaults(config: Mem0Config): Promise<void> {
console.log();
printInfo("Set default entity IDs (press Enter to skip).\n");
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)")}`,
_systemUser,
);
if (userId) config.defaults.userId = userId;
const userId = await promptLine(` ${brand("Default User ID")} ${dim("(recommended)")}`, "mem0-cli");
if (userId) config.defaults.userId = userId;
}
async function validatePlatform(config: Mem0Config): Promise<void> {
console.log();
printInfo("Validating connection...");
try {
const backend = new PlatformBackend(config.platform);
const status = await backend.status({
userId: config.defaults.userId || undefined,
agentId: config.defaults.agentId || undefined,
});
if (status.connected) {
printSuccess("Connected to mem0 Platform!");
} else {
printError(
`Could not connect: ${status.error ?? "Unknown error"}`,
"Visit https://app.mem0.ai/dashboard/api-keys to get a new key, or run mem0 init again.",
);
}
} catch (e) {
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
}
console.log();
printInfo("Validating connection...");
try {
const backend = new PlatformBackend(config.platform);
const status = await backend.status({
userId: config.defaults.userId || undefined,
agentId: config.defaults.agentId || undefined,
});
if (status.connected) {
printSuccess("Connected to mem0 Platform!");
} else {
printError(
`Could not connect: ${status.error ?? "Unknown error"}`,
"Check your API key and try again.",
);
}
} catch (e) {
printError(`Connection test failed: ${e instanceof Error ? e.message : e}`);
}
}
export async function runInit(
opts: {
apiKey?: string;
userId?: string;
email?: string;
code?: string;
force?: boolean;
} = {},
): Promise<void> {
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
export async function runInit(opts: { apiKey?: string; userId?: string } = {}): Promise<void> {
const config = createDefaultConfig();
// Guards
if (opts.code && !opts.email) {
printError("--code requires --email.");
process.exit(1);
}
if (opts.email && opts.apiKey) {
printError("Cannot use both --api-key and --email.");
process.exit(1);
}
// Non-interactive: both flags provided
if (opts.apiKey && opts.userId) {
config.platform.apiKey = opts.apiKey;
config.defaults.userId = opts.userId;
await validatePlatform(config);
saveConfig(config);
printSuccess("Configuration saved to ~/.mem0/config.json");
return;
}
// 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<string>((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);
}
}
// 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 <key> --user-id <id>",
);
process.exit(1);
}
// ── Email login flow ──────────────────────────────────────────────────────
if (opts.email) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printBanner();
console.log();
printInfo("Welcome! Let's set up your mem0 CLI.\n");
printBanner();
console.log();
printInfo(`Logging in as ${email}...\n`);
// Use provided API key or prompt
if (opts.apiKey) {
config.platform.apiKey = opts.apiKey;
} else {
await setupPlatform(config);
}
const result = await emailLogin(email, opts.code, baseUrl);
// Use provided user ID or prompt
if (opts.userId) {
config.defaults.userId = opts.userId;
} else {
await setupDefaults(config);
}
const apiKeyVal = result.api_key as string | undefined;
if (!apiKeyVal) {
printError(
"Auth succeeded but no API key was returned. Contact support.",
);
process.exit(1);
}
await validatePlatform(config);
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
saveConfig(config);
console.log();
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
console.log();
return;
}
// ── 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 <key> [--user-id <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;
config.defaults.userId = opts.userId;
await validatePlatform(config);
saveConfig(config);
printSuccess("Configuration saved to ~/.mem0/config.json");
return;
}
printBanner();
console.log();
printInfo("Welcome! Let's set up your mem0 CLI.\n");
// Use provided API key or prompt
if (opts.apiKey) {
config.platform.apiKey = opts.apiKey;
} else {
console.log(` ${brand("How would you like to authenticate?")}`);
console.log(` ${dim("1.")} Login with email ${dim("(recommended)")}`);
console.log(` ${dim("2.")} Enter API key manually`);
console.log();
const choice = await promptLine(` ${brand("Choose")} [1/2]`, "1");
if (choice === "1") {
console.log();
const emailAddr = await promptLine(` ${brand("Email")}`);
if (!emailAddr) {
printError("Email is required.");
process.exit(1);
}
const email = emailAddr.trim().toLowerCase();
validateEmail(email);
printInfo(`Logging in as ${email}...\n`);
const result = await emailLogin(email, undefined, baseUrl);
const apiKeyVal = result.api_key as string | undefined;
if (!apiKeyVal) {
printError(
"Auth succeeded but no API key was returned. Contact support.",
);
process.exit(1);
}
config.platform.apiKey = apiKeyVal;
config.platform.baseUrl = baseUrl;
config.defaults.userId =
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
saveConfig(config);
console.log();
printSuccess("Authenticated! Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
console.log();
return;
}
// choice === "2": fall through to API key prompt
await setupPlatform(config);
}
// Use provided user ID or prompt
if (opts.userId) {
config.defaults.userId = opts.userId;
} else {
await setupDefaults(config);
}
await validatePlatform(config);
saveConfig(config);
console.log();
printSuccess("Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
if (config.defaults.userId) {
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
} else {
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`);
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
}
console.log();
saveConfig(config);
console.log();
printSuccess("Configuration saved to ~/.mem0/config.json");
console.log();
console.log(` ${dim("Get started:")}`);
if (config.defaults.userId) {
console.log(` ${dim(' mem0 add "I prefer dark mode"')}`);
console.log(` ${dim(' mem0 search "preferences"')}`);
} else {
console.log(` ${dim(' mem0 add "I prefer dark mode" --user-id alice')}`);
console.log(` ${dim(' mem0 search "preferences" --user-id alice')}`);
}
console.log();
}
File diff suppressed because it is too large Load Diff
+109 -125
View File
@@ -3,153 +3,137 @@
*/
import fs from "node:fs";
import boxen from "boxen";
import { createRequire } from "node:module";
import { printError, printSuccess, timedStatus, colors } from "../branding.js";
import type { Backend } from "../backend/base.js";
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
import { setCurrentCommand } from "../state.js";
import { CLI_VERSION } from "../version.js";
import { formatJsonEnvelope } from "../output.js";
import boxen from "boxen";
const { brand, dim, success, error: errorColor } = colors;
const _require = createRequire(import.meta.url);
const VERSION: string = _require("../../package.json").version;
export async function cmdStatus(
backend: Backend,
opts: { userId?: string; agentId?: string; output?: string } = {},
backend: Backend,
opts: { userId?: string; agentId?: string; output?: string } = {},
): Promise<void> {
setCurrentCommand("status");
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Checking connection...", async () => {
return backend.status({ userId: opts.userId, agentId: opts.agentId });
});
} catch (e) {
result = {
connected: false,
error: e instanceof Error ? e.message : String(e),
};
}
const elapsed = (performance.now() - start) / 1000;
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Checking connection...", async () => {
return backend.status({ userId: opts.userId, agentId: opts.agentId });
});
} catch (e) {
result = { connected: false, error: e instanceof Error ? e.message : String(e) };
}
const elapsed = (performance.now() - start) / 1000;
if (opts.output === "agent" || opts.output === "json") {
formatAgentEnvelope({
command: "status",
data: {
connected: result.connected,
backend: result.backend ?? null,
base_url: result.base_url ?? null,
},
durationMs: Math.round(elapsed * 1000),
});
return;
}
if (opts.output === "json") {
formatJsonEnvelope({
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),
});
return;
}
const lines: string[] = [];
if (result.connected) {
lines.push(` ${success("\u25cf")} Connected`);
} else {
lines.push(` ${errorColor("\u25cf")} Disconnected`);
}
const lines: string[] = [];
if (result.connected) {
lines.push(` ${success("\u25cf")} Connected`);
} else {
lines.push(` ${errorColor("\u25cf")} Disconnected`);
}
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
if (result.base_url) {
lines.push(` ${dim("API URL:")} ${result.base_url}`);
}
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`);
lines.push(` ${dim("Backend:")} ${result.backend ?? "?"}`);
if (result.base_url) {
lines.push(` ${dim("API URL:")} ${result.base_url}`);
}
if (result.error) {
lines.push(` ${errorColor("Error:")} ${result.error}`);
}
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
const content = lines.join("\n");
console.log();
console.log(
boxen(content, {
title: brand("Connection Status"),
titleAlignment: "left",
borderColor: "magenta",
padding: 1,
}),
);
console.log();
const content = lines.join("\n");
console.log();
console.log(
boxen(content, {
title: brand("Connection Status"),
titleAlignment: "left",
borderColor: "magenta",
padding: 1,
}),
);
console.log();
}
export function cmdVersion(): void {
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
console.log(` ${brand("◆ Mem0")} CLI v${VERSION}`);
}
export async function cmdImport(
backend: Backend,
filePath: string,
opts: { userId?: string; agentId?: string; output?: string },
backend: Backend,
filePath: string,
opts: { userId?: string; agentId?: string; output?: string },
): Promise<void> {
setCurrentCommand("import");
let data: Record<string, unknown>[];
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
data = Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
process.exit(1);
}
let data: Record<string, unknown>[];
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
data = Array.isArray(parsed) ? parsed : [parsed];
} catch (e) {
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
process.exit(1);
}
let added = 0;
let failed = 0;
const start = performance.now();
let added = 0;
let failed = 0;
const start = performance.now();
for (let i = 0; i < data.length; i++) {
const item = data[i];
const content = (item.memory ?? item.text ?? item.content ?? "") as string;
if (!content) {
failed++;
continue;
}
for (let i = 0; i < data.length; i++) {
const item = data[i];
const content = (item.memory ?? item.text ?? item.content ?? "") as string;
if (!content) {
failed++;
continue;
}
try {
await backend.add(content, undefined, {
userId: opts.userId ?? (item.user_id as string | undefined),
agentId: opts.agentId ?? (item.agent_id as string | undefined),
metadata: item.metadata as Record<string, unknown> | undefined,
});
added++;
} catch {
failed++;
}
try {
await backend.add(content, undefined, {
userId: opts.userId ?? (item.user_id as string | undefined),
agentId: opts.agentId ?? (item.agent_id as string | undefined),
metadata: item.metadata as Record<string, unknown> | undefined,
});
added++;
} catch {
failed++;
}
// Simple progress indicator
if ((i + 1) % 10 === 0 || i === data.length - 1) {
process.stdout.write(
`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`,
);
}
}
// Simple progress indicator
if ((i + 1) % 10 === 0 || i === data.length - 1) {
process.stdout.write(`\r ${dim(`Importing memories... ${i + 1}/${data.length}`)}`);
}
}
const elapsed = (performance.now() - start) / 1000;
console.log(); // Clear progress line
const elapsed = (performance.now() - start) / 1000;
console.log(); // Clear progress line
if (opts.output === "agent" || opts.output === "json") {
formatAgentEnvelope({
command: "import",
data: {
added,
failed,
},
durationMs: Math.round(elapsed * 1000),
});
return;
}
if (opts.output === "json") {
formatJsonEnvelope({
command: "import",
data: { added, failed, duration_s: parseFloat(elapsed.toFixed(2)) },
durationMs: Math.round(elapsed * 1000),
});
return;
}
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
if (failed > 0) {
printError(`${failed} memories failed to import.`);
}
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
if (failed > 0) {
printError(`${failed} memories failed to import.`);
}
}
+99 -115
View File
@@ -18,158 +18,142 @@ export const DEFAULT_BASE_URL = "https://api.mem0.ai";
export const CONFIG_VERSION = 1;
export interface PlatformConfig {
apiKey: string;
baseUrl: string;
apiKey: string;
baseUrl: string;
}
export interface DefaultsConfig {
userId: string;
agentId: string;
appId: string;
runId: string;
enableGraph: boolean;
userId: string;
agentId: string;
appId: string;
runId: string;
enableGraph: boolean;
}
export interface Mem0Config {
version: number;
defaults: DefaultsConfig;
platform: PlatformConfig;
version: number;
defaults: DefaultsConfig;
platform: PlatformConfig;
}
export function createDefaultConfig(): Mem0Config {
return {
version: CONFIG_VERSION,
defaults: {
userId: "",
agentId: "",
appId: "",
runId: "",
enableGraph: false,
},
platform: {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
},
};
return {
version: CONFIG_VERSION,
defaults: {
userId: "",
agentId: "",
appId: "",
runId: "",
enableGraph: false,
},
platform: {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
},
};
}
export function ensureConfigDir(): string {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
return CONFIG_DIR;
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
return CONFIG_DIR;
}
export function loadConfig(): Mem0Config {
const config = createDefaultConfig();
const config = createDefaultConfig();
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
const data = JSON.parse(raw);
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
const data = JSON.parse(raw);
config.version = data.version ?? CONFIG_VERSION;
config.version = data.version ?? CONFIG_VERSION;
const plat = data.platform ?? {};
config.platform.apiKey = plat.api_key ?? "";
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
const plat = data.platform ?? {};
config.platform.apiKey = plat.api_key ?? "";
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? "";
config.defaults.agentId = defaults.agent_id ?? "";
config.defaults.appId = defaults.app_id ?? "";
config.defaults.runId = defaults.run_id ?? "";
config.defaults.enableGraph = defaults.enable_graph ?? false;
}
const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? "";
config.defaults.agentId = defaults.agent_id ?? "";
config.defaults.appId = defaults.app_id ?? "";
config.defaults.runId = defaults.run_id ?? "";
config.defaults.enableGraph = defaults.enable_graph ?? false;
}
// Environment variable overrides
if (process.env.MEM0_API_KEY)
config.platform.apiKey = process.env.MEM0_API_KEY;
if (process.env.MEM0_BASE_URL)
config.platform.baseUrl = process.env.MEM0_BASE_URL;
if (process.env.MEM0_USER_ID)
config.defaults.userId = process.env.MEM0_USER_ID;
if (process.env.MEM0_AGENT_ID)
config.defaults.agentId = process.env.MEM0_AGENT_ID;
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
if (process.env.MEM0_ENABLE_GRAPH) {
config.defaults.enableGraph = ["true", "1", "yes"].includes(
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
);
}
// Environment variable overrides
if (process.env.MEM0_API_KEY) config.platform.apiKey = process.env.MEM0_API_KEY;
if (process.env.MEM0_BASE_URL) config.platform.baseUrl = process.env.MEM0_BASE_URL;
if (process.env.MEM0_USER_ID) config.defaults.userId = process.env.MEM0_USER_ID;
if (process.env.MEM0_AGENT_ID) config.defaults.agentId = process.env.MEM0_AGENT_ID;
if (process.env.MEM0_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
if (process.env.MEM0_ENABLE_GRAPH) {
config.defaults.enableGraph = ["true", "1", "yes"].includes(
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
);
}
return config;
return config;
}
export function saveConfig(config: Mem0Config): void {
ensureConfigDir();
ensureConfigDir();
const data = {
version: config.version,
defaults: {
user_id: config.defaults.userId,
agent_id: config.defaults.agentId,
app_id: config.defaults.appId,
run_id: config.defaults.runId,
enable_graph: config.defaults.enableGraph,
},
platform: {
api_key: config.platform.apiKey,
base_url: config.platform.baseUrl,
},
};
const data = {
version: config.version,
defaults: {
user_id: config.defaults.userId,
agent_id: config.defaults.agentId,
app_id: config.defaults.appId,
run_id: config.defaults.runId,
enable_graph: config.defaults.enableGraph,
},
platform: {
api_key: config.platform.apiKey,
base_url: config.platform.baseUrl,
},
};
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600);
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600);
}
export function redactKey(key: string): string {
if (!key) return "(not set)";
if (key.length <= 8) return `${key.slice(0, 2)}***`;
return `${key.slice(0, 4)}...${key.slice(-4)}`;
if (!key) return "(not set)";
if (key.length <= 8) return key.slice(0, 2) + "***";
return key.slice(0, 4) + "..." + key.slice(-4);
}
/** Key map from dotted config path to the config object fields. */
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
"platform.api_key": ["platform", "apiKey"],
"platform.base_url": ["platform", "baseUrl"],
"defaults.user_id": ["defaults", "userId"],
"defaults.agent_id": ["defaults", "agentId"],
"defaults.app_id": ["defaults", "appId"],
"defaults.run_id": ["defaults", "runId"],
"defaults.enable_graph": ["defaults", "enableGraph"],
// Short-form aliases
api_key: ["platform", "apiKey"],
base_url: ["platform", "baseUrl"],
user_id: ["defaults", "userId"],
agent_id: ["defaults", "agentId"],
app_id: ["defaults", "appId"],
run_id: ["defaults", "runId"],
enable_graph: ["defaults", "enableGraph"],
"platform.api_key": ["platform", "apiKey"],
"platform.base_url": ["platform", "baseUrl"],
"defaults.user_id": ["defaults", "userId"],
"defaults.agent_id": ["defaults", "agentId"],
"defaults.app_id": ["defaults", "appId"],
"defaults.run_id": ["defaults", "runId"],
"defaults.enable_graph": ["defaults", "enableGraph"],
};
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
const mapping = KEY_MAP[dottedKey];
if (!mapping) return undefined;
const [section, field] = mapping;
return (config[section] as unknown as Record<string, unknown>)[field];
const mapping = KEY_MAP[dottedKey];
if (!mapping) return undefined;
const [section, field] = mapping;
return (config[section] as unknown as Record<string, unknown>)[field];
}
export function setNestedValue(
config: Mem0Config,
dottedKey: string,
value: string,
): boolean {
const mapping = KEY_MAP[dottedKey];
if (!mapping) return false;
const [section, field] = mapping;
const obj = config[section] as unknown as Record<string, unknown>;
export function setNestedValue(config: Mem0Config, dottedKey: string, value: string): boolean {
const mapping = KEY_MAP[dottedKey];
if (!mapping) return false;
const [section, field] = mapping;
const obj = config[section] as unknown as Record<string, unknown>;
const current = obj[field];
if (typeof current === "boolean") {
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
} else if (typeof current === "number") {
obj[field] = Number.parseInt(value, 10);
} else {
obj[field] = value;
}
return true;
const current = obj[field];
if (typeof current === "boolean") {
obj[field] = ["true", "1", "yes"].includes(value.toLowerCase());
} else if (typeof current === "number") {
obj[field] = parseInt(value, 10);
} else {
obj[field] = value;
}
return true;
}
-2
View File
@@ -1,2 +0,0 @@
/** Injected by tsup at build time from package.json version field. Undefined in dev/test. */
declare const __CLI_VERSION__: string | undefined;
+280 -284
View File
@@ -4,40 +4,40 @@
*/
import chalk from "chalk";
import type { Argument, Command, Help, Option } from "commander";
import type { Command, Help, Option, Argument } from "commander";
// Colors imported from chalk directly to match Typer/Rich defaults
// ── Colors (matching Typer/Rich defaults) ────────────────────────────────
const cyanBold = chalk.cyan.bold; // option flags, command names
const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
const yellowBold = chalk.yellow.bold; // metavar <value>
const yellow = chalk.yellow; // "Usage:" label
const bold = chalk.bold; // command name in usage
const dim = chalk.dim; // defaults, descriptions
const dimBorder = chalk.dim; // panel borders
const cyanBold = chalk.cyan.bold; // option flags, command names
const greenBold = chalk.green.bold; // switch flags (boolean --force etc)
const yellowBold = chalk.yellow.bold; // metavar <value>
const yellow = chalk.yellow; // "Usage:" label
const bold = chalk.bold; // command name in usage
const dim = chalk.dim; // defaults, descriptions
const dimBorder = chalk.dim; // panel borders
// ── Strip ANSI ───────────────────────────────────────────────────────────
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence is intentional
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): number {
return str.replace(ANSI_RE, "").length;
return str.replace(ANSI_RE, "").length;
}
// ── Command display order (matches Python CLI) ──────────────────────────
/** Commands grouped into panels, matching Python CLI's rich_help_panel. */
const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [
{
panel: "Memory",
commands: ["add", "search", "get", "list", "update", "delete"],
},
{
panel: "Management",
commands: ["init", "status", "import", "help", "entity", "event", "config"],
},
{
panel: "Memory",
commands: ["add", "search", "get", "list", "update", "delete"],
},
{
panel: "Management",
commands: ["init", "status", "import", "help", "entity", "config"],
},
];
/** Flat order derived from COMMAND_GROUPS. */
@@ -46,88 +46,88 @@ const COMMAND_ORDER: string[] = COMMAND_GROUPS.flatMap((g) => g.commands);
// ── Option-to-panel mapping (derived from Python's rich_help_panel) ─────
const OPTION_PANELS: Record<string, Record<string, string>> = {
add: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
search: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--top-k": "Search",
"--threshold": "Search",
"--rerank": "Search",
"--keyword": "Search",
"--filter": "Search",
"--fields": "Search",
"--graph": "Search",
"--no-graph": "Search",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
get: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
list: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--page": "Pagination",
"--page-size": "Pagination",
"--category": "Filters",
"--after": "Filters",
"--before": "Filters",
"--graph": "Filters",
"--no-graph": "Filters",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
update: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
delete: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
status: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
import: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
add: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
search: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--top-k": "Search",
"--threshold": "Search",
"--rerank": "Search",
"--keyword": "Search",
"--filter": "Search",
"--fields": "Search",
"--graph": "Search",
"--no-graph": "Search",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
get: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
list: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--page": "Pagination",
"--page-size": "Pagination",
"--category": "Filters",
"--after": "Filters",
"--before": "Filters",
"--graph": "Filters",
"--no-graph": "Filters",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
update: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
delete: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--app-id": "Scope",
"--run-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
status: {
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
import: {
"--user-id": "Scope",
"--agent-id": "Scope",
"--output": "Output",
"--api-key": "Connection",
"--base-url": "Connection",
},
};
const PANEL_ORDER: string[] = [
"Scope",
"Search",
"Pagination",
"Filters",
"Output",
"Connection",
"Scope",
"Search",
"Pagination",
"Filters",
"Output",
"Connection",
];
// ── Panel rendering ─────────────────────────────────────────────────────
@@ -141,238 +141,234 @@ const PANEL_ORDER: string[] = [
* ╰────────────────────────────────╯
* ```
*/
function renderPanel(title: string, rows: string[], width: number): string {
if (rows.length === 0) return "";
function renderPanel(
title: string,
rows: string[],
width: number,
): string {
if (rows.length === 0) return "";
// Inner width is total width minus the two border chars
const inner = width - 2;
// Inner width is total width minus the two border chars
const inner = width - 2;
// Top border: ╭─ Title ─...─╮
const titleStr = ` ${title} `;
const fillLen = Math.max(0, inner - 1 - titleStr.length);
const topLine =
dimBorder("╭─") +
dimBorder(titleStr) +
dimBorder("─".repeat(fillLen)) +
dimBorder("╮");
// Top border: ╭─ Title ─...─╮
const titleStr = ` ${title} `;
const fillLen = Math.max(0, inner - 1 - titleStr.length);
const topLine =
dimBorder("╭─") +
dimBorder(titleStr) +
dimBorder("─".repeat(fillLen)) +
dimBorder("╮");
// Bottom border: ╰─...─╯
const bottomLine =
dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
// Bottom border: ╰─...─╯
const bottomLine = dimBorder("╰") + dimBorder("─".repeat(inner)) + dimBorder("╯");
// Content rows
const contentLines = rows.map((row) => {
const visLen = stripAnsi(row);
const pad = Math.max(0, inner - 1 - visLen);
return `${dimBorder("│")} ${row}${" ".repeat(pad)}${dimBorder("│")}`;
});
// Content rows
const contentLines = rows.map((row) => {
const visLen = stripAnsi(row);
const pad = Math.max(0, inner - 1 - visLen);
return dimBorder("│") + " " + row + " ".repeat(pad) + dimBorder("│");
});
return [topLine, ...contentLines, bottomLine].join("\n");
return [topLine, ...contentLines, bottomLine].join("\n");
}
// ── Format an option term (short + long) ────────────────────────────────
function formatOptionTerm(opt: Option): string {
const parts: string[] = [];
if (opt.short) parts.push(opt.short);
if (opt.long) parts.push(opt.long);
let term = parts.join(", ");
const parts: string[] = [];
if (opt.short) parts.push(opt.short);
if (opt.long) parts.push(opt.long);
let term = parts.join(", ");
// Append value placeholder for non-boolean options
if (opt.flags) {
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
if (match) {
term += ` ${match[0]}`;
}
}
return term;
// Append value placeholder for non-boolean options
if (opt.flags) {
const match = opt.flags.match(/<[^>]+>|\[[^\]]+\]/);
if (match) {
term += " " + match[0];
}
}
return term;
}
// ── Get the long flag name for panel lookup ─────────────────────────────
function getLongFlag(opt: Option): string {
if (opt.long) return opt.long;
return opt.short || "";
if (opt.long) return opt.long;
return opt.short || "";
}
// ── Format a default value ──────────────────────────────────────────────
function formatDefault(opt: Option): string {
if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
return dim(` [default: ${opt.defaultValue}]`);
}
return "";
if (opt.defaultValue !== undefined && opt.defaultValue !== false) {
return dim(` [default: ${opt.defaultValue}]`);
}
return "";
}
// ── The main help formatter ─────────────────────────────────────────────
export function richFormatHelp(cmd: Command, helper: Help): string {
const width = process.stdout.columns || 80;
const lines: string[] = [];
const width = process.stdout.columns || 80;
const lines: string[] = [];
const isRoot = !cmd.parent;
const isRoot = !cmd.parent;
// ── Usage line ──
const usage = helper.commandUsage(cmd);
lines.push("");
if (isRoot) {
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
lines.push(
` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`,
);
} else {
// Subcommands: split into command path (bold) and args (yellow)
const usageParts = usage.split(" ");
const cmdPath: string[] = [];
const argParts: string[] = [];
let pastCmd = false;
for (const part of usageParts) {
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
cmdPath.push(part);
} else {
pastCmd = true;
argParts.push(part);
}
}
lines.push(
` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`,
);
}
lines.push("");
// ── Usage line ──
const usage = helper.commandUsage(cmd);
lines.push("");
if (isRoot) {
// Root: "Usage: mem0 <command> [options]" — <command> yellow, [options] bold
lines.push(` ${yellow("Usage:")} ${bold(cmd.name())} ${yellow("<command>")} ${bold("[options]")}`);
} else {
// Subcommands: split into command path (bold) and args (yellow)
const usageParts = usage.split(" ");
const cmdPath: string[] = [];
const argParts: string[] = [];
let pastCmd = false;
for (const part of usageParts) {
if (!pastCmd && !part.startsWith("[") && !part.startsWith("<")) {
cmdPath.push(part);
} else {
pastCmd = true;
argParts.push(part);
}
}
lines.push(` ${yellow("Usage:")} ${bold(cmdPath.join(" "))} ${yellow(argParts.join(" "))}`);
}
lines.push("");
// ── Description ──
const desc = helper.commandDescription(cmd);
if (desc) {
// Split multi-line descriptions (e.g., title + tagline)
const descLines = desc.split("\n");
for (let i = 0; i < descLines.length; i++) {
const dLine = descLines[i];
// First line is the title, subsequent non-empty lines are tagline (dimmed)
if (i === 0 || dLine.trim() === "") {
lines.push(` ${dLine}`);
} else {
lines.push(` ${dim(dLine)}`);
}
}
lines.push("");
}
// ── Description ──
const desc = helper.commandDescription(cmd);
if (desc) {
// Split multi-line descriptions (e.g., title + tagline)
const descLines = desc.split("\n");
for (let i = 0; i < descLines.length; i++) {
const dLine = descLines[i];
// First line is the title, subsequent non-empty lines are tagline (dimmed)
if (i === 0 || dLine.trim() === "") {
lines.push(` ${dLine}`);
} else {
lines.push(` ${dim(dLine)}`);
}
}
lines.push("");
}
// ── Arguments panel (subcommands only) ──
if (!isRoot) {
const visibleArgs = helper.visibleArguments(cmd);
if (visibleArgs.length > 0) {
const maxLen = Math.max(
...visibleArgs.map((a: Argument) => a.name().length),
);
const argRows = visibleArgs.map((a: Argument) => {
const name = cyanBold(a.name().padEnd(maxLen));
const description = helper.argumentDescription(a);
return ` ${name} ${description}`;
});
const panel = renderPanel("Arguments", argRows, width);
if (panel) lines.push(panel);
}
}
// ── Arguments panel (subcommands only) ──
if (!isRoot) {
const visibleArgs = helper.visibleArguments(cmd);
if (visibleArgs.length > 0) {
const maxLen = Math.max(...visibleArgs.map((a: Argument) => a.name().length));
const argRows = visibleArgs.map((a: Argument) => {
const name = cyanBold(a.name().padEnd(maxLen));
const description = helper.argumentDescription(a);
return ` ${name} ${description}`;
});
const panel = renderPanel("Arguments", argRows, width);
if (panel) lines.push(panel);
}
}
// ── Collect options (grouped into panels for subcommands) ──
const visibleOpts = helper.visibleOptions(cmd);
const cmdName = cmd.name();
const panelMap =
!isRoot && OPTION_PANELS[cmdName] ? OPTION_PANELS[cmdName] : {};
// ── Collect options (grouped into panels for subcommands) ──
const visibleOpts = helper.visibleOptions(cmd);
const cmdName = cmd.name();
const panelMap = (!isRoot && OPTION_PANELS[cmdName]) ? OPTION_PANELS[cmdName] : {};
const grouped: Record<string, Option[]> = { Options: [] };
for (const panelName of PANEL_ORDER) {
grouped[panelName] = [];
}
const grouped: Record<string, Option[]> = { Options: [] };
for (const panelName of PANEL_ORDER) {
grouped[panelName] = [];
}
for (const opt of visibleOpts) {
const flag = getLongFlag(opt);
const panel = panelMap[flag];
if (panel && PANEL_ORDER.includes(panel)) {
grouped[panel].push(opt);
} else {
grouped.Options.push(opt);
}
}
for (const opt of visibleOpts) {
const flag = getLongFlag(opt);
const panel = panelMap[flag];
if (panel && PANEL_ORDER.includes(panel)) {
grouped[panel].push(opt);
} else {
grouped["Options"].push(opt);
}
}
// ── Collect commands ──
const visibleCmds = helper.visibleCommands(cmd);
// ── Collect commands ──
const visibleCmds = helper.visibleCommands(cmd);
if (isRoot) {
// ROOT: Options first, then command groups (matches Python/Typer ordering)
if (grouped.Options.length > 0) {
const optRows = formatOptionRows(grouped.Options);
const panel = renderPanel("Options", optRows, width);
if (panel) lines.push(panel);
}
if (visibleCmds.length > 0) {
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
for (const group of COMMAND_GROUPS) {
const groupCmds = group.commands
.map((name) => cmdMap.get(name))
.filter((c): c is Command => c !== undefined);
if (groupCmds.length === 0) continue;
const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
const cmdRows = groupCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c);
return ` ${name} ${description}`;
});
const panel = renderPanel(group.panel, cmdRows, width);
if (panel) lines.push(panel);
}
}
} else {
// SUBCOMMANDS: Options/panels first, then sub-subcommands
const panelSequence = ["Options", ...PANEL_ORDER];
for (const panelName of panelSequence) {
const opts = grouped[panelName];
if (opts && opts.length > 0) {
const optRows = formatOptionRows(opts);
const panel = renderPanel(panelName, optRows, width);
if (panel) lines.push(panel);
}
}
// Sub-subcommands (e.g., config show/get/set, entity list/delete)
if (visibleCmds.length > 0) {
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
const cmdRows = visibleCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c);
return ` ${name} ${description}`;
});
const panel = renderPanel("Commands", cmdRows, width);
if (panel) lines.push(panel);
}
}
if (isRoot) {
// ROOT: Options first, then command groups (matches Python/Typer ordering)
if (grouped["Options"].length > 0) {
const optRows = formatOptionRows(grouped["Options"]);
const panel = renderPanel("Options", optRows, width);
if (panel) lines.push(panel);
}
if (visibleCmds.length > 0) {
const cmdMap = new Map(visibleCmds.map((c) => [c.name(), c]));
for (const group of COMMAND_GROUPS) {
const groupCmds = group.commands
.map((name) => cmdMap.get(name))
.filter((c): c is Command => c !== undefined);
if (groupCmds.length === 0) continue;
const maxLen = Math.max(...groupCmds.map((c) => c.name().length));
const cmdRows = groupCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c);
return ` ${name} ${description}`;
});
const panel = renderPanel(group.panel, cmdRows, width);
if (panel) lines.push(panel);
}
}
} else {
// SUBCOMMANDS: Options/panels first, then sub-subcommands
const panelSequence = ["Options", ...PANEL_ORDER];
for (const panelName of panelSequence) {
const opts = grouped[panelName];
if (opts && opts.length > 0) {
const optRows = formatOptionRows(opts);
const panel = renderPanel(panelName, optRows, width);
if (panel) lines.push(panel);
}
}
// Sub-subcommands (e.g., config show/get/set, entity list/delete)
if (visibleCmds.length > 0) {
const maxLen = Math.max(...visibleCmds.map((c) => c.name().length));
const cmdRows = visibleCmds.map((c) => {
const name = cyanBold(c.name().padEnd(maxLen));
const description = helper.subcommandDescription(c);
return ` ${name} ${description}`;
});
const panel = renderPanel("Commands", cmdRows, width);
if (panel) lines.push(panel);
}
}
lines.push("");
return lines.join("\n");
lines.push("");
return lines.join("\n");
}
// ── Format option rows with aligned columns ─────────────────────────────
function formatOptionRows(opts: Option[]): string[] {
const terms = opts.map((o) => formatOptionTerm(o));
const maxTermLen = Math.max(...terms.map((t) => t.length));
const terms = opts.map((o) => formatOptionTerm(o));
const maxTermLen = Math.max(...terms.map((t) => t.length));
return opts.map((opt, i) => {
const term = cyanBold(terms[i].padEnd(maxTermLen));
const desc = opt.description || "";
const def = formatDefault(opt);
return ` ${term} ${desc}${def}`;
});
return opts.map((opt, i) => {
const term = cyanBold(terms[i].padEnd(maxTermLen));
const desc = opt.description || "";
const def = formatDefault(opt);
return ` ${term} ${desc}${def}`;
});
}
// ── Sort commands by COMMAND_ORDER ──────────────────────────────────────
function sortCommands(cmds: Command[]): Command[] {
return [...cmds].sort((a, b) => {
const ai = COMMAND_ORDER.indexOf(a.name());
const bi = COMMAND_ORDER.indexOf(b.name());
// Unknown commands go to end, preserving original order
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
return aIdx - bIdx;
});
return [...cmds].sort((a, b) => {
const ai = COMMAND_ORDER.indexOf(a.name());
const bi = COMMAND_ORDER.indexOf(b.name());
// Unknown commands go to end, preserving original order
const aIdx = ai === -1 ? COMMAND_ORDER.length : ai;
const bIdx = bi === -1 ? COMMAND_ORDER.length : bi;
return aIdx - bIdx;
});
}
+389 -599
View File
File diff suppressed because it is too large Load Diff
+177 -328
View File
@@ -2,380 +2,229 @@
* Output formatting for mem0 CLI — text, JSON, table, quiet modes.
*/
import boxen from "boxen";
import Table from "cli-table3";
import boxen from "boxen";
import { colors, sym } from "./branding.js";
const { brand, accent, success, error: errorColor, dim } = colors;
function formatDate(dtStr?: string): string | undefined {
if (!dtStr) return undefined;
try {
const dt = new Date(dtStr.replace("Z", "+00:00"));
return dt.toISOString().slice(0, 10);
} catch {
return dtStr?.slice(0, 10);
}
if (!dtStr) return undefined;
try {
const dt = new Date(dtStr.replace("Z", "+00:00"));
return dt.toISOString().slice(0, 10);
} catch {
return dtStr?.slice(0, 10);
}
}
export function formatMemoriesText(
memories: Record<string, unknown>[],
title = "memories",
memories: Record<string, unknown>[],
title = "memories",
): void {
const count = memories.length;
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
const count = memories.length;
console.log(`\n${brand(`Found ${count} ${title}:`)}\n`);
for (let i = 0; i < memories.length; i++) {
const mem = memories[i];
const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = ((mem.id as string) ?? "").slice(0, 8);
const score = mem.score as number | undefined;
const created = formatDate(mem.created_at as string | undefined);
let category: string | undefined;
const cats = mem.categories;
if (Array.isArray(cats)) {
category = cats[0] as string | undefined;
}
for (let i = 0; i < memories.length; i++) {
const mem = memories[i];
const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = ((mem.id as string) ?? "").slice(0, 8);
const score = mem.score as number | undefined;
const created = formatDate(mem.created_at as string | undefined);
let category: string | undefined;
const cats = mem.categories;
if (Array.isArray(cats)) {
category = cats[0] as string | undefined;
}
console.log(` ${i + 1}. ${memoryText}`);
console.log(` ${i + 1}. ${memoryText}`);
const details: string[] = [];
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
if (memId) details.push(`ID: ${memId}`);
if (created) details.push(`Created: ${created}`);
if (category) details.push(`Category: ${category}`);
const details: string[] = [];
if (score !== undefined) details.push(`Score: ${score.toFixed(2)}`);
if (memId) details.push(`ID: ${memId}`);
if (created) details.push(`Created: ${created}`);
if (category) details.push(`Category: ${category}`);
if (details.length > 0) {
console.log(` ${dim(details.join(" · "))}`);
}
console.log();
}
if (details.length > 0) {
console.log(` ${dim(details.join(" · "))}`);
}
console.log();
}
}
export function formatMemoriesTable(
memories: Record<string, unknown>[],
opts: { showScore?: boolean } = {},
): void {
const head = opts.showScore
? [
accent("ID"),
accent("Score"),
accent("Memory"),
accent("Category"),
accent("Created"),
]
: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")];
const colWidths = opts.showScore ? [38, 8, 40, 16, 14] : [38, 40, 16, 14];
const table = new Table({
head,
colWidths,
wordWrap: true,
style: { head: [], border: [] },
});
export function formatMemoriesTable(memories: Record<string, unknown>[]): void {
const table = new Table({
head: [accent("ID"), accent("Memory"), accent("Category"), accent("Created")],
colWidths: [12, 52, 16, 14],
wordWrap: true,
style: { head: [], border: [] },
});
for (const mem of memories) {
const memId = (mem.id as string) ?? "";
let memoryText = (mem.memory ?? mem.text ?? "") as string;
if (memoryText.length > 60) {
memoryText = `${memoryText.slice(0, 57)}...`;
}
const categories = mem.categories;
const cat =
Array.isArray(categories) && categories.length > 0
? categories.length > 1
? `${categories[0]} (+${categories.length - 1})`
: (categories[0] as string)
: "—";
const created = formatDate(mem.created_at as string | undefined) ?? "—";
if (opts.showScore) {
const score = mem.score as number | undefined;
const scoreStr = score !== undefined ? score.toFixed(2) : "—";
table.push([dim(memId), scoreStr, memoryText, cat, created]);
} else {
table.push([dim(memId), memoryText, cat, created]);
}
}
for (const mem of memories) {
const memId = ((mem.id as string) ?? "").slice(0, 8);
let memoryText = (mem.memory ?? mem.text ?? "") as string;
if (memoryText.length > 60) {
memoryText = memoryText.slice(0, 57) + "...";
}
const categories = mem.categories;
const cat =
Array.isArray(categories) && categories.length > 0
? (categories[0] as string)
: "—";
const created = formatDate(mem.created_at as string | undefined) ?? "—";
table.push([dim(memId), memoryText, cat, created]);
}
console.log();
console.log(table.toString());
console.log();
console.log();
console.log(table.toString());
console.log();
}
export function formatJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
console.log(JSON.stringify(data, null, 2));
}
export function formatSingleMemory(
mem: Record<string, unknown>,
output = "text",
mem: Record<string, unknown>,
output = "text",
): void {
if (output === "json") {
formatJson(mem);
return;
}
if (output === "json") {
formatJson(mem);
return;
}
const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = (mem.id ?? "") as string;
const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = (mem.id ?? "") as string;
const lines: string[] = [];
lines.push(` ${memoryText}`);
lines.push("");
const lines: string[] = [];
lines.push(` ${memoryText}`);
lines.push("");
if (memId) lines.push(` ${dim("ID:")} ${memId}`);
const created = formatDate(mem.created_at as string | undefined);
if (created) lines.push(` ${dim("Created:")} ${created}`);
const updated = formatDate(mem.updated_at as string | undefined);
if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
const meta = mem.metadata;
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
const categories = mem.categories;
if (categories) {
const catStr = Array.isArray(categories)
? categories.join(", ")
: String(categories);
lines.push(` ${dim("Categories:")} ${catStr}`);
}
if (memId) lines.push(` ${dim("ID:")} ${memId}`);
const created = formatDate(mem.created_at as string | undefined);
if (created) lines.push(` ${dim("Created:")} ${created}`);
const updated = formatDate(mem.updated_at as string | undefined);
if (updated) lines.push(` ${dim("Updated:")} ${updated}`);
const meta = mem.metadata;
if (meta) lines.push(` ${dim("Metadata:")} ${JSON.stringify(meta)}`);
const categories = mem.categories;
if (categories) {
const catStr = Array.isArray(categories) ? categories.join(", ") : String(categories);
lines.push(` ${dim("Categories:")} ${catStr}`);
}
const content = lines.join("\n");
console.log();
console.log(
boxen(content, {
title: brand("Memory"),
titleAlignment: "left",
borderColor: "magenta",
padding: 1,
}),
);
console.log();
const content = lines.join("\n");
console.log();
console.log(
boxen(content, {
title: brand("Memory"),
titleAlignment: "left",
borderColor: "magenta",
padding: 1,
}),
);
console.log();
}
export function formatAddResult(
result: Record<string, unknown> | Record<string, unknown>[],
output = "text",
result: Record<string, unknown> | Record<string, unknown>[],
output = "text",
): void {
if (output === "json") {
formatJson(result);
return;
}
if (output === "quiet") return;
if (output === "json") {
formatJson(result);
return;
}
if (output === "quiet") return;
const results: Record<string, unknown>[] = Array.isArray(result)
? result
: ((result.results as Record<string, unknown>[]) ?? [result]);
const results: Record<string, unknown>[] = Array.isArray(result)
? result
: ((result.results as Record<string, unknown>[]) ?? [result]);
if (!results.length) {
console.log(` ${dim("No memories extracted.")}`);
return;
}
if (!results.length) {
console.log(` ${dim("No memories extracted.")}`);
return;
}
console.log();
const seenPendingEvents = new Set<string>();
for (const r of results) {
// Detect async PENDING response
if (r.status === "PENDING") {
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",
];
console.log(parts.join(" "));
if (eventId) {
console.log(` ${dim(` event_id: ${eventId}`)}`);
console.log(
` ${dim(` → Check status: mem0 event status ${eventId}`)}`,
);
}
continue;
}
console.log();
for (const r of results) {
// Detect async PENDING response
if (r.status === "PENDING") {
const eventId = ((r.event_id as string) ?? "").slice(0, 8);
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(" "));
continue;
}
const event = (r.event ?? "ADD") as string;
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string;
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(
0,
8,
);
const event = (r.event ?? "ADD") as string;
const memory = (r.memory ?? r.text ?? r.content ?? r.data ?? "") as string;
const memId = ((r.id as string) ?? (r.memory_id as string) ?? "").slice(0, 8);
let icon: string;
let label: string;
if (event === "ADD") {
icon = success("+");
label = "Added";
} else if (event === "UPDATE") {
icon = accent("~");
label = "Updated";
} else if (event === "DELETE") {
icon = errorColor("-");
label = "Deleted";
} else if (event === "NOOP") {
icon = dim("·");
label = "No change";
} else {
icon = dim("?");
label = event;
}
let icon: string;
let label: string;
if (event === "ADD") {
icon = success("+");
label = "Added";
} else if (event === "UPDATE") {
icon = accent("~");
label = "Updated";
} else if (event === "DELETE") {
icon = errorColor("-");
label = "Deleted";
} else if (event === "NOOP") {
icon = dim("·");
label = "No change";
} else {
icon = dim("?");
label = event;
}
const parts = [` ${icon} ${dim(label.padEnd(10))}`];
if (memory) parts.push(memory);
if (memId) parts.push(dim(`(${memId})`));
console.log(parts.join(" "));
}
console.log();
const parts = [` ${icon} ${dim(label.padEnd(10))}`];
if (memory) parts.push(memory);
if (memId) parts.push(dim(`(${memId})`));
console.log(parts.join(" "));
}
console.log();
}
export function formatJsonEnvelope(opts: {
command: string;
data: unknown;
durationMs?: number;
scope?: Record<string, string | undefined>;
count?: number;
status?: string;
error?: string;
command: string;
data: unknown;
durationMs?: number;
scope?: Record<string, string | undefined>;
count?: number;
status?: string;
error?: string;
}): void {
const envelope: Record<string, unknown> = {
status: opts.status ?? "success",
command: opts.command,
};
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
if (opts.scope !== undefined) envelope.scope = opts.scope;
if (opts.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error;
envelope.data = opts.data;
console.log(JSON.stringify(envelope, null, 2));
}
function pick(
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>;
if (r.status === "PENDING") return pick(r, ["status", "event_id"]);
return pick(r, ["id", "memory", "event"]);
});
}
case "search":
return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "score", "created_at", "categories"]),
);
case "list":
return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "created_at", "categories"]),
);
case "get": {
const r = data as Record<string, unknown>;
return pick(r, [
"id",
"memory",
"created_at",
"updated_at",
"categories",
"metadata",
]);
}
case "update": {
const r = data as Record<string, unknown>;
return pick(r, ["id", "memory"]);
}
case "delete":
case "delete-all":
case "entity delete":
return data;
case "entity list":
return (data as Record<string, unknown>[]).map((r) => ({
name: (r.name ?? r.id) as string,
...pick(r, ["type", "count"]),
}));
case "event list":
return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "event_type", "status", "latency", "created_at"]),
);
case "event status": {
const ev = data as Record<string, unknown>;
const rawResults =
(ev.results as Record<string, unknown>[] | undefined) ?? [];
const sanitizedResults = rawResults.map((r) => {
const nested = r.data as Record<string, unknown> | 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<string, string | undefined>;
count?: number;
}): void {
const envelope: Record<string, unknown> = {
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));
const envelope: Record<string, unknown> = {
status: opts.status ?? "success",
command: opts.command,
};
if (opts.durationMs !== undefined) envelope.duration_ms = opts.durationMs;
if (opts.scope !== undefined) envelope.scope = opts.scope;
if (opts.count !== undefined) envelope.count = opts.count;
if (opts.error) envelope.error = opts.error;
envelope.data = opts.data;
console.log(JSON.stringify(envelope, null, 2));
}
export function printResultSummary(opts: {
count: number;
durationSecs?: number;
page?: number;
scopeIds?: Record<string, string | undefined>;
count: number;
durationSecs?: number;
page?: number;
scopeIds?: Record<string, string | undefined>;
}): void {
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
if (opts.page !== undefined) parts.push(`page ${opts.page}`);
if (opts.scopeIds) {
const scopeParts = Object.entries(opts.scopeIds)
.filter(([, v]) => v)
.map(([k, v]) => `${k}=${v}`);
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
}
if (opts.durationSecs !== undefined)
parts.push(`${opts.durationSecs.toFixed(2)}s`);
const parts = [`${opts.count} result${opts.count !== 1 ? "s" : ""}`];
if (opts.page !== undefined) parts.push(`page ${opts.page}`);
if (opts.scopeIds) {
const scopeParts = Object.entries(opts.scopeIds)
.filter(([, v]) => v)
.map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`);
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
}
if (opts.durationSecs !== undefined) parts.push(`${opts.durationSecs.toFixed(2)}s`);
console.log(` ${dim(parts.join(" · "))}`);
console.log();
console.log(` ${dim(parts.join(" · "))}`);
console.log();
}
-23
View File
@@ -1,23 +0,0 @@
/**
* 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;
}
-10
View File
@@ -1,10 +0,0 @@
import { createRequire } from "node:module";
// __CLI_VERSION__ is replaced at build time by tsup (see tsup.config.ts).
// When running via tsx in dev/test mode, fall back to reading package.json.
// typeof is safe to use on undeclared identifiers — it returns 'undefined' without throwing.
export const CLI_VERSION: string =
typeof __CLI_VERSION__ !== "undefined"
? (__CLI_VERSION__ as string)
: (createRequire(import.meta.url)("../package.json") as { version: string })
.version;
+5 -5
View File
@@ -39,7 +39,7 @@ afterEach(() => {
describe("branding constants", () => {
it("has correct brand color", () => {
expect(BRAND_COLOR).toBe("#F1C96C");
expect(BRAND_COLOR).toBe("#8b5cf6");
});
it("has correct tagline", () => {
@@ -80,19 +80,19 @@ describe("printWarning", () => {
describe("printInfo", () => {
it("prints info message", () => {
printInfo("Important note");
expect(errOutput).toContain("Important note");
expect(output).toContain("Important note");
});
});
describe("printScope", () => {
it("prints scope when IDs present", () => {
printScope({ user_id: "alice", agent_id: "bot" });
expect(errOutput).toContain("alice");
expect(errOutput).toContain("bot");
expect(output).toContain("alice");
expect(output).toContain("bot");
});
it("prints nothing when no IDs", () => {
printScope({});
expect(errOutput).toBe("");
expect(output).toBe("");
});
});
+7
View File
@@ -44,6 +44,13 @@ describe("CLI Integration — help and version", () => {
expect(result.stdout).toContain("search");
});
it("shows version with --version", () => {
const result = run(["--version"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("0.1.0");
});
it("help --json produces valid JSON", () => {
const result = run(["help", "--json"]);
expect(result.exitCode).toBe(0);
+9 -222
View File
@@ -5,7 +5,6 @@
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;
@@ -32,7 +31,6 @@ import { afterEach } from "vitest";
afterEach(() => {
console.log = originalLog;
console.error = originalError;
setAgentMode(false);
});
describe("cmdAdd", () => {
@@ -86,59 +84,6 @@ 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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown>) => r.status === "PENDING");
expect(pending).toHaveLength(1);
});
it("agent shows one pending entry", async () => {
(mockBackend.add as ReturnType<typeof vi.fn>).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");
@@ -180,7 +125,7 @@ describe("cmdSearch", () => {
enableGraph: false,
output: "text",
});
expect(errOutput).toContain("No memories found");
expect(output).toContain("No memories found");
});
});
@@ -221,7 +166,7 @@ describe("cmdList", () => {
enableGraph: false,
output: "text",
});
expect(errOutput).toContain("No memories found");
expect(output).toContain("No memories found");
});
});
@@ -253,6 +198,13 @@ describe("cmdDeleteAll", () => {
});
});
describe("cmdVersion", () => {
it("shows version", async () => {
const { cmdVersion } = await import("../src/commands/utils.js");
cmdVersion();
expect(output).toContain("0.1.0");
});
});
describe("cmdEntitiesList", () => {
it("lists users in table mode", async () => {
@@ -267,168 +219,3 @@ 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<typeof vi.fn>).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");
});
});
-83
View File
@@ -10,7 +10,6 @@ import {
formatSingleMemory,
formatAddResult,
printResultSummary,
sanitizeAgentData,
} from "../src/output.js";
let output: string;
@@ -99,18 +98,6 @@ 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", () => {
@@ -126,73 +113,3 @@ 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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>;
const firstResult = (result.results as Record<string, unknown>[])[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();
});
});
-37
View File
@@ -71,42 +71,5 @@ 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" },
},
],
}),
};
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'tsup';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
const pkg = _require('./package.json');
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
});
-11
View File
@@ -1,11 +0,0 @@
import { createRequire } from "node:module";
import { defineConfig } from "vitest/config";
const _require = createRequire(import.meta.url);
const pkg = _require("./package.json") as { version: string };
export default defineConfig({
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
},
});
-7
View File
@@ -34,13 +34,6 @@ mem0 search "What are Alice's preferences?" --user-id alice
mem0 list --user-id alice
```
## Releasing
1. Update `version` in `pyproject.toml`
2. Create a GitHub Release with tag `cli-v<version>` (e.g. `cli-v0.2.0`)
For a pre-release, use a beta version like `0.2.0b1` and check the **pre-release** checkbox.
## License
Apache-2.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.0"
version = "0.1.0"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
+26 -177
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import json as _json
import os
import stat as _stat_mod
import sys
from pathlib import Path
@@ -45,14 +43,7 @@ entity_app = typer.Typer(
no_args_is_help=True,
rich_markup_mode="rich",
)
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
# entity_app registered after Memory commands to control panel ordering
# ── Helpers ───────────────────────────────────────────────────────────────
@@ -123,22 +114,9 @@ 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 an actual pipe or file redirect (not a TTY, not agent mode)."""
if _stdin_is_piped():
"""Read from stdin if it is piped (not a TTY)."""
if not sys.stdin.isatty():
return sys.stdin.read().strip() or None
return None
@@ -150,18 +128,7 @@ 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
@@ -286,12 +253,8 @@ def search(
help="Specific fields to return (comma-separated).",
rich_help_panel="Search",
),
graph: bool = typer.Option(
False, "--graph", help="Enable graph in search.", rich_help_panel="Search"
),
no_graph: bool = typer.Option(
False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"
),
graph: bool = typer.Option(False, "--graph", help="Enable graph in search.", rich_help_panel="Search"),
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"),
output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
),
@@ -306,7 +269,7 @@ def search(
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
),
) -> None:
"""Query your memory store — semantic, keyword, or hybrid retrieval.
"""Search memories by semantic query.
Examples:
mem0 search "preferences" --user-id alice
@@ -318,8 +281,8 @@ def search(
# STEP 7: stdin fallback for query
if query is None:
query = _read_stdin()
if not query or not query.strip():
print_error(err_console, "Search query cannot be empty.")
if query is None:
print_error(err_console, "No query provided. Pass a query argument or pipe via stdin.")
raise typer.Exit(1)
backend, config = _get_backend_and_config(api_key, base_url)
@@ -409,12 +372,8 @@ def list_cmd(
before: str | None = typer.Option(
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
),
graph: bool = typer.Option(
False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"
),
no_graph: bool = typer.Option(
False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"
),
graph: bool = typer.Option(False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"),
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"),
output: str = typer.Option(
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
),
@@ -504,19 +463,11 @@ def update(
@app.command(rich_help_panel="Memory")
def delete(
memory_id: str | None = typer.Argument(
None, help="Memory ID to delete (omit when using --all or --entity)."
),
memory_id: str | None = typer.Argument(None, help="Memory ID to delete (omit when using --all or --entity)."),
all_: bool = typer.Option(False, "--all", help="Delete all memories matching scope filters."),
entity: bool = typer.Option(
False, "--entity", help="Delete the entity itself and all its memories (cascade)."
),
project: bool = typer.Option(
False, "--project", help="With --all: delete ALL memories project-wide."
),
dry_run: bool = typer.Option(
False, "--dry-run", help="Show what would be deleted without deleting."
),
entity: bool = typer.Option(False, "--entity", help="Delete the entity itself and all its memories (cascade)."),
project: bool = typer.Option(False, "--project", help="With --all: delete ALL memories project-wide."),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."),
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
user_id: str | None = typer.Option(
None, "--user-id", "-u", help="Scope to user.", rich_help_panel="Scope"
@@ -690,12 +641,14 @@ def entity_delete(
agent_id: str | None = typer.Option(
None, "--agent-id", help="Agent ID.", rich_help_panel="Scope"
),
app_id: str | None = typer.Option(None, "--app-id", help="App ID.", rich_help_panel="Scope"),
run_id: str | None = typer.Option(None, "--run-id", help="Run ID.", rich_help_panel="Scope"),
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
dry_run: bool = typer.Option(
False, "--dry-run", help="Show what would be deleted without deleting."
app_id: str | None = typer.Option(
None, "--app-id", help="App ID.", rich_help_panel="Scope"
),
run_id: str | None = typer.Option(
None, "--run-id", help="Run ID.", rich_help_panel="Scope"
),
force: bool = typer.Option(False, "--force", help="Skip confirmation."),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting."),
output: str = typer.Option(
"text", "--output", "-o", help="Output: text, json, quiet.", rich_help_panel="Output"
),
@@ -735,98 +688,23 @@ 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 <event-id>
mem0 event status <event-id> -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 ───────────────────────────────────────────────────
@app.command(rich_help_panel="Management")
def init(
api_key: str | None = typer.Option(None, "--api-key", help="API key (skip prompt)."),
user_id: str | None = typer.Option(
None, "--user-id", "-u", help="Default user ID (skip prompt)."
),
email: str | None = typer.Option(None, "--email", help="Login via email verification code."),
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."
),
user_id: str | None = typer.Option(None, "--user-id", "-u", help="Default user ID (skip prompt)."),
) -> None:
"""Interactive setup wizard for mem0 CLI.
Examples:
mem0 init
mem0 init --api-key m0-xxx --user-id alice
mem0 init --email alice@company.com
mem0 init --email alice@company.com --code 482901
"""
from mem0_cli.commands.init_cmd import run_init
run_init(api_key=api_key, user_id=user_id, email=email, code=code, force=force)
run_init(api_key=api_key, user_id=user_id)
# (entity_app registered at module level, below sub-group definitions)
@@ -865,6 +743,7 @@ def status(
)
@app.command("import", rich_help_panel="Management")
def import_cmd(
file_path: str = typer.Argument(..., help="JSON file to import."),
@@ -931,7 +810,7 @@ def _build_help_json() -> dict:
},
},
"search": {
"description": "Query your memory store — semantic, keyword, or hybrid retrieval.",
"description": "Search memories by semantic query.",
"usage": "mem0 search <query> [OPTIONS]",
"arguments": {"query": {"description": "Search query.", "required": False}},
"options": {
@@ -1035,24 +914,6 @@ 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 <event_id> [OPTIONS]",
"arguments": {
"event_id": {"description": "Event ID to inspect.", "required": True}
},
"options": {"--output, -o": "Output format: text, json."},
},
},
},
"entity": {
"description": "Manage entities.",
"subcommands": {
@@ -1104,7 +965,6 @@ 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.",
},
@@ -1134,7 +994,7 @@ def help(
console.print("Usage: mem0 <command> [OPTIONS]\n")
console.print("[bold]Commands:[/]")
console.print(" add Add a memory from text, messages, file, or stdin")
console.print(" search Query your memory store (semantic, keyword, hybrid)")
console.print(" search Search memories by semantic query")
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")
@@ -1142,7 +1002,6 @@ 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()
@@ -1159,14 +1018,4 @@ 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()
-5
View File
@@ -104,11 +104,6 @@ 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:
+23 -22
View File
@@ -265,21 +265,18 @@ class PlatformBackend(Backend):
app_id: str | None = None,
run_id: str | None = None,
) -> dict:
# 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:
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:
raise ValueError("At least one entity ID is required for delete_entities.")
# 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
return self._request("DELETE", "/v1/entities/", params=params)
def status(
self,
@@ -287,9 +284,19 @@ class PlatformBackend(Backend):
user_id: str | None = None,
agent_id: str | None = None,
) -> dict[str, Any]:
"""Check connectivity using the ping endpoint."""
"""Check connectivity by making a lightweight API call."""
try:
self._request("GET", "/v1/ping/")
# 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/")
return {"connected": True, "backend": "platform", "base_url": self.base_url}
except Exception as e:
return {"connected": False, "backend": "platform", "error": str(e)}
@@ -304,12 +311,6 @@ 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):
+4 -50
View File
@@ -26,8 +26,8 @@ LOGO_MINI = "◆ mem0"
TAGLINE = "The Memory Layer for AI Agents"
BRAND_COLOR = "#F1C96C" # Golden
ACCENT_COLOR = "#F5D78E"
BRAND_COLOR = "#8b5cf6" # Purple
ACCENT_COLOR = "#a78bfa"
SUCCESS_COLOR = "#22c55e"
ERROR_COLOR = "#ef4444"
WARNING_COLOR = "#f59e0b"
@@ -43,10 +43,6 @@ 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}")
@@ -65,28 +61,11 @@ 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:
@@ -94,19 +73,11 @@ 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}")
@@ -118,9 +89,7 @@ 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):
@@ -128,13 +97,6 @@ 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):
@@ -143,11 +105,6 @@ 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
@@ -157,14 +114,11 @@ 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:
parts.append(f"{key}={val}")
label = key.replace("_", " ").replace("id", "ID").strip()
parts.append(f"{label}={val}")
if parts:
scope_str = ", ".join(parts)
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
+9 -33
View File
@@ -20,17 +20,12 @@ err_console = Console(stderr=True)
def cmd_config_show(*, output: str = "text") -> None:
"""Display current configuration (secrets redacted)."""
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"
from mem0_cli.output import format_json_envelope
config = load_config()
if output in ("json", "agent"):
format_agent_envelope(
if output == "json":
format_json_envelope(
console,
command="config show",
data={
@@ -89,44 +84,25 @@ 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:
console.print(display_value)
# Redact secrets
if "api_key" in key or "key" in key.split(".")[-1:]:
console.print(redact_key(str(value)))
else:
console.print(str(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
if is_agent_mode():
format_agent_envelope(
console, command="config set", data={"key": key, "value": display}
)
else:
print_success(console, f"{key} = {display}")
print_success(console, f"{key} = {display}")
else:
print_error(err_console, f"Unknown config key: {key}")
+27 -62
View File
@@ -18,7 +18,7 @@ from mem0_cli.branding import (
print_success,
timed_status,
)
from mem0_cli.output import format_agent_envelope, format_json
from mem0_cli.output import format_json
console = Console()
err_console = Console(stderr=True)
@@ -26,16 +26,9 @@ 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(
err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}"
)
print_error(err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}")
raise typer.Exit(1)
_start = _time.perf_counter()
@@ -47,16 +40,6 @@ 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
@@ -92,39 +75,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."
)
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:
print_info(console, f"Would delete entity {scope_str} and all its memories.")
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, "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_str} AND all its memories? This cannot be undone."
f"\n \u26a0 Delete entity {scope} AND all its memories? This cannot be undone."
)
if not confirm:
print_info(console, "Cancelled.")
@@ -144,25 +127,7 @@ def cmd_entities_delete(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
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":
if output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)")
@@ -1,176 +0,0 @@
"""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()
+14 -214
View File
@@ -2,11 +2,8 @@
from __future__ import annotations
import os
import re
import sys
import httpx
import typer
from rich.console import Console
from rich.prompt import Prompt
@@ -19,7 +16,7 @@ from mem0_cli.branding import (
print_info,
print_success,
)
from mem0_cli.config import CONFIG_FILE, DEFAULT_BASE_URL, Mem0Config, load_config, save_config
from mem0_cli.config import Mem0Config, save_config
console = Console()
err_console = Console(stderr=True)
@@ -87,90 +84,7 @@ def _prompt_secret(label: str) -> str:
return "".join(chars)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _validate_email(email: str) -> None:
"""Exit with an error if *email* doesn't look like a valid address."""
if not _EMAIL_RE.match(email):
print_error(err_console, f"Invalid email address: {email!r}")
raise typer.Exit(1)
def _email_login(
email: str,
code: str | None,
base_url: str,
) -> dict:
"""Run the email verification code login flow.
Returns the parsed JSON response from the verify endpoint.
The caller expects at minimum an ``api_key`` field.
"""
url = base_url.rstrip("/")
with httpx.Client(timeout=30.0) as client:
# If code is already provided, skip sending — user already has a code
if not code:
# Step 1: Request verification code
resp = client.post(
f"{url}/api/v1/auth/email_code/",
json={"email": email},
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Failed to send code: {detail}")
raise typer.Exit(1)
print_success(console, "Verification code sent! Check your email.")
# Step 2: Get code from user
if not sys.stdin.isatty():
print_error(
err_console,
"No --code provided and terminal is non-interactive.",
hint="Run: mem0 init --email <email> --code <code>",
)
raise typer.Exit(1)
console.print()
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
if not code:
print_error(err_console, "Code is required.")
raise typer.Exit(1)
# Step 3: Verify code
resp = client.post(
f"{url}/api/v1/auth/email_code/verify/",
json={"email": email, "code": code.strip()},
)
if resp.status_code == 429:
print_error(err_console, "Too many attempts. Try again in a few minutes.")
raise typer.Exit(1)
if resp.status_code != 200:
try:
detail = resp.json().get("error", resp.text)
except Exception:
detail = resp.text
print_error(err_console, f"Verification failed: {detail}")
raise typer.Exit(1)
return resp.json()
def run_init(
*,
api_key: str | None = None,
user_id: str | None = None,
email: str | None = None,
code: str | None = None,
force: bool = False,
) -> None:
def run_init(*, api_key: str | None = None, user_id: str | None = None) -> None:
"""Interactive setup wizard for mem0 CLI.
When both *api_key* and *user_id* are supplied, all prompts are skipped
@@ -179,84 +93,6 @@ def run_init(
"""
config = Mem0Config()
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
if code and not email:
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:
print_error(err_console, "Cannot use both --api-key and --email.")
raise typer.Exit(1)
email = email.strip().lower()
_validate_email(email)
print_banner(console)
console.print()
print_info(console, f"Logging in as {email}...\n")
result = _email_login(email, code, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# ── 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 <key> [--user-id <id>]",
)
raise typer.Exit(1)
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
# Fully non-interactive when both flags provided
if api_key and user_id:
config.platform.api_key = api_key
@@ -266,55 +102,20 @@ 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 <key> --user-id <id>",
)
raise typer.Exit(1)
print_banner(console)
console.print()
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
# If no flags at all, ask user how they want to authenticate
if not api_key:
console.print(f" [{BRAND_COLOR}]How would you like to authenticate?[/]")
console.print(f" [{DIM_COLOR}]1.[/] Login with email [{DIM_COLOR}](recommended)[/]")
console.print(f" [{DIM_COLOR}]2.[/] Enter API key manually")
console.print()
choice = Prompt.ask(f" [{BRAND_COLOR}]Choose[/]", choices=["1", "2"], default="1")
if choice == "1":
console.print()
email_addr = Prompt.ask(f" [{BRAND_COLOR}]Email[/]")
if not email_addr:
print_error(err_console, "Email is required.")
raise typer.Exit(1)
email_addr = email_addr.strip().lower()
_validate_email(email_addr)
print_info(console, f"Logging in as {email_addr}...\n")
result = _email_login(email_addr, None, base_url)
api_key_val = result.get("api_key")
if not api_key_val:
print_error(
err_console, "Auth succeeded but no API key was returned. Contact support."
)
raise typer.Exit(1)
config.platform.api_key = api_key_val
config.platform.base_url = base_url
config.defaults.user_id = (
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
)
save_config(config)
console.print()
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
console.print()
console.print(f" [{DIM_COLOR}]Get started:[/]")
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
console.print()
return
# API key flow
# Use provided flags or prompt
if api_key:
config.platform.api_key = api_key
else:
@@ -361,10 +162,9 @@ 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=_default_user,
default="mem0-cli",
)
if user_id:
config.defaults.user_id = user_id
@@ -388,7 +188,7 @@ def _validate_platform(config: Mem0Config) -> None:
print_error(
err_console,
f"Could not connect: {status.get('error', 'Unknown error')}",
hint="Visit https://app.mem0.ai/dashboard/api-keys to get a new key, then run mem0 init again.",
hint="Check your API key and try again.",
)
except Exception as e:
print_error(err_console, f"Connection test failed: {e}")
+21 -229
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import json
import os
import stat as _stat_mod
import sys
import time as _time
from pathlib import Path
@@ -22,7 +20,6 @@ from mem0_cli.branding import (
)
from mem0_cli.output import (
format_add_result,
format_agent_envelope,
format_json,
format_memories_table,
format_memories_text,
@@ -34,19 +31,6 @@ 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,
@@ -66,11 +50,6 @@ 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
@@ -91,8 +70,8 @@ def cmd_add(
print_error(err_console, f"Invalid JSON in --messages: {e}")
raise typer.Exit(1) from None
# Read from stdin only if stdin is an actual pipe or file redirect
elif not content and _stdin_is_piped():
# Read from stdin if no text and stdin is piped
elif not content and not sys.stdin.isatty():
content = sys.stdin.read().strip()
if not content and not msgs:
@@ -116,21 +95,6 @@ def cmd_add(
except json.JSONDecodeError:
cats = [c.strip() for c in categories.split(",")]
# Validate --expires
if expires:
import re
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
print_error(
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
)
raise typer.Exit(1)
from datetime import date
if date.fromisoformat(expires) <= date.today():
print_error(err_console, "--expires date must be in the future.")
raise typer.Exit(1)
with timed_status(err_console, "Adding memory...") as ts:
try:
result = backend.add(
@@ -149,66 +113,24 @@ def cmd_add(
)
except Exception as e:
ts.error_msg = str(e)
print_error(err_console, str(e))
raise typer.Exit(1) from None
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 = 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"
)
# 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"
)
format_add_result(console, result, output)
@@ -230,11 +152,6 @@ 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:
@@ -247,13 +164,6 @@ def cmd_search(
if fields:
field_list = [f.strip() for f in fields.split(",")]
if top_k < 1:
print_error(err_console, "--top-k must be >= 1.")
raise typer.Exit(1)
if not (0.0 <= threshold <= 1.0):
print_error(err_console, "--threshold must be between 0.0 and 1.0.")
raise typer.Exit(1)
_start = _time.perf_counter()
with timed_status(err_console, "Searching memories...") as _ts:
try:
@@ -276,35 +186,11 @@ def cmd_search(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
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":
if results:
format_memories_table(console, results, show_score=True)
format_memories_table(console, results)
print_result_summary(
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
)
@@ -326,11 +212,6 @@ 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)
@@ -338,10 +219,7 @@ def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
print_error(err_console, str(e))
raise typer.Exit(1) from None
if output == "agent":
format_agent_envelope(console, command="get", data=result)
else:
format_single_memory(console, result, output)
format_single_memory(console, result, output)
def cmd_list(
@@ -360,18 +238,6 @@ 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)
if page < 1:
print_error(err_console, "--page must be >= 1.")
raise typer.Exit(1)
_start = _time.perf_counter()
with timed_status(err_console, "Listing memories...") as _ts:
try:
@@ -392,28 +258,8 @@ def cmd_list(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "quiet":
return
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),
duration_ms=int(_elapsed * 1000),
)
if output == "json":
format_json(console, results)
elif output == "table":
if results:
format_memories_table(console, results)
@@ -455,11 +301,6 @@ 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:
@@ -477,14 +318,7 @@ def cmd_update(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="update",
data=result,
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
if output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
@@ -499,11 +333,6 @@ 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:
@@ -524,14 +353,7 @@ def cmd_delete(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="delete",
data={"id": memory_id, "deleted": True},
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
if output == "json":
format_json(console, result)
elif output != "quiet":
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
@@ -550,17 +372,12 @@ 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
# Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
if dry_run:
print_info(console, "Would delete ALL memories project-wide.")
print_info(console, "No changes made (dry run).")
return
if not force:
confirm = typer.confirm(
@@ -585,14 +402,7 @@ def cmd_delete_all(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
if output == "agent":
format_agent_envelope(
console,
command="delete-all",
data={"deleted": True, "scope": "project"},
duration_ms=int(_elapsed * 1000),
)
elif output == "json":
if output == "json":
format_json(console, result)
elif output != "quiet":
if isinstance(result, dict) and "message" in result:
@@ -650,25 +460,7 @@ def cmd_delete_all(
raise typer.Exit(1) from None
_elapsed = _time.perf_counter() - _start
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":
if output == "json":
format_json(console, result)
elif output != "quiet":
if isinstance(result, dict) and "message" in result:
+9 -30
View File
@@ -35,26 +35,22 @@ def cmd_status(
output: str = "text",
) -> None:
"""Check connectivity and auth."""
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"
from mem0_cli.output import format_json_envelope
_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 in ("json", "agent"):
format_agent_envelope(
if output == "json":
format_json_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),
)
@@ -71,14 +67,6 @@ 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)
@@ -108,12 +96,7 @@ def cmd_import(
output: str = "text",
) -> None:
"""Import memories from a JSON file."""
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"
from mem0_cli.output import format_json_envelope
try:
data = json.loads(Path(file_path).read_text())
@@ -127,9 +110,7 @@ def cmd_import(
added = 0
failed = 0
_start = _time.perf_counter()
for item in track(
data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console
):
for item in track(data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console):
content = item.get("memory", item.get("text", item.get("content", "")))
if not content:
failed += 1
@@ -146,13 +127,11 @@ def cmd_import(
failed += 1
_elapsed = _time.perf_counter() - _start
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(
if output == "json":
format_json_envelope(
console,
command="import",
data={"added": added, "failed": failed},
scope=scope or None,
data={"added": added, "failed": failed, "duration_s": round(_elapsed, 2)},
duration_ms=int(_elapsed * 1000),
)
return
+1 -14
View File
@@ -45,17 +45,6 @@ class Mem0Config:
platform: PlatformConfig = field(default_factory=PlatformConfig)
SHORT_KEY_ALIASES: dict[str, str] = {
"api_key": "platform.api_key",
"base_url": "platform.base_url",
"user_id": "defaults.user_id",
"agent_id": "defaults.agent_id",
"app_id": "defaults.app_id",
"run_id": "defaults.run_id",
"enable_graph": "defaults.enable_graph",
}
def ensure_config_dir() -> Path:
"""Create ~/.mem0 directory with secure permissions if it doesn't exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -151,8 +140,7 @@ def redact_key(key: str) -> str:
def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
"""Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'."""
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
"""Get a config value by dotted path, e.g. 'platform.api_key'."""
parts = dotted_key.split(".")
obj: Any = config
for part in parts:
@@ -165,7 +153,6 @@ def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool:
"""Set a config value by dotted path. Returns True on success."""
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
parts = dotted_key.split(".")
obj: Any = config
for part in parts[:-1]:
+9 -126
View File
@@ -49,9 +49,7 @@ def format_memories_text(console: Console, memories: list[dict], title: str = "m
console.print()
def format_memories_table(
console: Console, memories: list[dict], *, show_score: bool = False
) -> None:
def format_memories_table(console: Console, memories: list[dict]) -> None:
"""Render memories in a rich table."""
table = Table(
border_style=BRAND_COLOR,
@@ -59,34 +57,20 @@ def format_memories_table(
row_styles=["", "dim"],
padding=(0, 1),
)
table.add_column("ID", style="dim", max_width=38, no_wrap=True)
if show_score:
table.add_column("Score", max_width=7, justify="right")
table.add_column("ID", style="dim", max_width=10)
table.add_column("Memory", max_width=50, no_wrap=False)
table.add_column("Category", max_width=14)
table.add_column("Created", max_width=12)
for mem in memories:
mem_id = mem.get("id", "")
mem_id = mem.get("id", "")[:8]
memory_text = mem.get("memory", mem.get("text", ""))
if len(memory_text) > 60:
memory_text = memory_text[:57] + "..."
categories = mem.get("categories", [])
if isinstance(categories, list) and categories:
cat = (
categories[0]
if len(categories) == 1
else f"{categories[0]} (+{len(categories) - 1})"
)
else:
cat = "—"
cat = categories[0] if isinstance(categories, list) and categories else "—"
created = _format_date(mem.get("created_at")) or "—"
if show_score:
score = mem.get("score")
score_str = f"{score:.2f}" if score is not None else "—"
table.add_row(mem_id, score_str, memory_text, cat, created)
else:
table.add_row(mem_id, memory_text, cat, created)
table.add_row(mem_id, memory_text, cat, created)
console.print()
console.print(table)
@@ -155,23 +139,16 @@ 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", "")
# 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)
event_id = r.get("event_id", "")[:8]
icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]"
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
parts.append("[white]Processing in background[/]")
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}[/]")
parts.append(f"[{DIM_COLOR}](event {event_id})[/]")
console.print(" ".join(parts))
continue
event = r.get("event", "ADD")
@@ -232,100 +209,6 @@ 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,
@@ -338,7 +221,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}={v}" for k, v in scope_ids.items() if v]
scope_parts = [f"{k.replace('_', ' ')}={v}" for k, v in scope_ids.items() if v]
if scope_parts:
parts.append(", ".join(scope_parts))
if duration_secs is not None:
-24
View File
@@ -1,24 +0,0 @@
"""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
-37
View File
@@ -96,43 +96,6 @@ 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
+3 -28
View File
@@ -8,19 +8,11 @@ boundaries).
from __future__ import annotations
import os
import re
import subprocess
import sys
import pytest
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
return _ANSI_RE.sub("", text)
def _run(
args: list[str],
@@ -36,35 +28,22 @@ def _run(
reads config from ``<home_dir>/.mem0/config.json`` instead
of the user's real config. This is critical for tests that
depend on a clean (no API key) or custom config state.
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
fragment option names like --user-id into separately-styled ANSI segments,
making plain ``in`` checks fail. Stripping here is version-agnostic and
ensures all assertions see the same plain text regardless of terminal env.
"""
env = os.environ.copy()
# Strip all MEM0_ env vars so tests start clean
for key in list(env.keys()):
if key.startswith("MEM0_"):
del env[key]
env.pop("FORCE_COLOR", None)
if home_dir:
env["HOME"] = home_dir
if env_override:
env.update(env_override)
result = subprocess.run(
return subprocess.run(
[sys.executable, "-m", "mem0_cli", *args],
capture_output=True,
text=True,
env=env,
)
return subprocess.CompletedProcess(
args=result.args,
returncode=result.returncode,
stdout=_strip_ansi(result.stdout),
stderr=_strip_ansi(result.stderr),
)
@pytest.fixture
@@ -88,6 +67,7 @@ class TestCLIIntegration:
assert result.returncode == 0
assert "0.1.0" in result.stdout
def test_add_help(self):
result = _run(["add", "--help"])
assert result.returncode == 0
@@ -181,12 +161,7 @@ class TestCLIIsolated:
)
assert result.returncode != 0
combined = result.stderr + result.stdout
assert (
"memory ID" in combined.lower()
or "--all" in combined
or "--entity" in combined
or "Error" in combined
)
assert "memory ID" in combined.lower() or "--all" in combined or "--entity" in combined or "Error" in combined
def test_config_show_clean(self, clean_home):
"""config show with no config should still work."""
+50 -495
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import typing
from io import StringIO
from unittest.mock import patch
@@ -17,7 +16,6 @@ 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,
@@ -64,7 +62,7 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="text",
)
@@ -90,7 +88,7 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="text",
)
@@ -115,7 +113,7 @@ class TestAddCommand:
metadata='{"source": "test"}',
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="text",
)
@@ -141,7 +139,7 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="json",
)
@@ -167,7 +165,7 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="quiet",
)
@@ -178,28 +176,30 @@ 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._stdin_is_piped", return_value=False),
pytest.raises((SystemExit, ClickExit)),
patch("mem0_cli.commands.memory.sys") as mock_sys,
):
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",
)
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",
)
def test_add_invalid_metadata_json(self, mock_backend):
console, _buf = _make_console()
@@ -221,7 +221,7 @@ class TestAddCommand:
metadata="not-json",
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="text",
)
@@ -247,7 +247,7 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories=None,
output="text",
)
@@ -272,73 +272,13 @@ class TestAddCommand:
metadata=None,
immutable=False,
no_infer=False,
expires=None,
expires=None,
categories="health,prefs",
output="text",
)
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()
@@ -678,6 +618,28 @@ 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()
@@ -926,28 +888,6 @@ 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()
@@ -1084,388 +1024,3 @@ 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
-146
View File
@@ -11,7 +11,6 @@ from mem0_cli.output import (
format_memories_table,
format_memories_text,
format_single_memory,
sanitize_agent_data,
)
@@ -130,153 +129,8 @@ 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
@@ -82,7 +82,7 @@ new_project = client.project.create(
### Update Project Settings
Modify project configuration including custom instructions, categories, graph settings, and language preferences:
Modify project configuration including custom instructions, categories, and graph settings:
```python
# Update project with custom categories
@@ -101,9 +101,6 @@ client.project.update(
# Enable graph memory for the project
client.project.update(enable_graph=True)
# Use the input language for memory storage and retrieval
client.project.update(multilingual=True)
# Update multiple settings at once
client.project.update(
custom_instructions="...",
@@ -111,8 +108,7 @@ client.project.update(
{"personal_info": "User personal information and preferences"},
{"work_context": "Professional context and work-related information"}
],
enable_graph=True,
multilingual=True
enable_graph=True
)
```
-24
View File
@@ -8,21 +8,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-04-01" description="v1.0.10">
**New Features & Updates:**
- **LLMs:** Added MiniMax provider support for AWS Bedrock ([#4609](https://github.com/mem0ai/mem0/pull/4609))
**Bug Fixes:**
- **Configs:** Migrated CassandraConfig and AzureMySQLConfig to pydantic v2 ConfigDict ([#4646](https://github.com/mem0ai/mem0/pull/4646))
- **LLMs:** Forward `response_format` to OpenAI-compatible API for DeepSeek ([#4635](https://github.com/mem0ai/mem0/pull/4635))
- **LLMs:** Forward `response_format` to OpenAI-compatible API for vLLM ([#4608](https://github.com/mem0ai/mem0/pull/4608))
- **Vector Stores:** Only list authorized collections when listing MongoDB collections ([#3888](https://github.com/mem0ai/mem0/pull/3888))
- **Core:** Reset graph database in `Memory.reset()` ([#4185](https://github.com/mem0ai/mem0/pull/4185))
- **Core:** Make `AsyncMemory.from_config` a regular classmethod ([#4183](https://github.com/mem0ai/mem0/pull/4183))
</Update>
<Update label="2026-03-28" description="v1.0.9">
**New Features & Updates:**
@@ -832,15 +817,6 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-04-01" description="v2.4.5">
**Bug Fixes:**
- **OSS:** Replace `.single()` with `.maybeSingle()` in SupabaseDB.get() to handle missing rows ([#4599](https://github.com/mem0ai/mem0/pull/4599))
- **Embeddings:** Pass dimensions parameter to OpenAI embeddings API ([#4632](https://github.com/mem0ai/mem0/pull/4632))
- **OSS:** Extract JSON from chatty LLM responses in fact retrieval ([#4533](https://github.com/mem0ai/mem0/pull/4533))
</Update>
<Update label="2026-03-28" description="v2.4.4">
**Bug Fixes:**
@@ -24,7 +24,6 @@ config = {
"provider": "gemini",
"config": {
"model": "gemini-2.0-flash-001",
"api_key": "your-gemini-api-key",
"temperature": 0.2,
"max_tokens": 2000,
"top_p": 1.0
@@ -53,7 +52,6 @@ const config = {
provider: "gemini",
config: {
model: "gemini-2.0-flash-001",
apiKey: process.env.GOOGLE_API_KEY || '',
temperature: 0.1
}
}
-4
View File
@@ -3686,10 +3686,6 @@
"type": "object"
},
"description": "List of custom categories to be used for memory categorization."
},
"multilingual": {
"type": "boolean",
"description": "Whether to use the input language for memory storage and retrieval."
}
}
}
+16 -88
View File
@@ -1,6 +1,6 @@
---
title: CLI
description: "Manage memories from your terminal — built for both humans and AI agents."
description: "Manage memories from your terminal. Available for Node.js and Python."
icon: "terminal"
iconType: "solid"
---
@@ -9,10 +9,6 @@ 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.
<Tip>
**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.
</Tip>
## Installation
<CodeGroup>
@@ -78,23 +74,8 @@ 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.
@@ -221,32 +202,16 @@ mem0 config get api_key # Get a specific value
mem0 config set user_id bob # Set a value
```
### `mem0 entity`
### `mem0 entities`
List or delete entities (users, agents, apps, runs).
List or delete entities (users, agents, apps).
```bash
mem0 entity list users
mem0 entity list agents --output json
mem0 entity delete --user-id alice --force
mem0 entities list
mem0 entities list --type agent --output json
mem0 entities 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 <event-id>
```
| Flag | Description |
|------|-------------|
| `-o, --output` | Output format: `text`, `json` |
### `mem0 status`
Verify your API connection and display the current project.
@@ -273,7 +238,6 @@ 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:
@@ -283,54 +247,20 @@ mem0 search "user preferences" --user-id alice --output json | jq '.data.results
## Use with AI agents
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:
The CLI is designed to be used by AI agents and automation tools. Two features make this straightforward:
- 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.
- **`--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
```bash
# 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
# 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
```
<CodeGroup>
```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" }
]
}
```
</CodeGroup>
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.
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.
## Environment variables
@@ -348,12 +278,10 @@ Environment variables take precedence over values in the config file, which take
## Global flags
These flags are available on all commands:
These flags are available on all commands that interact with the API:
| 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 |
+1 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.4.5",
"version": "2.4.4",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -68,11 +68,6 @@
"!src/community/**"
]
},
"repository": {
"type": "git",
"url": "https://github.com/mem0ai/mem0",
"directory": "mem0-ts"
},
"keywords": [
"mem0",
"api",
-1
View File
@@ -167,7 +167,6 @@ export interface PromptUpdatePayload {
exclusion_prompt?: string;
memory_depth?: string | null;
usecase_setting?: string | number;
multilingual?: boolean;
[key: string]: any;
}
+2 -8
View File
@@ -5,7 +5,7 @@ import { EmbeddingConfig } from "../types";
export class AzureOpenAIEmbedder implements Embedder {
private client: AzureOpenAI;
private model: string;
private embeddingDims: number | undefined;
private embeddingDims?: number;
constructor(config: EmbeddingConfig) {
if (!config.apiKey || !config.modelProperties?.endpoint) {
@@ -20,16 +20,13 @@ export class AzureOpenAIEmbedder implements Embedder {
...rest,
});
this.model = config.model || "text-embedding-3-small";
this.embeddingDims = config.embeddingDims;
this.embeddingDims = config.embeddingDims || 1536;
}
async embed(text: string): Promise<number[]> {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data[0].embedding;
}
@@ -38,9 +35,6 @@ export class AzureOpenAIEmbedder implements Embedder {
const response = await this.client.embeddings.create({
model: this.model,
input: texts,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data.map((item) => item.embedding);
}
+4 -8
View File
@@ -5,23 +5,21 @@ import { EmbeddingConfig } from "../types";
export class GoogleEmbedder implements Embedder {
private google: GoogleGenAI;
private model: string;
private embeddingDims: number | undefined;
private embeddingDims?: number;
constructor(config: EmbeddingConfig) {
this.google = new GoogleGenAI({
apiKey: config.apiKey || process.env.GOOGLE_API_KEY,
});
this.model = config.model || "gemini-embedding-001";
this.embeddingDims = config.embeddingDims;
this.embeddingDims = config.embeddingDims || 1536;
}
async embed(text: string): Promise<number[]> {
const response = await this.google.models.embedContent({
model: this.model,
contents: text,
...(this.embeddingDims !== undefined && {
config: { outputDimensionality: this.embeddingDims },
}),
config: { outputDimensionality: this.embeddingDims },
});
return response.embeddings![0].values!;
}
@@ -30,9 +28,7 @@ export class GoogleEmbedder implements Embedder {
const response = await this.google.models.embedContent({
model: this.model,
contents: texts,
...(this.embeddingDims !== undefined && {
config: { outputDimensionality: this.embeddingDims },
}),
config: { outputDimensionality: this.embeddingDims },
});
return response.embeddings!.map((item) => item.values!);
}
+2 -8
View File
@@ -5,7 +5,7 @@ import { EmbeddingConfig } from "../types";
export class OpenAIEmbedder implements Embedder {
private openai: OpenAI;
private model: string;
private embeddingDims: number | undefined;
private embeddingDims?: number;
constructor(config: EmbeddingConfig) {
this.openai = new OpenAI({
@@ -13,16 +13,13 @@ export class OpenAIEmbedder implements Embedder {
baseURL: config.baseURL || config.url,
});
this.model = config.model || "text-embedding-3-small";
this.embeddingDims = config.embeddingDims;
this.embeddingDims = config.embeddingDims || 1536;
}
async embed(text: string): Promise<number[]> {
const response = await this.openai.embeddings.create({
model: this.model,
input: text,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data[0].embedding;
}
@@ -31,9 +28,6 @@ export class OpenAIEmbedder implements Embedder {
const response = await this.openai.embeddings.create({
model: this.model,
input: texts,
...(this.embeddingDims !== undefined && {
dimensions: this.embeddingDims,
}),
});
return response.data.map((item) => item.embedding);
}
@@ -267,7 +267,7 @@ See the SQL migration instructions in the code comments.`,
.from(this.tableName)
.select("*")
.eq("id", vectorId)
.maybeSingle();
.single();
if (error) throw error;
if (!data) return null;
@@ -1,163 +0,0 @@
/// <reference types="jest" />
/**
* Azure OpenAI Embedder — unit tests (mocked Azure OpenAI client).
* Verifies that the `dimensions` parameter is only passed to the API
* when the user explicitly configures `embeddingDims`.
*/
const mockEmbeddingsCreate = jest.fn();
jest.mock("openai", () => {
return {
__esModule: true,
AzureOpenAI: jest.fn().mockImplementation(() => ({
embeddings: { create: mockEmbeddingsCreate },
})),
};
});
import { AzureOpenAIEmbedder } from "../src/embeddings/azure";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
const baseConfig = {
apiKey: "test-key",
modelProperties: { endpoint: "https://test.openai.azure.com" },
};
describe("AzureOpenAIEmbedder (unit)", () => {
beforeEach(() => {
mockEmbeddingsCreate.mockReset();
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }],
});
});
describe("dimensions parameter", () => {
it("does NOT pass dimensions when embeddingDims is not set", async () => {
const embedder = new AzureOpenAIEmbedder(baseConfig);
await embedder.embed("hello");
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("dimensions");
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: "hello",
});
});
it("passes dimensions when embeddingDims is explicitly set", async () => {
const embedder = new AzureOpenAIEmbedder({
...baseConfig,
embeddingDims: 1024,
});
await embedder.embed("hello");
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: "hello",
dimensions: 1024,
});
});
it("passes dimensions=1536 when embeddingDims is explicitly set to 1536", async () => {
const embedder = new AzureOpenAIEmbedder({
...baseConfig,
embeddingDims: 1536,
});
await embedder.embed("hello");
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toHaveProperty("dimensions", 1536);
});
it("does NOT pass dimensions in embedBatch when embeddingDims is not set", async () => {
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
});
const embedder = new AzureOpenAIEmbedder(baseConfig);
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("dimensions");
});
it("passes dimensions in embedBatch when embeddingDims is explicitly set", async () => {
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
});
const embedder = new AzureOpenAIEmbedder({
...baseConfig,
embeddingDims: 512,
});
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: ["hello", "world"],
dimensions: 512,
});
});
});
describe("basic functionality", () => {
it("embed() returns the embedding vector", async () => {
const embedder = new AzureOpenAIEmbedder(baseConfig);
const result = await embedder.embed("hello");
expect(result).toEqual(mockEmbedding);
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const batch = [
[0.1, 0.2],
[0.3, 0.4],
];
mockEmbeddingsCreate.mockResolvedValue({
data: batch.map((embedding) => ({ embedding })),
});
const embedder = new AzureOpenAIEmbedder(baseConfig);
const result = await embedder.embedBatch(["text1", "text2"]);
expect(result).toEqual(batch);
});
it("uses custom model when provided", async () => {
const embedder = new AzureOpenAIEmbedder({
...baseConfig,
model: "text-embedding-3-large",
});
await embedder.embed("hello");
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs.model).toBe("text-embedding-3-large");
});
it("throws when API key is missing", () => {
expect(() => {
new AzureOpenAIEmbedder({
modelProperties: { endpoint: "https://test.openai.azure.com" },
});
}).toThrow("Azure OpenAI requires both API key and endpoint");
});
it("throws when endpoint is missing", () => {
expect(() => {
new AzureOpenAIEmbedder({ apiKey: "test-key" });
}).toThrow("Azure OpenAI requires both API key and endpoint");
});
});
});
@@ -1,153 +0,0 @@
/// <reference types="jest" />
/**
* Google Embedder — unit tests (mocked Google GenAI client).
* Verifies that the `outputDimensionality` config is only passed to the API
* when the user explicitly configures `embeddingDims`.
*/
const mockEmbedContent = jest.fn();
jest.mock("@google/genai", () => {
return {
__esModule: true,
GoogleGenAI: jest.fn().mockImplementation(() => ({
models: { embedContent: mockEmbedContent },
})),
};
});
import { GoogleEmbedder } from "../src/embeddings/google";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
describe("GoogleEmbedder (unit)", () => {
beforeEach(() => {
mockEmbedContent.mockReset();
mockEmbedContent.mockResolvedValue({
embeddings: [{ values: mockEmbedding }],
});
});
describe("outputDimensionality parameter", () => {
it("does NOT pass config when embeddingDims is not set", async () => {
const embedder = new GoogleEmbedder({
apiKey: "test-key",
});
await embedder.embed("hello");
expect(mockEmbedContent).toHaveBeenCalledTimes(1);
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("config");
expect(callArgs).toEqual({
model: "gemini-embedding-001",
contents: "hello",
});
});
it("passes outputDimensionality when embeddingDims is explicitly set", async () => {
const embedder = new GoogleEmbedder({
apiKey: "test-key",
embeddingDims: 768,
});
await embedder.embed("hello");
expect(mockEmbedContent).toHaveBeenCalledTimes(1);
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs).toEqual({
model: "gemini-embedding-001",
contents: "hello",
config: { outputDimensionality: 768 },
});
});
it("passes outputDimensionality=1536 when embeddingDims is explicitly set to 1536", async () => {
const embedder = new GoogleEmbedder({
apiKey: "test-key",
embeddingDims: 1536,
});
await embedder.embed("hello");
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs).toHaveProperty("config");
expect(callArgs.config).toEqual({ outputDimensionality: 1536 });
});
it("does NOT pass config in embedBatch when embeddingDims is not set", async () => {
mockEmbedContent.mockResolvedValue({
embeddings: [{ values: mockEmbedding }, { values: mockEmbedding }],
});
const embedder = new GoogleEmbedder({
apiKey: "test-key",
});
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("config");
});
it("passes outputDimensionality in embedBatch when embeddingDims is explicitly set", async () => {
mockEmbedContent.mockResolvedValue({
embeddings: [{ values: mockEmbedding }, { values: mockEmbedding }],
});
const embedder = new GoogleEmbedder({
apiKey: "test-key",
embeddingDims: 512,
});
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs).toEqual({
model: "gemini-embedding-001",
contents: ["hello", "world"],
config: { outputDimensionality: 512 },
});
});
});
describe("basic functionality", () => {
it("embed() returns the embedding vector", async () => {
const embedder = new GoogleEmbedder({
apiKey: "test-key",
});
const result = await embedder.embed("hello");
expect(result).toEqual(mockEmbedding);
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const batch = [
[0.1, 0.2],
[0.3, 0.4],
];
mockEmbedContent.mockResolvedValue({
embeddings: batch.map((values) => ({ values })),
});
const embedder = new GoogleEmbedder({
apiKey: "test-key",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(result).toEqual(batch);
});
it("uses custom model when provided", async () => {
const embedder = new GoogleEmbedder({
apiKey: "test-key",
model: "text-embedding-004",
});
await embedder.embed("hello");
const callArgs = mockEmbedContent.mock.calls[0][0];
expect(callArgs.model).toBe("text-embedding-004");
});
});
});
@@ -1,152 +0,0 @@
/// <reference types="jest" />
/**
* OpenAI Embedder — unit tests (mocked OpenAI client).
* Verifies that the `dimensions` parameter is only passed to the API
* when the user explicitly configures `embeddingDims`.
*/
const mockEmbeddingsCreate = jest.fn();
jest.mock("openai", () => {
return {
__esModule: true,
default: jest.fn().mockImplementation(() => ({
embeddings: { create: mockEmbeddingsCreate },
})),
};
});
import { OpenAIEmbedder } from "../src/embeddings/openai";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
describe("OpenAIEmbedder (unit)", () => {
beforeEach(() => {
mockEmbeddingsCreate.mockReset();
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }],
});
});
describe("dimensions parameter", () => {
it("does NOT pass dimensions when embeddingDims is not set", async () => {
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
});
await embedder.embed("hello");
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("dimensions");
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: "hello",
});
});
it("passes dimensions when embeddingDims is explicitly set", async () => {
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
embeddingDims: 1024,
});
await embedder.embed("hello");
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: "hello",
dimensions: 1024,
});
});
it("passes dimensions=1536 when embeddingDims is explicitly set to 1536", async () => {
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
embeddingDims: 1536,
});
await embedder.embed("hello");
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toHaveProperty("dimensions", 1536);
});
it("does NOT pass dimensions in embedBatch when embeddingDims is not set", async () => {
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
});
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
});
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("dimensions");
});
it("passes dimensions in embedBatch when embeddingDims is explicitly set", async () => {
mockEmbeddingsCreate.mockResolvedValue({
data: [{ embedding: mockEmbedding }, { embedding: mockEmbedding }],
});
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
embeddingDims: 512,
});
await embedder.embedBatch(["hello", "world"]);
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs).toEqual({
model: "text-embedding-3-small",
input: ["hello", "world"],
dimensions: 512,
});
});
});
describe("basic functionality", () => {
it("embed() returns the embedding vector", async () => {
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
});
const result = await embedder.embed("hello");
expect(result).toEqual(mockEmbedding);
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const batch = [
[0.1, 0.2],
[0.3, 0.4],
];
mockEmbeddingsCreate.mockResolvedValue({
data: batch.map((embedding) => ({ embedding })),
});
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(result).toEqual(batch);
});
it("uses custom model when provided", async () => {
const embedder = new OpenAIEmbedder({
apiKey: "test-key",
model: "text-embedding-3-large",
});
await embedder.embed("hello");
const callArgs = mockEmbeddingsCreate.mock.calls[0][0];
expect(callArgs.model).toBe("text-embedding-3-large");
});
});
});
-10
View File
@@ -676,7 +676,6 @@ class MemoryClient:
exclusion_prompt: Optional[str] = None,
memory_depth: Optional[str] = None,
usecase_setting: Optional[str] = None,
multilingual: Optional[bool] = None,
) -> Dict[str, Any]:
"""Update the project settings.
@@ -690,7 +689,6 @@ class MemoryClient:
exclusion_prompt: Exclusion prompt for the project
memory_depth: Memory depth for the project
usecase_setting: Usecase setting for the project
multilingual: Whether to use the input language for memory storage and retrieval
Returns:
Dictionary containing the API response.
@@ -720,7 +718,6 @@ class MemoryClient:
and exclusion_prompt is None
and memory_depth is None
and usecase_setting is None
and multilingual is None
):
raise ValueError(
"Currently we only support updating custom_instructions or "
@@ -739,7 +736,6 @@ class MemoryClient:
"exclusion_prompt": exclusion_prompt,
"memory_depth": memory_depth,
"usecase_setting": usecase_setting,
"multilingual": multilingual,
}
)
response = self.client.patch(
@@ -760,7 +756,6 @@ class MemoryClient:
"exclusion_prompt": exclusion_prompt,
"memory_depth": memory_depth,
"usecase_setting": usecase_setting,
"multilingual": multilingual,
"sync_type": "sync",
},
)
@@ -1559,7 +1554,6 @@ class AsyncMemoryClient:
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
enable_graph: Optional[bool] = None,
version: Optional[str] = None,
multilingual: Optional[bool] = None,
) -> Dict[str, Any]:
"""Update the project settings.
@@ -1569,7 +1563,6 @@ class AsyncMemoryClient:
retrieval_criteria: New retrieval criteria for the project
enable_graph: Enable or disable the graph for the project
version: Version of the project
multilingual: Whether to use the input language for memory storage and retrieval
Returns:
Dictionary containing the API response.
@@ -1595,7 +1588,6 @@ class AsyncMemoryClient:
and retrieval_criteria is None
and enable_graph is None
and version is None
and multilingual is None
):
raise ValueError(
"Currently we only support updating custom_instructions or custom_categories or retrieval_criteria, so you must provide at least one of them"
@@ -1608,7 +1600,6 @@ class AsyncMemoryClient:
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"version": version,
"multilingual": multilingual,
}
)
response = await self.async_client.patch(
@@ -1625,7 +1616,6 @@ class AsyncMemoryClient:
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"version": version,
"multilingual": multilingual,
"sync_type": "async",
},
)
+2 -12
View File
@@ -399,7 +399,6 @@ class Project(BaseProject):
custom_categories: Optional[List[str]] = None,
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
enable_graph: Optional[bool] = None,
multilingual: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Update project settings.
@@ -409,7 +408,6 @@ class Project(BaseProject):
custom_categories: New categories for the project
retrieval_criteria: New retrieval criteria for the project
enable_graph: Enable or disable the graph for the project
multilingual: Whether to use the input language for memory storage and retrieval
Returns:
Dictionary containing the API response.
@@ -426,12 +424,11 @@ class Project(BaseProject):
and custom_categories is None
and retrieval_criteria is None
and enable_graph is None
and multilingual is None
):
raise ValueError(
"At least one parameter must be provided for update: "
"custom_instructions, custom_categories, retrieval_criteria, "
"enable_graph, multilingual"
"enable_graph"
)
payload = self._prepare_params(
@@ -440,7 +437,6 @@ class Project(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"multilingual": multilingual,
}
)
response = self._client.patch(
@@ -456,7 +452,6 @@ class Project(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"multilingual": multilingual,
"sync_type": "sync",
},
)
@@ -721,7 +716,6 @@ class AsyncProject(BaseProject):
custom_categories: Optional[List[str]] = None,
retrieval_criteria: Optional[List[Dict[str, Any]]] = None,
enable_graph: Optional[bool] = None,
multilingual: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Update project settings.
@@ -731,7 +725,6 @@ class AsyncProject(BaseProject):
custom_categories: New categories for the project
retrieval_criteria: New retrieval criteria for the project
enable_graph: Enable or disable the graph for the project
multilingual: Whether to use the input language for memory storage and retrieval
Returns:
Dictionary containing the API response.
@@ -748,12 +741,11 @@ class AsyncProject(BaseProject):
and custom_categories is None
and retrieval_criteria is None
and enable_graph is None
and multilingual is None
):
raise ValueError(
"At least one parameter must be provided for update: "
"custom_instructions, custom_categories, retrieval_criteria, "
"enable_graph, multilingual"
"enable_graph"
)
payload = self._prepare_params(
@@ -762,7 +754,6 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"multilingual": multilingual,
}
)
response = await self._client.patch(
@@ -778,7 +769,6 @@ class AsyncProject(BaseProject):
"custom_categories": custom_categories,
"retrieval_criteria": retrieval_criteria,
"enable_graph": enable_graph,
"multilingual": multilingual,
"sync_type": "async",
},
)
+3 -2
View File
@@ -1,6 +1,6 @@
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class AzureMySQLConfig(BaseModel):
@@ -80,4 +80,5 @@ class AzureMySQLConfig(BaseModel):
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
class Config:
arbitrary_types_allowed = True
+3 -2
View File
@@ -1,6 +1,6 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class CassandraConfig(BaseModel):
@@ -72,5 +72,6 @@ class CassandraConfig(BaseModel):
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
class Config:
arbitrary_types_allowed = True
+1 -3
View File
@@ -1,7 +1,7 @@
from collections.abc import Callable
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class ElasticsearchConfig(BaseModel):
@@ -63,5 +63,3 @@ class ElasticsearchConfig(BaseModel):
f"Please input only the following fields: {', '.join(allowed_fields)}"
)
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
+1 -3
View File
@@ -1,6 +1,6 @@
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class MongoDBConfig(BaseModel):
@@ -23,5 +23,3 @@ class MongoDBConfig(BaseModel):
f"Please provide only the following fields: {', '.join(allowed_fields)}."
)
return values
model_config = ConfigDict(arbitrary_types_allowed=False)
+4 -2
View File
@@ -5,7 +5,7 @@ This module provides configuration settings for integrating with Amazon Neptune
as a vector store backend for Mem0's memory layer.
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, Field
class NeptuneAnalyticsConfig(BaseModel):
@@ -22,4 +22,6 @@ class NeptuneAnalyticsConfig(BaseModel):
collection_name: str = Field("mem0", description="Default name for the collection")
endpoint: str = Field("endpoint", description="Graph ID for the runtime")
model_config = ConfigDict(arbitrary_types_allowed=False)
model_config = {
"arbitrary_types_allowed": False,
}
+1 -3
View File
@@ -1,6 +1,6 @@
from typing import Any, Dict, Optional, Type, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class OpenSearchConfig(BaseModel):
@@ -39,5 +39,3 @@ class OpenSearchConfig(BaseModel):
f"Extra fields not allowed: {', '.join(extra_fields)}. Allowed fields: {', '.join(allowed_fields)}"
)
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
+1 -3
View File
@@ -1,6 +1,6 @@
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class PGVectorConfig(BaseModel):
@@ -50,5 +50,3 @@ class PGVectorConfig(BaseModel):
f"Extra fields not allowed: {', '.join(extra_fields)}. Please input only the following fields: {', '.join(allowed_fields)}"
)
return values
model_config = ConfigDict(arbitrary_types_allowed=True)
+1 -3
View File
@@ -1,7 +1,7 @@
from enum import Enum
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, Field, model_validator
class IndexMethod(str, Enum):
@@ -42,5 +42,3 @@ class SupabaseConfig(BaseModel):
f"Extra fields not allowed: {', '.join(extra_fields)}. Please input only the following fields: {', '.join(allowed_fields)}"
)
return values
model_config = ConfigDict(arbitrary_types_allowed=False)
+10 -26
View File
@@ -1,31 +1,15 @@
from typing import Any, Dict
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel
class ValkeyConfig(BaseModel):
"""Configuration for Valkey vector store."""
valkey_url: str = Field(..., description="Valkey server URL (e.g., redis://localhost:6379)")
collection_name: str = Field(..., description="Name of the index / collection")
embedding_model_dims: int = Field(..., description="Dimensions of the embedding model")
timezone: str = Field("UTC", description="Timezone for timestamp handling")
index_type: str = Field("hnsw", description="Index type: 'hnsw' (default) or 'flat'")
hnsw_m: int = Field(16, description="HNSW: number of connections per layer")
hnsw_ef_construction: int = Field(200, description="HNSW: search width during index construction")
hnsw_ef_runtime: int = Field(10, description="HNSW: search width during queries")
@model_validator(mode="before")
@classmethod
def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
allowed_fields = set(cls.model_fields.keys())
input_fields = set(values.keys())
extra_fields = input_fields - allowed_fields
if extra_fields:
raise ValueError(
f"Extra fields not allowed: {', '.join(extra_fields)}. "
f"Please input only the following fields: {', '.join(allowed_fields)}"
)
return values
model_config = ConfigDict(arbitrary_types_allowed=False)
valkey_url: str
collection_name: str
embedding_model_dims: int
timezone: str = "UTC"
index_type: str = "hnsw" # Default to HNSW, can be 'hnsw' or 'flat'
# HNSW specific parameters with recommended defaults
hnsw_m: int = 16 # Number of connections per layer (default from Valkey docs)
hnsw_ef_construction: int = 200 # Search width during construction
hnsw_ef_runtime: int = 10 # Search width during queries
-2
View File
@@ -99,8 +99,6 @@ class DeepSeekLLM(LLMBase):
}
)
if response_format:
params["response_format"] = response_format
if tools:
params["tools"] = tools
params["tool_choice"] = tool_choice
-2
View File
@@ -99,8 +99,6 @@ class VllmLLM(LLMBase):
}
)
if response_format:
params["response_format"] = response_format
if tools:
params["tools"] = tools
params["tool_choice"] = tool_choice
+2 -2
View File
@@ -51,7 +51,7 @@ class MongoDB(VectorStoreBase):
"""Create new collection with vector search index."""
try:
database = self.client[self.db_name]
collection_names = database.list_collection_names(authorizedCollections=True)
collection_names = database.list_collection_names()
if self.collection_name not in collection_names:
logger.info(f"Collection '{self.collection_name}' does not exist. Creating it now.")
collection = database[self.collection_name]
@@ -240,7 +240,7 @@ class MongoDB(VectorStoreBase):
List[str]: List of collection names.
"""
try:
collections = self.db.list_collection_names(authorizedCollections=True)
collections = self.db.list_collection_names()
logger.info(f"Listing collections in database '{self.db_name}': {collections}")
return collections
except PyMongoError as e:
-26
View File
@@ -1,10 +1,8 @@
import logging
import re
from typing import Optional
from qdrant_client import QdrantClient
from qdrant_client.models import (
DatetimeRange,
Distance,
FieldCondition,
Filter,
@@ -141,23 +139,6 @@ class Qdrant(VectorStoreBase):
]
self.client.upsert(collection_name=self.collection_name, points=points)
# ISO 8601 datetime pattern for detecting datetime strings in range filters
_ISO_DATETIME_RE = re.compile(
r"^\d{4}-\d{2}-\d{2}" # date part
r"([T ]\d{2}:\d{2}(:\d{2})?" # optional time part
r"(\.\d+)?" # optional fractional seconds
r"(Z|[+-]\d{2}:?\d{2})?" # optional timezone
r")?$"
)
@staticmethod
def _is_datetime_range(range_kwargs: dict) -> bool:
"""Check if all values in range kwargs are ISO datetime strings."""
return all(
isinstance(v, str) and Qdrant._ISO_DATETIME_RE.match(v)
for v in range_kwargs.values()
)
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
"""
Build a single FieldCondition from a key-value filter pair.
@@ -196,13 +177,6 @@ class Qdrant(VectorStoreBase):
f"Use AND to combine them as separate conditions."
)
range_kwargs = {op: value[op] for op in range_ops if op in value}
if self._is_datetime_range(range_kwargs):
try:
return FieldCondition(key=key, range=DatetimeRange(**range_kwargs))
except (ValueError, TypeError) as e:
raise ValueError(
f"Invalid datetime value in range filter for field '{key}': {e}"
) from e
return FieldCondition(key=key, range=Range(**range_kwargs))
elif "eq" in value:
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
-24
View File
@@ -2,30 +2,6 @@
All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file.
## [1.0.1] - 2026-04-02
### Added
- **CD workflow**: Added continuous deployment workflow for `@mem0/openclaw-mem0` with OIDC trusted publishing ([#4672](https://github.com/mem0ai/mem0/pull/4672))
- **Plugin configuration manifest**: Added `compat` and `build` metadata to `package.json` specifying minimum gateway version and OpenClaw SDK compatibility (`>=2026.3.24-beta.2`) ([#4667](https://github.com/mem0ai/mem0/pull/4667))
- **LICENSE**: Added Apache-2.0 license file to the package ([#4667](https://github.com/mem0ai/mem0/pull/4667))
### Fixed
- **Dream gate correctness**: Fixed cheap-first ordering, session isolation, and verified completion in the dream gate memory consolidation pipeline ([#4666](https://github.com/mem0ai/mem0/pull/4666))
- **Graceful startup without API key**: Plugin now starts gracefully when no API key is configured instead of crashing on init ([#4669](https://github.com/mem0ai/mem0/pull/4669))
## [1.0.0] - 2026-04-01
### Added
- **Skills-based memory architecture**: New skill-loader and skill-based extraction pipeline with batched extraction for higher quality memory capture ([#4624](https://github.com/mem0ai/mem0/pull/4624))
- **Dream gate**: Added `dream-gate.ts` for memory consolidation and dream-cycle processing
- **Enhanced recall**: New `recall.ts` module with improved recall logic and skill-aware retrieval
- **Memory triage skill**: Domain-aware memory triage with companion domain support and recall protocol
- **Memory dream skill**: Skill for memory consolidation during idle periods
- **Plugin configuration**: Added `openclaw.plugin.json` manifest and `scripts/configure.py` setup helper
### Changed
- Extraction pipeline refactored to use skills-based architecture for more contextual and higher quality memory capture
## [0.4.1] - 2026-03-26
### Added
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2023] [Taranjeet Singh]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-175
View File
@@ -1,175 +0,0 @@
# ============================================================================
# Agentic Memory Skills — OpenClaw Plugin Installer
# ============================================================================
#
# Usage:
# make install # Interactive — prompts for API key
# make install MEM0_API_KEY=m0-xxx # Non-interactive
# make uninstall # Revert to stock plugin
# make restart # Rebuild + restart gateway
# make status # Check everything is working
# make logs # Tail gateway logs filtered to mem0
# make clean # Full teardown
#
# ============================================================================
SHELL := /bin/bash
.PHONY: install uninstall restart status logs clean build check-deps configure help
# Defaults
MEM0_API_KEY ?=
MEM0_USER_ID ?= $(shell whoami)
OPENCLAW_CONFIG := $(HOME)/.openclaw/openclaw.json
PLUGIN_DIR := $(shell pwd)
help: ## Show this help
@echo ""
@echo " Agentic Memory Skills — OpenClaw Plugin"
@echo " ========================================"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
@echo ""
# ============================================================================
# Dependency checks
# ============================================================================
check-deps:
@echo "Checking dependencies..."
@command -v node >/dev/null 2>&1 || { echo "Error: Node.js is required. Install from https://nodejs.org"; exit 1; }
@NODE_MAJOR=$$(node -v | sed 's/v//' | cut -d. -f1); \
if [ "$$NODE_MAJOR" -lt 18 ]; then echo "Error: Node.js >= 18 required (found $$(node -v))"; exit 1; fi
@command -v npm >/dev/null 2>&1 || { echo "Error: npm is required"; exit 1; }
@if ! command -v openclaw >/dev/null 2>&1; then \
echo "OpenClaw not found. Installing..."; \
npm install -g openclaw || { echo "Error: Failed to install OpenClaw. Try: sudo npm install -g openclaw"; exit 1; }; \
echo "OpenClaw installed: $$(openclaw --version)"; \
else \
echo "OpenClaw: $$(openclaw --version 2>/dev/null || echo 'installed')"; \
fi
@echo "Node.js: $$(node -v)"
@echo "All dependencies OK."
# ============================================================================
# Build
# ============================================================================
build: ## Build the plugin from source
@echo "Installing npm dependencies..."
@npm install --silent 2>/dev/null
@echo "Building plugin..."
@npm run build --silent 2>/dev/null
@echo "Build OK: $$(ls -lh dist/index.js | awk '{print $$5}') ESM bundle"
# ============================================================================
# Install
# ============================================================================
install: check-deps build ## Full install — build, link, configure, restart
@# Remove existing plugin (suppress errors if not installed)
@echo "Removing existing openclaw-mem0 plugin (if any)..."
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
@# Link local build
@echo "Linking local plugin build..."
@openclaw plugins install "$(PLUGIN_DIR)" --link 2>&1 | grep -v "plugins.allow"
@# Configure AFTER link (link may overwrite config)
@$(MAKE) --no-print-directory configure
@# Restart gateway
@echo "Restarting gateway..."
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
@sleep 2
@openclaw gateway >/dev/null 2>&1 &
@sleep 4
@echo ""
@echo "============================================"
@echo " Installation complete!"
@echo "============================================"
@echo ""
@echo " Plugin: openclaw-mem0 (skills mode)"
@echo " User: $(MEM0_USER_ID)"
@echo " Web UI: http://127.0.0.1:18789"
@echo ""
@echo " Verify with: make status"
@echo " View logs: make logs"
@echo ""
# ============================================================================
# Configure — patch openclaw.json with required settings
# ============================================================================
configure:
@if [ -z "$(MEM0_API_KEY)" ]; then \
echo ""; \
echo " Enter your Mem0 API key (from https://app.mem0.ai/dashboard/api-keys):"; \
echo -n " > "; \
read -r key; \
if [ -z "$$key" ]; then echo "Error: API key is required."; exit 1; fi; \
MEM0_API_KEY="$$key" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
else \
MEM0_API_KEY="$(MEM0_API_KEY)" MEM0_USER_ID="$(MEM0_USER_ID)" python3 scripts/configure.py; \
fi
# ============================================================================
# Status & Logs
# ============================================================================
status: ## Check plugin health and skills registration
@echo "Plugin health:"
@openclaw plugins doctor 2>&1 | grep -v "plugins.allow"
@echo ""
@echo "Skills:"
@openclaw skills list 2>&1 | grep -E "memory|Status|---" | head -10
@echo ""
@echo "Last gateway registration:"
@tail -50 /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep "openclaw-mem0: registered" | tail -1 | grep -o '"1":"[^"]*"' || echo " No gateway log found. Is the gateway running?"
logs: ## Tail gateway logs (mem0 activity only)
@tail -f /tmp/openclaw/openclaw-$$(date +%Y-%m-%d).log 2>/dev/null | grep --line-buffered -o '"1":"[^"]*"' | grep --line-buffered -i "mem0\|skills-mode\|stored\|inject\|recall"
# ============================================================================
# Restart
# ============================================================================
restart: build ## Rebuild plugin and restart gateway
@echo "Restarting gateway..."
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
@sleep 2
@openclaw gateway >/dev/null 2>&1 &
@sleep 4
@echo "Gateway restarted. Verify: make status"
# ============================================================================
# Uninstall — revert to stock plugin
# ============================================================================
uninstall: ## Revert to stock openclaw-mem0 from npm
@echo "Uninstalling local plugin..."
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
@echo "Installing stock plugin from npm..."
@openclaw plugins install @mem0/openclaw-mem0 2>&1 | grep -v "plugins.allow"
@# Restore config backup if it exists
@if [ -f "$(OPENCLAW_CONFIG).pre-skills-backup" ]; then \
cp "$(OPENCLAW_CONFIG).pre-skills-backup" "$(OPENCLAW_CONFIG)"; \
echo "Restored config from backup."; \
else \
echo "Note: No config backup found. You may need to manually revert openclaw.json changes."; \
fi
@echo "Restarting gateway..."
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
@sleep 2
@openclaw gateway >/dev/null 2>&1 &
@sleep 4
@echo "Reverted to stock openclaw-mem0."
# ============================================================================
# Clean
# ============================================================================
clean: ## Full teardown — stop gateway, remove plugin, clean build
@echo "Stopping gateway..."
@kill -9 $$(lsof -ti:18789) 2>/dev/null || true
@echo "Removing plugin..."
@echo "y" | openclaw plugins uninstall openclaw-mem0 >/dev/null 2>&1 || true
@echo "Cleaning build artifacts..."
@rm -rf dist/ node_modules/
@echo "Clean complete."
+8 -9
View File
@@ -171,7 +171,6 @@ const ALLOWED_KEYS = [
"searchThreshold",
"topK",
"oss",
"skills",
];
function assertAllowedKeys(
@@ -196,9 +195,14 @@ export const mem0ConfigSchema = {
const mode: Mem0Mode =
cfg.mode === "oss" || cfg.mode === "open-source" ? "open-source" : "platform";
// Platform mode requires apiKey — but don't throw on missing config.
// The plugin should register successfully and log a setup message.
const needsSetup = mode === "platform" && (typeof cfg.apiKey !== "string" || !cfg.apiKey);
// Platform mode requires apiKey
if (mode === "platform") {
if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
throw new Error(
"apiKey is required for platform mode (set mode: \"open-source\" for self-hosted)",
);
}
}
// Resolve env vars in oss config
let ossConfig: Mem0Config["oss"];
@@ -236,12 +240,7 @@ export const mem0ConfigSchema = {
searchThreshold:
typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.5,
topK: typeof cfg.topK === "number" ? cfg.topK : 5,
needsSetup,
oss: ossConfig,
skills:
cfg.skills && typeof cfg.skills === "object" && !Array.isArray(cfg.skills)
? (cfg.skills as Mem0Config["skills"])
: undefined,
};
},
};
-197
View File
@@ -1,197 +0,0 @@
/**
* Dream Gate — activity tracking, gate logic, and lock mechanism
* for automatic memory consolidation.
*
* State persists in the plugin's stateDir so it survives gateway restarts.
* Lock prevents concurrent consolidation runs.
*/
import * as fs from "fs";
import * as path from "path";
// ============================================================================
// Types
// ============================================================================
interface DreamState {
lastConsolidatedAt: number; // ms since epoch, 0 = never
sessionsSince: number; // interactive sessions since last consolidation
lastSessionId: string | null;
}
interface DreamLock {
pid: number;
startedAt: number;
}
interface DreamGateConfig {
minHours: number;
minSessions: number;
minMemories: number;
}
const DEFAULTS: DreamGateConfig = {
minHours: 24,
minSessions: 5,
minMemories: 20,
};
const LOCK_STALE_MS = 60 * 60 * 1000; // 1 hour
// ============================================================================
// State Persistence
// ============================================================================
function statePath(stateDir: string): string {
return path.join(stateDir, "dream-state.json");
}
function lockPath(stateDir: string): string {
return path.join(stateDir, "dream.lock");
}
function ensureDir(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true });
} catch { /* exists */ }
}
function readState(stateDir: string): DreamState {
try {
const raw = fs.readFileSync(statePath(stateDir), "utf-8");
return JSON.parse(raw) as DreamState;
} catch {
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
}
}
function writeState(stateDir: string, state: DreamState): void {
ensureDir(stateDir);
fs.writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
}
// ============================================================================
// Session Tracking
// ============================================================================
/**
* Called from agent_end on every interactive turn.
* Increments session counter (deduped by sessionId).
*/
export function incrementSessionCount(stateDir: string, sessionId: string): void {
const state = readState(stateDir);
if (state.lastSessionId !== sessionId) {
state.sessionsSince++;
state.lastSessionId = sessionId;
writeState(stateDir, state);
}
}
// ============================================================================
// Gate Logic
// ============================================================================
/**
* Check cheap gates (time + sessions). These are local file reads only.
* Call this BEFORE any API calls. If this fails, skip the expensive
* memory count check entirely.
*/
export function checkCheapGates(
stateDir: string,
config: { minHours?: number; minSessions?: number },
): { proceed: boolean; reason?: string } {
const minHours = config.minHours ?? DEFAULTS.minHours;
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
const state = readState(stateDir);
// Gate 1: Time (one local file read)
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < minHours) {
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
}
// Gate 2: Sessions (same file, already read)
if (state.sessionsSince < minSessions) {
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
}
return { proceed: true };
}
/**
* Check expensive memory count gate. Only call AFTER checkCheapGates passes.
*/
export function checkMemoryGate(
memoryCount: number,
config: { minMemories?: number },
): { pass: boolean; reason?: string } {
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
if (memoryCount < minMemories) {
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
}
return { pass: true };
}
// ============================================================================
// Lock
// ============================================================================
/**
* Try to acquire the dream lock. Returns true if acquired.
* Stale locks (older than 1 hour) are reclaimed.
*/
export function acquireDreamLock(stateDir: string): boolean {
ensureDir(stateDir);
const lp = lockPath(stateDir);
// Check existing lock
try {
const raw = fs.readFileSync(lp, "utf-8");
const lock = JSON.parse(raw) as DreamLock;
const age = Date.now() - lock.startedAt;
if (age < LOCK_STALE_MS) {
return false; // Held and not stale
}
// Stale lock — remove it before attempting exclusive create
try { fs.unlinkSync(lp); } catch { /* race ok */ }
} catch {
// No lock file, proceed
}
// Atomic create with exclusive flag (wx). If two processes race,
// only one succeeds. The other gets EEXIST.
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
try {
fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false; // Lost race
}
}
/**
* Release the dream lock and record successful completion.
*/
export function releaseDreamLock(stateDir: string): void {
try {
fs.unlinkSync(lockPath(stateDir));
} catch { /* already gone */ }
}
/**
* Record that consolidation completed. Resets session counter.
*/
export function recordDreamCompletion(stateDir: string): void {
const state = readState(stateDir);
state.lastConsolidatedAt = Date.now();
state.sessionsSince = 0;
state.lastSessionId = null;
writeState(stateDir, state);
}
/**
* Get current dream state for logging/diagnostics.
*/
export function getDreamState(stateDir: string): DreamState {
return readState(stateDir);
}
+21 -614
View File
@@ -38,22 +38,6 @@ import {
isNonInteractiveTrigger,
isSubagentSession,
} from "./isolation.ts";
import {
loadTriagePrompt,
loadDreamPrompt,
resolveCategories,
ttlToExpirationDate,
isSkillsMode,
} from "./skill-loader.ts";
import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream-gate.ts";
// ============================================================================
// Re-exports (for tests and external consumers)
@@ -94,23 +78,6 @@ const memoryPlugin = {
register(api: OpenClawPluginApi) {
const cfg = mem0ConfigSchema.parse(api.pluginConfig);
if (cfg.needsSetup) {
api.logger.warn(
"openclaw-mem0: API key not configured. Memory features are disabled.\n" +
" To set up, run:\n" +
' openclaw config set plugins.entries.openclaw-mem0.config.apiKey "m0-your-key"\n' +
" openclaw gateway restart\n" +
" Get your key at: https://app.mem0.ai/dashboard/api-keys"
);
api.registerService({
id: "openclaw-mem0",
start: () => { api.logger.info("openclaw-mem0: waiting for API key configuration"); },
stop: () => {},
});
return;
}
const provider = createProvider(cfg, api);
// Track current session ID for tool-level session scoping.
@@ -128,9 +95,8 @@ const memoryPlugin = {
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
const skillsActive = isSkillsMode(cfg.skills);
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, skills: ${skillsActive})`,
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
// Helper: build add options
@@ -149,24 +115,22 @@ const memoryPlugin = {
return opts;
}
// Helper: build search options (skills config overrides legacy defaults)
// Helper: build search options
function buildSearchOptions(
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const recallCfg = cfg.skills?.recall;
const opts: SearchOptions = {
user_id: userIdOverride || _effectiveUserId(sessionKey),
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: recallCfg?.threshold ?? cfg.searchThreshold,
keyword_search: recallCfg?.keywordSearch !== false,
reranking: recallCfg?.rerank !== false,
threshold: cfg.searchThreshold,
keyword_search: true,
reranking: true,
source: "OPENCLAW",
};
if (recallCfg?.filterMemories) opts.filter_memories = true;
if (runId) opts.run_id = runId;
return opts;
}
@@ -175,7 +139,7 @@ const memoryPlugin = {
// Tools
// ========================================================================
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId, skillsActive);
registerTools(api, provider, cfg, _resolveUserId, _effectiveUserId, _agentUserId, buildAddOptions, buildSearchOptions, () => currentSessionId);
// ========================================================================
// CLI Commands
@@ -189,22 +153,17 @@ const memoryPlugin = {
registerHooks(api, provider, cfg, _effectiveUserId, buildAddOptions, buildSearchOptions, {
setCurrentSessionId: (id: string) => { currentSessionId = id; },
getStateDir: () => pluginStateDir,
}, skillsActive);
});
// ========================================================================
// Service
// ========================================================================
// State directory for persistent gate tracking (dream consolidation)
let pluginStateDir: string | undefined;
api.registerService({
id: "openclaw-mem0",
start: (...args: any[]) => {
pluginStateDir = args[0]?.stateDir;
start: () => {
api.logger.info(
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, stateDir: ${pluginStateDir ?? "none"})`,
`openclaw-mem0: initialized (mode: ${cfg.mode}, user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
},
stop: () => {
@@ -228,7 +187,6 @@ function registerTools(
buildAddOptions: (userIdOverride?: string, runId?: string, sessionKey?: string) => AddOptions,
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
getCurrentSessionId: () => string | undefined,
skillsActive: boolean = false,
) {
api.registerTool(
{
@@ -265,28 +223,14 @@ function registerTools(
'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
}),
),
categories: Type.Optional(
Type.Array(Type.String(), {
description:
'Filter results by category (e.g. ["identity", "preference"]). Only returns memories tagged with these categories.',
}),
),
filters: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description:
'Advanced filters object. Supports date ranges and metadata filtering. Examples: {"created_at": {"gte": "2026-03-01"}} for recent memories, {"AND": [{"categories": {"contains": "decision"}}, {"created_at": {"gte": "2026-01-01"}}]} for decisions this year. Operators: eq, ne, gt, gte, lt, lte, in, contains, icontains. Logical: AND, OR, NOT.',
}),
),
}),
async execute(_toolCallId, params) {
const { query, limit, userId, agentId, scope = "all", categories: filterCategories, filters: agentFilters } = params as {
const { query, limit, userId, agentId, scope = "all" } = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
categories?: string[];
filters?: Record<string, unknown>;
};
try {
@@ -294,36 +238,29 @@ function registerTools(
const uid = _resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
// Apply agent-provided filters to search options
const applyFilters = (opts: SearchOptions): SearchOptions => {
if (filterCategories?.length) opts.categories = filterCategories;
if (agentFilters) opts.filters = agentFilters;
return opts;
};
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(
query,
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
buildSearchOptions(uid, limit, currentSessionId),
);
}
} else if (scope === "long-term") {
results = await provider.search(
query,
applyFilters(buildSearchOptions(uid, limit)),
buildSearchOptions(uid, limit),
);
} else {
// "all" — search both scopes and combine
const longTermResults = await provider.search(
query,
applyFilters(buildSearchOptions(uid, limit)),
buildSearchOptions(uid, limit),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider.search(
query,
applyFilters(buildSearchOptions(uid, limit, currentSessionId)),
buildSearchOptions(uid, limit, currentSessionId),
);
}
// Deduplicate by ID, preferring long-term
@@ -390,25 +327,7 @@ function registerTools(
description:
"Save important information in long-term memory via Mem0. Use for preferences, facts, decisions, and anything worth remembering.",
parameters: Type.Object({
text: Type.Optional(
Type.String({ description: "Single fact to remember. Use 'facts' array instead when storing multiple facts from one conversation turn." }),
),
facts: Type.Optional(
Type.Array(Type.String(), {
description: "Array of facts to store in one call. ALL facts MUST share the same category. If a turn has facts in different categories, make one call per category. Category determines retention policy (TTL, immutability).",
}),
),
category: Type.Optional(
Type.String({
description:
'Memory category. Determines retention policy (TTL, immutability). All facts in this call inherit this category. Options: "identity", "preference", "decision", "rule", "project", "configuration", "technical", "relationship"',
}),
),
importance: Type.Optional(
Type.Number({
description: "Importance override (0.0-1.0). Omit to use category default. Applies to all facts in this call. Defaults: identity/config 0.95, rules 0.90, preferences 0.85, decisions 0.80, projects 0.75, operational 0.60",
}),
),
text: Type.String({ description: "Information to remember" }),
userId: Type.Optional(
Type.String({
description: "User ID to scope this memory",
@@ -422,7 +341,7 @@ function registerTools(
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Additional metadata to attach to this memory",
description: "Optional metadata to attach to this memory",
}),
),
longTerm: Type.Optional(
@@ -433,121 +352,22 @@ function registerTools(
),
}),
async execute(_toolCallId, params) {
const p = params as {
text?: string;
facts?: string[];
category?: string;
importance?: number;
const { text, userId, agentId, longTerm = true } = params as {
text: string;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
};
const { userId, agentId, longTerm = true } = p;
// Resolve facts: prefer 'facts' array, fall back to single 'text'
const allFacts: string[] = p.facts?.length ? p.facts : (p.text ? [p.text] : []);
if (allFacts.length === 0) {
return {
content: [{ type: "text", text: "No facts provided. Pass 'text' or 'facts' array." }],
details: { error: "missing_facts" },
};
}
try {
const currentSessionId = getCurrentSessionId();
// Block subagent writes at the tool level. The system prompt
// instructs subagents not to store, but a disobedient tool call
// would write to a transient namespace that is never read again.
if (isSubagentSession(currentSessionId)) {
api.logger.warn("openclaw-mem0: blocked memory_store from subagent session");
return {
content: [{ type: "text", text: "Memory storage is not available in subagent sessions. The main agent handles memory." }],
details: { error: "subagent_blocked" },
};
}
const uid = _resolveUserId({ agentId, userId });
const currentSessionId = getCurrentSessionId();
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
// Skills mode: bypass extraction LLM, store directly via infer=false
if (skillsActive) {
// Enforce batch homogeneity: if no category provided for a multi-fact
// batch, warn. The prompt teaches batch-by-category but this is the
// runtime safety net.
if (allFacts.length > 1 && !p.category) {
api.logger.warn(
`openclaw-mem0: multi-fact batch (${allFacts.length} facts) without category. Retention policy defaults to uncategorized. Prompt instructs batch-by-category.`,
);
}
// Resolve metadata: prefer explicit params, fall back to metadata record
const rawMetadata = p.metadata;
const category = p.category ?? rawMetadata?.category as string | undefined;
const importance = p.importance ?? rawMetadata?.importance as number | undefined;
const parsedMetadata: Record<string, unknown> = {
...(rawMetadata ?? {}),
...(category && { category }),
...(importance !== undefined && { importance }),
};
const categories = resolveCategories(cfg.skills);
const catConfig = category ? categories[category] : undefined;
const expirationDate = catConfig ? ttlToExpirationDate(catConfig.ttl) : undefined;
const isImmutable = catConfig?.immutable ?? false;
// Single API call: all facts go as deduced_memories array
const addOpts: AddOptions = {
user_id: uid,
source: "OPENCLAW",
infer: false,
deduced_memories: allFacts,
metadata: parsedMetadata ?? {},
...(expirationDate && { expiration_date: expirationDate }),
...(isImmutable && { immutable: true }),
};
if (runId) addOpts.run_id = runId;
if (cfg.mode === "platform") {
addOpts.output_format = "v1.1";
if (cfg.enableGraph || cfg.skills?.triage?.enableGraph) {
addOpts.enable_graph = true;
}
}
const result = await provider.add(
[{ role: "user", content: allFacts.join("\n") }],
addOpts,
);
const count = result.results?.length ?? 0;
api.logger.info(
`openclaw-mem0: skills-mode stored ${count} memor${count === 1 ? "y" : "ies"} from ${allFacts.length} fact(s) in 1 API call (infer=false, category=${category ?? "none"})`,
);
return {
content: [
{
type: "text",
text: `Stored ${allFacts.length} fact(s) [${category ?? "uncategorized"}]: ${allFacts.map(f => `"${f.slice(0, 60)}${f.length > 60 ? "..." : ""}"`).join(", ")}`,
},
],
details: {
action: "stored",
mode: "skills",
infer: false,
category,
factCount: allFacts.length,
results: result.results,
},
};
}
// Legacy mode: let mem0 extraction LLM handle it
const combinedText = allFacts.join("\n");
// Pre-check for near-duplicates so the extraction model has
// context about existing memories and can UPDATE rather than ADD
const preview = combinedText.slice(0, 200);
const preview = text.slice(0, 200);
const dedupOpts = buildSearchOptions(uid, 3);
dedupOpts.threshold = 0.85;
const existing = await provider.search(preview, dedupOpts);
@@ -558,7 +378,7 @@ function registerTools(
}
const result = await provider.add(
[{ role: "user", content: combinedText }],
[{ role: "user", content: text }],
buildAddOptions(uid, runId, currentSessionId),
);
@@ -790,16 +610,6 @@ function registerTools(
};
try {
// Block subagent deletes at the tool level.
const currentSessionId = getCurrentSessionId();
if (isSubagentSession(currentSessionId)) {
api.logger.warn("openclaw-mem0: blocked memory_forget from subagent session");
return {
content: [{ type: "text", text: "Memory deletion is not available in subagent sessions. The main agent handles memory." }],
details: { error: "subagent_blocked" },
};
}
if (memoryId) {
await provider.delete(memoryId);
return {
@@ -888,150 +698,6 @@ function registerTools(
},
{ name: "memory_forget" },
);
api.registerTool(
{
name: "memory_update",
label: "Memory Update",
description:
"Update an existing memory's text in place. Use when a fact has changed and you have the memory ID. This is atomic and preserves the memory's history. Preferred over delete-then-store for corrections.",
parameters: Type.Object({
memoryId: Type.String({ description: "The memory ID to update" }),
text: Type.String({ description: "The new text for this memory (replaces the old text)" }),
}),
async execute(_toolCallId, params) {
const { memoryId, text } = params as { memoryId: string; text: string };
try {
const currentSessionId = getCurrentSessionId();
if (isSubagentSession(currentSessionId)) {
api.logger.warn("openclaw-mem0: blocked memory_update from subagent session");
return {
content: [{ type: "text", text: "Memory update is not available in subagent sessions." }],
details: { error: "subagent_blocked" },
};
}
await provider.update(memoryId, text);
return {
content: [
{ type: "text", text: `Updated memory ${memoryId}: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"` },
],
details: { action: "updated", id: memoryId },
};
} catch (err) {
return {
content: [
{ type: "text", text: `Memory update failed: ${String(err)}` },
],
details: { error: String(err) },
};
}
},
},
{ name: "memory_update" },
);
api.registerTool(
{
name: "memory_delete_all",
label: "Memory Delete All",
description:
"Delete ALL memories for a user. Use with extreme caution. This is irreversible. Only use when the user explicitly asks to forget everything or reset their memory.",
parameters: Type.Object({
confirm: Type.Boolean({
description: "Must be true to proceed. Safety gate to prevent accidental bulk deletion.",
}),
userId: Type.Optional(
Type.String({ description: "User ID to delete all memories for (default: configured userId)" }),
),
}),
async execute(_toolCallId, params) {
const { confirm, userId } = params as { confirm: boolean; userId?: string };
try {
const currentSessionId = getCurrentSessionId();
if (isSubagentSession(currentSessionId)) {
api.logger.warn("openclaw-mem0: blocked memory_delete_all from subagent session");
return {
content: [{ type: "text", text: "Bulk memory deletion is not available in subagent sessions." }],
details: { error: "subagent_blocked" },
};
}
if (!confirm) {
return {
content: [{ type: "text", text: "Bulk deletion requires confirm: true. Ask the user to confirm before proceeding." }],
details: { error: "confirmation_required" },
};
}
const uid = _resolveUserId({ userId });
await provider.deleteAll(uid);
api.logger.info(`openclaw-mem0: deleted all memories for user ${uid}`);
return {
content: [
{ type: "text", text: `All memories deleted for user "${uid}".` },
],
details: { action: "deleted_all", user_id: uid },
};
} catch (err) {
return {
content: [
{ type: "text", text: `Bulk memory deletion failed: ${String(err)}` },
],
details: { error: String(err) },
};
}
},
},
{ name: "memory_delete_all" },
);
api.registerTool(
{
name: "memory_history",
label: "Memory History",
description:
"View the edit history of a specific memory. Shows all changes over time including previous values, new values, and timestamps. Useful for understanding how a memory evolved.",
parameters: Type.Object({
memoryId: Type.String({ description: "The memory ID to view history for" }),
}),
async execute(_toolCallId, params) {
const { memoryId } = params as { memoryId: string };
try {
const history = await provider.history(memoryId);
if (!history || history.length === 0) {
return {
content: [{ type: "text", text: `No history found for memory ${memoryId}.` }],
details: { count: 0 },
};
}
const text = history
.map((h, i) => `${i + 1}. [${h.event}] ${h.created_at}\n Old: ${h.old_memory || "(none)"}\n New: ${h.new_memory || "(none)"}`)
.join("\n\n");
return {
content: [
{ type: "text", text: `History for memory ${memoryId} (${history.length} entries):\n\n${text}` },
],
details: { count: history.length, history },
};
} catch (err) {
return {
content: [
{ type: "text", text: `Memory history failed: ${String(err)}` },
],
details: { error: String(err) },
};
}
},
},
{ name: "memory_history" },
);
}
// ============================================================================
@@ -1147,72 +813,6 @@ function registerCli(
console.error(`Stats failed: ${String(err)}`);
}
});
mem0
.command("dream")
.description("Run memory consolidation (review, merge, prune stored memories)")
.option("--dry-run", "Show memory inventory without running consolidation")
.action(async (opts: { dryRun?: boolean }) => {
try {
const uid = cfg.userId;
const memories = await provider.getAll({ user_id: uid, source: "OPENCLAW" });
const count = Array.isArray(memories) ? memories.length : 0;
if (count === 0) {
console.log("No memories to consolidate.");
return;
}
// Show current state summary on stderr (keeps stdout clean for piping)
const catCounts = new Map<string, number>();
for (const mem of memories) {
const cat = (mem.metadata as any)?.category ?? mem.categories?.[0] ?? "uncategorized";
catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1);
}
process.stderr.write(`\nMemory inventory for "${uid}":\n`);
for (const [cat, num] of [...catCounts.entries()].sort((a, b) => b[1] - a[1])) {
process.stderr.write(` ${cat}: ${num}\n`);
}
process.stderr.write(` TOTAL: ${count}\n\n`);
if (opts.dryRun) {
process.stderr.write("Dry run — no changes made.\n");
return;
}
// Load dream prompt and format it with the full memory inventory
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (!dreamPrompt) {
process.stderr.write("Dream skill file not found at skills/memory-dream/SKILL.md\n");
return;
}
// Build the full dream context: protocol + memory dump
const memoryDump = (memories as MemoryItem[]).map((m, i) => {
const cat = (m.metadata as any)?.category ?? m.categories?.[0] ?? "uncategorized";
const imp = (m.metadata as any)?.importance ?? "?";
const created = m.created_at ?? "unknown";
return `${i + 1}. [${m.id}] (${cat}, importance: ${imp}, created: ${created}) ${m.memory}`;
}).join("\n");
const fullPrompt = [
"<dream-protocol>",
dreamPrompt,
"</dream-protocol>",
"",
`<all-memories count="${count}" user="${uid}">`,
memoryDump,
"</all-memories>",
"",
"Begin consolidation. Review all memories above and execute merge, delete, and rewrite operations using the available tools.",
].join("\n");
// Only the prompt goes to stdout — safe to pipe directly
process.stdout.write(fullPrompt + "\n");
process.stderr.write(`Dream prompt written to stdout (${fullPrompt.length} chars). Pipe with: openclaw mem0 dream | openclaw run --stdin\n`);
} catch (err) {
console.error(`Dream failed: ${String(err)}`);
}
});
},
{ commands: ["mem0"] },
);
@@ -1231,201 +831,8 @@ function registerHooks(
buildSearchOptions: (userIdOverride?: string, limit?: number, runId?: string, sessionKey?: string) => SearchOptions,
session: {
setCurrentSessionId: (id: string) => void;
getStateDir: () => string | undefined;
},
skillsActive: boolean = false,
) {
// ========================================================================
// SKILLS MODE: Agentic memory via before_prompt_build
// ========================================================================
if (skillsActive) {
// Use before_prompt_build instead of before_agent_start:
// - prependSystemContext: static memory protocol (provider-cacheable, no per-turn cost)
// - prependContext: dynamic recalled memories (changes every turn)
//
// NOTE: We previously used a shared `lastCleanUserMessage` variable populated
// by message_received to get clean user content. That variable was process-global
// mutable state vulnerable to cross-session races. Removed in favor of using
// sanitizeQuery() on event.prompt within this hook, where ctx.sessionKey is
// available and the execution is scoped to the correct session.
api.on("before_prompt_build", async (event: any, ctx: any) => {
if (!event.prompt || event.prompt.length < 5) return;
const trigger = ctx?.trigger ?? undefined;
const sessionId = ctx?.sessionKey ?? undefined;
if (isNonInteractiveTrigger(trigger, sessionId)) {
api.logger.info("openclaw-mem0: skills-mode skipping non-interactive trigger");
return;
}
// Skip recall for system/bootstrap prompts. These are OpenClaw internal
// commands (/new, /reset) that contain system instructions, not user queries.
// Sending them to mem0 search wastes API calls and returns noise.
const promptLower = event.prompt.toLowerCase();
const isSystemPrompt =
promptLower.includes("a new session was started") ||
promptLower.includes("session startup sequence") ||
promptLower.includes("/new or /reset") ||
promptLower.startsWith("system:") ||
promptLower.startsWith("run your session");
if (isSystemPrompt) {
api.logger.info("openclaw-mem0: skills-mode skipping recall for system/bootstrap prompt");
// Still inject the protocol, just skip recall search
const systemContext = loadTriagePrompt(cfg.skills ?? {});
return { prependSystemContext: systemContext };
}
if (sessionId) session.setCurrentSessionId(sessionId);
const isSubagent = isSubagentSession(sessionId);
const userId = _effectiveUserId(isSubagent ? undefined : sessionId);
// Static protocol goes in prependSystemContext (cacheable across turns)
let systemContext = loadTriagePrompt(cfg.skills ?? {});
if (isSubagent) {
systemContext = "You are a subagent — use these memories for context but do not assume you are this user. Do NOT store new memories.\n\n" + systemContext;
}
// Dynamic recall goes in prependContext (changes every turn).
// Strategy controls how much the plugin searches automatically:
// "always" — long-term + session search every turn (2 searches)
// "smart" — long-term search only, no session search (1 search) [default]
// "manual" — no auto-recall; agent controls all search via memory_search (0 searches)
let recallContext = "";
const recallEnabled = cfg.skills?.recall?.enabled !== false;
const recallStrategy = cfg.skills?.recall?.strategy ?? "smart";
if (recallEnabled && recallStrategy !== "manual") {
try {
const query = sanitizeQuery(event.prompt);
// Smart mode: skip session search (saves 1 API call per turn)
const sessionIdForRecall = recallStrategy === "always"
? (isSubagent ? undefined : sessionId)
: undefined; // smart: long-term only
const recallResult = await skillRecall(
provider,
query,
userId,
cfg.skills ?? {},
sessionIdForRecall,
);
api.logger.info(
`openclaw-mem0: skills-mode recall (strategy=${recallStrategy}) injecting ${recallResult.memories.length} memories (~${recallResult.tokenEstimate} tokens)`,
);
recallContext = recallResult.context;
} catch (err) {
api.logger.warn(`openclaw-mem0: skills-mode recall failed: ${String(err)}`);
}
} else if (recallEnabled && recallStrategy === "manual") {
api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search");
}
// Auto-dream: check CHEAP gates first (local file reads only).
// Only hit the API for memory count if time + session gates pass.
const stateDir = session.getStateDir();
const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false;
let dreamContext = "";
if (dreamEnabled && stateDir && !isSubagent) {
try {
const cheapResult = checkCheapGates(stateDir, cfg.skills?.dream ?? {});
if (cheapResult.proceed) {
// Cheap gates passed. Now do the expensive memory count check.
const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" });
const memCount = Array.isArray(memories) ? memories.length : 0;
const memResult = checkMemoryGate(memCount, cfg.skills?.dream ?? {});
if (memResult.pass && acquireDreamLock(stateDir)) {
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
if (dreamPrompt) {
dreamContext = "\n\n<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>";
// Track which session triggered dream (session-keyed, not global)
dreamSessionId = sessionId;
api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`);
} else {
releaseDreamLock(stateDir);
}
}
}
} catch (err) {
api.logger.warn(`openclaw-mem0: auto-dream gate check failed: ${String(err)}`);
}
}
return {
prependSystemContext: systemContext, // cached by provider
prependContext: recallContext + dreamContext, // per-turn dynamic
};
});
// Session-keyed dream tracking. Only the session that triggered dream
// can complete it. Prevents cross-session false completion.
let dreamSessionId: string | undefined;
api.on("agent_end", async (event: any, ctx: any) => {
const sessionId = ctx?.sessionKey ?? undefined;
const trigger = ctx?.trigger ?? undefined;
if (sessionId) session.setCurrentSessionId(sessionId);
// If dream was triggered for THIS session, handle cleanup regardless
// of success/failure. A failed turn must still release the lock.
const stateDir = session.getStateDir();
if (dreamSessionId && dreamSessionId === sessionId && stateDir) {
dreamSessionId = undefined;
if (!event.success) {
// Turn failed/aborted after lock acquired. Release lock, do not
// record completion. Gates will re-trigger next eligible turn.
releaseDreamLock(stateDir);
api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry");
return;
}
// Verify the model actually performed WRITE operations (not just reads).
// Only count memory_store, memory_update, memory_forget, memory_delete_all.
// Exclude memory_list and memory_search (read-only, orient-only pass).
// Scan only the LAST assistant message (this turn), not the full session
// snapshot, to avoid matching earlier tool calls from prior turns.
const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]);
const messages = event.messages ?? [];
// Find the last assistant message (this turn's output)
const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant");
const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content)
? lastAssistant.content.some((block: any) =>
block.type === "tool_use" && WRITE_TOOLS.has(block.name)
)
: false;
if (writeToolUsed) {
releaseDreamLock(stateDir);
recordDreamCompletion(stateDir);
api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released");
} else {
releaseDreamLock(stateDir);
api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry.");
}
return;
}
if (!event.success) return;
// Track session for dream gating (interactive turns only)
if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) {
incrementSessionCount(stateDir, sessionId);
}
api.logger.info("openclaw-mem0: skills-mode agent_end (no auto-capture)");
});
return; // Skip legacy hook registration
}
// ========================================================================
// LEGACY MODE: Original auto-recall + auto-capture behavior
// ========================================================================
// Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) {
api.on("before_agent_start", async (event, ctx) => {
-56
View File
@@ -1,7 +1,6 @@
{
"id": "openclaw-mem0",
"kind": "memory",
"skills": ["skills"],
"uiHints": {
"mode": {
"label": "Mode",
@@ -69,11 +68,6 @@
"label": "Open-Source Configuration",
"advanced": true,
"help": "Optional. Configure custom embedder, vector store, LLM, or history DB for open-source mode. Has sensible defaults — only override what you need."
},
"skills": {
"label": "Agentic Memory Skills",
"advanced": true,
"help": "Enable skills-based memory extraction. The agent controls what to remember (triage), how to recall (recall), and periodic cleanup (dream). Disables auto-capture when active."
}
},
"configSchema": {
@@ -167,56 +161,6 @@
"type": "string"
}
}
},
"skills": {
"type": "object",
"properties": {
"triage": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"importanceThreshold": { "type": "number" },
"enableGraph": { "type": "boolean" },
"credentialPatterns": { "type": "array", "items": { "type": "string" } }
}
},
"recall": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"strategy": { "type": "string", "enum": ["always", "smart", "manual"] },
"tokenBudget": { "type": "number" },
"maxMemories": { "type": "number" },
"rerank": { "type": "boolean" },
"keywordSearch": { "type": "boolean" },
"filterMemories": { "type": "boolean" },
"threshold": { "type": "number" },
"identityAlwaysInclude": { "type": "boolean" },
"categoryOrder": { "type": "array", "items": { "type": "string" } }
}
},
"dream": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"schedule": { "type": "string" },
"mergeThreshold": { "type": "number" },
"maxMemoriesPerUser": { "type": "number" },
"preserveImmutable": { "type": "boolean" },
"credentialScan": { "type": "boolean" },
"expireStaleAfterDays": { "type": "number" }
}
},
"domain": { "type": "string" },
"customRules": {
"type": "object",
"properties": {
"include": { "type": "array", "items": { "type": "string" } },
"exclude": { "type": "array", "items": { "type": "string" } }
}
},
"categories": { "type": "object" }
}
}
},
"required": []
+3 -17
View File
@@ -1,14 +1,9 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.1",
"version": "0.4.1",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/mem0ai/mem0",
"directory": "openclaw"
},
"keywords": [
"openclaw",
"plugin",
@@ -26,8 +21,7 @@
},
"files": [
"dist",
"openclaw.plugin.json",
"skills"
"openclaw.plugin.json"
],
"scripts": {
"build": "tsup",
@@ -40,15 +34,7 @@
"openclaw": {
"extensions": [
"./dist/index.js"
],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
]
},
"devDependencies": {
"@types/node": "^22.15.0",
+4 -73
View File
@@ -111,12 +111,6 @@ class PlatformProvider implements Mem0Provider {
if (options.enable_graph) opts.enable_graph = options.enable_graph;
if (options.output_format) opts.output_format = options.output_format;
if (options.source) opts.source = options.source;
// Agentic harness: direct storage bypass
if (options.infer !== undefined) opts.infer = options.infer;
if (options.deduced_memories) opts.deduced_memories = options.deduced_memories;
if (options.metadata) opts.metadata = options.metadata;
if (options.expiration_date) opts.expiration_date = options.expiration_date;
if (options.immutable) opts.immutable = options.immutable;
const result = await this.client.add(messages, opts);
return normalizeAddResult(result);
@@ -124,26 +118,17 @@ class PlatformProvider implements Mem0Provider {
async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
await this.ensureClient();
// Base filters: always scope by user_id, optionally by run_id
const baseFilters: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) baseFilters.run_id = options.run_id;
// Merge agent-provided filters (created_at ranges, metadata, etc.)
// with base filters. Agent filters extend, never override user scoping.
const mergedFilters = options.filters
? { AND: [baseFilters, options.filters] }
: baseFilters;
const filters: Record<string, unknown> = { user_id: options.user_id };
if (options.run_id) filters.run_id = options.run_id;
const opts: Record<string, unknown> = {
api_version: "v2",
filters: mergedFilters,
filters,
};
if (options.top_k != null) opts.top_k = options.top_k;
if (options.threshold != null) opts.threshold = options.threshold;
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.rerank = options.reranking;
if (options.filter_memories != null) opts.filter_memories = options.filter_memories;
if (options.categories != null) opts.categories = options.categories;
const results = await this.client.search(query, opts);
return normalizeSearchResults(results);
@@ -170,26 +155,10 @@ class PlatformProvider implements Mem0Provider {
return [];
}
async update(memoryId: string, text: string): Promise<void> {
await this.ensureClient();
await this.client.update(memoryId, { text });
}
async delete(memoryId: string): Promise<void> {
await this.ensureClient();
await this.client.delete(memoryId);
}
async deleteAll(userId: string): Promise<void> {
await this.ensureClient();
await this.client.deleteAll({ user_id: userId });
}
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
await this.ensureClient();
const result = await this.client.history(memoryId);
return Array.isArray(result) ? result : [];
}
}
// ============================================================================
@@ -268,24 +237,7 @@ class OSSProvider implements Mem0Provider {
const addOpts: Record<string, unknown> = { userId: options.user_id };
if (options.run_id) addOpts.runId = options.run_id;
if (options.source) addOpts.source = options.source;
// Agentic harness: direct storage bypass
if (options.infer !== undefined) addOpts.infer = options.infer;
if (options.metadata) addOpts.metadata = options.metadata;
if (options.expiration_date) addOpts.expirationDate = options.expiration_date;
if (options.immutable) addOpts.immutable = options.immutable;
// OSS SDK doesn't support deduced_memories — when infer=false, it stores
// raw message content directly. Rewrite messages to contain the facts so
// OSS stores the right text.
let effectiveMessages = messages;
if (options.infer === false && options.deduced_memories?.length) {
effectiveMessages = options.deduced_memories.map((fact) => ({
role: "user",
content: fact,
}));
}
const result = await this.memory.add(effectiveMessages, addOpts);
const result = await this.memory.add(messages, addOpts);
return normalizeAddResult(result);
}
@@ -331,31 +283,10 @@ class OSSProvider implements Mem0Provider {
return [];
}
async update(memoryId: string, text: string): Promise<void> {
await this.ensureMemory();
await this.memory.update(memoryId, { data: text });
}
async delete(memoryId: string): Promise<void> {
await this.ensureMemory();
await this.memory.delete(memoryId);
}
async deleteAll(userId: string): Promise<void> {
await this.ensureMemory();
await this.memory.deleteAll({ userId });
}
async history(memoryId: string): Promise<Array<{ id: string; old_memory: string; new_memory: string; event: string; created_at: string }>> {
await this.ensureMemory();
try {
const result = await this.memory.history(memoryId);
return Array.isArray(result) ? result : [];
} catch {
// OSS may not support history depending on config
return [];
}
}
}
// ============================================================================
-292
View File
@@ -1,292 +0,0 @@
/**
* Token-budgeted, category-ranked recall engine.
*
* Replaces naive "dump all search results" with:
* 1. Search with rerank + keyword_search
* 2. Rank by category priority (identity first)
* 3. Token-budget the results
* 4. Format by category with importance scores
*/
import type { Mem0Provider, MemoryItem, SkillsConfig, SearchOptions } from "./types.ts";
// ============================================================================
// Defaults
// ============================================================================
const DEFAULT_TOKEN_BUDGET = 1500;
const DEFAULT_MAX_MEMORIES = 15;
const DEFAULT_THRESHOLD = 0.4;
const DEFAULT_CATEGORY_ORDER = [
"identity",
"configuration",
"rule",
"preference",
"decision",
"technical",
"relationship",
"project",
"operational",
];
// Rough token estimate: ~4 chars per token for English text
const CHARS_PER_TOKEN = 4;
// ============================================================================
// Types
// ============================================================================
interface RecallResult {
/** Formatted context string for injection */
context: string;
/** Raw memories retrieved */
memories: MemoryItem[];
/** Token count estimate */
tokenEstimate: number;
}
// ============================================================================
// Category Detection
// ============================================================================
function getMemoryCategory(memory: MemoryItem): string {
// Check metadata first (skill-stored memories have explicit category)
if (memory.metadata?.category && typeof memory.metadata.category === "string") {
return memory.metadata.category;
}
// Check categories array (mem0-extracted memories)
if (memory.categories?.length) {
return memory.categories[0];
}
return "uncategorized";
}
function getMemoryImportance(memory: MemoryItem): number {
if (memory.metadata?.importance && typeof memory.metadata.importance === "number") {
return memory.metadata.importance;
}
// Default importance by category
const cat = getMemoryCategory(memory);
const defaults: Record<string, number> = {
identity: 0.95,
configuration: 0.95,
rule: 0.90,
preference: 0.85,
decision: 0.80,
technical: 0.80,
relationship: 0.75,
project: 0.75,
operational: 0.60,
};
return defaults[cat] ?? 0.50;
}
// ============================================================================
// Token Estimation
// ============================================================================
function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}
// ============================================================================
// Memory Ranking
// ============================================================================
function rankMemories(
memories: MemoryItem[],
categoryOrder: string[],
): MemoryItem[] {
const orderMap = new Map(categoryOrder.map((cat, i) => [cat, i]));
return [...memories].sort((a, b) => {
const catA = getMemoryCategory(a);
const catB = getMemoryCategory(b);
const orderA = orderMap.get(catA) ?? 999;
const orderB = orderMap.get(catB) ?? 999;
// Primary sort: category priority
if (orderA !== orderB) return orderA - orderB;
// Secondary sort: importance (higher first)
const impA = getMemoryImportance(a);
const impB = getMemoryImportance(b);
if (impA !== impB) return impB - impA;
// Tertiary sort: search relevance score
return (b.score ?? 0) - (a.score ?? 0);
});
}
// ============================================================================
// Token Budgeting
// ============================================================================
function budgetMemories(
rankedMemories: MemoryItem[],
tokenBudget: number,
maxMemories: number,
identityAlwaysInclude: boolean,
): MemoryItem[] {
const selected: MemoryItem[] = [];
let usedTokens = 0;
for (const memory of rankedMemories) {
if (selected.length >= maxMemories) break;
const memTokens = estimateTokens(memory.memory);
const isIdentity = getMemoryCategory(memory) === "identity" ||
getMemoryCategory(memory) === "configuration";
// Identity/config always included if flag is set
if (identityAlwaysInclude && isIdentity) {
selected.push(memory);
usedTokens += memTokens;
continue;
}
// Budget check for non-identity memories
if (usedTokens + memTokens > tokenBudget) continue;
selected.push(memory);
usedTokens += memTokens;
}
return selected;
}
// ============================================================================
// Formatting
// ============================================================================
function formatRecalledMemories(
memories: MemoryItem[],
userId: string,
): string {
if (memories.length === 0) {
return `<recalled-memories>\nNo stored memories found for "${userId}".\n</recalled-memories>`;
}
// Group by category
const grouped = new Map<string, MemoryItem[]>();
for (const mem of memories) {
const cat = getMemoryCategory(mem);
const existing = grouped.get(cat) || [];
existing.push(mem);
grouped.set(cat, existing);
}
const lines: string[] = [
`<recalled-memories>`,
`Stored memories for "${userId}" (${memories.length} total, ranked by importance):`,
"",
];
// Format each category group
for (const [category, mems] of grouped.entries()) {
const label = category.charAt(0).toUpperCase() + category.slice(1);
lines.push(`${label}:`);
for (const mem of mems) {
const imp = getMemoryImportance(mem);
const cats = mem.categories?.length ? ` [${mem.categories.join(", ")}]` : "";
lines.push(`- ${mem.memory}${cats} (${Math.round(imp * 100)}%)`);
}
lines.push("");
}
lines.push("</recalled-memories>");
return lines.join("\n");
}
// ============================================================================
// Query Sanitization
// ============================================================================
/**
* Strip OpenClaw metadata prefix from event.prompt before using as search query.
* This only removes framework noise (sender metadata, timestamps) — NOT
* conversational rewriting. Query rewriting is the agent's responsibility
* via the skill protocol (the agent formulates search queries with context).
*/
export function sanitizeQuery(raw: string): string {
let cleaned = raw.replace(/Sender\s*\(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
cleaned = cleaned.replace(/^\[.*?\]\s*/g, "");
cleaned = cleaned.trim();
return cleaned || raw;
}
// ============================================================================
// Public API
// ============================================================================
/**
* Perform token-budgeted, category-ranked recall.
*/
export async function recall(
provider: Mem0Provider,
query: string,
userId: string,
config: SkillsConfig = {},
sessionId?: string,
): Promise<RecallResult> {
const recallConfig = config.recall ?? {};
const tokenBudget = recallConfig.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
const maxMemories = recallConfig.maxMemories ?? DEFAULT_MAX_MEMORIES;
const threshold = recallConfig.threshold ?? DEFAULT_THRESHOLD;
const categoryOrder = recallConfig.categoryOrder ?? DEFAULT_CATEGORY_ORDER;
const identityAlwaysInclude = recallConfig.identityAlwaysInclude !== false;
// Build search options with enhanced features
const searchOpts: SearchOptions = {
user_id: userId,
top_k: maxMemories * 2, // Over-fetch for ranking
threshold,
keyword_search: recallConfig.keywordSearch !== false, // Default on
reranking: recallConfig.rerank !== false, // Default on
};
if (recallConfig.filterMemories) {
searchOpts.filter_memories = true;
}
// Sanitize query: strip OpenClaw metadata prefix before searching
const cleanQuery = sanitizeQuery(query);
// Search long-term memories
let longTermMemories: MemoryItem[] = [];
try {
longTermMemories = await provider.search(cleanQuery, searchOpts);
} catch (err) {
// Graceful degradation — recall failure shouldn't block the agent
console.warn("[mem0] Recall search failed:", err instanceof Error ? err.message : err);
}
// Search session memories if we have a session
let sessionMemories: MemoryItem[] = [];
if (sessionId) {
try {
sessionMemories = await provider.search(cleanQuery, {
...searchOpts,
run_id: sessionId,
top_k: 5,
});
} catch {
// Session search failure is non-critical
}
}
// Deduplicate: session memories that are also in long-term
const longTermIds = new Set(longTermMemories.map((m) => m.id));
const uniqueSession = sessionMemories.filter((m) => !longTermIds.has(m.id));
// Combine and rank
const allMemories = [...longTermMemories, ...uniqueSession];
const ranked = rankMemories(allMemories, categoryOrder);
const budgeted = budgetMemories(ranked, tokenBudget, maxMemories, identityAlwaysInclude);
// Format for injection
const context = formatRecalledMemories(budgeted, userId);
const tokenEstimate = estimateTokens(context);
return { context, memories: budgeted, tokenEstimate };
}
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
"""Patch ~/.openclaw/openclaw.json with skills-mode settings."""
import json
import os
import shutil
import sys
def main():
api_key = os.environ.get("MEM0_API_KEY", "")
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
if not api_key:
print("Error: MEM0_API_KEY not set.", file=sys.stderr)
sys.exit(1)
if not os.path.exists(config_path):
print(f"Error: {config_path} not found. Run 'openclaw configure' first.", file=sys.stderr)
sys.exit(1)
# Backup original config
backup_path = config_path + ".pre-skills-backup"
if not os.path.exists(backup_path):
shutil.copy2(config_path, backup_path)
print(f" Backed up config to {backup_path}")
else:
print(f" Backup already exists at {backup_path}")
with open(config_path) as f:
cfg = json.load(f)
# 1. Tools profile = full (exposes plugin tools to the model)
cfg["tools"] = {"profile": "full"}
# 2. Disable built-in session-memory hook
cfg.setdefault("hooks", {}).setdefault("internal", {}).setdefault("entries", {})
cfg["hooks"]["internal"]["entries"]["session-memory"] = {"enabled": False}
# 3. Plugin config with skills enabled
entries = cfg.setdefault("plugins", {}).setdefault("entries", {})
entries["openclaw-mem0"] = {
"enabled": True,
"config": {
"apiKey": api_key,
"userId": user_id,
"skills": {
"triage": {"enabled": True},
"recall": {
"enabled": True,
"tokenBudget": 1500,
"rerank": True,
"keywordSearch": True,
"identityAlwaysInclude": True,
},
"dream": {"enabled": True},
"domain": "companion",
},
},
}
with open(config_path, "w") as f:
json.dump(cfg, f, indent=2)
print(" Config updated:")
print(" tools.profile = full")
print(" session-memory = disabled")
print(f" skills = enabled (user: {user_id}, domain: companion)")
if __name__ == "__main__":
main()
-392
View File
@@ -1,392 +0,0 @@
/**
* Skill Loader — reads skill markdown files, merges domain overlays,
* injects user config, and produces the final injectable prompt string.
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import type { SkillsConfig, CategoryConfig } from "./types.ts";
// ============================================================================
// Defaults
// ============================================================================
const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
configuration: { importance: 0.95, ttl: null },
rule: { importance: 0.90, ttl: null },
identity: { importance: 0.95, ttl: null, immutable: true },
preference: { importance: 0.85, ttl: null },
decision: { importance: 0.80, ttl: null },
technical: { importance: 0.80, ttl: null },
relationship: { importance: 0.75, ttl: null },
project: { importance: 0.75, ttl: "90d" },
operational: { importance: 0.60, ttl: "7d" },
};
const DEFAULT_CREDENTIAL_PATTERNS = [
"sk-", "m0-", "ghp_", "AKIA", "ak_", "Bearer ",
"bot\\d+:AA", "password=", "token=", "secret=",
];
// ============================================================================
// Skill File Reader
// ============================================================================
interface SkillFrontmatter {
name: string;
description?: string;
"user-invocable"?: boolean;
metadata?: string;
applies_to?: string;
}
interface ParsedSkill {
frontmatter: SkillFrontmatter;
body: string;
}
function parseSkillFile(content: string): ParsedSkill {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!fmMatch) {
return {
frontmatter: { name: "unknown" },
body: content,
};
}
const fmBlock = fmMatch[1];
const body = fmMatch[2].trim();
// Simple YAML-like parsing (no dependency needed)
const fm: Record<string, any> = {};
for (const line of fmBlock.split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value: any = line.slice(colonIdx + 1).trim();
if (value === "false") value = false;
else if (value === "true") value = true;
fm[key] = value;
}
return {
frontmatter: fm as SkillFrontmatter,
body,
};
}
// ============================================================================
// Skill Loader
// ============================================================================
// Resolve skills directory with multiple fallback strategies.
// OpenClaw may load the plugin via jiti or custom loaders that break
// import.meta.url, so we try several paths.
function resolveSkillsDir(): string {
const candidates: string[] = [];
// Strategy 1: import.meta.url (works in native ESM)
try {
const metaDir = path.dirname(fileURLToPath(import.meta.url));
candidates.push(path.join(metaDir, "skills"));
candidates.push(path.join(metaDir, "..", "skills"));
} catch { /* import.meta.url may not be available */ }
// Strategy 2: __dirname (works in CJS / jiti)
if (typeof __dirname !== "undefined") {
candidates.push(path.join(__dirname, "skills"));
candidates.push(path.join(__dirname, "..", "skills"));
}
// Validate: must contain the expected subdirectory structure
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, "memory-triage", "SKILL.md"))) {
return dir;
}
}
return candidates[0] ?? "skills"; // Will fail gracefully in readSkillFile
}
const SKILLS_DIR = resolveSkillsDir();
function readSkillFile(skillName: string): string | null {
// Skills use OpenClaw directory format: <skill-name>/SKILL.md
const filePath = path.join(SKILLS_DIR, skillName, "SKILL.md");
try {
return fs.readFileSync(filePath, "utf-8");
} catch {
return null;
}
}
/**
* Read a domain overlay, scoped to a specific skill.
* Domain overlays live inside the skill directory: <skill>/domains/<domain>.md
* The `applies_to` frontmatter field is checked for backward compatibility.
*/
function readDomainOverlay(domain: string, targetSkill: string): string | null {
// Domain overlays are stored inside the target skill's directory
const filePath = path.join(SKILLS_DIR, targetSkill, "domains", `${domain}.md`);
try {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseSkillFile(content);
// Check applies_to for backward compat (skip if targeting a different skill)
const appliesTo = parsed.frontmatter.applies_to;
if (appliesTo && appliesTo !== targetSkill) {
return null;
}
return parsed.body;
} catch {
return null;
}
}
// ============================================================================
// Config Injection — render user-configured knobs into prompt text
// ============================================================================
function renderCategoriesBlock(categories: Record<string, CategoryConfig>): string {
const lines: string[] = ["\n## Active Category Configuration (overrides defaults above)\n"];
for (const [name, cat] of Object.entries(categories)) {
const ttlLabel = cat.ttl ? `expires: ${cat.ttl}` : "permanent";
const immLabel = cat.immutable ? ", immutable" : "";
lines.push(`- **${name.toUpperCase()}** (importance: ${cat.importance} | ${ttlLabel}${immLabel})`);
}
return lines.join("\n");
}
function renderTriageKnobs(config: SkillsConfig): string {
const triage = config.triage;
if (!triage) return "";
const lines: string[] = [];
if (triage.importanceThreshold !== undefined) {
lines.push(`- Only store facts with importance >= ${triage.importanceThreshold}`);
}
const patterns = resolveCredentialPatterns(config);
if (config.triage?.credentialPatterns) {
lines.push(`- Credential patterns to scan: ${patterns.join(", ")}`);
}
if (lines.length === 0) return "";
return "\n## Active Configuration Overrides\n\n" + lines.join("\n");
}
// ============================================================================
// TTL Helpers
// ============================================================================
/** Convert TTL string like "7d", "90d" to ISO date from today */
export function ttlToExpirationDate(ttl: string | null): string | null {
if (!ttl) return null;
const match = ttl.match(/^(\d+)d$/);
if (!match) return null;
const days = parseInt(match[1], 10);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split("T")[0]; // YYYY-MM-DD
}
// ============================================================================
// Public API
// ============================================================================
export interface LoadedSkill {
name: string;
prompt: string;
frontmatter: SkillFrontmatter;
}
/**
* Load a skill by name, merge domain overlays and user config,
* and return the final injectable prompt string.
*/
export function loadSkill(
skillName: string,
config: SkillsConfig = {},
): LoadedSkill | null {
const raw = readSkillFile(skillName);
if (!raw) return null;
const parsed = parseSkillFile(raw);
const parts: string[] = [parsed.body];
// Domain overlays only apply to the skill they target (checked via applies_to)
if (config.domain) {
const overlay = readDomainOverlay(config.domain, skillName);
if (overlay) {
parts.push("\n" + overlay);
}
}
// Inject user-configured categories into triage skill prompt
if (skillName === "memory-triage" && config.categories) {
const mergedCats = resolveCategories(config);
parts.push(renderCategoriesBlock(mergedCats));
}
// Inject triage knobs (maxFactsPerTurn, importanceThreshold, credentialPatterns)
if (skillName === "memory-triage") {
const knobs = renderTriageKnobs(config);
if (knobs) parts.push(knobs);
}
// Append user custom rules (triage-only — extraction rules don't apply to recall/dream)
if (skillName === "memory-triage" && config.customRules) {
const rulesBlock: string[] = ["\n## User Custom Rules\n"];
if (config.customRules.include?.length) {
rulesBlock.push("Additionally extract:");
for (const rule of config.customRules.include) {
rulesBlock.push(`- ${rule}`);
}
}
if (config.customRules.exclude?.length) {
rulesBlock.push("\nAdditionally skip:");
for (const rule of config.customRules.exclude) {
rulesBlock.push(`- ${rule}`);
}
}
parts.push(rulesBlock.join("\n"));
}
return {
name: skillName,
prompt: parts.join("\n"),
frontmatter: parsed.frontmatter,
};
}
/**
* Build the memory system prompt for injection via prependSystemContext.
*
* Primary path: load the full SKILL.md via loadSkill(), which merges
* domain overlays, category overrides, custom rules, and triage knobs.
* This ensures the config surface (skills.domain, customRules, categories)
* is what the live before_prompt_build path actually sends.
*
* Fallback: if SKILL.md cannot be read (missing file, broken path), use
* a minimal inline protocol so memory still functions.
*/
export function loadTriagePrompt(config: SkillsConfig = {}): string {
// Try to load the full skill with all config-driven overlays
const triage = loadSkill("memory-triage", config);
if (triage) {
// Full SKILL.md loaded with domain overlays, categories, custom rules, knobs merged.
// Wrap in <memory-system> and append the operational instructions that
// are not part of the SKILL.md (tool format, batching, search protocol).
const parts: string[] = [];
parts.push("<memory-system>");
parts.push("IMPORTANT: Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
parts.push("");
parts.push(triage.prompt);
parts.push("");
parts.push("## Tool Usage");
parts.push("");
parts.push("Batch facts by CATEGORY. All facts in one memory_store call must share the same category because category determines retention policy (TTL, immutability). If a turn has facts in different categories, make one call per category.");
parts.push("");
parts.push("FORMAT (single category):");
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
parts.push("FORMAT (mixed categories in one turn, separate calls):");
parts.push(' memory_store(facts: ["User is Alex, backend engineer at Stripe, PST timezone"], category: "identity")');
parts.push(' memory_store(facts: ["As of 2026-04-01, migrating from Postgres to CockroachDB"], category: "decision")');
// Only include search instructions if recall is enabled
if (config.recall?.enabled !== false) {
const strategy = config.recall?.strategy ?? "smart";
parts.push("");
parts.push("## Searching Memory");
parts.push("");
// In manual mode, the agent is fully responsible for all search
if (strategy === "manual") {
parts.push("You control all memory search. No automatic recall happens. Use memory_search proactively:");
parts.push("- At the start of a new conversation, search for user identity and context.");
parts.push("- When the user references something you do not have context for.");
parts.push("- When the conversation topic shifts to a new domain.");
parts.push("- Before updating a memory, search to find the existing version.");
parts.push("");
}
parts.push("When calling memory_search, ALWAYS rewrite the query. NEVER pass the user's raw message.");
parts.push("Stored memories are third-person factual statements. Write a query that matches storage language, not conversation language.");
parts.push("Process: (1) Name your target. (2) Extract signal: proper nouns, technical terms, domain concepts. (3) Bridge to storage language: add terms the stored memory contains (user, decided, prefers, rule, configured, based in). (4) Compose 3-6 keywords.");
parts.push('WRONG: memory_search("Who was that nutritionist my wife recommended?")');
parts.push('RIGHT: memory_search("nutritionist wife recommended relationship")');
parts.push('WRONG: memory_search("What timezone am I in?")');
parts.push('RIGHT: memory_search("user timezone location based")');
parts.push("");
parts.push("ENTITY SCOPING: Memories are scoped by user_id, agent_id, and run_id. You do not need to pass these in most cases. The plugin handles scoping automatically based on the current session.");
parts.push("- Default behavior: all memory operations use the configured userId and current session. You do not need to pass userId or agentId.");
parts.push("- Use agentId only when you need to read or write memories for a DIFFERENT agent (e.g., querying what the 'researcher' agent knows). This accesses a separate namespace.");
parts.push("- Use userId only when explicitly instructed to operate on a different user's memories.");
parts.push("- Do not pass run_id directly. The plugin manages session scoping through the scope parameter.");
parts.push("- In multi-agent setups, each agent has isolated memory. The main agent's memories are separate from subagent memories.");
parts.push("");
parts.push("SEARCH SCOPE: Choose the right scope for each search:");
parts.push('- scope: "long-term" for user context, identity, preferences, decisions (default, most common)');
parts.push('- scope: "session" for facts from this conversation only');
parts.push('- scope: "all" only when you truly need both scopes combined');
parts.push("Using a specific scope avoids unnecessary backend fan-out.");
parts.push("");
parts.push("SEARCH FILTERS: When the user's intent implies a time range or category constraint, pass a `filters` object alongside your rewritten query.");
parts.push('- Time: "last week" -> filters: {"created_at": {"gte": "2026-03-24"}}');
parts.push('- Category: "my preferences" -> categories: ["preference"]');
parts.push("- Available operators: eq, ne, gt, gte, lt, lte, in, contains. Logical: AND, OR, NOT.");
}
parts.push("</memory-system>");
return parts.join("\n");
}
// Fallback: SKILL.md not found. Minimal inline protocol.
const parts: string[] = [];
parts.push("<memory-system>");
parts.push("You have persistent long-term memory via mem0. After EVERY response, evaluate the turn for facts worth storing.");
parts.push("Use `memory_store` tool for ALL user facts. NEVER write user info to workspace files (USER.md, memory/).");
parts.push("Most turns produce ZERO memory operations. That is correct.");
parts.push("Only store facts a new agent would need days later: identity, preferences, decisions, rules, projects, configs.");
parts.push("Batch facts by CATEGORY. All facts in one call must share the same category.");
parts.push('Format: memory_store(facts: ["fact text"], category: "identity")');
parts.push("NEVER store credentials (sk-, m0-, ghp_, AKIA, Bearer tokens, passwords).");
if (config.recall?.enabled !== false) {
parts.push("When searching, rewrite queries for retrieval. Do not pass raw user messages.");
}
parts.push("</memory-system>");
return parts.join("\n");
}
/**
* Load the dream skill prompt for consolidation sessions.
*/
export function loadDreamPrompt(config: SkillsConfig = {}): string {
const dream = loadSkill("memory-dream", config);
if (!dream) return "";
return dream.prompt;
}
/**
* Resolve the effective categories — user overrides merged with defaults.
*/
export function resolveCategories(
config: SkillsConfig = {},
): Record<string, CategoryConfig> {
return { ...DEFAULT_CATEGORIES, ...(config.categories || {}) };
}
/**
* Resolve credential patterns — user overrides merged with defaults.
*/
export function resolveCredentialPatterns(config: SkillsConfig = {}): string[] {
return config.triage?.credentialPatterns ?? DEFAULT_CREDENTIAL_PATTERNS;
}
/**
* Check if skills mode is active (triage enabled).
*/
export function isSkillsMode(config: SkillsConfig | undefined): boolean {
if (!config) return false;
return config.triage?.enabled !== false; // enabled by default when skills config exists
}
-110
View File
@@ -1,110 +0,0 @@
---
name: memory-dream
description: >
Memory consolidation protocol. Reviews all stored memories, merges duplicates,
removes noise and credentials, rewrites unclear entries, and enforces TTL expiration.
Use when the user asks to clean up, consolidate, or review their memories.
Also triggers automatically after sufficient activity (configurable).
user-invocable: true
metadata:
{"openclaw": {"emoji": "💤"}}
---
# Memory Consolidation
You are performing a memory consolidation pass. Your goal is to review all stored memories for this user and improve their overall quality. Think of this as compressing raw observations into clean, durable knowledge.
Follow these four phases in order. Do not skip phases.
## Phase 1: Orient
Survey the current memory landscape before making any changes.
1. Call `memory_list` to load all stored memories.
2. Count memories by category. Note the total.
3. Identify the oldest and newest memories by their timestamps.
4. Note any obvious problems visible in the list: duplicates, very short entries, entries without temporal anchors.
Do not modify anything in this phase. The goal is to understand what you are working with.
## Phase 2: Gather Targets
Identify which memories need action. Use the tools to investigate.
**Search for recent additions:**
Call `memory_search` with a `created_at` filter to find memories added since the last consolidation. These are the most likely to need merging or cleanup.
**Check edit history on suspicious entries:**
If a memory looks like it was updated multiple times or has contradictory content, call `memory_history` on it to understand how it evolved.
**Classify each target into one of these actions:**
- DELETE: contains credentials, expired by TTL, pure noise, raw tool output, standalone timestamps
- MERGE: two or more memories express the same fact in different words, or a series tracks incremental changes to the same entity
- REWRITE: vague, missing temporal anchor, uses first person instead of third, wrong category, overly verbose
## Phase 3: Consolidate
Execute the actions identified in Phase 2. Work in this priority order:
### 3a. Delete dangerous and expired entries
Delete immediately using `memory_forget`:
- Credentials, API keys, tokens, passwords, secrets (patterns: sk-, m0-, ghp_, AKIA, Bearer, password=, token=, secret=)
- Pure timestamps with no context
- Raw tool output stored as memory
- Heartbeat or cron execution records
- Generic acknowledgments stored as memory ("ok", "got it")
- Operational memories older than 7 days
- Project memories older than 90 days
### 3b. Merge duplicates
When two or more memories express the same fact:
1. Pick the most complete version as the base
2. Call `memory_update` on the best version to incorporate missing details from the others
3. Call `memory_forget` on the redundant entries
`memory_update` is preferred over forget-then-store because it is atomic and preserves edit history.
When merging, follow these rules:
- Keep the user's original words for opinions and preferences
- Preserve temporal anchors from both versions
- Do not exceed 50 words in the merged result
- The merged memory must be self-contained (understandable without the deleted ones)
### 3c. Rewrite unclear entries
When a memory needs improvement but is not a duplicate:
1. Call `memory_update` with the improved text
Rewrite when:
- Memory uses first person ("I prefer") instead of third ("User prefers")
- Memory lacks a temporal anchor for time-sensitive information
- Memory is vague ("likes python") and can be made specific ("User prefers Python for backend development")
- Memory has the wrong category assignment
- Memory is over 50 words and can be compressed without losing information
## Phase 4: Report
After completing all operations, summarize what you did:
```
Consolidation complete.
- Reviewed: [total count]
- Deleted (credentials/secrets): [count]
- Deleted (expired/stale): [count]
- Merged: [count] groups into [count] memories
- Rewritten: [count]
- Final count: [total remaining]
- Issues found: [any notable problems or observations]
```
## Quality Targets
After consolidation, the memory store should have:
- Zero memories containing credentials or secrets
- Zero duplicate memories (same fact in different words)
- All project and operational memories have temporal anchors ("As of YYYY-MM-DD")
- All memories use third person voice
- All memories are correctly categorized
- Each memory is 15-50 words, self-contained, and atomic (one fact per memory)
-340
View File
@@ -1,340 +0,0 @@
---
name: memory-triage
description: >
ALWAYS USE THIS SKILL. Persistent long-term memory protocol powered by mem0.
After EVERY response, evaluate the conversation for durable facts worth storing
via memory_store. Handles identity, preferences, decisions, configurations, rules,
projects, and relationships. Use memory_store (NOT workspace files) for all user facts.
Read this skill at the start of every session.
user-invocable: false
metadata:
{"openclaw": {"always": true, "emoji": "🧠"}}
---
# Memory Protocol
You have persistent long-term memory powered by mem0. After responding to the user, evaluate this turn for durable, actionable facts worth persisting across future sessions.
Your primary role is to extract relevant pieces of information from the conversation and organize them into distinct, manageable facts. This allows for easy retrieval and personalization in future interactions.
**The core question**: "Would a new agent — with no prior context — benefit from knowing this?" If no → do nothing. Most turns produce zero memory operations. That is correct and expected.
## Decision Gate
Every candidate fact must pass ALL four gates:
**Gate 1 — FUTURE UTILITY**: Would this matter to a new agent days or weeks from now?
- Pass: identity, configurations, standing rules, preferences with rationale, decisions, project milestones, relationships, important personal details
- Fail: tool outputs, status checks, one-time commands, transient state, small talk, generic responses → SKIP
**Gate 2 — NOVELTY**: Check your recalled memories below — is this already known?
- Already known and unchanged → SKIP
- Known but materially changed → UPDATE (find old → forget → store new)
- Genuinely new → proceed
- **Material difference test**: Only UPDATE if new information adds real context, details, or changes meaning. Cosmetic differences (synonyms, rephrasing, punctuation) are NOT updates. "Loves daily walks" vs "enjoys daily walks" = no material change = SKIP.
**Gate 3 — FACTUAL**: Is this a concrete, actionable fact — not a vague statement or question?
- Pass: specific names, configs, choices with rationale, deadlines, system states, plans, preferences
- Fail: vague impressions, questions, small talk, acknowledgments, generic assistant responses ("Sure, I can help") → SKIP
**Gate 4 — SAFE**: Does this contain ANY credential, secret, or token?
- Scan for: `sk-`, `m0-`, `ghp_`, `AKIA`, `ak_`, `Bearer `, bot tokens (digits:alphanumeric), webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env context, `password=`, `token=`, `secret=`, `.env` values
- ANY match → NEVER STORE the value. Instead, store that the credential was configured:
- WRONG: "User's API key is sk-abc123..."
- RIGHT: "API key was configured for the service (as of 2026-03-30)"
- When in doubt → SKIP. No exceptions.
All four gates must pass. If any fails → do nothing.
## What to Extract (Priority Order)
### 1. Configuration & System State (importance: 0.95 | permanent)
Tools/services configured, installed, or removed (with versions/dates). Model assignments for agents. Cron schedules, automation pipelines, deployment configs. Architecture decisions. Specific identifiers: file paths, sheet IDs, channel IDs, machine specs.
```
"User's Tailscale machine 'mac' (IP 100.71.135.41) is configured under beau@rizedigital.io (as of 2026-02-20)"
"User's executive orchestrator agent Quin runs on Claude Opus, heartbeat every 10 min"
```
### 2. Standing Rules & Policies (importance: 0.90 | permanent)
Explicit user directives about behavior. Workflow policies. Security constraints, permission boundaries. Always capture the reason.
```
"User rule: never create accounts without explicit user consent. Reason: security policy"
"User rule: each agent must review model selection before completing a task"
```
### 3. Identity & Demographics (importance: 0.95 | permanent)
Name, location, timezone, language preferences. Occupation, employer, job role, industry. Keep related facts together in a single memory.
```
"User is Chris, senior platform engineer at Mem0, based in EST timezone"
```
### 4. Preferences & Opinions (importance: 0.85 | permanent)
Communication style, tool preferences, technology opinions. Always capture the WHY when stated. Preserve the user's exact words for feelings and opinions.
```
"User prefers Cursor over VS Code for AI-assisted coding because of inline completions"
"User prefers terse responses with no trailing summaries"
```
### 5. Goals, Projects & Milestones (importance: 0.75 | expires: 90 days)
Active projects with name, description, current status. Completed milestones with dates. Deadlines, roadmaps, progress.
```
"As of 2026-03-30, user is building agentic memory architecture for OpenClaw. Status: active development, team demo planned early April"
"ElevenLabs voice integration fully configured as of 2026-02-20"
```
### 6. Technical Context (importance: 0.80 | permanent)
Tech stack, development environment, agent ecosystem structure (names, roles, relationships). Skill levels.
```
"User's stack: Python/Django backend, Next.js 15 frontend, PostgreSQL with pgvector, deployed on EKS"
```
### 7. Relationships & People (importance: 0.75 | permanent)
Names and roles of people mentioned. Team structure, key contacts.
```
"Deshraj owns the frontend, Taranjeet owns the backend platform at Mem0"
```
### 8. Decisions & Lessons (importance: 0.80 | permanent)
Important decisions made with reasoning. Lessons learned. Strategies that worked or failed.
```
"As of 2026-03-30, user decided to use infer=false for all skill-based memory storage — agent extracts, mem0 stores directly without re-extraction"
```
## CRITICAL: Memory Completeness and Self-Containment
Each memory you store must be a **self-contained, independently understandable fact**. This is the single most important quality rule.
### Entity-Based Grouping
**ALWAYS group all information about the same entity, concept, event, or subject into a SINGLE unified memory.** If multiple pieces of information refer to the same entity (e.g., a conference, a project, a person, a system), they MUST be combined into one comprehensive memory.
**DO NOT split requirements, specifications, or details about the same entity across multiple memory_store calls.** Even if information is phrased differently ("Budget for X", "X requires Y", "X needs Z"), if they all refer to the same entity, combine ALL into ONE call.
**WRONG** — fragmented into separate facts:
```
memory_store(facts: ["Conference requires at least 4 breakout rooms", "Conference requires vegan options", "Conference requires parking"], category: "project")
```
**CORRECT** — grouped into one self-contained fact:
```
memory_store(facts: ["Conference requires at least 4 breakout rooms for 30-40 people each, robust vegan and vegetarian options with allergen-free alternatives, parking for at least 100 vehicles, venue within walking distance of transit"], category: "project")
```
**WRONG** — same entity split into separate facts:
```
memory_store(facts: ["Budget is $150-175 per person for TechForward event", "TechForward event requires strong WiFi", "TechForward event requires hybrid capabilities"], category: "project")
```
**CORRECT** — combined into one fact about TechForward:
```
memory_store(facts: ["TechForward event has a budget of $150-175 per person per day including venue rental, standard AV setup, and catering. Requires strong WiFi and hybrid event capabilities for remote attendees."], category: "project")
```
**Only create separate memories when information refers to genuinely different entities, concepts, or unrelated topics** (e.g., "TechForward event" vs "Marketing campaign" are separate).
### No Pronouns — Use Specific Names
DO NOT create memories that rely on pronouns (they, them, he, she, it). Always use specific names and entities.
- **WRONG**: "They work at Google" and "They live in San Francisco"
- **CORRECT**: "John works at Google and lives in San Francisco"
### No Inference
Do not infer unstated attributes (gender, age, ethnicity, beliefs) from names or context.
- **WRONG**: "Kiran's sister visited him last week"
- **CORRECT**: "Kiran's sister visited last week"
### No Assistant Attribution
Do not store characterizations from assistant messages (e.g., "user seems excited") unless the user explicitly confirmed them.
## How to Store
Use `memory_store` with the `facts` array. All facts in one call MUST share the same category because category determines retention policy (TTL, immutability).
```
memory_store(
facts: ["fact one in third person", "fact two in third person"],
category: "identity"
)
```
If a turn produces facts in different categories, make one call per category:
```
memory_store(facts: ["User is Alex, senior engineer at Stripe, PST timezone"], category: "identity")
memory_store(facts: ["As of 2026-04-01, user decided to migrate from Postgres to CockroachDB"], category: "decision")
```
Categories: `identity`, `configuration`, `rule`, `preference`, `decision`, `technical`, `relationship`, `project`
### Storage Principles
**15-50 WORDS per fact**: Each fact should be 1-2 sentences. If combining would exceed this, consolidate into key facts rather than creating a paragraph. Distill rather than append.
**OUTCOMES OVER INTENT**: Extract what WAS DONE, not what was requested.
- GOOD: "Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates"
- BAD: "User wants to update call scripts"
**TEMPORAL ANCHORING**: Time-sensitive facts MUST include "As of YYYY-MM-DD, ..."
- If no date available, note "date unknown" rather than omitting.
- Extract dates from conversation context or the current date.
**PRESERVE USER'S WORDS**: When the user expresses feelings, opinions, or preferences, keep their exact phrasing.
- GOOD: "User says daily walks with Poppy are the best part of their day"
- BAD: "User finds emotional significance in walking their dog"
**THIRD PERSON**: "User prefers..." not "I prefer..."
**NO PRONOUNS**: Use specific names and entities. Not "they" or "it."
**PRESERVE LANGUAGE**: If the user speaks Spanish, store in Spanish. Do not translate.
**BATCH BY CATEGORY**: Group all same-category facts into one call. Different categories require separate calls. Most turns need zero or one call.
### Updating Existing Memories
When a recalled memory needs updating (fact changed, status changed, new detail added):
1. `memory_search` to find the existing memory
2. `memory_forget` on the old memory's ID
3. `memory_store` with the corrected/expanded fact
**Choose the MORE COMPLETE version.** When both old and new have unique context, COMBINE them into a unified memory using the user's stated words.
**Material difference test**: Only update if the new version adds real information.
- "User likes Python" → "User prefers Python for backend services because of async support" = material update (added rationale, specificity)
- "User likes Python" → "User enjoys Python" = NOT material = SKIP
- When both have unique context, combine: Old "Trip to Paris in September with Jack" + New "User can't wait to visit Eiffel Tower" → "Trip to Paris in September 2025 with friend Jack, user says they can't wait to visit the Eiffel Tower and try authentic French pastries"
**Consolidation**: When a rich new fact encompasses multiple existing memories, update one to the comprehensive version and forget the others.
- Old: "User has a dog" + "Dog's name is Poppy" + "User walks dog daily"
- New: "User has a dog named Poppy and says taking him for walks is the best part of their day"
- Action: forget all three old memories, store one consolidated memory
**Temporary vs permanent changes**: A temporary constraint (e.g., injury pausing a hobby) does NOT contradict the underlying preference. Store the constraint as a new memory; don't delete the preference.
- Old: "User enjoys hiking on weekends"
- New: "User has temporarily paused hiking due to knee injury"
- Action: store the new constraint, leave old preference untouched
## What NEVER to Store
- **Credentials and secrets** — even embedded in config blocks, setup logs, or tool output. Includes sk-, m0-, ak_, ghp_, bot tokens, bearer tokens, webhook URLs with tokens, pairing codes, long alphanumeric strings in config/env contexts. Record that the credential was configured, never the value itself.
- **Raw tool output** — bash results, file contents, API responses, logs, diffs, test output. Extract only the durable OUTCOME or ROOT CAUSE.
- **One-time commands** — "stop the script", "continue where you left off", "run this"
- **Acknowledgments and emotional reactions** — "ok", "sure", "sounds good", "sir", "got it", "thanks", "you're right"
- **Transient UI/navigation states** — "user is in admin panel", "relay is attached"
- **Ephemeral process status** — "download at 50%", "daemon not running", "still syncing"
- **Cron heartbeat outputs** — NO_REPLY, HEARTBEAT_OK, compaction directives
- **Timestamps as standalone facts** — "Current time is 3:25 PM" is NEVER worth storing. But DO use timestamps to anchor other facts.
- **System routing metadata** — message IDs, sender IDs, channel routing info
- **Generic small talk** — no informational content
- **Raw code snippets** — capture the intent/decision, not the code itself
- **Information the user explicitly asks not to remember**
- **Facts already in recalled memories that haven't materially changed**
- **Generic assistant responses** — "Sure, I can help", "How can I assist you?"
## Worked Examples
### Example 1: Configuration extraction (entity-grouped)
```
User: "I set up the research agent on Claude Sonnet with a 30-min cron. It checks HackerNews and sends summaries to #research-feed in Slack."
Agent: [responds helpfully]
→ memory_store(facts: ["User's research agent runs on Claude Sonnet, cron every 30 minutes, monitors HackerNews and posts summaries to Slack #research-feed"], category: "configuration")
```
### Example 2: NOOP — tool output
```
User: "Run the healthcheck on all services"
Agent: [executes healthcheck, returns results]
→ No memory operations. Tool output fails Gate 1.
```
### Example 3: NOOP — already recalled, no material change
```
Recalled: ["User is Chris, senior platform engineer at Mem0"]
User: "Hey Chris here again"
→ No memory operations. Already known, no material change.
```
### Example 4: Rule with rationale (preserving user's words)
```
User: "Never use Docker for local dev, it ate 40GB of disk last time and my Mac mini only has 256GB"
→ memory_store(facts: ["User rule: avoid Docker for local dev. Reason: ate 40GB of disk on 256GB Mac mini"], category: "rule")
```
### Example 5: UPDATE — combining contexts from both versions
```
Recalled: ["As of 2026-03-15, user is planning trip to Paris in September with friend Jack"]
User: "Can't wait for the Paris trip, definitely want to hit the Eiffel Tower and try authentic French pastries"
→ memory_search("Paris trip planning")
→ memory_forget("mem-id-of-old")
→ memory_store(facts: ["As of 2026-03-30, user is planning trip to Paris in September 2025 with friend Jack, says they can't wait to visit the Eiffel Tower and try authentic French pastries"], category: "project")
```
### Example 6: Outcome over intent
```
User: "Update the call scripts sheet with the new truth-based templates"
Agent: [updates the sheet successfully]
→ memory_store(facts: ["Call scripts sheet (ID: 146Qbb...) was updated with truth-based templates (as of 2026-03-30)"], category: "configuration")
```
### Example 7: Credential — store the fact, not the value
```
User: "Use this API key for the new service: sk-proj-abc123def456"
Agent: [configures the service]
→ memory_store(facts: ["API key was configured for the new service (as of 2026-03-30)"], category: "configuration")
```
### Example 8: NOOP — cosmetic difference, not material
```
Recalled: ["User has a dog named Poppy and enjoys their daily walks together"]
User: "Yeah me and Poppy love our daily walks"
→ No memory operations. Semantically equivalent. No new context.
```
### Example 9: Entity grouping — single call, not fragmented
```
User: "The budget for the offsite is $200 per head. We need a venue with WiFi, parking for 50 cars, and a projector."
→ memory_store(facts: ["Team offsite budget is $200 per person. Venue requirements: WiFi, parking for 50 vehicles, and projector setup."], category: "project")
All details about the same entity (offsite) go in one fact, one call.
```
### Example 10: Temporary constraint — don't delete the preference
```
Recalled: ["User enjoys hiking on weekends and finds it therapeutic"]
User: "I hurt my knee last week, can't hike for a while"
→ memory_store(facts: ["As of 2026-03-30, user has temporarily paused hiking due to knee injury"], category: "project")
DO NOT delete the hiking preference. It is temporarily paused, not contradicted.
```
### Example 11: Mixed categories in one turn — separate calls
```
User: "I'm Sarah, I work at Cloudflare. I just decided to switch our monitoring from Datadog to Grafana because of cost."
→ memory_store(facts: ["User is Sarah, works at Cloudflare"], category: "identity")
→ memory_store(facts: ["As of 2026-03-30, user decided to switch monitoring from Datadog to Grafana due to cost"], category: "decision")
Two calls because identity and decision have different retention policies.
```
### Example 12: NOOP — generic greeting
```
User: "Hi"
Agent: "Hello! How can I help?"
→ No memory operations. No extractable facts.
```
### Example 11: Consolidation — rich memory absorbs atomic ones
```
Recalled: ["User has a dog", "Dog's name is Poppy", "User walks dog daily"]
User: "Poppy learned fetch! Our walks are even better now, honestly it's the best part of my day"
→ memory_search("dog Poppy walks") → find all three old memory IDs
→ memory_forget(id-1), memory_forget(id-2), memory_forget(id-3)
→ memory_store(facts: ["User has a dog named Poppy and says taking him for walks is the best part of their day. Poppy recently learned fetch, making walks more enjoyable."], category: "preference")
```
### Example 12: NOOP — generic greeting, nothing to store
```
User: "Hi"
Agent: "Hello! How can I help?"
→ No memory operations. No extractable facts.
```

Some files were not shown because too many files have changed in this diff Show More