chore: import upstream snapshot with attribution
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:45 +08:00
commit 555e282cc4
1802 changed files with 338080 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "mem0-plugins",
"interface": {
"displayName": "Mem0 Plugins"
},
"plugins": [
{
"name": "mem0",
"source": {
"source": "local",
"path": "./integrations/mem0-plugin"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mem0-plugins",
"owner": {
"name": "Mem0",
"email": "support@mem0.ai"
},
"metadata": {
"description": "Official Mem0 plugins for Claude"
},
"plugins": [
{
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.12"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "mem0-plugins",
"interface": {
"displayName": "Mem0 Plugins"
},
"plugins": [
{
"name": "mem0",
"source": {
"source": "local",
"path": "./integrations/mem0-plugin"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mem0-plugins",
"owner": {
"name": "Mem0",
"email": "support@mem0.ai"
},
"metadata": {
"description": "Official Mem0 plugins for Cursor"
},
"plugins": [
{
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.12"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
name: Bug Report
description: Report a bug in mem0
labels: ["bug"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of mem0 is affected?
options:
- Core / Python SDK
- TypeScript SDK
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
- Graph Memory (Neo4j, Memgraph, etc.)
- Ollama / Local Models
- OpenClaw
- REST API
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
value: |
### Summary
A clear summary of the bug.
### Steps to Reproduce
```python
from mem0 import Memory
m = Memory()
# Your code here...
```
### Expected Behavior
What you expected to happen.
### Actual Behavior
What actually happened. Paste the full error traceback if applicable.
### Environment
- mem0 version:
- Python/Node version:
- OS:
validations:
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Discord Community
url: https://discord.gg/6PzXDgEjG5
about: Ask questions and discuss with the community
- name: Documentation
url: https://docs.mem0.ai
about: Read the official mem0 documentation
@@ -0,0 +1,23 @@
name: Documentation Issue
description: Report an issue or suggest an improvement to the mem0 docs
labels: ["documentation"]
body:
- type: textarea
id: description
attributes:
label: Description
value: |
### Page
Link to the docs page: https://docs.mem0.ai/...
### What's Wrong or Missing
Describe what's incorrect, unclear, or missing.
### Suggested Fix
How should the docs be improved?
validations:
required: true
@@ -0,0 +1,41 @@
name: Feature Request
description: Suggest a new feature or improvement for mem0
labels: ["enhancement"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of mem0 does this relate to?
options:
- Core / Python SDK
- TypeScript SDK
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
- Graph Memory (Neo4j, Memgraph, etc.)
- Ollama / Local Models
- OpenClaw
- REST API
- Benchmarks / Evals
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
value: |
### Use Case
What problem are you trying to solve?
### Proposed Solution
How should this work? Include API examples or pseudocode if helpful.
### Alternatives Considered
Any workarounds you've tried or other approaches considered.
validations:
required: true
+38
View File
@@ -0,0 +1,38 @@
## Linked Issue
Closes #<!-- issue number -->
## Description
<!-- What does this PR do? Why is it needed? -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Refactor (no functional changes)
- [ ] Documentation update
## Breaking Changes
<!-- If this is a breaking change, describe what breaks and the migration path. Delete this section if not applicable. -->
N/A
## Test Coverage
- [ ] I added/updated unit tests
- [ ] I added/updated integration tests
- [ ] I tested manually (describe below)
- [ ] No tests needed (explain why)
<!-- Describe how you tested this, or link to CI results. -->
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have added tests that prove my fix/feature works
- [ ] New and existing tests pass locally
- [ ] I have updated documentation if needed
+18
View File
@@ -0,0 +1,18 @@
# Maps dropdown selections to GitHub labels
# Used by the advanced-issue-labeler GitHub Action
component:
- label: "sdk-python"
matcher: "Core / Python SDK"
- label: "sdk-typescript"
matcher: "TypeScript SDK"
- label: "vector-store"
matcher: "Vector Store"
- label: "graph-memory"
matcher: "Graph Memory"
- label: "ollama"
matcher: "Ollama"
- label: "openclaw"
matcher: "OpenClaw"
- label: "rest-api"
matcher: "REST API"
+59
View File
@@ -0,0 +1,59 @@
name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI
# Dispatched by release.yml (Release Router) when a release tagged v* is
# published. Can also be dispatched manually to re-publish a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. v1.2.3)'
required: true
type: string
prerelease:
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
# Pure SDK version tags only (v1.2.3) — excludes package-prefixed tags
# like vercel-ai-v* that also start with 'v'
if: startsWith(inputs.tag, 'v') && !contains(inputs.tag, '-v')
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v2
with:
ref: ${{ inputs.tag }}
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.11'
- name: Install Hatch
run: |
pip install hatch
- name: Install dependencies
run: |
hatch env create
- name: Build a binary wheel and a source tarball
run: |
hatch build --clean
# TODO: Needs to setup mem0 repo on Test PyPI
# - name: Publish distribution 📦 to Test PyPI
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
# repository_url: https://test.pypi.org/legacy/
# packages_dir: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages_dir: dist/
+171
View File
@@ -0,0 +1,171 @@
name: CI Gate
# Single required status check for all PRs.
#
# Path-filtered CI workflows can't be marked as required in branch
# protection: on a PR that doesn't touch their paths they never report, and
# the required check hangs at "Expected" forever. This gate solves that. It
# runs on every PR, detects which packages changed, calls only the relevant
# package CI workflows (as reusable workflows), and the final "CI Gate" job
# reports the aggregate result — success when every invoked pipeline passed
# (skipped pipelines are fine), failure when any failed.
#
# Branch protection should require exactly one status check: "CI Gate".
#
# Package CI workflows keep their own push-to-main and workflow_dispatch
# triggers; only their pull_request triggers moved here. To wire in a new
# package: add a filter under the `changes` job, a call job that `uses:` the
# package workflow, and list the call job in the gate's `needs`.
on:
pull_request:
concurrency:
group: ci-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
changes:
name: Detect changed packages
runs-on: ubuntu-latest
outputs:
python_sdk: ${{ steps.filter.outputs.python_sdk }}
ts_sdk: ${{ steps.filter.outputs.ts_sdk }}
cli_python: ${{ steps.filter.outputs.cli_python }}
cli_node: ${{ steps.filter.outputs.cli_node }}
openclaw: ${{ steps.filter.outputs.openclaw }}
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
# Each filter mirrors the package workflow's old pull_request
# paths, plus the package workflow file itself and this gate file
# (changing either must re-exercise the pipeline).
filters: |
python_sdk:
- 'mem0/**'
- 'tests/**'
- 'pyproject.toml'
- '.github/workflows/ci.yml'
- '.github/workflows/ci-gate.yml'
ts_sdk:
- 'mem0-ts/**'
- '.github/workflows/ts-sdk-ci.yml'
- '.github/workflows/ci-gate.yml'
cli_python:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
- '.github/workflows/ci-gate.yml'
cli_node:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
- '.github/workflows/ci-gate.yml'
openclaw:
- 'integrations/openclaw/**'
- '.github/workflows/openclaw-checks.yml'
- '.github/workflows/ci-gate.yml'
opencode_plugin:
- 'integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
pi_agent_plugin:
- 'integrations/pi-agent-plugin/**'
- '.github/workflows/pi-agent-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
docs_llms_txt:
- 'docs/**/*.mdx'
- 'docs/llms.txt'
- 'scripts/check-llms-txt-coverage.py'
- 'scripts/llms-txt-ignore.txt'
- '.github/workflows/docs-llms-txt-check.yml'
- '.github/workflows/ci-gate.yml'
python-sdk:
name: Python SDK
needs: changes
if: needs.changes.outputs.python_sdk == 'true'
uses: ./.github/workflows/ci.yml
secrets: inherit
ts-sdk:
name: TypeScript SDK
needs: changes
if: needs.changes.outputs.ts_sdk == 'true'
uses: ./.github/workflows/ts-sdk-ci.yml
secrets: inherit
cli-python:
name: Python CLI
needs: changes
if: needs.changes.outputs.cli_python == 'true'
uses: ./.github/workflows/cli-python-ci.yml
secrets: inherit
cli-node:
name: Node CLI
needs: changes
if: needs.changes.outputs.cli_node == 'true'
uses: ./.github/workflows/cli-node-ci.yml
secrets: inherit
openclaw:
name: OpenClaw
needs: changes
if: needs.changes.outputs.openclaw == 'true'
uses: ./.github/workflows/openclaw-checks.yml
secrets: inherit
opencode-plugin:
name: OpenCode Plugin
needs: changes
if: needs.changes.outputs.opencode_plugin == 'true'
uses: ./.github/workflows/opencode-plugin-checks.yml
secrets: inherit
pi-agent-plugin:
name: Pi Agent Plugin
needs: changes
if: needs.changes.outputs.pi_agent_plugin == 'true'
uses: ./.github/workflows/pi-agent-plugin-checks.yml
secrets: inherit
docs-llms-txt:
name: docs llms.txt
needs: changes
if: needs.changes.outputs.docs_llms_txt == 'true'
uses: ./.github/workflows/docs-llms-txt-check.yml
secrets: inherit
gate:
name: CI Gate
needs:
- changes
- python-sdk
- ts-sdk
- cli-python
- cli-node
- openclaw
- opencode-plugin
- pi-agent-plugin
- docs-llms-txt
if: always()
runs-on: ubuntu-latest
steps:
- name: Evaluate pipeline results
env:
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
failed=$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")')
if [ -n "$failed" ]; then
echo "::error::Failing pipelines: $failed"
exit 1
fi
echo "All pipelines relevant to this change passed."
+115
View File
@@ -0,0 +1,115 @@
name: ci
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main runs remain standalone.
on:
push:
branches: [main]
workflow_call:
jobs:
changelog_check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require CHANGELOG entry when Python SDK version changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
extract_version() {
python3 -c "import sys, re; m = re.search(r'^\s*version\s*=\s*\"([^\"]+)\"', sys.stdin.read(), re.M); print(m.group(1) if m else '')"
}
base_version=$(git show "$BASE_SHA:pyproject.toml" 2>/dev/null | extract_version || echo "")
head_version=$(extract_version < pyproject.toml)
echo "Base version: ${base_version:-<unknown>}"
echo "Head version: $head_version"
if [ -z "$base_version" ] || [ "$base_version" = "$head_version" ]; then
echo "pyproject.toml version unchanged — no CHANGELOG entry required."
exit 0
fi
echo "Detected version bump ${base_version} -> ${head_version}. Checking docs/changelog/sdk.mdx…"
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- docs/changelog/sdk.mdx | grep -q .; then
echo "Changelog update present in docs/changelog/sdk.mdx ✅"
else
echo "::error file=pyproject.toml::pyproject.toml version changed from ${base_version} to ${head_version} but docs/changelog/sdk.mdx was not updated in this PR. Add a new <Update> entry under the Python tab for v${head_version}."
exit 1
fi
check_changes:
runs-on: ubuntu-latest
outputs:
mem0_changed: ${{ steps.filter.outputs.mem0 }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
mem0:
- 'mem0/**'
- 'tests/**'
- '.github/workflows/ci.yml'
- 'pyproject.toml'
build_mem0:
needs: check_changes
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Skip — no relevant changes
if: needs.check_changes.outputs.mem0_changed != 'true'
run: echo "No changes in mem0/, tests/, pyproject.toml, or ci.yml — skipping"
- uses: actions/checkout@v4
if: needs.check_changes.outputs.mem0_changed == 'true'
- name: Set up Python ${{ matrix.python-version }}
if: needs.check_changes.outputs.mem0_changed == 'true'
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Clean up disk space
if: needs.check_changes.outputs.mem0_changed == 'true'
run: |
df -h
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
sudo docker builder prune -a
df -h
- name: Install Hatch
if: needs.check_changes.outputs.mem0_changed == 'true'
run: pip install hatch
- name: Load cached venv
if: needs.check_changes.outputs.mem0_changed == 'true'
id: cached-hatch-dependencies
uses: actions/cache@v3
with:
path: .venv
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
- name: Install GEOS Libraries
if: needs.check_changes.outputs.mem0_changed == 'true'
run: sudo apt-get update && sudo apt-get install -y libgeos-dev
- name: Install dependencies
if: needs.check_changes.outputs.mem0_changed == 'true' && steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
run: |
pip install --upgrade pip
pip install -e ".[test,graph,vector_stores,llms,extras]"
pip install ruff
- name: Run Linting
if: needs.check_changes.outputs.mem0_changed == 'true'
run: make lint
- name: Run tests and generate coverage report
if: needs.check_changes.outputs.mem0_changed == 'true'
run: make test
+60
View File
@@ -0,0 +1,60 @@
name: Publish @mem0/cli 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged
# cli-node-v* is published. Can also be dispatched manually to re-publish
# a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. cli-node-v0.2.0)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish @mem0/cli 📦 to npm
if: startsWith(inputs.tag, 'cli-node-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: cli/node
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
+99
View File
@@ -0,0 +1,99 @@
name: CLI Node CI
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/node/**'
- '.github/workflows/cli-node-ci.yml'
workflow_call:
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)
+47
View File
@@ -0,0 +1,47 @@
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
# Dispatched by release.yml (Release Router) when a release tagged cli-v* is
# published. Can also be dispatched manually to re-publish a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. cli-v0.2.0)'
required: true
type: string
prerelease:
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish mem0-cli 📦 to PyPI
if: startsWith(inputs.tag, 'cli-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: cli/python
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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/
+78
View File
@@ -0,0 +1,78 @@
name: CLI Python CI
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'cli/python/**'
- '.github/workflows/cli-python-ci.yml'
workflow_call:
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)
+42
View File
@@ -0,0 +1,42 @@
name: docs - llms.txt check
# Blocks PRs that introduce new .mdx pages without a matching entry in
# docs/llms.txt, or that link to pages that no longer exist. Contributors
# must update docs/llms.txt in the same PR. Run locally with:
# python scripts/check-llms-txt-coverage.py # read-only
# python scripts/check-llms-txt-coverage.py --write # scaffold placeholders
# On PRs this is invoked by ci-gate.yml (the single required check);
# manual runs remain standalone.
on:
workflow_call:
workflow_dispatch: {}
permissions:
contents: read
jobs:
check-llms-txt:
runs-on: ubuntu-24.04-arm
timeout-minutes: 2
steps:
- uses: actions/checkout@v4
- name: Verify docs/llms.txt coverage
run: |
if ! python3 scripts/check-llms-txt-coverage.py; then
echo ""
echo "::error title=llms.txt out of sync::docs/llms.txt does not match docs/**/*.mdx."
echo ""
echo "To fix:"
echo " 1. Run locally: python scripts/check-llms-txt-coverage.py --write"
echo " This appends placeholder entries under '## Unclassified - needs triage'."
echo " 2. For each placeholder:"
echo " - replace [TODO: Platform|OSS|Both] with the correct scope tag"
echo " - rewrite the description as 'Use when ...'"
echo " - move the entry into the appropriate section"
echo " - delete the '## Unclassified - needs triage' heading once empty"
echo " 3. Resolve any stale URLs listed above by updating or removing the link."
echo " 4. Commit the updated docs/llms.txt to this PR."
exit 1
fi
+39
View File
@@ -0,0 +1,39 @@
name: Auto-label issues
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: stefanbuck/github-issue-parser@v3
id: issue-parser
with:
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
- uses: redhat-plumbers-in-action/advanced-issue-labeler@v3
with:
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
section: component
token: ${{ secrets.GITHUB_TOKEN }}
config-path: .github/advanced-issue-labeler.yml
- uses: stefanbuck/github-issue-parser@v3
id: feature-parser
if: contains(github.event.issue.labels.*.name, 'enhancement')
with:
template-path: .github/ISSUE_TEMPLATE/feature_request.yml
- uses: redhat-plumbers-in-action/advanced-issue-labeler@v3
if: contains(github.event.issue.labels.*.name, 'enhancement')
with:
issue-form: ${{ steps.feature-parser.outputs.jsonString }}
section: component
token: ${{ secrets.GITHUB_TOKEN }}
config-path: .github/advanced-issue-labeler.yml
+60
View File
@@ -0,0 +1,60 @@
name: Publish @mem0/openclaw-mem0 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged
# openclaw-v* is published. Can also be dispatched manually to re-publish
# a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. openclaw-v0.5.0)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish @mem0/openclaw-mem0 📦 to npm
if: startsWith(inputs.tag, 'openclaw-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: integrations/openclaw
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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: integrations/openclaw/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
+99
View File
@@ -0,0 +1,99 @@
name: openclaw checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/openclaw/**'
- '.github/workflows/openclaw-checks.yml'
workflow_call:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/openclaw && pnpm install --frozen-lockfile
- name: Type check
run: cd integrations/openclaw && pnpm exec tsc --noEmit
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: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/openclaw && pnpm install --frozen-lockfile
- name: Run tests with coverage
run: cd integrations/openclaw && pnpm exec vitest run --coverage
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
flags: openclaw
directory: integrations/openclaw/coverage
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/openclaw && pnpm install --frozen-lockfile
- name: Build
run: cd integrations/openclaw && pnpm build
- name: Verify dist output exists
run: |
test -f integrations/openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
test -f integrations/openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
+58
View File
@@ -0,0 +1,58 @@
name: Publish @mem0/opencode-plugin 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged
# opencode-v* is published. Can also be dispatched manually to re-publish
# a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. opencode-v0.2.0)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish @mem0/opencode-plugin 📦 to npm
if: startsWith(inputs.tag, 'opencode-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: integrations/mem0-plugin/.opencode-plugin
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
@@ -0,0 +1,39 @@
name: opencode-plugin checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/mem0-plugin/.opencode-plugin/**'
- '.github/workflows/opencode-plugin-checks.yml'
workflow_call:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/mem0-plugin/.opencode-plugin
steps:
- uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run type-check
- name: Build
run: bun run build
- name: Verify dist output exists
run: |
test -f dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
+60
View File
@@ -0,0 +1,60 @@
name: Publish @mem0/pi-agent-plugin 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged
# pi-agent-v* is published. Can also be dispatched manually to re-publish
# a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. pi-agent-v0.1.1)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish @mem0/pi-agent-plugin 📦 to npm
if: startsWith(inputs.tag, 'pi-agent-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: integrations/pi-agent-plugin
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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: integrations/pi-agent-plugin/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
@@ -0,0 +1,92 @@
name: pi-agent-plugin checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/pi-agent-plugin/**'
- '.github/workflows/pi-agent-plugin-checks.yml'
workflow_call:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
- name: Type check
run: cd integrations/pi-agent-plugin && pnpm exec tsc --noEmit
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: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
- name: Run tests
run: cd integrations/pi-agent-plugin && pnpm exec vitest run
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
- name: Build
run: cd integrations/pi-agent-plugin && pnpm build
- name: Verify dist output exists
run: |
test -f integrations/pi-agent-plugin/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
test -f integrations/pi-agent-plugin/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
test -f integrations/pi-agent-plugin/dist/entry.js || (echo "Build output missing: dist/entry.js" && exit 1)
test -f integrations/pi-agent-plugin/dist/entry.d.ts || (echo "Build output missing: dist/entry.d.ts" && exit 1)
+68
View File
@@ -0,0 +1,68 @@
name: Release Router 🚦
# Single entry point for all release publishing.
#
# Package CD workflows no longer listen to release events themselves — this
# router inspects the release tag and dispatches only the matching pipeline,
# so each release produces one routed run instead of one real run plus seven
# skipped ones.
#
# Re-publishing a release (e.g. after fixing registry settings) does NOT
# require deleting and recreating it anymore — manually dispatch the
# package's CD workflow from the tag instead:
#
# gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>
#
# Note: dispatching runs the workflow file as it exists at the given ref, so
# this router can only dispatch tags created after the workflow_dispatch
# conversion landed on main. For older tags, dispatch manually from main.
on:
release:
types: [published]
permissions:
actions: write
jobs:
route:
name: Route ${{ github.event.release.tag_name }} to its CD pipeline
runs-on: ubuntu-latest
steps:
- name: Match tag prefix to CD workflow
id: match
env:
TAG: ${{ github.event.release.tag_name }}
run: |
# Specific package prefixes first; the bare v* (Python SDK) arm
# must stay last so prefixed tags that also start with 'v'
# (vercel-ai-v*) can never be routed to the Python pipeline.
case "$TAG" in
ts-v*) workflow="ts-sdk-cd.yml" ;;
cli-node-v*) workflow="cli-node-cd.yml" ;;
cli-v*) workflow="cli-python-cd.yml" ;;
vercel-ai-v*) workflow="vercel-ai-cd.yml" ;;
openclaw-v*) workflow="openclaw-cd.yml" ;;
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
v*) workflow="cd.yml" ;;
*)
echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md."
exit 1
;;
esac
echo "workflow=$workflow" >> "$GITHUB_OUTPUT"
echo ":outbox_tray: Routed \`$TAG\` → \`$workflow\`" >> "$GITHUB_STEP_SUMMARY"
- name: Dispatch ${{ steps.match.outputs.workflow }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: |
# --ref points at the tag so the dispatched run builds (and signs
# provenance for) the exact tagged commit.
gh workflow run "${{ steps.match.outputs.workflow }}" \
--repo "$GITHUB_REPOSITORY" \
--ref "refs/tags/$TAG" \
-f tag="$TAG" \
-f prerelease="${{ github.event.release.prerelease }}"
+48
View File
@@ -0,0 +1,48 @@
name: Close stale issues
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
# Issue settings
days-before-issue-stale: 90
days-before-issue-close: 14
stale-issue-label: 'stale'
stale-issue-message: >
This issue has been automatically marked as stale because it has not
had any activity in 90 days. It will be closed in 14 days if no
further activity occurs. If this is still relevant, please leave a
comment or remove the `stale` label.
close-issue-message: >
This issue has been closed due to inactivity. If this is still
relevant, feel free to reopen it or create a new issue.
# PR settings — mark stale but never auto-close
days-before-pr-stale: 90
days-before-pr-close: -1
stale-pr-label: 'stale'
stale-pr-message: >
This pull request has been automatically marked as stale because it
has not had any activity in 90 days. Please update your branch and
address any review comments, or it may be closed in the future.
# Exempt these labels from stale processing
exempt-issue-labels: 'P0-critical,P1-high,good first issue,security'
exempt-pr-labels: 'P0-critical,P1-high'
# Remove stale label when there is new activity
remove-stale-when-updated: true
# Process up to 100 issues per run to stay within API limits
operations-per-run: 100
+59
View File
@@ -0,0 +1,59 @@
name: Publish mem0ai 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged ts-v* is
# published. Can also be dispatched manually to re-publish a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. ts-v2.1.0)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish mem0ai 📦 to npm
if: startsWith(inputs.tag, 'ts-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: mem0-ts
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
+146
View File
@@ -0,0 +1,146 @@
name: TypeScript SDK CI
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main runs remain standalone.
on:
push:
branches: [main]
paths:
- 'mem0-ts/**'
- '.github/workflows/ts-sdk-ci.yml'
workflow_call:
jobs:
check_changes:
runs-on: ubuntu-latest
outputs:
ts_sdk_changed: ${{ steps.filter.outputs.ts_sdk }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
ts_sdk:
- 'mem0-ts/**'
changelog_check:
needs: check_changes
if: github.event_name == 'pull_request' && needs.check_changes.outputs.ts_sdk_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require CHANGELOG entry when SDK version changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
base_version=$(git show "$BASE_SHA:mem0-ts/package.json" 2>/dev/null | jq -r .version || echo "")
head_version=$(jq -r .version mem0-ts/package.json)
echo "Base version: ${base_version:-<unknown>}"
echo "Head version: $head_version"
if [ -z "$base_version" ] || [ "$base_version" = "$head_version" ]; then
echo "mem0-ts/package.json version unchanged — no CHANGELOG entry required."
exit 0
fi
echo "Detected version bump ${base_version} -> ${head_version}. Checking docs/changelog/sdk.mdx…"
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- docs/changelog/sdk.mdx | grep -q .; then
echo "Changelog update present in docs/changelog/sdk.mdx ✅"
else
echo "::error file=mem0-ts/package.json::mem0-ts/package.json version changed from ${base_version} to ${head_version} but docs/changelog/sdk.mdx was not updated in this PR. Add a new <Update> entry under the TypeScript tab for v${head_version}."
exit 1
fi
build_ts_sdk:
needs: check_changes
if: needs.check_changes.outputs.ts_sdk_changed == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: mem0-ts/pnpm-lock.yaml
- name: Install dependencies
working-directory: mem0-ts
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: mem0-ts
run: npx prettier --check .
- name: Build
working-directory: mem0-ts
run: pnpm run build
- name: Run unit tests
working-directory: mem0-ts
run: pnpm run test:unit
- name: Verify package exports
working-directory: mem0-ts
run: |
node -e "const m = require('./dist/index.js'); console.log('Client exports:', Object.keys(m).length)"
node -e "const m = require('./dist/oss/index.js'); console.log('OSS exports:', Object.keys(m).length)"
- name: Upload coverage
if: matrix.node-version == 20
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: mem0-ts/coverage/
integration_ts_sdk:
needs: build_ts_sdk
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: mem0-ts/pnpm-lock.yaml
- name: Install dependencies
working-directory: mem0-ts
run: pnpm install --frozen-lockfile
- name: Build
working-directory: mem0-ts
run: pnpm run build
- name: Run integration tests (with cleanup)
working-directory: mem0-ts
env:
MEM0_API_KEY: ${{ secrets.MEM0_API_KEY }}
run: pnpm run test:integration
+60
View File
@@ -0,0 +1,60 @@
name: Publish @mem0/vercel-ai-provider 📦 to npm
# Dispatched by release.yml (Release Router) when a release tagged
# vercel-ai-v* is published. Can also be dispatched manually to re-publish
# a tag.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and publish (e.g. vercel-ai-v2.0.7)'
required: true
type: string
prerelease:
description: 'Publish under the version preid dist-tag instead of latest'
required: false
type: boolean
default: false
jobs:
build-n-publish:
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
if: startsWith(inputs.tag, 'vercel-ai-v')
runs-on: ubuntu-latest
permissions:
id-token: write
defaults:
run:
working-directory: integrations/vercel-ai-sdk
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- 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: integrations/vercel-ai-sdk/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Publish to npm
run: |
if [ "${{ inputs.prerelease }}" = "true" ]; then
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
npx npm@latest publish --provenance --access public --tag "$PREID"
else
npx npm@latest publish --provenance --access public
fi
+193
View File
@@ -0,0 +1,193 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
**/node_modules/
# Self-hosted server local runtime state
server/history/
server/.env
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended not to include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
pyenv/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.ideas.md
.todos.md
# Database
db
test-db
.vscode
.idea/
.DS_Store
notebooks/*.yaml
.ipynb_checkpoints/
!configs/*.yaml
# cache db
*.db
# local directories for testing
eval/
qdrant_storage/
.crossnote
testing.ipynb
.weave/
+4
View File
@@ -0,0 +1,4 @@
[submodule "evaluation"]
path = evaluation
url = https://github.com/mem0ai/memory-benchmarks
branch = main
+16
View File
@@ -0,0 +1,16 @@
repos:
- repo: local
hooks:
- id: ruff
name: Ruff
entry: ruff check
language: system
types: [python]
args: [--fix]
- id: isort
name: isort
entry: isort
language: system
types: [python]
args: ["--profile", "black"]
+611
View File
@@ -0,0 +1,611 @@
# AGENTS.md
This file provides context for AI coding assistants (Claude Code, Cursor, GitHub Copilot, Codex, etc.) working with the Mem0 repository.
## Project Overview
**Mem0** ("mem-zero") is an intelligent memory layer for AI agents and assistants. It provides persistent, personalized memory via both a hosted platform API and self-hosted open-source SDKs.
- **Repository**: https://github.com/mem0ai/mem0
- **Documentation**: https://docs.mem0.ai
- **License**: Apache-2.0
## Repository Structure
This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs, servers, plugins, and documentation.
### Key Directories
| Directory | Description |
|-----------|-------------|
| `mem0/` | Core Python SDK (`mem0ai` on PyPI) — memory, LLMs, embeddings, vector stores, graphs, rerankers |
| `mem0-ts/` | TypeScript SDK (`mem0ai` on npm) — client + OSS memory |
| `cli/python/` | Python CLI (`mem0-cli` on PyPI) — Typer-based, entry point `mem0` |
| `cli/node/` | Node CLI (`@mem0/cli` on npm) — Commander-based, entry point `mem0` |
| `integrations/` | **Agent & editor integrations**, one directory per integration (see "Adding a New Integration") |
| `integrations/mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills. Contains nested `.opencode-plugin/` (`@mem0/opencode-plugin`) |
| `integrations/openclaw/` | `@mem0/openclaw-mem0` — OpenClaw plugin for Claude Code / AI editors |
| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin |
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` |
| `docs/` | Documentation site (Mintlify) |
| `tests/` | Python SDK tests (pytest) |
| `evaluation/` | Submodule → [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) — benchmarking (LOCOMO, LongMemEval, BEAM) lives in that repo |
| `examples/` | Sample projects & runnable demos — apps, Chrome extension, multi-agent patterns, and Jupyter notebooks (`notebooks/`) |
| `pr-reviews/` | Pull request review materials |
| `scripts/` | Repo-wide utility scripts (e.g., `check-llms-txt-coverage.py` for docs/llms.txt sync) |
### Core Package Dependencies
```
mem0 (Python SDK) mem0-ts (TypeScript SDK)
├── mem0/memory/ ├── src/client/ (MemoryClient — hosted)
├── mem0/llms/ └── src/oss/ (Memory — self-hosted)
├── mem0/embeddings/ ├── src/llms/
├── mem0/vector_stores/ ├── src/embeddings/
├── mem0/graphs/ ├── src/vector_stores/
└── mem0/reranker/ └── src/graphs/
cli/python/ ──▶ mem0ai (optional, for OSS mode)
cli/node/ ──▶ mem0ai (npm, for API calls)
integrations/vercel-ai-sdk/ ──▶ ai, @ai-sdk/* providers
integrations/openclaw/ ──▶ mem0ai (npm)
```
## Development Setup
### Requirements
- **Python**: 3.9+ (3.10+ for CLI)
- **Node.js**: v18+ (v20 or v22 recommended)
- **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages
- **Hatch**: Python build/environment tool (`pip install hatch`)
- **Docker**: Required for `server/` and `openmemory/` development
### Initial Setup
```bash
# Python SDK
hatch shell dev_py_3_11 # creates environment with all deps
pre-commit install # install git hooks
# TypeScript packages
cd mem0-ts && pnpm install # TS SDK
cd cli/node && pnpm install # Node CLI
cd integrations/vercel-ai-sdk && pnpm install # Vercel AI provider
cd integrations/openclaw && pnpm install # OpenClaw plugin
```
## Build, Lint, and Test Commands
### Python SDK (`mem0/`)
```bash
# Environment setup (uses Hatch)
hatch shell dev_py_3_11 # or dev_py_3_9, dev_py_3_10, dev_py_3_12
# Linting and formatting
make lint # ruff check
make format # ruff format
make sort # isort mem0/
# Tests
make test # pytest tests/
make test-py-3.9 # test specific Python version (3.93.12)
# Build and publish
make build # hatch build
make publish # hatch publish
```
- **Python:** 3.9, 3.10, 3.11, 3.12
- **Linter/formatter:** Ruff (line length **120**)
- **Import sorting:** isort (`profile = "black"`)
- **Test framework:** pytest (with pytest-mock, pytest-asyncio)
- **Pre-commit hooks:** ruff + isort — run `pre-commit install` before committing
### TypeScript SDK (`mem0-ts/`)
```bash
cd mem0-ts
pnpm install
pnpm run build # tsup
pnpm run test # jest (all tests)
pnpm run test:unit # jest --coverage (unit tests only)
pnpm run test:integration # jest (integration tests, needs MEM0_API_KEY)
pnpm run test:ci # jest --coverage --ci (CI mode)
pnpm run test:watch # jest watch mode
```
- **Node:** 20, 22 (CI-tested)
- **Build:** tsup (CJS + ESM)
- **Test:** jest
- **Formatter:** prettier
### Python CLI (`cli/python/`)
```bash
cd cli/python
pip install -e ".[dev]" # dev install with ruff + pytest
ruff check . # lint
ruff format . # format
pytest # test
hatch build # build
```
- **Python:** 3.10+ (not 3.9)
- **Linter/formatter:** Ruff (line length **100** — different from root SDK)
- **Ruff rules:** E, F, I, W, UP, B, SIM, RUF (ignores E501, B008 for Typer patterns, SIM108)
- **Framework:** Typer + Rich + httpx
- **Entry point:** `mem0 = "mem0_cli.app:main"`
- **Source layout:** `src/mem0_cli/`
- **Optional dependency:** `mem0ai` (for OSS mode, via `[oss]` extra)
### Node CLI (`cli/node/`)
```bash
cd cli/node
pnpm install
pnpm run build # tsup
pnpm run lint # biome check src/
pnpm run lint:fix # biome check --write src/
pnpm run typecheck # tsc --noEmit
pnpm run test # vitest run
pnpm run test:watch # vitest (watch mode)
pnpm run dev # tsx src/index.ts (development)
```
- **Node:** 18+ required
- **Build:** tsup (ESM)
- **Linter:** Biome (not ESLint, not Ruff)
- **Test:** vitest (not jest)
- **Framework:** Commander + Chalk + ora + cli-table3
### Vercel AI SDK Provider (`integrations/vercel-ai-sdk/`)
```bash
cd integrations/vercel-ai-sdk
pnpm install
pnpm run build # tsup
pnpm run lint # eslint
pnpm run type-check # tsc --noEmit
pnpm run prettier-check # prettier --check
pnpm run test # jest
pnpm run test:edge # vitest (edge runtime)
pnpm run test:node # vitest (node runtime)
```
- **Build:** tsup (CJS + ESM)
- **Lint:** ESLint + Prettier
- **Test:** jest + vitest (edge/node configs)
### OpenClaw Plugin (`integrations/openclaw/`)
```bash
cd integrations/openclaw
pnpm install
pnpm run build # tsup
pnpm run test # vitest run
```
- **Build:** tsup (ESM)
- **Test:** vitest (with Codecov in CI)
- **Plugin manifest:** `openclaw.plugin.json`
### Server (`server/`)
```bash
# Docker production build
cd server
make build # docker build -t mem0-api-server .
make run_local # docker run -p 8000:8000 with .env
# Docker Compose development (FastAPI + PostgreSQL/pgvector + Neo4j)
cd server
docker-compose up # starts all 3 services
# mem0 API: localhost:8888
# PostgreSQL: localhost:8432
# Neo4j HTTP: localhost:8474, Bolt: localhost:8687
```
- **Framework:** FastAPI with uvicorn (auto-reload in dev)
- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin
- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes
### OpenMemory (`openmemory/`)
```bash
# Full stack via Docker Compose
cd openmemory
docker-compose up
# Qdrant: localhost:6333
# API (MCP): localhost:8765
# UI: localhost:3000
# Individual development
cd openmemory/api && uvicorn main:app --reload # FastAPI backend
cd openmemory/ui && npm run dev # Next.js frontend
# Tests
cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py)
```
- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol)
- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts
- **Vector store:** Qdrant
### Documentation (`docs/`)
```bash
make docs # or: cd docs && mintlify dev
```
- **Framework:** Mintlify
- **API spec:** `docs/openapi.json`
- **Structure:** `api-reference/`, `open-source/`, `platform/`, `integrations/`, `cookbooks/`, `core-concepts/`
### Evaluation / Benchmarking
Benchmarking lives in the external [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) repo (LOCOMO + LongMemEval + BEAM). The in-repo `evaluation/` path is a **git submodule** pinned to that repo's `main` — populate it with `git submodule update --init evaluation` (or clone mem0 with `--recurse-submodules`), or clone the benchmarks repo standalone:
```bash
git clone https://github.com/mem0ai/memory-benchmarks.git
cd memory-benchmarks
pip install -r requirements.txt
# Run a benchmark (Mem0 Cloud; use docker compose for OSS)
python -m benchmarks.locomo.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY
python -m benchmarks.longmemeval.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --all-questions
python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --chat-sizes 100K --conversations 0-9
```
## Core APIs
### Python
| Function / Class | Purpose | Import |
|-----------------|---------|--------|
| `Memory` | Self-hosted memory (sync) | `from mem0 import Memory` |
| `AsyncMemory` | Self-hosted memory (async) | `from mem0 import AsyncMemory` |
| `MemoryClient` | Hosted platform client (sync) | `from mem0 import MemoryClient` |
| `AsyncMemoryClient` | Hosted platform client (async) | `from mem0 import AsyncMemoryClient` |
**Key `Memory` / `MemoryClient` methods:**
| Method | Purpose |
|--------|---------|
| `add(messages, *, user_id, agent_id, run_id, metadata)` | Store a new memory |
| `search(query, *, user_id, agent_id, run_id, limit, filters)` | Search memories |
| `get(memory_id)` | Retrieve a single memory by ID |
| `get_all(*, user_id, agent_id, run_id, limit)` | List all memories |
| `update(memory_id, data)` | Update a memory |
| `delete(memory_id)` | Delete a memory |
| `delete_all(*, user_id, agent_id, run_id)` | Delete all memories |
| `history(memory_id)` | Get change history for a memory |
### TypeScript
| Export | Purpose | Import |
|--------|---------|--------|
| `MemoryClient` | Hosted platform client | `import { MemoryClient } from 'mem0ai'` |
| `Memory` | Self-hosted OSS memory | `import { Memory } from 'mem0ai/oss'` |
## Import Patterns
### Python
| What | Import |
|------|--------|
| Core memory classes | `from mem0 import Memory, AsyncMemory` |
| Platform client | `from mem0 import MemoryClient, AsyncMemoryClient` |
| Configuration | `from mem0.configs.base import MemoryConfig` |
| LLM providers | `from mem0.llms.<provider> import <ProviderLLM>` |
| Embedding providers | `from mem0.embeddings.<provider> import <ProviderEmbedding>` |
| Vector store providers | `from mem0.vector_stores.<provider> import <ProviderVectorStore>` |
### TypeScript
| What | Import |
|------|--------|
| Hosted client | `import { MemoryClient } from 'mem0ai'` |
| OSS memory | `import { Memory } from 'mem0ai/oss'` |
| Specific providers (OSS) | `import { OpenAIEmbedding } from 'mem0ai/oss'` |
## Coding Standards
### File Naming Conventions
- **Python source files:** `snake_case.py` (e.g., `azure_openai.py`, `cohere_reranker.py`)
- **Python test files:** `test_<module>.py` (e.g., `test_memory.py`, `test_main.py`)
- **TypeScript source files:** `snake_case.ts` (e.g., `azure_ai_search.ts`)
- **TypeScript test files:** `<module>.test.ts` (e.g., `memory.test.ts`)
- **Config/manifest files:** `kebab-case` (e.g., `openclaw.plugin.json`, `jest.config.js`)
### Python Conventions
- **Provider pattern:** All providers (LLMs, embeddings, vector stores, graphs, rerankers) inherit from a `base.py` abstract class in their directory. Config classes live in `configs.py`.
- **Pydantic v2** for all data models and configuration.
- **Ruff** is the single linting and formatting tool — no black, no flake8.
- Root SDK: line length **120**
- Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF)
- **isort** with `profile = "black"` for import sorting.
- Ruff excludes `openmemory/` from root config.
### TypeScript Conventions
- **Build:** tsup across all packages.
- **Package manager:** pnpm everywhere (no npm, no yarn).
- **TypeScript strict mode** across all packages.
- **Linting varies by package:**
| Package | Linter | Formatter | Test Framework |
|---------|--------|-----------|---------------|
| `mem0-ts/` | — | Prettier | jest |
| `cli/node/` | Biome | Biome | vitest |
| `integrations/vercel-ai-sdk/` | ESLint | Prettier | jest + vitest |
| `integrations/openclaw/` | — | — | vitest |
### Type Checking
Always run type checking after modifying TypeScript code:
```bash
cd <package> && pnpm run typecheck # or: tsc --noEmit
```
## Architecture
### Provider Pattern
The SDK uses a consistent plugin architecture across 5 categories. Each category has a `base.py` abstract class and concrete provider implementations:
| Category | Count | Examples |
|----------|-------|---------|
| **LLMs** | 24 | OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, Gemini, Groq, Ollama, Together, DeepSeek, vLLM, LiteLLM, LM Studio, xAI |
| **Vector Stores** | 30 | Qdrant, Pinecone, Chroma, Weaviate, Milvus, MongoDB, Redis, Elasticsearch, pgvector, Supabase, Faiss, S3 Vectors |
| **Embeddings** | 15 | OpenAI, Azure OpenAI, Gemini, HuggingFace, FastEmbed, Together, AWS Bedrock, Ollama, Vertex AI |
| **Graph Stores** | 4 | Neo4j, Memgraph, Kuzu, Apache AGE |
| **Rerankers** | 5 | Cohere, HuggingFace, LLM-based, Sentence Transformer, Zero Entropy |
### Two Usage Modes
Self-hosted `Memory` / `AsyncMemory` classes and hosted-platform `MemoryClient` — both in Python and TypeScript.
### Graph Memory
Optional layer on top of vector memory for relationship-aware retrieval. Configured via the `graph` section of `MemoryConfig`.
### MCP Integration
Model Context Protocol support in multiple places:
- **Remote:** MCP server at `mcp.mem0.ai`
- **Local:** MCP server in `openmemory/api/` (FastAPI-based)
- **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`
### Plugin & Skills System
- `integrations/mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
- `skills/` contains structured skill definitions for AI agents, split into two categories:
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch (the two are loosely coupled via `.mem0-integration/` artifacts); `mem0-oss-to-platform` migrates an existing project from Mem0 OSS to the hosted Platform SDK (plan, then execute on approval).
### Adding a New Provider
To add a new LLM, embedding, vector store, or reranker provider:
1. Create `mem0/<category>/<provider_name>.py`
2. Inherit from the abstract base class in `mem0/<category>/base.py`
3. Add configuration to `mem0/<category>/configs.py` (if the category uses one)
4. Register the provider in `mem0/<category>/__init__.py`
5. Add tests in `tests/<category>/<provider_name>/`
6. Add any new dependencies to the appropriate optional group in `pyproject.toml` (never to core `dependencies`)
7. Follow the exact pattern of existing providers in the same category — match method signatures, error handling, and config structure
### Adding a New Integration
Agent/editor integrations live under `integrations/`. Each is a self-contained directory (its own `package.json`/lockfile, build, and tests). To add one:
1. Create `integrations/<name>/` and build the integration there.
2. If it publishes to a registry, set `repository.directory: "integrations/<name>"` in its `package.json` so npm provenance links to the correct subdirectory.
3. Add CI/CD under `.github/workflows/` (`<name>-checks.yml`, `<name>-cd.yml`). Use `integrations/<name>` in `paths:` triggers, `working-directory`, and `cache-dependency-path`. Register the release tag prefix in the `case` block in `release.yml` (keep the bare `v*` arm last). Keep workflow **filenames** stable — npm OIDC trusted publishing is pinned to repo + workflow filename.
4. If it is a Claude Code / editor marketplace plugin, register its path in the five `marketplace.json` files (root + `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`).
5. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`.
6. Add rows to the "Key Directories" table and the CI/CD tables in this file.
## CI/CD
### CI Workflows (automated testing)
PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** runs on every PR, detects which packages changed, and invokes only the relevant package workflows below as reusable workflows (`workflow_call`). Its final **`CI Gate`** job aggregates the results (skipped pipelines pass; failed or cancelled ones fail) and is the **only status check that needs to be required** in branch protection. Package workflows keep their own push-to-main and manual triggers; their `pull_request` triggers moved into the gate's path filters.
| Workflow | File | Standalone Triggers | Tests |
|----------|------|---------------------|-------|
| CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates the workflows below |
| Python SDK | `ci.yml` | Push to main | Ruff lint + pytest on Python 3.10, 3.11, 3.12 |
| TypeScript SDK | `ts-sdk-ci.yml` | Push to main (on `mem0-ts/`) | Prettier + build + jest on Node 20, 22 |
| Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 |
| Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 |
| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check |
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check |
When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` — a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
### CD Workflows (automated publishing)
Publishing is routed through a single entry point: **`release.yml` (Release Router)** is the only workflow that listens to `release: published` events. It matches the release tag prefix and dispatches the corresponding package workflow via `workflow_dispatch`, so each release produces exactly one routed run (no skipped runs from the other pipelines).
| Workflow | File | Tag Prefix | Target |
|----------|------|------------|--------|
| Release Router | `release.yml` | (all releases) | dispatches the matching workflow below |
| Python SDK | `cd.yml` | `v*` | PyPI (`mem0ai`) |
| TypeScript SDK | `ts-sdk-cd.yml` | `ts-v*` | npm (`mem0ai`) |
| Python CLI | `cli-python-cd.yml` | `cli-v*` | PyPI (`mem0-cli`) |
| Node CLI | `cli-node-cd.yml` | `cli-node-v*` | npm (`@mem0/cli`) |
| Vercel AI SDK | `vercel-ai-cd.yml` | `vercel-ai-v*` | npm (`@mem0/vercel-ai-provider`) |
| OpenClaw | `openclaw-cd.yml` | `openclaw-v*` | npm (`@mem0/openclaw-mem0`) |
| OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) |
| Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) |
- Package CD workflows are `workflow_dispatch`-only (inputs: `tag`, `prerelease`); they check out and build the given tag. Registry trusted-publisher settings stay pinned to each package's own workflow filename.
- All publishing uses **OIDC trusted publishing** — no tokens or secrets required.
- First publish of a new npm package must be done manually; OIDC works for subsequent versions.
- To re-publish a release (e.g. after a registry settings fix), do **not** delete/recreate the GitHub release — manually dispatch the package workflow instead: `gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`.
- When adding a new package: add its CD workflow (`workflow_dispatch` with `tag`/`prerelease` inputs), then register its tag prefix in the `case` block in `release.yml`. Keep the bare `v*` arm last.
### Utility Workflows
| Workflow | File | Purpose |
|----------|------|---------|
| Issue Labeler | `issue-labeler.yml` | Automatic issue labeling |
| Stale Bot | `stale.yml` | Marks stale issues and PRs |
| llms.txt Check | `docs-llms-txt-check.yml` | Blocks PRs touching `docs/**/*.mdx` when `docs/llms.txt` is out of sync. Fix locally with `python scripts/check-llms-txt-coverage.py --write`. |
## Task Completion Guidelines
These guidelines outline typical artifacts for different task types. Use judgment to adapt based on scope and context.
### Bug Fixes
1. **Unit tests**: Add tests that would fail without the fix (regression tests)
2. **Implementation**: Fix the bug
3. **Manual verification**: Run the relevant test suite to confirm the fix
4. **Lint**: Run the appropriate linter for the package you modified
### New Features
1. **Implementation**: Build the feature following existing patterns
2. **Unit tests**: Comprehensive test coverage for new functionality
3. **Documentation**: Update relevant docs in `docs/` for public APIs
4. **Examples**: Add usage examples if the feature introduces new user-facing behavior
5. **llms.txt**: Any new `.mdx` page under `docs/` must be linked in `docs/llms.txt` with a scope tag (`[Platform]` / `[OSS]` / `[Both]`) and a `Use when ...` description. The `docs-llms-txt-check.yml` workflow runs on every PR that touches docs and **fails the check** if the index is out of sync. To fix: run `python scripts/check-llms-txt-coverage.py --write` locally to scaffold placeholders under `## Unclassified - needs triage`, then replace the `[TODO: ...]` tags, rewrite descriptions as `Use when ...`, move entries into the right section, and delete the triage heading when empty.
### New Provider (LLM / Embedding / Vector Store / Reranker)
1. **Implementation**: Follow the "Adding a New Provider" steps above
2. **Tests**: Add unit tests matching the pattern of existing providers
3. **Configuration**: Add to the appropriate `configs.py` and `__init__.py`
4. **Dependencies**: Add to the correct optional group in `pyproject.toml`
5. **Documentation**: Add an integration guide in `docs/integrations/`
### Refactoring / Internal Changes
- Unit tests for any changed behavior
- No documentation needed for internal-only changes
- Ensure all existing tests still pass
### When to Deviate
These are guidelines, not rigid rules. Adjust based on:
- **Scope**: Trivial fixes (typos, comments) may not need tests
- **Visibility**: Internal changes may not need documentation
- **Context**: Some changes span multiple categories — use judgment
When uncertain about expected artifacts, ask for clarification.
## Contributing Guidelines
### Workflow
1. Fork and clone the repository.
2. Create a feature branch from `main` (e.g., `feature/my-new-feature`).
3. Make your changes — add tests, docs, and examples as appropriate.
4. Run linting and tests for every package you modified (see commands above).
5. Run `pre-commit install` on first setup — hooks run ruff + isort automatically.
6. Commit with a clear message following [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`, `refactor:`).
7. Push and open a Pull Request against `main`.
### Pull Request Requirements
Every PR must follow the repo's PR template (`.github/PULL_REQUEST_TEMPLATE.md`):
1. **Linked Issue** — Reference the issue with `Closes #<number>`. If no issue exists, create one first or explain why in the description.
2. **Description** — Explain what the PR does and why it's needed.
3. **Type of Change** — Check the appropriate box:
- Bug fix / New feature / Breaking change / Refactor / Documentation update
4. **Breaking Changes** — If applicable, describe what breaks and the migration path.
5. **Test Coverage** — Check what applies:
- Added/updated unit tests
- Added/updated integration tests
- Tested manually (describe how)
- No tests needed (explain why)
6. **Checklist** — All must be checked before merge:
- [ ] Code follows the project's style guidelines
- [ ] Self-review performed
- [ ] Tests added that prove the fix/feature works
- [ ] New and existing tests pass locally
- [ ] Documentation updated if needed
### PR Description Template
```markdown
## Linked Issue
Closes #<!-- issue number -->
## Description
<!-- What does this PR do? Why is it needed? -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Refactor (no functional changes)
- [ ] Documentation update
## Breaking Changes
N/A
## Test Coverage
- [ ] I added/updated unit tests
- [ ] I added/updated integration tests
- [ ] I tested manually (describe below)
- [ ] No tests needed (explain why)
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have added tests that prove my fix/feature works
- [ ] New and existing tests pass locally
- [ ] I have updated documentation if needed
```
### General Rules
- Follow existing code patterns — don't introduce new frameworks or abstractions without discussion.
- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript).
- For `server/` and `openmemory/` work, use Docker Compose for local development.
- Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`).
### Contributing Guides
| Task | Guide |
|------|-------|
| Code contributions | `docs/contributing/development.mdx` |
| Documentation contributions | `docs/contributing/documentation.mdx` |
| PR template | `.github/PULL_REQUEST_TEMPLATE.md` |
| Bug reports | `.github/ISSUE_TEMPLATE/bug_report.yml` |
| Feature requests | `.github/ISSUE_TEMPLATE/feature_request.yml` |
| Documentation issues | `.github/ISSUE_TEMPLATE/documentation_issue.yml` |
## Do NOT
- Modify CI/CD workflows without explicit approval.
- Add new Python dependencies to the core `dependencies` list in `pyproject.toml` without discussion — use optional dependency groups instead.
- Commit `.env` files, API keys, or credentials.
- Skip pre-commit hooks.
- Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively.
- Use `require()` for imports in TypeScript — use ES module `import` syntax.
- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff).
- Modify `openmemory/` database migrations without understanding the Alembic migration chain.
- Change public APIs without updating documentation in `docs/`.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+178
View File
@@ -0,0 +1,178 @@
# Contributing to Mem0
First off, thank you for taking the time to contribute! 🎉 Mem0 is a
community-driven project and we welcome contributions of all kinds — bug fixes,
new features, documentation, examples, and integrations.
Mem0 is a polyglot monorepo, and this guide covers contributing to both the
**Python SDK** and the **TypeScript SDK** (and the rest of the repository).
## Before You Start
### 1. Open an Issue First
**Always open an issue before opening a pull request.** This lets us discuss the
change, avoid duplicate effort, and agree on the approach before you invest time
in code.
- Search [existing issues](https://github.com/mem0ai/mem0/issues) first to see if
your bug or idea already exists.
- If it doesn't, open a
[bug report](https://github.com/mem0ai/mem0/issues/new?template=bug_report.yml) or
[feature request](https://github.com/mem0ai/mem0/issues/new?template=feature_request.yml).
- For anything beyond a trivial fix, wait for a maintainer to confirm the approach
before starting significant work.
Every pull request must link to an issue using `Closes #<issue-number>`.
### 2. Sign the Contributor License Agreement (CLA)
**We cannot accept or merge any pull request until you have signed our Contributor
License Agreement (CLA).**
When you open your first PR, the CLA bot will automatically comment with a link to
sign. Signing takes less than a minute and only needs to be done once. Pull
requests from contributors who have not signed the CLA will be blocked from
merging.
## Repository Layout
The two most common contribution targets are the SDKs:
| Package | Path | Language | Package manager |
| --------------------- | ---------- | ------------ | --------------- |
| Python SDK (`mem0ai`) | `mem0/` | Python 3.9+ | `hatch` |
| TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` |
Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations
(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site
(`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository.
## Development Workflow
1. **Fork** the repository and **clone** your fork.
2. Create a **feature branch** from `main` (e.g. `feature/my-new-feature` or
`fix/issue-1234`).
3. Make your changes — add **tests**, **documentation**, and **examples** as
appropriate.
4. Run **linting and tests** for every package you touched (see below).
5. Commit using [Conventional Commits](https://www.conventionalcommits.org/)
(e.g. `feat:`, `fix:`, `docs:`, `refactor:`, `test:`).
6. Push and open a **pull request** against `main`, linking the issue with
`Closes #<number>` and filling out the
[PR template](./.github/PULL_REQUEST_TEMPLATE.md).
### Contributing to the Python SDK (`mem0/`)
We use [`hatch`](https://hatch.pypa.io/latest/install/) to manage environments.
**Do not use `pip` or `conda` for dependency management.**
```bash
# Activate a dev environment (3.9 / 3.10 / 3.11 / 3.12)
hatch shell dev_py_3_11
# Install pre-commit hooks (runs ruff + isort on commit)
pre-commit install
# Lint, format, and sort imports
make lint
make format
make sort
# Run the test suite (run `make install_all` first if deps are missing)
make test
```
- **Linter / formatter:** Ruff (line length **120**)
- **Import sorting:** isort (`profile = "black"`)
- **Tests:** pytest (in `tests/`)
See the full [Development guide](https://docs.mem0.ai/contributing/development) for
environment details.
### Contributing to the TypeScript SDK (`mem0-ts/`)
We use [`pnpm`](https://pnpm.io/) (v10+) for all TypeScript packages. **Do not use
`npm` or `yarn`.**
```bash
cd mem0-ts
pnpm install
pnpm run build # tsup (CJS + ESM)
pnpm run test # jest (all tests)
pnpm run test:unit # unit tests with coverage
```
- **Build:** tsup
- **Formatter:** Prettier
- **Tests:** jest
- Always run type checking after changes: `pnpm run typecheck` (or `tsc --noEmit`).
- Use ES module `import` syntax — never `require()`.
## Good Contribution Practices
- **Keep PRs small and focused.** One logical change per PR is easier to review and
merge.
- **Follow existing patterns.** Match the style, structure, and conventions of the
code around you. Don't introduce new frameworks or abstractions without
discussion.
- **Write tests** that would fail without your change — regression tests for bugs,
coverage for new features.
- **Update documentation** in `docs/` for any user-facing change. New `.mdx` pages
must be added to `docs/llms.txt` (run
`python scripts/check-llms-txt-coverage.py --write` to scaffold entries).
- **Add examples** when introducing new user-facing behavior.
- **Run linters and tests locally** before pushing — CI re-runs them on every PR
via the CI Gate.
- **Never commit secrets** — no `.env` files, API keys, or credentials.
- **Don't add core dependencies lightly.** New Python dependencies belong in an
optional group in `pyproject.toml`, not the core `dependencies` list.
- **Be responsive** to review feedback and keep your branch up to date with `main`.
## Pull Request Checklist
Before requesting review, make sure:
- [ ] An issue exists and is linked with `Closes #<number>`
- [ ] You have signed the CLA
- [ ] Your code follows the project's style guidelines (lint passes)
- [ ] You performed a self-review of your changes
- [ ] Tests are added/updated and pass locally
- [ ] Documentation is updated if needed
## Reporting Security Issues
**Do not report security vulnerabilities through public issues or pull requests.**
Please follow our [Security Policy](./SECURITY.md) to report them privately.
## 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
We look forward to your pull requests and can't wait to see your contributions!
+201
View File
@@ -0,0 +1,201 @@
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.
+1324
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
.PHONY: format sort lint
# Variables
ISORT_OPTIONS = --profile black
PROJECT_NAME := mem0ai
# Default target
all: format sort lint
install:
hatch env create
install_all:
pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
google-generativeai elasticsearch opensearch-py vecs "pinecone<7.0.0" pinecone-text faiss-cpu langchain-community \
upstash-vector azure-search-documents langchain-memgraph langchain-neo4j langchain-aws rank-bm25 pymochow pymongo psycopg kuzu databricks-sdk valkey
# Format code with ruff
format:
hatch run format
# Sort imports with isort
sort:
hatch run isort mem0/
# Lint code with ruff
lint:
hatch run lint
docs:
cd docs && mintlify dev
build:
hatch build
publish:
hatch publish
clean:
rm -rf dist
test:
hatch run test
test-py-3.10:
hatch run dev_py_3_10:test
test-py-3.11:
hatch run dev_py_3_11:test
test-py-3.12:
hatch run dev_py_3_12:test
+269
View File
@@ -0,0 +1,269 @@
<p align="center">
<a href="https://github.com/mem0ai/mem0">
<img src="docs/images/banner-sm.png" width="800px" alt="Mem0 - The Memory Layer for Personalized AI">
</a>
</p>
<p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
<a href="https://trendshift.io/repositories/11194" target="blank">
<img src="https://trendshift.io/api/badge/repositories/11194" alt="mem0ai%2Fmem0 | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="https://mem0.ai">Learn more</a>
·
<a href="https://mem0.dev/DiG">Join Discord</a>
·
<a href="https://mem0.dev/demo">Demo</a>
</p>
<p align="center">
<a href="https://mem0.dev/DiG">
<img src="https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white" alt="Mem0 Discord">
</a>
<a href="https://pepy.tech/project/mem0ai">
<img src="https://img.shields.io/pypi/dm/mem0ai" alt="Mem0 PyPI - Downloads">
</a>
<a href="https://github.com/mem0ai/mem0">
<img src="https://img.shields.io/github/commit-activity/m/mem0ai/mem0?style=flat-square" alt="GitHub commit activity">
</a>
<a href="https://pypi.org/project/mem0ai" target="blank">
<img src="https://img.shields.io/pypi/v/mem0ai?color=%2334D058&label=pypi%20package" alt="Package version">
</a>
<a href="https://www.npmjs.com/package/mem0ai" target="blank">
<img src="https://img.shields.io/npm/v/mem0ai" alt="Npm package">
</a>
<a href="https://www.ycombinator.com/companies/mem0">
<img src="https://img.shields.io/badge/Y%20Combinator-S24-orange?style=flat-square" alt="Y Combinator S24">
</a>
</p>
<p align="center">
<a href="https://mem0.ai/research"><strong>📄 Benchmarking Mem0's token-efficient memory algorithm →</strong></a>
</p>
## New Memory Algorithm (April 2026)
| Benchmark | Old | New | Tokens | Latency p50 |
| --- | --- | --- | --- | --- |
| **LoCoMo** | 71.4 | **92.5** | 7.0K | 0.88s |
| **LongMemEval** | 67.8 | **94.4** | 6.8K | 1.09s |
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
All benchmarks run on the same production-representative model stack. Single-pass retrieval (one call, no agentic loops) at a top_200 retrieval budget. Scores reflect Mem0's managed platform, which includes proprietary optimizations not available in the open-source SDK; open-source users should expect directionally similar gains but not identical numbers.
**What changed:**
- **Single-pass ADD-only extraction** -- one LLM call, no UPDATE/DELETE. Memories accumulate; nothing is overwritten.
- **Agent-generated facts are first-class** -- when an agent confirms an action, that information is now stored with equal weight.
- **Entity linking** -- entities are extracted, embedded, and linked across memories for retrieval boosting.
- **Multi-signal retrieval** -- semantic, BM25 keyword, and entity matching scored in parallel and fused.
- **Temporal Reasoning** -- time-aware retrieval that ranks the right dated instance for queries about current state, past events, and upcoming plans.
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgrade instructions. The [evaluation framework](https://github.com/mem0ai/memory-benchmarks) is open-sourced so anyone can reproduce the numbers.
## Research Highlights
- **92.5 on LoCoMo** -- +21 points over the previous algorithm
- **94.4 on LongMemEval** -- +27 points, with 98.2 on assistant memory recall
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
- [Read the full paper](https://mem0.ai/research)
# Introduction
[Mem0](https://mem0.ai) ("mem-zero") enhances AI assistants and agents with an intelligent memory layer, enabling personalized AI interactions. It remembers user preferences, adapts to individual needs, and continuously learns over time—ideal for customer support chatbots, AI assistants, and autonomous systems.
### Key Features & Use Cases
**Core Capabilities:**
- **Multi-Level Memory**: Seamlessly retains User, Session, and Agent state with adaptive personalization
- **Developer-Friendly**: Intuitive API, cross-platform SDKs, and a fully managed service option
**Applications:**
- **AI Assistants**: Consistent, context-rich conversations
- **Customer Support**: Recall past tickets and user history for tailored help
- **Healthcare**: Track patient preferences and history for personalized care
- **Productivity & Gaming**: Adaptive workflows and environments based on user behavior
## 🚀 Quickstart Guide <a name="quickstart"></a>
### Sign up as an agent
AI agents can mint a working Mem0 API key in under five seconds — no email, no dashboard, no OTP. Four commands end-to-end:
```bash
# 1. Install
npm install -g @mem0/cli # or: pip install mem0-cli
# 2. Sign up as an agent (replace `claude-code` with your name)
mem0 init --agent --agent-caller claude-code
# 3. Add a memory
mem0 add "I am using mem0"
# 4. Search
mem0 search "am I using mem0"
```
The human owner can claim the account later with `mem0 init --email <their-email>` — same key, memories preserved. Full guide: [Sign up as an agent](https://docs.mem0.ai/platform/agent-signup).
| | Library | Self-Hosted Server | Cloud Platform |
|---|---------|-------------------|----------------|
| **Best for** | Testing, prototyping | Teams running on their own infrastructure | Zero-ops production use |
| **Setup** | `pip install mem0ai` | `docker compose up` | Sign up at [app.mem0.ai](https://app.mem0.ai?utm_source=oss&utm_medium=readme) |
| **Dashboard** | -- | [Yes](https://docs.mem0.ai/open-source/setup) | Yes |
| **Auth & API Keys** | -- | Yes | Yes |
| **Advanced Features** | -- | Teasers | All included |
Just testing? Use the library. Building for a team? Self-hosted. Want zero ops? Cloud.
### Library (pip / npm)
```bash
pip install mem0ai
```
For enhanced hybrid search with BM25 keyword matching and entity extraction, install with NLP support:
```bash
pip install mem0ai[nlp]
python -m spacy download en_core_web_sm
```
Install sdk via npm:
```bash
npm install mem0ai
```
### Self-Hosted Server
> **Note:** Self-hosted auth is on by default. Upgrading from a pre-auth build? Set `ADMIN_API_KEY`, register an admin through the wizard, or `AUTH_DISABLED=true` for local dev only. See [upgrade notes](https://docs.mem0.ai/open-source/setup#upgrade-notes).
```bash
# Recommended: one command — start the stack, create an admin, issue the first API key.
cd server && make bootstrap
# Manual: start the stack and finish setup via the browser wizard.
cd server && docker compose up -d # http://localhost:3000
```
See the [self-hosted docs](https://docs.mem0.ai/open-source/overview) for configuration.
### Cloud Platform
1. Sign up on [Mem0 Platform](https://app.mem0.ai?utm_source=oss&utm_medium=readme)
2. Embed the memory layer via SDK or API keys
3. Using hosted Qdrant vectors? See the [Platform migration guide](https://docs.mem0.ai/migration/oss-to-platform) to import them into Mem0 Platform.
### CLI
Manage memories from your terminal:
```bash
npm install -g @mem0/cli # or: pip install mem0-cli
mem0 init
mem0 add "Prefers dark mode and vim keybindings" --user-id alice
mem0 search "What does Alice prefer?" --user-id alice
```
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
### Agent Skills
Teach your AI coding assistant (Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any tool that supports the skills standard) how to build with Mem0. Two categories:
**Reference skills — always on** (SDK knowledge loaded into the assistant's context):
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
```
**Pipeline skills — run on demand** (execute an end-to-end workflow in an existing repo):
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
```
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. Use `/mem0-oss-to-platform` to migrate an existing project from Mem0 OSS to the hosted Platform SDK. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
### Basic Usage
Mem0 requires an LLM to function, with `gpt-5-mini` from OpenAI as the default. However, it supports a variety of LLMs; for details, refer to our [Supported LLMs documentation](https://docs.mem0.ai/components/llms/overview).
Mem0 uses `text-embedding-3-small` from OpenAI as the default embedding model. For best results with hybrid search (semantic + keyword + entity boosting), we recommend using at least [Qwen 600M](https://huggingface.co/Alibaba-NLP/gte-Qwen2-1.5B-instruct) or a comparable embedding model. See [Supported Embeddings](https://docs.mem0.ai/components/embedders/overview) for configuration details.
First step is to instantiate the memory:
```python
from openai import OpenAI
from mem0 import Memory
openai_client = OpenAI()
memory = Memory()
def chat_with_memories(message: str, user_id: str = "default_user") -> str:
# Retrieve relevant memories
relevant_memories = memory.search(query=message, filters={"user_id": user_id}, top_k=3)
memories_str = "\n".join(f"- {entry['memory']}" for entry in relevant_memories["results"])
# Generate Assistant response
system_prompt = f"You are a helpful AI. Answer the question based on query and memories.\nUser Memories:\n{memories_str}"
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": message}]
response = openai_client.chat.completions.create(model="gpt-5-mini", messages=messages)
assistant_response = response.choices[0].message.content
# Create new memories from the conversation
messages.append({"role": "assistant", "content": assistant_response})
memory.add(messages, user_id=user_id)
return assistant_response
def main():
print("Chat with AI (type 'exit' to quit)")
while True:
user_input = input("You: ").strip()
if user_input.lower() == 'exit':
print("Goodbye!")
break
print(f"AI: {chat_with_memories(user_input)}")
if __name__ == "__main__":
main()
```
For detailed integration steps, see the [Quickstart](https://docs.mem0.ai/quickstart) and [API Reference](https://docs.mem0.ai/api-reference).
## 🔗 Integrations & Demos
- **ChatGPT with Memory**: Personalized chat powered by Mem0 ([Live Demo](https://mem0.dev/demo))
- **Browser Extension**: Store memories across ChatGPT, Perplexity, and Claude ([Chrome Extension](https://chromewebstore.google.com/detail/onihkkbipkfeijkadecaafbgagkhglop?utm_source=item-share-cb))
- **Langgraph Support**: Build a customer bot with Langgraph + Mem0 ([Guide](https://docs.mem0.ai/integrations/langgraph))
- **CrewAI Integration**: Tailor CrewAI outputs with Mem0 ([Example](https://docs.mem0.ai/integrations/crewai))
## 📚 Documentation & Support
- Full docs: https://docs.mem0.ai
- Community: [Discord](https://mem0.dev/DiG) · [X (formerly Twitter)](https://x.com/mem0ai)
- Contact: founders@mem0.ai
## Citation
We now have a paper you can cite:
```bibtex
@article{mem0,
title={Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory},
author={Chhikara, Prateek and Khant, Dev and Aryan, Saket and Singh, Taranjeet and Yadav, Deshraj},
journal={arXiv preprint arXiv:2504.19413},
year={2025}
}
```
## ⚖️ License
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`mem0ai/mem0`
- 原始仓库:https://github.com/mem0ai/mem0
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+48
View File
@@ -0,0 +1,48 @@
# Security Policy
We take the security of Mem0 and our community seriously. Thank you for helping
keep Mem0 and its users safe by disclosing vulnerabilities responsibly.
## Reporting a Vulnerability
Please **do not** report security vulnerabilities through public GitHub issues,
pull requests, or discussions.
If you believe you have found a security vulnerability in Mem0, please report it
privately through one of the following channels:
1. **GitHub Private Vulnerability Reporting** — open a
[private security advisory](https://github.com/mem0ai/mem0/security/advisories/new)
directly on this repository.
2. **Email** the maintainers at **support@mem0.ai** with the subject line:
`SECURITY: Mem0 vulnerability report`
To help us triage and resolve the issue quickly, please include as much of the
following as you can:
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory)
- Affected version, tag, or commit
- Clear, step-by-step reproduction instructions
- The security impact and a proof of concept, if available
- Any suggested fix or mitigation
## Response Process
- We will acknowledge receipt of your report within **72 hours**.
- We will work with you privately to confirm the issue and assess its impact.
- Once a fix or mitigation is ready, we will coordinate a disclosure timeline
with you and credit you for the discovery, unless you prefer to remain anonymous.
## Public Disclosure
Please avoid sharing technical details of the vulnerability publicly until the
maintainers have reviewed the issue and a fix or mitigation has been released. We
are committed to resolving valid reports promptly and keeping you informed
throughout the process.
## Supported Versions
We release security fixes against the latest published version of each package.
Whenever possible, please reproduce the issue on the most recent release before
reporting.
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
# mem0 CLI
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
npm install -g @mem0/cli
```
```bash
pip install mem0-cli
```
Both packages install a `mem0` binary with identical behavior.
## Quick start
```bash
# Interactive setup wizard
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
# Search memories
mem0 search "What are Alice's preferences?" --user-id alice
# List all memories for a user
mem0 list --user-id alice
# Update a memory
mem0 update <memory-id> "I switched to light mode"
# Delete a memory
mem0 delete <memory-id>
```
## Commands
| Command | Description |
|---------|-------------|
| `mem0 init` | Setup wizard — login via email or configure API key manually |
| `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 |
| `mem0 get` | Retrieve a specific memory by ID |
| `mem0 update` | Update the text or metadata of a memory |
| `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 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`:
| Format | Description |
|--------|-------------|
| `text` | Human-readable with colors and formatting (default) |
| `json` | Structured JSON for piping to `jq` (raw API response) |
| `table` | Tabular format (default for `list`) |
| `quiet` | Minimal — just IDs or status codes |
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
## Environment variables
| Variable | Description |
|----------|-------------|
| `MEM0_API_KEY` | API key (overrides config file) |
| `MEM0_BASE_URL` | API base URL |
| `MEM0_USER_ID` | Default user ID |
| `MEM0_AGENT_ID` | Default agent ID |
| `MEM0_APP_ID` | Default app ID |
| `MEM0_RUN_ID` | Default run ID |
| `MEM0_ENABLE_GRAPH` | Enable graph memory (`true` / `false`) |
## Implementations
| Language | Directory | Package | Docs |
|----------|-----------|---------|------|
| TypeScript | [`node/`](./node/) | `@mem0/cli` | [README](./node/README.md) |
| Python | [`python/`](./python/) | `mem0-cli` | [README](./python/README.md) |
## Documentation
Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli).
## License
Apache-2.0
+551
View File
@@ -0,0 +1,551 @@
{
"specVersion": 1,
"cli": {
"name": "mem0",
"version": "0.1.0",
"description": "The Memory Layer for AI Agents",
"helpText": "\u25c6 mem0 CLI \u2014 The Memory Layer for AI Agents"
},
"branding": {
"logoMini": "\u25c6 mem0",
"tagline": "The Memory Layer for AI Agents",
"colors": {
"brand": "#8b5cf6",
"accent": "#a78bfa",
"success": "#22c55e",
"error": "#ef4444",
"warning": "#f59e0b",
"dim": "#6b7280"
},
"icons": {
"success": "\u2713",
"error": "\u2717",
"warning": "\u26a0",
"info": "\u25c6",
"pending": "\u29d7",
"add": "+",
"update": "~",
"delete": "-",
"noop": "\u00b7",
"connected": "\u25cf",
"disconnected": "\u25cf"
},
"logo": "███\u2557 ███\u2557███████\u2557███\u2557 ███\u2557 ██████\u2557 ██████\u2557██\u2557 ██\u2557\n████\u2557 ████\u2551██\u2554\u2550\u2550\u2550\u2550\u255d████\u2557 ████\u2551██\u2554\u2550████\u2557 ██\u2554\u2550\u2550\u2550\u2550\u255d██\u2551 ██\u2551\n██\u2554████\u2554██\u2551█████\u2557 ██\u2554████\u2554██\u2551██\u2551██\u2554██\u2551 ██\u2551 ██\u2551 ██\u2551\n██\u2551\u255a██\u2554\u255d██\u2551██\u2554\u2550\u2550\u255d ██\u2551\u255a██\u2554\u255d██\u2551████\u2554\u255d██\u2551 ██\u2551 ██\u2551 ██\u2551\n██\u2551 \u255a\u2550\u255d ██\u2551███████\u2557██\u2551 \u255a\u2550\u255d ██\u2551\u255a██████\u2554\u255d \u255a██████\u2557███████\u2557██\u2551\n\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d"
},
"config": {
"configDir": "~/.mem0",
"configFile": "config.json",
"version": 1,
"defaultBaseUrl": "https://api.mem0.ai",
"sections": {
"platform": {
"fields": {
"api_key": {
"type": "string",
"default": "",
"envVar": "MEM0_API_KEY",
"redact": true
},
"base_url": {
"type": "string",
"default": "https://api.mem0.ai",
"envVar": "MEM0_BASE_URL"
}
}
},
"defaults": {
"fields": {
"user_id": {
"type": "string",
"default": "",
"envVar": "MEM0_USER_ID"
},
"agent_id": {
"type": "string",
"default": "",
"envVar": "MEM0_AGENT_ID"
},
"app_id": {
"type": "string",
"default": "",
"envVar": "MEM0_APP_ID"
},
"run_id": {
"type": "string",
"default": "",
"envVar": "MEM0_RUN_ID"
},
"enable_graph": {
"type": "boolean",
"default": false,
"envVar": "MEM0_ENABLE_GRAPH"
}
}
}
}
},
"api": {
"endpoints": {
"add": { "method": "POST", "path": "/v1/memories/" },
"search": { "method": "POST", "path": "/v2/memories/search/" },
"get": { "method": "GET", "path": "/v1/memories/{memory_id}/" },
"list": { "method": "POST", "path": "/v2/memories/" },
"update": { "method": "PUT", "path": "/v1/memories/{memory_id}/" },
"delete": { "method": "DELETE", "path": "/v1/memories/{memory_id}/" },
"deleteAll": { "method": "DELETE", "path": "/v1/memories/" },
"entities": { "method": "GET", "path": "/v1/entities/" },
"deleteEntities": { "method": "DELETE", "path": "/v1/entities/" }
},
"authHeader": "Authorization",
"authScheme": "Token",
"timeout": 30,
"entityTypeMap": {
"users": "user",
"agents": "agent",
"apps": "app",
"runs": "run"
}
},
"errors": {
"AuthError": {
"httpStatus": 401,
"message": "Authentication failed. Your API key may be invalid or expired."
},
"NotFoundError": {
"httpStatus": 404,
"messageTemplate": "Resource not found: {path}"
},
"APIError": {
"httpStatus": 400,
"messageTemplate": "Bad request to {path}: {detail}"
},
"noApiKey": {
"message": "No API key configured.",
"hint": "Run 'mem0 init' or set MEM0_API_KEY environment variable."
}
},
"optionGroups": {
"scope": {
"label": "Scope",
"options": ["user_id", "agent_id", "app_id", "run_id"]
},
"search": {
"label": "Search",
"options": ["top_k", "threshold", "rerank", "keyword", "filter_json", "fields", "graph", "no_graph"]
},
"pagination": {
"label": "Pagination",
"options": ["page", "page_size"]
},
"filters": {
"label": "Filters",
"options": ["category", "after", "before", "graph", "no_graph"]
},
"output": {
"label": "Output",
"options": ["output"]
},
"connection": {
"label": "Connection",
"options": ["api_key", "base_url"]
}
},
"globalOptions": [
{
"name": "api_key",
"flags": ["--api-key"],
"type": "string",
"required": false,
"envVar": "MEM0_API_KEY",
"help": "Override API key.",
"panel": "Connection"
},
{
"name": "base_url",
"flags": ["--base-url"],
"type": "string",
"required": false,
"help": "Override API base URL.",
"panel": "Connection"
},
{
"name": "version",
"flags": ["--version"],
"type": "boolean",
"required": false,
"help": "Show version and exit."
}
],
"commands": [
{
"name": "add",
"description": "Add a memory from text, messages, file, or stdin.",
"usage": "mem0 add <text> [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": true,
"resolveGraph": true,
"confirmDangerous": false,
"outputFormats": ["text", "json", "quiet"],
"defaultOutput": "text",
"arguments": [
{
"name": "text",
"type": "string",
"required": false,
"help": "Text content to add as a memory."
}
],
"options": [
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Scope to agent.", "panel": "Scope" },
{ "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Scope to app.", "panel": "Scope" },
{ "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Scope to run.", "panel": "Scope" },
{ "name": "messages", "flags": ["--messages"], "type": "string", "help": "Conversation messages as JSON." },
{ "name": "file", "flags": ["--file", "-f"], "type": "path", "help": "Read messages from JSON file." },
{ "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Custom metadata as JSON." },
{ "name": "immutable", "flags": ["--immutable"], "type": "boolean", "default": false, "help": "Prevent future updates." },
{ "name": "no_infer", "flags": ["--no-infer"], "type": "boolean", "default": false, "help": "Skip inference, store raw." },
{ "name": "expires", "flags": ["--expires"], "type": "string", "help": "Expiration date (YYYY-MM-DD)." },
{ "name": "categories", "flags": ["--categories"], "type": "string", "help": "Categories (JSON array or comma-separated)." },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph memory extraction.", "panel": "Scope" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph memory extraction.", "panel": "Scope" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output format: text, json, quiet.", "panel": "Output" }
],
"apiEndpoint": "add"
},
{
"name": "search",
"description": "Search memories by semantic query.",
"usage": "mem0 search <query> [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": true,
"resolveGraph": true,
"confirmDangerous": false,
"outputFormats": ["text", "json", "table"],
"defaultOutput": "text",
"arguments": [
{
"name": "query",
"type": "string",
"required": true,
"help": "Search query."
}
],
"options": [
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Filter by user.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Filter by agent.", "panel": "Scope" },
{ "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Filter by app.", "panel": "Scope" },
{ "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Filter by run.", "panel": "Scope" },
{ "name": "top_k", "flags": ["--top-k", "-k", "--limit"], "type": "integer", "default": 10, "help": "Number of results.", "panel": "Search" },
{ "name": "threshold", "flags": ["--threshold"], "type": "float", "default": 0.3, "help": "Minimum similarity score.", "panel": "Search" },
{ "name": "rerank", "flags": ["--rerank"], "type": "boolean", "default": false, "help": "Enable reranking (Platform only).", "panel": "Search" },
{ "name": "keyword", "flags": ["--keyword"], "type": "boolean", "default": false, "help": "Use keyword search.", "panel": "Search" },
{ "name": "filter_json", "flags": ["--filter"], "type": "string", "help": "Advanced filter expression (JSON).", "panel": "Search" },
{ "name": "fields", "flags": ["--fields"], "type": "string", "help": "Specific fields to return (comma-separated).", "panel": "Search" },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in search.", "panel": "Search" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in search.", "panel": "Search" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, table.", "panel": "Output" }
],
"apiEndpoint": "search"
},
{
"name": "get",
"description": "Get a specific memory by ID.",
"usage": "mem0 get <memory_id> [OPTIONS]",
"needsBackend": true,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"outputFormats": ["text", "json"],
"defaultOutput": "text",
"arguments": [
{
"name": "memory_id",
"type": "string",
"required": true,
"help": "Memory ID to retrieve."
}
],
"options": [
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json.", "panel": "Output" }
],
"apiEndpoint": "get"
},
{
"name": "list",
"description": "List memories with optional filters.",
"usage": "mem0 list [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": true,
"resolveGraph": true,
"confirmDangerous": false,
"outputFormats": ["text", "json", "table"],
"defaultOutput": "table",
"arguments": [],
"options": [
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Filter by user.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Filter by agent.", "panel": "Scope" },
{ "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Filter by app.", "panel": "Scope" },
{ "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Filter by run.", "panel": "Scope" },
{ "name": "page", "flags": ["--page"], "type": "integer", "default": 1, "help": "Page number.", "panel": "Pagination" },
{ "name": "page_size", "flags": ["--page-size"], "type": "integer", "default": 100, "help": "Results per page.", "panel": "Pagination" },
{ "name": "category", "flags": ["--category"], "type": "string", "help": "Filter by category.", "panel": "Filters" },
{ "name": "after", "flags": ["--after"], "type": "string", "help": "Created after (YYYY-MM-DD).", "panel": "Filters" },
{ "name": "before", "flags": ["--before"], "type": "string", "help": "Created before (YYYY-MM-DD).", "panel": "Filters" },
{ "name": "graph", "flags": ["--graph"], "type": "boolean", "default": false, "help": "Enable graph in listing.", "panel": "Filters" },
{ "name": "no_graph", "flags": ["--no-graph"], "type": "boolean", "default": false, "help": "Disable graph in listing.", "panel": "Filters" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: text, json, table.", "panel": "Output" }
],
"apiEndpoint": "list"
},
{
"name": "update",
"description": "Update a memory's text or metadata.",
"usage": "mem0 update <memory_id> [text] [OPTIONS]",
"needsBackend": true,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"outputFormats": ["text", "json", "quiet"],
"defaultOutput": "text",
"arguments": [
{
"name": "memory_id",
"type": "string",
"required": true,
"help": "Memory ID to update."
},
{
"name": "text",
"type": "string",
"required": false,
"help": "New memory text."
}
],
"options": [
{ "name": "metadata", "flags": ["--metadata", "-m"], "type": "string", "help": "Update metadata (JSON)." },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" }
],
"apiEndpoint": "update"
},
{
"name": "delete",
"description": "Delete a memory, all memories matching a scope, or an entity.",
"usage": "mem0 delete [memory_id] [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": true,
"resolveGraph": false,
"confirmDangerous": true,
"outputFormats": ["text", "json", "quiet"],
"defaultOutput": "text",
"arguments": [
{
"name": "memory_id",
"type": "string",
"required": false,
"help": "Memory ID to delete (omit when using --all or --entity)."
}
],
"options": [
{ "name": "all", "flags": ["--all"], "type": "boolean", "default": false, "help": "Delete all memories matching scope filters." },
{ "name": "entity", "flags": ["--entity"], "type": "boolean", "default": false, "help": "Delete the entity itself and all its memories (cascade)." },
{ "name": "project", "flags": ["--project"], "type": "boolean", "default": false, "help": "With --all: delete ALL memories project-wide." },
{ "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." },
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." },
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Scope to user.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Scope to agent.", "panel": "Scope" },
{ "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "Scope to app.", "panel": "Scope" },
{ "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Scope to run.", "panel": "Scope" },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" }
],
"apiEndpoint": "delete",
"notes": "Mutually exclusive modes: (1) mem0 delete <id> -- single memory, (2) mem0 delete --all [scope] -- bulk delete, (3) mem0 delete --entity [scope] -- entity cascade delete. Cannot combine <memoryId> with --all or --entity, and cannot combine --all with --entity."
},
{
"name": "import",
"description": "Import memories from a JSON file.",
"usage": "mem0 import <file_path> [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": true,
"resolveGraph": false,
"confirmDangerous": false,
"outputFormats": ["text"],
"defaultOutput": "text",
"arguments": [
{
"name": "file_path",
"type": "string",
"required": true,
"help": "JSON file to import."
}
],
"options": [
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "Override user ID.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Override agent ID.", "panel": "Scope" }
],
"apiEndpoint": "add"
},
{
"name": "config",
"description": "Manage mem0 configuration.",
"isGroup": true,
"subcommands": [
{
"name": "show",
"description": "Display current configuration (secrets redacted).",
"usage": "mem0 config show",
"needsBackend": false,
"needsConfig": false,
"arguments": [],
"options": []
},
{
"name": "get",
"description": "Get a configuration value.",
"usage": "mem0 config get <key>",
"needsBackend": false,
"needsConfig": false,
"arguments": [
{
"name": "key",
"type": "string",
"required": true,
"help": "Config key (e.g. platform.api_key)."
}
],
"options": []
},
{
"name": "set",
"description": "Set a configuration value.",
"usage": "mem0 config set <key> <value>",
"needsBackend": false,
"needsConfig": false,
"arguments": [
{
"name": "key",
"type": "string",
"required": true,
"help": "Config key (e.g. platform.api_key)."
},
{
"name": "value",
"type": "string",
"required": true,
"help": "Value to set."
}
],
"options": []
}
]
},
{
"name": "entity",
"description": "Manage entities.",
"isGroup": true,
"subcommands": [
{
"name": "list",
"description": "List all entities of a given type.",
"usage": "mem0 entity list <entity_type>",
"needsBackend": true,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"outputFormats": ["table", "json"],
"defaultOutput": "table",
"arguments": [
{
"name": "entity_type",
"type": "string",
"required": true,
"help": "Entity type: users, agents, apps, runs.",
"choices": ["users", "agents", "apps", "runs"]
}
],
"options": [
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "table", "help": "Output: table, json.", "panel": "Output" }
],
"apiEndpoint": "entities"
},
{
"name": "delete",
"description": "Delete an entity and ALL its memories (cascade).",
"usage": "mem0 entity delete [OPTIONS]",
"needsBackend": true,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": true,
"outputFormats": ["text", "json", "quiet"],
"defaultOutput": "text",
"arguments": [],
"options": [
{ "name": "user_id", "flags": ["--user-id", "-u"], "type": "string", "help": "User ID.", "panel": "Scope" },
{ "name": "agent_id", "flags": ["--agent-id"], "type": "string", "help": "Agent ID.", "panel": "Scope" },
{ "name": "app_id", "flags": ["--app-id"], "type": "string", "help": "App ID.", "panel": "Scope" },
{ "name": "run_id", "flags": ["--run-id"], "type": "string", "help": "Run ID.", "panel": "Scope" },
{ "name": "dry_run", "flags": ["--dry-run"], "type": "boolean", "default": false, "help": "Show what would be deleted without deleting." },
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Skip confirmation." },
{ "name": "output", "flags": ["--output", "-o"], "type": "string", "default": "text", "help": "Output: text, json, quiet.", "panel": "Output" }
],
"apiEndpoint": "deleteEntities"
}
]
},
{
"name": "init",
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
"usage": "mem0 init [OPTIONS]",
"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." },
{ "name": "agent", "flags": ["--agent"], "type": "boolean", "default": false, "help": "Bootstrap an unattended Agent Mode account (no email required)." },
{ "name": "source", "flags": ["--source"], "type": "string", "default": null, "help": "Channel attribution for signup (e.g. github, hn, ph)." }
]
},
{
"name": "status",
"description": "Check connectivity and authentication.",
"usage": "mem0 status [OPTIONS]",
"needsBackend": true,
"needsConfig": true,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"arguments": [],
"options": []
},
{
"name": "help",
"description": "Show help. Use --json for machine-readable output (for LLM agents).",
"usage": "mem0 help [OPTIONS]",
"needsBackend": false,
"needsConfig": false,
"resolveIds": false,
"resolveGraph": false,
"confirmDangerous": false,
"arguments": [],
"options": [
{ "name": "json", "flags": ["--json"], "type": "boolean", "default": false, "help": "Output machine-readable JSON for LLM agents." }
]
}
]
}
+337
View File
@@ -0,0 +1,337 @@
# mem0 CLI (Node.js)
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. TypeScript implementation.
> **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.
## Prerequisites
- Node.js **18+**
- pnpm (`npm install -g pnpm`) — for development only
## Installation
```bash
npm install -g @mem0/cli
```
## Quick start
```bash
# Interactive setup wizard
mem0 init
# Or login via email
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
# Search memories
mem0 search "What are Alice's preferences?" --user-id alice
# List all memories for a user
mem0 list --user-id alice
# Get a specific memory
mem0 get <memory-id>
# Update a memory
mem0 update <memory-id> "I switched to light mode"
# Delete a memory
mem0 delete <memory-id>
```
## Commands
### `mem0 init`
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 asks for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD).
```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.
```bash
mem0 add "I prefer dark mode" --user-id alice
mem0 add --file conversation.json --user-id alice
echo "Loves hiking on weekends" | mem0 add --user-id alice
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Scope to a user |
| `--agent-id` | Scope to an agent |
| `--messages` | Conversation messages as JSON |
| `-f, --file` | Read messages from a JSON file |
| `-m, --metadata` | Custom metadata as JSON |
| `--categories` | Categories (JSON array or comma-separated) |
| `--graph / --no-graph` | Enable or disable graph memory extraction |
| `-o, --output` | Output format: `text`, `json`, `quiet` |
### `mem0 search`
Search memories using natural language.
```bash
mem0 search "dietary restrictions" --user-id alice
mem0 search "preferred tools" --user-id alice --output json --top-k 5
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `-k, --top-k` | Number of results (default: 10) |
| `--threshold` | Minimum similarity score (default: 0.3) |
| `--rerank` | Enable reranking |
| `--keyword` | Use keyword search instead of semantic |
| `--filter` | Advanced filter expression (JSON) |
| `--graph / --no-graph` | Enable or disable graph in search |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 list`
List memories with optional filters and pagination.
```bash
mem0 list --user-id alice
mem0 list --user-id alice --category preferences --output json
mem0 list --user-id alice --after 2024-01-01 --page-size 50
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `--page` | Page number (default: 1) |
| `--page-size` | Results per page (default: 100) |
| `--category` | Filter by category |
| `--after` | Created after date (YYYY-MM-DD) |
| `--before` | Created before date (YYYY-MM-DD) |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 get`
Retrieve a specific memory by ID.
```bash
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 --output json
```
### `mem0 update`
Update the text or metadata of an existing memory.
```bash
mem0 update <memory-id> "Updated preference text"
mem0 update <memory-id> --metadata '{"priority": "high"}'
echo "new text" | mem0 update <memory-id>
```
### `mem0 delete`
Delete a single memory, all memories for a scope, or an entire entity.
```bash
# Delete a single memory
mem0 delete <memory-id>
# Delete all memories for a user
mem0 delete --all --user-id alice --force
# Delete all memories project-wide
mem0 delete --all --project --force
# Preview what would be deleted
mem0 delete --all --user-id alice --dry-run
```
| Flag | Description |
|------|-------------|
| `--all` | Delete all memories matching scope filters |
| `--entity` | Delete the entity and all its memories |
| `--project` | With `--all`: delete all memories project-wide |
| `--dry-run` | Preview without deleting |
| `--force` | Skip confirmation prompt |
### `mem0 import`
Bulk import memories from a JSON file.
```bash
mem0 import data.json --user-id alice
```
The file should be a JSON array where each item has a `memory` (or `text` or `content`) field and optional `user_id`, `agent_id`, and `metadata` fields.
### `mem0 config`
View or modify the local CLI configuration.
```bash
mem0 config show # Display current config (secrets redacted)
mem0 config get api_key # Get a specific value
mem0 config set user_id bob # Set a value
```
### `mem0 entity`
List or delete entities (users, agents, apps, runs).
```bash
mem0 entity list users
mem0 entity list agents --output json
mem0 entity delete --user-id alice --force
```
### `mem0 event`
Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs).
```bash
# List recent events
mem0 event list
# Check the status of a specific event
mem0 event status <event-id>
```
| Flag | Description |
|------|-------------|
| `-o, --output` | Output format: `text`, `json` |
### `mem0 status`
Verify your API connection and display the current project.
```bash
mem0 status
```
### `mem0 version`
Print the CLI version.
```bash
mem0 version
```
## 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
mem0 --agent delete --all --user-id alice --force
```
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`:
| Format | Description |
|--------|-------------|
| `text` | Human-readable with colors and formatting (default) |
| `json` | Structured JSON for piping to `jq` (raw API response) |
| `table` | Tabular format (default for `list`) |
| `quiet` | Minimal — just IDs or status codes |
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
## Global flags
These flags are available on all commands:
| Flag | Description |
|------|-------------|
| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners |
| `--agent` | Alias for `--json` |
| `--api-key` | Override the configured API key for this request |
| `--base-url` | Override the configured API base URL for this request |
| `-o, --output` | Set the output format |
## Environment variables
| Variable | Description |
|----------|-------------|
| `MEM0_API_KEY` | API key (overrides config file) |
| `MEM0_BASE_URL` | API base URL |
| `MEM0_USER_ID` | Default user ID |
| `MEM0_AGENT_ID` | Default agent ID |
| `MEM0_APP_ID` | Default app ID |
| `MEM0_RUN_ID` | Default run ID |
| `MEM0_ENABLE_GRAPH` | Enable graph memory (`true` / `false`) |
Environment variables take precedence over values in the config file, which take precedence over defaults.
## Development
```bash
cd cli/node
pnpm install
# Development mode (runs TypeScript directly, no build needed)
pnpm dev --help
pnpm dev add "test memory" --user-id alice
pnpm dev search "test" --user-id alice
# Or build first, then run the compiled JS
pnpm build
node dist/index.js --help
```
## Documentation
Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli).
## License
Apache-2.0
+91
View File
@@ -0,0 +1,91 @@
# Development
## Prerequisites
- Node.js **18+**
- pnpm (`npm install -g pnpm`)
## Setup
From the `node/` directory:
```bash
pnpm install
```
## Running the CLI
There are two ways to run the CLI during development:
### Option 1: Development mode (no build needed)
Uses `tsx` to run TypeScript directly. Pass CLI arguments after `pnpm dev`:
```bash
pnpm dev --help
pnpm dev version
pnpm dev add "test memory" --user-id alice
pnpm dev search "test" --user-id alice
pnpm dev config show
```
> **Note:** Do NOT use `pnpm dev -- --help`. With pnpm, arguments pass through directly — adding `--` inserts a literal `--` that breaks the CLI parser.
### Option 2: Build and run compiled JS
```bash
# Build first
pnpm build
# Run the compiled CLI
node dist/index.js --help
node dist/index.js version
node dist/index.js add "test memory" --user-id alice
```
### Option 3: Link globally (makes `mem0` available system-wide)
```bash
pnpm build
pnpm link --global
# Now use it like a normal CLI
mem0 --help
mem0 version
```
> **Warning:** If you also have the Python CLI installed, both register the `mem0` command. The last one linked/installed wins. Unlink with `pnpm unlink --global`.
## Build
```bash
pnpm build
```
The compiled output is in `dist/`.
## Run tests
```bash
# Run all tests
pnpm test
# Watch mode
pnpm test:watch
```
## Lint
```bash
# Check
pnpm lint
# Auto-fix
pnpm lint:fix
```
## Type checking
```bash
pnpm typecheck
```
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@mem0/cli",
"version": "0.2.10",
"description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module",
"bin": {
"mem0": "./dist/index.js"
},
"scripts": {
"build": "tsup",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
"typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=18.0.0"
},
"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"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"cli-table3": "^0.6.4",
"ora": "^8.0.0",
"boxen": "^7.1.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"tsup": "^8.0.0",
"tsx": "^4.7.0",
"vite": "^6.0.0",
"vitest": "^4.1.0",
"@biomejs/biome": "^1.7.0",
"@types/node": "^20.0.0"
},
"pnpm": {
"overrides": {
"jws@4.0.0": "4.0.1",
"langsmith@<0.6.0": "^0.6.0",
"tar-fs@>=2.0.0 <2.1.4": "^2.1.4",
"picomatch@<2.3.2": "^2.3.2",
"postcss@<8.5.10": ">=8.5.10",
"esbuild": ">=0.28.1"
}
}
}
+1653
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
packages:
- '.'
onlyBuiltDependencies:
- "@biomejs/biome"
- esbuild
overrides:
jws@4.0.0: 4.0.1
langsmith@<0.6.0: ^0.6.0
tar-fs@>=2.0.0 <2.1.4: ^2.1.4
picomatch@<2.3.2: ^2.3.2
"postcss@<8.5.10": ">=8.5.10"
"esbuild": ">=0.28.1"
+32
View File
@@ -0,0 +1,32 @@
/**
* Detect whether the CLI is being invoked from inside an AI-agent context.
*
* Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
* agent runtime env var is present. The return value is a context **trigger
* only** — the canonical agent identity is self-declared by the agent via
* `--agent-caller <name>` (Proof Editor-style) and never sniffed from env
* vars to fill the `agent_caller` field on the APIKey row.
*
* Returns a short name or null. Honest reporting depends on `--agent-caller`;
* this list is just enough to enable the zero-friction auto-bootstrap UX.
*/
const AGENT_CALLER_ENV: ReadonlyArray<readonly [string, readonly string[]]> = [
["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]],
["cursor", ["CURSOR_AGENT", "CURSOR_SESSION_ID"]],
["codex", ["CODEX_CLI", "OPENAI_CODEX"]],
["cline", ["CLINE_AGENT", "CLINE"]],
["continue", ["CONTINUE_AGENT", "CONTINUE_SESSION"]],
["aider", ["AIDER_SESSION"]],
["goose", ["GOOSE_AGENT"]],
["windsurf", ["WINDSURF_AGENT"]],
] as const;
export function detectAgentCaller(): string | null {
for (const [name, envVars] of AGENT_CALLER_ENV) {
if (envVars.some((v) => process.env[v])) {
return name;
}
}
return null;
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Abstract backend interface and factory.
*/
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[];
}
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[];
}
export interface ListOptions {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
page?: number;
pageSize?: number;
category?: string;
after?: string;
before?: string;
}
export interface DeleteOptions {
all?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface EntityIds {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
}
export interface Backend {
add(
content?: string,
messages?: Record<string, unknown>[],
opts?: AddOptions,
): Promise<Record<string, unknown>>;
search(
query: string,
opts?: SearchOptions,
): Promise<Record<string, unknown>[]>;
get(memoryId: string): Promise<Record<string, unknown>>;
listMemories(opts?: ListOptions): 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>>;
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
ping(): 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>>;
}
export class AuthError extends Error {
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";
}
}
export class APIError extends Error {
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);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Backend factory re-export.
*/
export { getBackend } from "./base.js";
export type {
Backend,
AddOptions,
SearchOptions,
ListOptions,
DeleteOptions,
EntityIds,
} from "./base.js";
export { AuthError, NotFoundError, APIError } from "./base.js";
+410
View File
@@ -0,0 +1,410 @@
/**
* Platform (SaaS) backend — communicates with api.mem0.ai.
*/
import type { PlatformConfig } from "../config.js";
import { captureNotice, isAgentMode } from "../state.js";
import { CLI_VERSION } from "../version.js";
import {
APIError,
type AddOptions,
AuthError,
type Backend,
type DeleteOptions,
type EntityIds,
type ListOptions,
NotFoundError,
type SearchOptions,
} from "./base.js";
function encodePathSegment(value: unknown): string {
return encodeURIComponent(String(value));
}
export class PlatformBackend implements Backend {
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",
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
"X-Mem0-Client-Version": CLI_VERSION,
};
}
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 headers = {
...this.headers,
"X-Mem0-Caller-Type": isAgentMode() ? "agent" : "user",
};
const fetchOpts: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30_000),
};
if (opts?.json) {
fetchOpts.body = JSON.stringify(opts.json);
}
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 {};
}
const data = await resp.json();
// Pull the unclaimed-Agent-Mode notice out of the body (or the header
// fallback for endpoints returning non-dict / non-dict-leading payloads)
// and stash for end-of-command surfacing.
let notice: string | null = null;
if (
data &&
typeof data === "object" &&
!Array.isArray(data) &&
"mem0_notice" in data
) {
notice = (data as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: intentional strip so downstream consumers don't see duplicate notice
delete (data as Record<string, unknown>).mem0_notice;
} else if (
Array.isArray(data) &&
data.length > 0 &&
typeof data[0] === "object" &&
data[0] !== null &&
"mem0_notice" in data[0]
) {
notice = (data[0] as Record<string, unknown>).mem0_notice as string;
// biome-ignore lint/performance/noDelete: see above.
delete (data[0] as Record<string, unknown>).mem0_notice;
}
if (!notice) {
notice = resp.headers.get("X-Mem0-Notice-Message") ?? null;
}
captureNotice(notice);
return data;
}
async add(
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 (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;
payload.source = "CLI";
return (await this._request("POST", "/v3/memories/add/", {
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;
}
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 (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,
};
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;
payload.source = "CLI";
const result = (await this._request("POST", "/v3/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/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
)) 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),
};
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;
payload.source = "CLI";
const result = (await this._request("POST", "/v3/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;
payload.source = "CLI";
return (await this._request(
"PUT",
`/v1/memories/${encodePathSegment(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> = { source: "CLI" };
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/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
)) 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. Key each
// response by entity type so a multi-entity delete (e.g. --user-id and
// --agent-id together) doesn't discard everything but the last result.
const results: Record<string, unknown> = {};
for (const [entityType, entityId] of entities) {
results[entityType] = (await this._request(
"DELETE",
`/v2/entities/${encodePathSegment(entityType)}/${encodePathSegment(entityId)}/`,
{ params: { source: "CLI" } },
)) as Record<string, unknown>;
}
return results;
}
async ping(): Promise<Record<string, unknown>> {
return (await this._request("GET", "/v1/ping/")) as Record<string, unknown>;
}
async status(
opts: { userId?: string; agentId?: string } = {},
): Promise<Record<string, unknown>> {
try {
await this.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 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/${encodePathSegment(eventId)}/`,
)) as Record<string, unknown>;
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Branding and ASCII art for mem0 CLI.
*/
import chalk from "chalk";
import ora, { type Ora } from "ora";
import { getCurrentCommand, isAgentMode } from "./state.js";
import { CLI_VERSION } from "./version.js";
export const LOGO = `
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
████╗ ████║██╔════╝████╗ ████║██╔═████╗ ██╔════╝██║ ██║
██╔████╔██║█████╗ ██╔████╔██║██║██╔██║ ██║ ██║ ██║
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║████╔╝██║ ██║ ██║ ██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ╚██████╗███████╗██║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
`;
export const LOGO_MINI = "◆ mem0";
export const TAGLINE = "The Memory Layer for AI Agents";
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";
export const DIM_COLOR = "#6b7280";
const brand = chalk.hex(BRAND_COLOR);
const accent = chalk.hex(ACCENT_COLOR);
const success = chalk.hex(SUCCESS_COLOR);
const error = chalk.hex(ERROR_COLOR);
const warning = chalk.hex(WARNING_COLOR);
const dim = chalk.hex(DIM_COLOR);
/**
* Choose a symbol based on TTY/NO_COLOR. Fancy for interactive terminals,
* 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;
}
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, ""];
// 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 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);
}
export function printSuccess(message: string): void {
if (isAgentMode()) return;
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
}
export function printError(message: string, hint?: string): void {
if (isAgentMode()) {
const envelope = {
status: "error",
command: getCurrentCommand(),
error: message,
data: null,
};
console.log(JSON.stringify(envelope));
return;
}
console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`);
const resolvedHint =
hint ??
(message.includes("Authentication failed")
? `Run ${brand("mem0 init")} to reconfigure your API key · https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-node`
: undefined);
if (resolvedHint) {
console.error(` ${dim(resolvedHint)}`);
}
}
export function printWarning(message: string): void {
console.error(`${warning(sym("⚠", "[warn]"))} ${message}`);
}
export function printInfo(message: string): void {
if (isAgentMode()) return;
console.error(`${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(", ")}`)}`);
}
}
export interface TimedStatusContext {
successMsg: string;
errorMsg: string;
}
/**
* Run an async function with a spinner, timing the operation.
* Equivalent to Python's timed_status context manager.
*/
export async function timedStatus<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();
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;
}
}
/** Format helpers using brand colors for external use. */
export const colors = { brand, accent, success, error, warning, dim };
+285
View File
@@ -0,0 +1,285 @@
/**
* Agent Mode commands — bootstrap (unattended signup) and OTP-based claim.
*/
import readline from "node:readline";
import { colors, printError, printInfo, printSuccess } from "../branding.js";
import { type Mem0Config, saveConfig } from "../config.js";
const { brand, dim } = colors;
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export interface BootstrapEnvelope {
api_key: string;
default_user_id: string;
org_id: string;
project_id: string;
mcp_url?: string;
smoke_test_url?: string;
claim_command?: string;
mem0_notice?: string;
}
function isValidEnvelope(v: unknown): v is BootstrapEnvelope {
return (
!!v &&
typeof v === "object" &&
typeof (v as BootstrapEnvelope).api_key === "string" &&
(v as BootstrapEnvelope).api_key.length > 0 &&
typeof (v as BootstrapEnvelope).default_user_id === "string" &&
(v as BootstrapEnvelope).default_user_id.length > 0
);
}
/**
* POST /api/v1/auth/agent_mode/ and mutate config in place.
*
* @param config - Mem0Config mutated in place with the new platform values.
* @param source - `--source` flag passthrough (analytics tag, free-form).
* @param agentCaller - Self-declared agent identity passed via `--agent-caller`
* (e.g. `claude-code`, `cursor`). May be null when the caller omitted the
* flag; the agent can backfill later via `mem0 identify <name>`. Sent to the
* backend in the request body and saved into `platform.agentCaller` for
* local introspection.
*/
export async function bootstrapViaBackend(
config: Mem0Config,
{
source,
agentCaller,
}: { source?: string | null; agentCaller?: string | null } = {},
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
const body: Record<string, unknown> = {};
if (source) body.source = source;
if (agentCaller) body.agent_caller = agentCaller;
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/`, {
method: "POST",
headers: {
...SOURCE_HEADERS,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error contacting Mem0: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (resp.status === 429) {
printError("Rate-limited. Try again in a few minutes.");
process.exit(1);
}
if (resp.status === 503) {
printError("Agent Mode is temporarily disabled. Try again later.");
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const errBody = (await resp.json()) as {
error?: string;
detail?: string;
};
detail = errBody.error ?? errBody.detail ?? resp.statusText;
} catch {
/* leave detail as statusText */
}
// Backend's @ratelimit decorator raises PermissionDenied, which DRF
// translates to a generic 403 "You do not have permission to perform
// this action." That's opaque — surface it as the rate-limit message
// it actually is.
if (resp.status === 403 && /permission/i.test(detail)) {
printError(
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
);
process.exit(1);
}
printError(`Bootstrap failed: ${detail}`);
process.exit(1);
}
const envelope = (await resp.json()) as BootstrapEnvelope;
if (!isValidEnvelope(envelope)) {
// Defend against partial/malformed backend responses (e.g. {api_key: null}).
// Without this guard, the typed `string` field is silently set to
// undefined/null and persisted, producing confusing downstream errors.
printError(
"Bootstrap response missing required fields — please update the CLI.",
);
process.exit(1);
}
config.platform.apiKey = envelope.api_key;
config.platform.baseUrl = baseUrl;
config.platform.agentMode = true;
config.platform.createdVia = "agent_mode";
config.platform.agentCaller = agentCaller ?? "";
config.platform.claimedAt = "";
config.platform.defaultUserId = envelope.default_user_id;
// Adopt the slug-derived user_id as the default scope for memory ops.
config.defaults.userId = envelope.default_user_id;
saveConfig(config);
printSuccess(
`Agent Mode active. Default user_id: ${envelope.default_user_id}`,
);
if (envelope.mem0_notice) {
console.log(`\n\x1b[33m🔔 ${envelope.mem0_notice}\x1b[0m\n`);
} else {
// Fallback for older backends without the unified notice field.
const claimCmd = envelope.claim_command ?? "mem0 init --email <your-email>";
console.log(` ${dim(`To claim this account later: ${claimCmd}`)}`);
}
if (!agentCaller) {
console.log(
` ${dim("If you are an AI agent, identify yourself: `mem0 identify <your-name>` (e.g. claude-code, cursor).")}`,
);
}
}
/**
* Claim an existing Agent Mode account via OTP — no browser, no polling.
*
* Hits /api/v1/auth/email_code/ to send a verification code, prompts for it
* interactively (or accepts via `code`), then sends it to /verify/ alongside
* `agent_mode_api_key`. Backend's verify_email_code runs upgrade-in-place
* inline and returns the claim result.
*/
export async function claimViaOtp(
config: Mem0Config,
{ email, code }: { email: string; code?: string },
): Promise<void> {
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
if (!config.platform.apiKey || !config.platform.agentMode) {
printError(
"This command requires an active Agent Mode config. Run `mem0 init` first.",
);
process.exit(1);
}
const rawKey = config.platform.apiKey;
// Step 1: request OTP (unless --code was supplied)
if (!code) {
const sendResp = await fetch(`${baseUrl}/api/v1/auth/email_code/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(30_000),
});
if (sendResp.status === 429) {
printError("Too many attempts. Try again in a few minutes.");
process.exit(1);
}
if (!sendResp.ok) {
let detail: string = sendResp.statusText;
try {
const errBody = (await sendResp.json()) as { error?: string };
if (errBody.error) detail = errBody.error;
} catch {
/* leave as statusText */
}
printError(`Failed to send code: ${detail}`);
process.exit(1);
}
printSuccess(`Verification code sent to ${email}. Check your inbox.`);
if (!process.stdin.isTTY) {
printError(
"No --code provided and terminal is non-interactive.",
`Re-run: mem0 init --email ${email} --code <code>`,
);
process.exit(1);
}
console.log();
code = await promptLine(` ${brand("Verification Code")}`);
if (!code) {
printError("Code is required.");
process.exit(1);
}
}
// Step 2: verify + claim atomically
const verifyResp = await fetch(`${baseUrl}/api/v1/auth/email_code/verify/`, {
method: "POST",
headers: { ...SOURCE_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
email,
code: code.trim(),
agent_mode_api_key: rawKey,
}),
signal: AbortSignal.timeout(30_000),
});
if (!verifyResp.ok) {
let detail: string = verifyResp.statusText;
let errCode = "";
try {
const errBody = (await verifyResp.json()) as {
error?: string;
code?: string;
};
if (errBody.error) detail = errBody.error;
if (errBody.code) errCode = errBody.code;
} catch {
/* leave as statusText */
}
printError(`Claim failed: ${detail}`);
if (errCode === "email_already_claimed") {
console.log(
` ${dim("Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.")}`,
);
}
process.exit(1);
}
const claimBody = (await verifyResp.json()) as {
claimed?: boolean;
claimed_at?: string;
};
if (!claimBody.claimed) {
printError(`Unexpected verify response: ${JSON.stringify(claimBody)}`);
process.exit(1);
}
config.platform.agentMode = false;
config.platform.claimedAt = claimBody.claimed_at ?? new Date().toISOString();
config.platform.userEmail = email;
config.platform.createdVia = "email";
saveConfig(config);
printSuccess(`Agent claimed to ${email}. Your API key is unchanged.`);
}
function promptLine(label: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${label}: `, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
+147
View File
@@ -0,0 +1,147 @@
/**
* `mem0 agent-rush <add|search> "..."` — wraps the AGENTRUSH platform endpoints.
* Project routing is implicit (server-side); zero flags needed.
*/
import readline from "node:readline";
import { colors, printError, printSuccess } from "../branding.js";
import { loadConfig, saveConfig } from "../config.js";
import { CLI_VERSION } from "../version.js";
const PII_WARNING = [
"",
"⚠️ AGENTRUSH memories are PUBLIC — visible to any other player.",
" Do not include real names, emails, secrets, work content, or PII.",
"",
].join("\n");
const ERROR_HINTS: Record<string, string> = {
agentrush_search_first:
"Run 3 'mem0 agent-rush search' commands before adding.",
agentrush_search_quota: "You've used your 3 lifetime searches.",
agentrush_add_quota: "You've used your 3 lifetime adds.",
agentrush_not_agent_mode:
"Re-run 'mem0 init --agent' to bootstrap an agent-mode key.",
agentrush_length: "Memory text must be 50-1000 characters.",
agentrush_no_urls: "URLs are not allowed.",
agentrush_blocklist: "Content contains a blocked term.",
agentrush_global_quota: "Event-wide cap reached. Try again later.",
agentrush_not_provisioned:
"AGENTRUSH is not provisioned in this environment.",
};
async function callEndpoint(
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
const config = loadConfig();
const baseUrl = (config.platform?.baseUrl ?? "https://api.mem0.ai").replace(
/\/+$/,
"",
);
if (!config.platform?.apiKey) {
printError("Not initialized. Run `mem0 init --agent` first.");
process.exit(1);
}
const resp = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Token ${config.platform.apiKey}`,
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
"X-Mem0-Client-Version": CLI_VERSION,
"X-Mem0-Mode": "agent-rush",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
const code =
(json as { error?: { code?: string } }).error?.code ?? "unknown";
printError(`AGENTRUSH error: ${code}`);
if (ERROR_HINTS[code]) {
console.log(` ${colors.dim(ERROR_HINTS[code])}`);
}
process.exit(1);
}
return json;
}
function promptLine(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Ensure the human has acknowledged that AGENTRUSH memories are PUBLIC.
*
* Interactive (TTY): show the prompt; on "y" persist `agentRush.acknowledgedAt`
* so we never ask the same machine twice. On anything else, abort.
*
* Non-interactive (agent invocation, no TTY): print the warning to stderr
* for the human reading the agent's transcript and proceed — agents can't
* answer y/N prompts.
*/
async function ensureWarningAcknowledged(): Promise<void> {
const config = loadConfig();
if (config.agentRush?.acknowledgedAt) return;
if (!process.stdin.isTTY || !process.stdout.isTTY) {
// Agent context: surface the warning to stderr, don't block.
console.error(PII_WARNING);
return;
}
console.log(PII_WARNING);
const answer = (await promptLine(" Continue? [y/N]: ")).toLowerCase();
if (answer !== "y" && answer !== "yes") {
printError("Aborted.");
process.exit(1);
}
config.agentRush.acknowledgedAt = new Date().toISOString();
saveConfig(config);
}
export async function cmdAgentRushAdd(content: string): Promise<void> {
await ensureWarningAcknowledged();
const result = await callEndpoint("/v1/agent-rush/memories/", { content });
printSuccess(
`Memory submitted (event_id: ${(result as { event_id?: string }).event_id ?? "?"})`,
);
}
export async function cmdAgentRushSearch(query: string): Promise<void> {
const result = (await callEndpoint("/v1/agent-rush/memories/search/", {
query,
})) as {
results?: Array<{ memory?: string }>;
memories?: Array<{ memory?: string }>;
};
const memories = result.results ?? result.memories ?? [];
if (memories.length === 0) {
console.log(colors.dim("(no results)"));
return;
}
memories.slice(0, 5).forEach((m, i) => {
console.log(` ${i + 1}. ${m.memory ?? JSON.stringify(m)}`);
});
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Config management commands: show, set, get.
*/
import Table from "cli-table3";
import { colors, printError, printSuccess } from "../branding.js";
import {
getNestedValue,
loadConfig,
redactKey,
saveConfig,
setNestedValue,
} from "../config.js";
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
import { isAgentMode, setCurrentCommand } from "../state.js";
const { brand, accent, dim } = colors;
export function cmdConfigShow(opts: { output?: string } = {}): void {
setCurrentCommand("config show");
const config = loadConfig();
if (opts.output === "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,
},
platform: {
api_key: redactKey(config.platform.apiKey),
base_url: config.platform.baseUrl,
},
},
});
return;
}
console.log();
console.log(` ${brand("◆ mem0 Configuration")}\n`);
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(["", ""]);
// Platform
table.push(["platform.api_key", redactKey(config.platform.apiKey)]);
table.push(["platform.base_url", config.platform.baseUrl]);
console.log(table.toString());
console.log();
}
export function cmdConfigGet(key: string): void {
setCurrentCommand("config get");
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);
}
}
}
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}`);
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Entity management commands.
*/
import readline from "node:readline";
import Table from "cli-table3";
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";
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 },
): Promise<void> {
setCurrentCommand("entity list");
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;
if (opts.output === "agent" || opts.output === "json") {
formatAgentEnvelope({
command: "entity list",
data: results,
count: results.length,
durationMs: Math.round(elapsed * 1000),
});
return;
}
if (!results.length) {
printInfo(`No ${entityType} found.`);
return;
}
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]);
}
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;
},
): 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);
}
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) {
printInfo(`Would delete entity ${scope} and all its memories.`);
printInfo("No changes made.");
return;
}
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 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)`);
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Event commands: list and status.
*/
import boxen from "boxen";
import Table from "cli-table3";
import type { Backend } from "../backend/base.js";
import { colors, printError, printInfo, timedStatus } from "../branding.js";
import { formatAgentEnvelope, formatJson } from "../output.js";
import { setCurrentCommand } from "../state.js";
const { brand, accent, success, error: errorColor, warning, dim } = colors;
function statusStyled(status: string): string {
switch (status.toUpperCase()) {
case "SUCCEEDED":
return success("SUCCEEDED");
case "PENDING":
return accent("PENDING");
case "FAILED":
return errorColor("FAILED");
case "PROCESSING":
return warning("PROCESSING");
default:
return status;
}
}
export async function cmdEventList(
backend: Backend,
opts: { output: string },
): Promise<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();
}
+75
View File
@@ -0,0 +1,75 @@
/**
* mem0 identify — declare which agent owns the current agent-mode key.
*
* Used when `mem0 init --agent` ran without --agent-caller, so the backend
* saved agent_caller=NULL. The agent re-runs `mem0 identify <name>` to PATCH
* its own row with its real identity. Idempotent.
*/
import { printError, printSuccess } from "../branding.js";
import { loadConfig, saveConfig } from "../config.js";
const SOURCE_HEADERS = {
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
} as const;
export async function runIdentify(name: string): Promise<void> {
const config = loadConfig();
if (!config.platform.apiKey) {
printError("No API key configured. Run `mem0 init --agent` first.");
process.exit(1);
}
if (!config.platform.agentMode) {
printError("This command only works on unclaimed agent-mode keys.");
process.exit(1);
}
const clean = (name ?? "").trim();
if (!clean) {
printError("Agent name is required.");
process.exit(1);
}
const baseUrl = (config.platform.baseUrl || "https://api.mem0.ai").replace(
/\/+$/,
"",
);
let resp: Response;
try {
resp = await fetch(`${baseUrl}/api/v1/auth/agent_mode/caller/`, {
method: "PATCH",
headers: {
...SOURCE_HEADERS,
Authorization: `Token ${config.platform.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: clean }),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
printError(
`Network error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
if (!resp.ok) {
let detail: string = resp.statusText;
try {
const body = (await resp.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* leave as statusText */
}
printError(`Identify failed: ${detail}`);
process.exit(1);
}
const body = (await resp.json()) as { agent_caller?: string };
const canonical = body.agent_caller ?? clean;
config.platform.agentCaller = canonical;
saveConfig(config);
printSuccess(`Identified as ${canonical}.`);
}
+612
View File
@@ -0,0 +1,612 @@
/**
* 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,
} from "../branding.js";
import {
CONFIG_FILE,
DEFAULT_BASE_URL,
type Mem0Config,
createDefaultConfig,
loadConfig,
redactKey,
saveConfig,
} from "../config.js";
import { formatJsonEnvelope } from "../output.js";
import { isAgentMode } from "../state.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);
}
}
/** @internal — exported for unit tests. */
export async function pingKey(
apiKey: string,
baseUrl: string,
timeoutMs = 5000,
): Promise<boolean> {
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
// Network errors, timeouts, and 5xx responses return true so we prefer
// reusing an existing key over silently minting a new shadow on a transient
// blip (which would also clobber config + plugin-sync targets).
try {
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
headers: { Authorization: `Token ${apiKey}` },
signal: AbortSignal.timeout(timeoutMs),
});
return resp.status !== 401 && resp.status !== 403;
} catch {
return true; // unknown — prefer reuse
}
}
async function maybeIdentify(
key: string,
baseUrl: string,
agentCaller: string | undefined,
): Promise<void> {
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
// reused key. Silent no-op on any failure — reuse must not break.
if (!agentCaller) return;
try {
const resp = await fetch(
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
{
method: "PATCH",
headers: {
Authorization: `Token ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ agent_caller: agentCaller }),
signal: AbortSignal.timeout(10_000),
},
);
if (resp.ok) {
try {
const body = (await resp.json()) as { agent_caller?: string };
if (fs.existsSync(CONFIG_FILE)) {
const cfg = loadConfig();
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
saveConfig(cfg);
}
} catch {
/* swallow — best effort */
}
}
} catch {
/* swallow — best effort */
}
}
async function emailLogin(
email: string,
code: string | undefined,
baseUrl: string,
): Promise<Record<string, unknown>> {
const url = baseUrl.replace(/\/+$/, "");
let codeValue = code;
const sourceHeaders = {
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "node",
};
if (!codeValue) {
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
method: "POST",
headers: sourceHeaders,
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: sourceHeaders,
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);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf-8");
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 cleanup = () => {
process.stdin.removeListener("data", onData);
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
};
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 || "");
});
});
}
async function setupPlatform(config: Mem0Config): Promise<void> {
console.log();
console.log(
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-node")}`,
);
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;
config.platform.createdVia = "api_key";
}
async function setupDefaults(config: Mem0Config): Promise<void> {
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;
}
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!");
// Cache user_email from ping response for telemetry distinct_id
try {
const pingData = (await backend.ping()) as Record<string, unknown>;
const userEmail = pingData?.user_email as string | undefined;
if (userEmail) {
config.platform.userEmail = userEmail;
}
} catch {
/* ignore — telemetry ID will fall back to API key hash */
}
} else {
printError(
`Could not connect: ${status.error ?? "Unknown error"}`,
"Visit https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-node to get a new key, or run mem0 init 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;
agent?: boolean;
source?: string;
agentCaller?: string;
} = {},
): Promise<void> {
const { detectAgentCaller } = await import("../agent-detect.js");
const { bootstrapViaBackend, claimViaOtp } = await import("./agent-mode.js");
const { isAgentMode } = await import("../state.js");
const { captureEvent } = await import("../telemetry.js");
const fireInit = (
mode: "agent" | "email" | "api_key" | "existing_key",
claimed = false,
) => {
const props: Record<string, unknown> = { command: "init", mode };
// Self-declared via --agent-caller; not sniffed from env vars.
if (opts.agentCaller) props.agent_caller = opts.agentCaller;
if (opts.source) props.signup_source = opts.source;
if (claimed) props.claimed_agent_mode = true;
captureEvent("cli.init", props);
};
const config = createDefaultConfig();
const savedConfig = loadConfig();
const baseUrl =
process.env.MEM0_BASE_URL ||
savedConfig.platform.baseUrl ||
DEFAULT_BASE_URL;
config.platform.baseUrl = baseUrl;
// Guards
if (opts.code && !opts.email) {
printError("--code requires --email.");
process.exit(1);
}
if (opts.email && opts.apiKey) {
printError("Cannot use both --api-key and --email.");
process.exit(1);
}
// ── Claim flow: --email against an existing agent-mode config ───────────
if (
opts.email &&
fs.existsSync(CONFIG_FILE) &&
savedConfig.platform.agentMode &&
savedConfig.platform.apiKey
) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printInfo(`Claiming Agent Mode account to ${email}...`);
await claimViaOtp(savedConfig, { email, code: opts.code });
fireInit("email", true);
return;
}
// ── Agent Mode path runs BEFORE the existing-config guard ──────────────
// Rule 1/2 will REUSE a valid existing key (not overwrite), so we must
// short-circuit before the guard prompts the user about overwriting.
// Rule 3 only mints when there's no valid key to reuse — in that case
// overwriting is what the user wants.
const agentCtx =
opts.agent === true || isAgentMode() || detectAgentCaller() !== null;
if (!opts.apiKey && !opts.email && agentCtx) {
const emitReuseEnvelope = (source: "env" | "config") => {
if (isAgentMode()) {
formatJsonEnvelope({
command: "init",
data: {
api_key_saved: false,
api_key_source: source,
agent_mode: false,
message:
"Existing Mem0 API key found and reused. No Agent Mode key was created.",
},
});
} else {
printSuccess(
source === "env"
? "Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
: "Existing API key in config is valid; reusing it. No new Agent Mode key was minted.",
);
}
};
// Rule 1: env MEM0_API_KEY valid → reuse, no new key.
const envKey = (process.env.MEM0_API_KEY || "").trim();
if (envKey && (await pingKey(envKey, baseUrl))) {
await maybeIdentify(envKey, baseUrl, opts.agentCaller);
emitReuseEnvelope("env");
fireInit("existing_key");
return;
}
// Rule 2: existing config api_key valid → reuse.
if (
savedConfig.platform.apiKey &&
(await pingKey(savedConfig.platform.apiKey, baseUrl))
) {
await maybeIdentify(
savedConfig.platform.apiKey,
baseUrl,
opts.agentCaller,
);
emitReuseEnvelope("config");
fireInit("existing_key");
return;
}
// Rule 3: mint a fresh shadow (no valid key to reuse).
// agent_caller is self-declared via --agent-caller (Proof Editor-style),
// not derived from env-var sniffing. detectAgentCaller() above is still
// used as a context trigger (does this look like an agent?) but never
// to fill identity.
await bootstrapViaBackend(config, {
source: opts.source ?? null,
agentCaller: opts.agentCaller ?? null,
});
fireInit("agent");
return;
}
// Warn if an existing config with an API key would be overwritten
if (
!opts.force &&
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);
}
}
// ── Email login flow ──────────────────────────────────────────────────────
if (opts.email) {
const email = opts.email.trim().toLowerCase();
validateEmail(email);
printBanner();
console.log();
printInfo(`Logging in as ${email}...\n`);
const result = await emailLogin(email, opts.code, 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.platform.userEmail = email;
config.platform.createdVia = "email";
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 ──────────────────────────────────────────────────────────
// (Agent Mode branch runs earlier — see above, before the existing-config
// guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
// Non-TTY: resolve defaults so partial flags work in pipelines / CI
if (!process.stdin.isTTY) {
if (!opts.apiKey) {
printError(
"Non-interactive terminal detected and --api-key is required.",
"Usage: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
);
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.platform.createdVia = "api_key";
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.platform.userEmail = email;
config.platform.createdVia = "email";
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();
}
+703
View File
@@ -0,0 +1,703 @@
/**
* Memory CRUD commands: add, search, get, list, update, delete.
*/
import fs from "node:fs";
import type { Backend } from "../backend/base.js";
import {
printError,
printInfo,
printScope,
printSuccess,
timedStatus,
} from "../branding.js";
import {
formatAddResult,
formatAgentEnvelope,
formatJson,
formatJsonEnvelope,
formatMemoriesTable,
formatMemoriesText,
formatSingleMemory,
printResultSummary,
} from "../output.js";
import { isAgentMode, setCurrentCommand } from "../state.js";
/** True only when stdin is an actual pipe or file redirect — never in agent mode. */
function _stdinIsPiped(): boolean {
if (isAgentMode()) return false;
try {
const stat = fs.fstatSync(0);
return stat.isFIFO() || stat.isFile();
} catch {
return false;
}
}
export async function cmdAdd(
backend: Backend,
text: string | undefined,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
messages?: string;
file?: string;
metadata?: string;
immutable: boolean;
infer?: boolean;
expires?: string;
categories?: string;
output: string;
},
): Promise<void> {
setCurrentCommand("add");
let msgs: Record<string, unknown>[] | undefined;
let content = text;
// Read from file
if (opts.file) {
try {
const raw = fs.readFileSync(opts.file, "utf-8");
msgs = JSON.parse(raw);
} catch (e) {
printError(`Failed to read file: ${e instanceof Error ? e.message : e}`);
process.exit(1);
}
}
// Parse messages JSON
else if (opts.messages) {
try {
msgs = JSON.parse(opts.messages);
} catch (e) {
printError(
`Invalid JSON in --messages: ${e instanceof Error ? e.message : e}`,
);
process.exit(1);
}
}
// Read from stdin only if stdin is an actual pipe or file redirect
else if (!content && _stdinIsPiped()) {
content = fs.readFileSync(0, "utf-8").trim();
}
if (content !== undefined && content.trim() === "") {
printError("Content cannot be empty.");
process.exit(1);
}
if (!content && !msgs) {
printError(
"No content provided. Pass text, --messages, --file, or pipe via stdin.",
);
process.exit(1);
}
// Validate --expires
if (opts.expires) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) {
printError(
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
);
process.exit(1);
}
if (new Date(opts.expires) <= new Date()) {
printError("--expires date must be in the future.");
process.exit(1);
}
}
let meta: Record<string, unknown> | undefined;
if (opts.metadata) {
try {
meta = JSON.parse(opts.metadata);
} catch {
printError("Invalid JSON in --metadata.");
process.exit(1);
}
}
let cats: string[] | undefined;
if (opts.categories) {
try {
cats = JSON.parse(opts.categories);
} catch {
cats = opts.categories.split(",").map((c) => c.trim());
}
}
let result: Record<string, unknown>;
try {
result = await timedStatus("Adding memory...", async () => {
return backend.add(content ?? undefined, msgs, {
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
metadata: meta,
immutable: opts.immutable,
infer: opts.infer !== false,
expires: opts.expires,
categories: cats,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
if (opts.output === "quiet") return;
// Deduplicate PENDING entries sharing the same event_id across all output modes
const rawResults: Record<string, unknown>[] = Array.isArray(result)
? result
: ((result.results as Record<string, unknown>[]) ?? [result]);
const seenEvents = new Set<string>();
const deduped: Record<string, unknown>[] = [];
for (const r of rawResults) {
if (r.status === "PENDING") {
const eid = (r.event_id as string) ?? "";
if (eid && seenEvents.has(eid)) continue;
if (eid) seenEvents.add(eid);
}
deduped.push(r);
}
// Write back so downstream formatters see deduplicated data
const dedupedResult: Record<string, unknown> = Array.isArray(result)
? (deduped as unknown as Record<string, unknown>)
: { ...result, results: deduped };
if (opts.output === "agent") {
const scope: Record<string, string | undefined> = {
user_id: opts.userId,
agent_id: opts.agentId,
app_id: opts.appId,
run_id: opts.runId,
};
formatAgentEnvelope({
command: "add",
data: deduped,
scope,
count: deduped.length,
});
return;
}
if (opts.output === "json") {
formatAddResult(dedupedResult, opts.output);
return;
}
console.log();
printScope({
user_id: opts.userId,
agent_id: opts.agentId,
app_id: opts.appId,
run_id: opts.runId,
});
const count = deduped.length;
const allPending = count > 0 && deduped.every((r) => r.status === "PENDING");
if (allPending) {
printSuccess(
`Memory queued — ${count} event${count !== 1 ? "s" : ""} pending`,
);
} else {
printSuccess(
`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`,
);
}
formatAddResult(dedupedResult, opts.output);
}
export async function cmdSearch(
backend: Backend,
query: string | undefined,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
topK: number;
threshold: number;
rerank: boolean;
keyword: boolean;
filterJson?: string;
fields?: string;
output: string;
},
): Promise<void> {
setCurrentCommand("search");
if (!query) {
printError("No query provided. Pass a query argument or pipe via stdin.");
process.exit(1);
}
let filters: Record<string, unknown> | undefined;
if (opts.filterJson) {
try {
filters = JSON.parse(opts.filterJson);
} catch {
printError("Invalid JSON in --filter.");
process.exit(1);
}
}
const fieldList = opts.fields
? opts.fields.split(",").map((f) => f.trim())
: undefined;
if (opts.topK < 1) {
printError("--top-k must be >= 1.");
process.exit(1);
}
if (opts.threshold < 0 || opts.threshold > 1) {
printError("--threshold must be between 0.0 and 1.0.");
process.exit(1);
}
const start = performance.now();
let results: Record<string, unknown>[];
try {
results = await timedStatus("Searching memories...", async () => {
// biome-ignore lint/style/noNonNullAssertion: guarded by process.exit above
return backend.search(query!, {
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
topK: opts.topK,
threshold: opts.threshold,
rerank: opts.rerank,
keyword: opts.keyword,
filters,
fields: fieldList,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
if (opts.output === "quiet") return;
if (opts.output === "agent") {
const scope: Record<string, string | undefined> = {
user_id: opts.userId,
agent_id: opts.agentId,
app_id: opts.appId,
run_id: opts.runId,
};
formatAgentEnvelope({
command: "search",
data: results,
scope,
count: results.length,
durationMs: Math.round(elapsed * 1000),
});
return;
}
if (opts.output === "json") {
formatJson(results);
} else if (opts.output === "table") {
if (results.length > 0) {
formatMemoriesTable(results, { showScore: true });
printResultSummary({
count: results.length,
durationSecs: elapsed,
scopeIds: { user_id: opts.userId, agent_id: opts.agentId },
});
} else {
console.log();
printInfo("No memories found matching your query.");
console.log();
}
} else {
if (results.length > 0) {
formatMemoriesText(results);
printResultSummary({
count: results.length,
durationSecs: elapsed,
scopeIds: { user_id: opts.userId, agent_id: opts.agentId },
});
} else {
console.log();
printInfo("No memories found matching your query.");
console.log();
}
}
}
export async function cmdGet(
backend: Backend,
memoryId: string,
opts: { output: string },
): Promise<void> {
setCurrentCommand("get");
let result: Record<string, unknown>;
try {
result = await timedStatus("Fetching memory...", async () => {
return backend.get(memoryId);
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
if (opts.output === "agent") {
formatAgentEnvelope({ command: "get", data: result });
} else {
formatSingleMemory(result, opts.output);
}
}
export async function cmdList(
backend: Backend,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
page: number;
pageSize: number;
category?: string;
after?: string;
before?: string;
output: string;
},
): Promise<void> {
setCurrentCommand("list");
if (opts.pageSize < 1) {
printError("--page-size must be >= 1.");
process.exit(1);
}
if (opts.page < 1) {
printError("--page must be >= 1.");
process.exit(1);
}
const start = performance.now();
let results: Record<string, unknown>[];
try {
results = await timedStatus("Listing memories...", async () => {
return backend.listMemories({
userId: opts.userId,
agentId: opts.agentId,
appId: opts.appId,
runId: opts.runId,
page: opts.page,
pageSize: opts.pageSize,
category: opts.category,
after: opts.after,
before: opts.before,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
const elapsed = (performance.now() - start) / 1000;
if (opts.output === "quiet") return;
if (opts.output === "agent" || opts.output === "json") {
const scope: Record<string, string | undefined> = {
user_id: opts.userId,
agent_id: opts.agentId,
app_id: opts.appId,
run_id: opts.runId,
};
formatAgentEnvelope({
command: "list",
data: results,
scope,
count: results.length,
durationMs: Math.round(elapsed * 1000),
});
} else if (opts.output === "table") {
if (results.length > 0) {
formatMemoriesTable(results);
printResultSummary({
count: results.length,
durationSecs: elapsed,
page: opts.page,
scopeIds: { user_id: opts.userId, agent_id: opts.agentId },
});
} else {
console.log();
printInfo("No memories found.");
console.log();
}
} else {
if (results.length > 0) {
formatMemoriesText(results, "memories");
printResultSummary({
count: results.length,
durationSecs: elapsed,
page: opts.page,
scopeIds: { user_id: opts.userId, agent_id: opts.agentId },
});
} else {
console.log();
printInfo("No memories found.");
console.log();
}
}
}
export async function cmdUpdate(
backend: Backend,
memoryId: string,
text: string | undefined,
opts: { metadata?: string; output: string },
): Promise<void> {
setCurrentCommand("update");
let meta: Record<string, unknown> | undefined;
if (opts.metadata) {
try {
meta = JSON.parse(opts.metadata);
} catch {
printError("Invalid JSON in --metadata.");
process.exit(1);
}
}
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Updating memory...", async () => {
return backend.update(memoryId, text, meta);
});
} 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: "update",
data: result,
durationMs: Math.round(elapsed * 1000),
});
} else if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
printSuccess(
`Memory ${memoryId.slice(0, 8)} updated (${elapsed.toFixed(2)}s)`,
);
}
}
export async function cmdDelete(
backend: Backend,
memoryId: string,
opts: { output: string; dryRun?: boolean; force?: boolean },
): Promise<void> {
setCurrentCommand("delete");
if (opts.dryRun) {
let mem: Record<string, unknown>;
try {
mem = await backend.get(memoryId);
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
process.exit(1);
}
const text = (mem.memory ?? mem.text ?? "") as string;
printInfo(`Would delete memory ${memoryId.slice(0, 8)}: ${text}`);
printInfo("No changes made.");
return;
}
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Deleting...", async () => {
return backend.delete(memoryId);
});
} 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: "delete",
data: { id: memoryId, deleted: true },
durationMs: Math.round(elapsed * 1000),
});
} else if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
printSuccess(
`Memory ${memoryId.slice(0, 8)} deleted (${elapsed.toFixed(2)}s)`,
);
}
}
export async function cmdDeleteAll(
backend: Backend,
opts: {
force: boolean;
dryRun?: boolean;
all?: boolean;
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
output: string;
},
): Promise<void> {
setCurrentCommand("delete-all");
const { isAgentMode } = await import("../state.js");
if (isAgentMode() && !opts.force) {
printError("Destructive operation requires --force in agent mode.");
process.exit(1);
}
if (opts.all) {
// Project-wide wipe using wildcard entity IDs
// Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
if (!opts.force) {
const readline = await import("node:readline");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
"\n \u26a0 Delete ALL memories across the ENTIRE project? 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 all memories project-wide...",
async () => {
return backend.delete(undefined, {
all: true,
userId: "*",
agentId: "*",
appId: "*",
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: "delete-all",
data: result,
durationMs: Math.round(elapsed * 1000),
});
} else if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
if (result.message) {
printInfo(
"Deletion started. Memories will be removed in the background.",
);
} else {
printSuccess(`All project memories deleted (${elapsed.toFixed(2)}s)`);
}
}
return;
}
if (opts.dryRun) {
let memories: Record<string, unknown>[];
try {
memories = await backend.listMemories({
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);
}
printInfo(`Would delete ${memories.length} 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.length > 0 ? scopeParts.join(", ") : "ALL entities";
const readline = await import("node:readline");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
`\n \u26a0 Delete ALL memories for ${scope}? 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 all memories...", async () => {
return backend.delete(undefined, {
all: true,
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: "delete-all",
data: result,
durationMs: Math.round(elapsed * 1000),
});
} else if (opts.output === "json") {
formatJson(result);
} else if (opts.output !== "quiet") {
if (result.message) {
printInfo(
"Deletion started. Memories will be removed in the background.",
);
} else {
printSuccess(`All matching memories deleted (${elapsed.toFixed(2)}s)`);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Utility commands: status, version, import.
*/
import fs from "node:fs";
import boxen from "boxen";
import type { Backend } from "../backend/base.js";
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
import { setCurrentCommand } from "../state.js";
import { CLI_VERSION } from "../version.js";
const { brand, dim, success, error: errorColor } = colors;
export async function cmdStatus(
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;
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;
}
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?utm_source=oss&utm_medium=cli-node")}`,
);
}
}
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();
}
export function cmdVersion(): void {
console.log(` ${brand("◆ Mem0")} CLI v${CLI_VERSION}`);
}
export async function cmdImport(
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 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;
}
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}`)}`,
);
}
}
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;
}
printSuccess(`Imported ${added} memories (${elapsed.toFixed(2)}s)`);
if (failed > 0) {
printError(`${failed} memories failed to import.`);
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* `mem0 whoami` — print the active agent's default_user_id (AGENTRUSH identifier).
* Reads from local config; no network call.
*/
import { colors, printError, printInfo } from "../branding.js";
import { loadConfig } from "../config.js";
export async function cmdWhoami(): Promise<void> {
const config = loadConfig();
const sessionId = config.platform?.defaultUserId;
if (!sessionId) {
printError("No default_user_id found. Run `mem0 init --agent` first.");
process.exit(1);
}
console.log(`Your AGENTRUSH identifier: ${colors.brand(sessionId)}`);
printInfo("Find your row at https://mem0.ai/agentrush");
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Configuration management for mem0 CLI.
*
* Config precedence (highest to lowest):
* 1. CLI flags (--api-key, --base-url, etc.)
* 2. Environment variables (MEM0_API_KEY, etc.)
* 3. Config file (~/.mem0/config.json)
* 4. Defaults
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const CONFIG_DIR = path.join(os.homedir(), ".mem0");
export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
export const DEFAULT_BASE_URL = "https://api.mem0.ai";
export const CONFIG_VERSION = 1;
export interface PlatformConfig {
apiKey: string;
baseUrl: string;
userEmail: string;
// Agent Mode (unclaimed-shadow signup)
agentMode: boolean; // true while the key is an unclaimed agent-mode key
createdVia: string; // "agent_mode" | "email" | "api_key" | "existing_key"
agentCaller: string; // canonical agent name when createdVia === "agent_mode" (e.g. "claude-code")
claimedAt: string; // ISO timestamp once the agent has been claimed
defaultUserId: string; // `user_<slug>` returned by bootstrap; auto-default scope
}
export interface DefaultsConfig {
userId: string;
agentId: string;
appId: string;
runId: string;
}
export interface TelemetryConfig {
anonymousId: string;
}
export interface AgentRushConfig {
// ISO timestamp the human acknowledged the "memories are public" warning.
// Empty until first interactive `mem0 agent-rush add`.
acknowledgedAt: string;
}
export interface Mem0Config {
version: number;
defaults: DefaultsConfig;
platform: PlatformConfig;
telemetry: TelemetryConfig;
agentRush: AgentRushConfig;
}
export function createDefaultConfig(): Mem0Config {
return {
version: CONFIG_VERSION,
defaults: {
userId: "",
agentId: "",
appId: "",
runId: "",
},
platform: {
apiKey: "",
baseUrl: DEFAULT_BASE_URL,
userEmail: "",
agentMode: false,
createdVia: "",
agentCaller: "",
claimedAt: "",
defaultUserId: "",
},
telemetry: {
anonymousId: "",
},
agentRush: {
acknowledgedAt: "",
},
};
}
export function ensureConfigDir(): string {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
return CONFIG_DIR;
}
export function loadConfig(): Mem0Config {
const config = createDefaultConfig();
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
const data = JSON.parse(raw);
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;
config.platform.userEmail = plat.user_email ?? "";
config.platform.agentMode = Boolean(plat.agent_mode ?? false);
config.platform.createdVia = plat.created_via ?? "";
config.platform.agentCaller = plat.agent_caller ?? "";
config.platform.claimedAt = plat.claimed_at ?? "";
config.platform.defaultUserId = plat.default_user_id ?? "";
const defaults = data.defaults ?? {};
config.defaults.userId = defaults.user_id ?? "";
config.defaults.agentId = defaults.agent_id ?? "";
config.defaults.appId = defaults.app_id ?? "";
config.defaults.runId = defaults.run_id ?? "";
const telemetry = data.telemetry ?? {};
config.telemetry.anonymousId = telemetry.anonymous_id ?? "";
const agentRush = data.agent_rush ?? {};
config.agentRush.acknowledgedAt = agentRush.acknowledged_at ?? "";
}
// 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;
return config;
}
export function saveConfig(config: Mem0Config): void {
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,
},
platform: {
api_key: config.platform.apiKey,
base_url: config.platform.baseUrl,
user_email: config.platform.userEmail,
agent_mode: config.platform.agentMode,
created_via: config.platform.createdVia,
agent_caller: config.platform.agentCaller,
claimed_at: config.platform.claimedAt,
default_user_id: config.platform.defaultUserId,
},
telemetry: {
anonymous_id: config.telemetry.anonymousId,
},
agent_rush: {
acknowledged_at: config.agentRush.acknowledgedAt,
},
};
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
fs.chmodSync(CONFIG_FILE, 0o600);
// Propagate api_key to ecosystem touchpoints (Claude plugin env injection,
// shell rc exports). Idempotent — updates only EXISTING entries; never
// creates new ones. Best-effort: errors swallowed so config.json is
// always authoritative, never blocked by plugin-state issues.
if (config.platform.apiKey) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { syncApiKey } = require("./plugin-sync.js");
syncApiKey(config.platform.apiKey);
} catch {
/* swallow */
}
}
}
export function redactKey(key: string): string {
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"],
"platform.user_email": ["platform", "userEmail"],
"defaults.user_id": ["defaults", "userId"],
"defaults.agent_id": ["defaults", "agentId"],
"defaults.app_id": ["defaults", "appId"],
"defaults.run_id": ["defaults", "runId"],
// Short-form aliases
api_key: ["platform", "apiKey"],
base_url: ["platform", "baseUrl"],
user_email: ["platform", "userEmail"],
user_id: ["defaults", "userId"],
agent_id: ["defaults", "agentId"],
app_id: ["defaults", "appId"],
run_id: ["defaults", "runId"],
};
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];
}
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;
}
+2
View File
@@ -0,0 +1,2 @@
/** Injected by tsup at build time from package.json version field. Undefined in dev/test. */
declare const __CLI_VERSION__: string | undefined;
+378
View File
@@ -0,0 +1,378 @@
/**
* Rich-style help formatter for Commander.js that matches the Python CLI's
* Typer + Rich output (rounded box panels, brand purple, grouped options).
*/
import chalk from "chalk";
import type { Argument, Command, Help, Option } 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
// ── Strip ANSI ───────────────────────────────────────────────────────────
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence is intentional
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function stripAnsi(str: string): number {
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"],
},
];
/** Flat order derived from COMMAND_GROUPS. */
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",
},
};
const PANEL_ORDER: string[] = [
"Scope",
"Search",
"Pagination",
"Filters",
"Output",
"Connection",
];
// ── Panel rendering ─────────────────────────────────────────────────────
/**
* Render a Rich-style ROUNDED box panel.
*
* ```
* ╭─ Title ────────────────────────╮
* │ row content padded │
* ╰────────────────────────────────╯
* ```
*/
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;
// 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("╯");
// 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");
}
// ── 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(", ");
// 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 || "";
}
// ── Format a default value ──────────────────────────────────────────────
function formatDefault(opt: Option): string {
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 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("");
// ── 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);
}
}
// ── 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] = [];
}
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);
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");
}
// ── 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));
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;
});
}
+880
View File
@@ -0,0 +1,880 @@
#!/usr/bin/env node
/**
* Main CLI application — the entrypoint for `mem0`.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import { AuthError, type Backend, getBackend } from "./backend/index.js";
import { colors, printError, printWarning } from "./branding.js";
import type { Mem0Config } from "./config.js";
import { loadConfig, saveConfig } from "./config.js";
import { richFormatHelp } from "./help.js";
import {
isAgentMode,
setAgentMode,
setCurrentCommand,
takeNotice,
} from "./state.js";
import { captureEvent } from "./telemetry.js";
import { CLI_VERSION } from "./version.js";
const program = new Command();
// ── Validated user identity (set by getBackendAndConfig) ─────────────────
let _validatedUserEmail: string | undefined;
// ── Helpers ──────────────────────────────────────────────────────────────
async function getBackendAndConfig(
apiKey?: string,
baseUrl?: string,
): Promise<{ backend: Backend; config: Mem0Config }> {
const config = loadConfig();
if (apiKey) config.platform.apiKey = apiKey;
if (baseUrl) config.platform.baseUrl = baseUrl;
if (!config.platform.apiKey) {
printError(
"No API key configured.",
"Run 'mem0 init' or set MEM0_API_KEY environment variable.",
);
process.exit(1);
}
const backend = getBackend(config);
// Validate the API key upfront with a fast timeout
try {
const pingData = (await Promise.race([
backend.ping(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), 5000),
),
])) as Record<string, unknown>;
const email = pingData?.user_email as string | undefined;
if (email) {
_validatedUserEmail = email;
if (config.platform.userEmail !== email) {
config.platform.userEmail = email;
try {
saveConfig(config);
} catch {
/* ignore */
}
}
}
} catch (e) {
if (e instanceof AuthError) {
printError(
"Invalid or expired API key.",
"Run 'mem0 init' or set MEM0_API_KEY environment variable.",
);
process.exit(1);
}
// Network error / timeout — warn but proceed
printWarning(
"Could not validate API key (network issue). Proceeding anyway.",
);
}
return { backend, config };
}
async function getBackendOnly(
apiKey?: string,
baseUrl?: string,
): Promise<Backend> {
return (await getBackendAndConfig(apiKey, baseUrl)).backend;
}
function checkAgentMode(): boolean {
const rootOpts = program.opts();
const isAgent = !!(rootOpts.json || rootOpts.agent);
if (isAgent) setAgentMode(true);
return isAgent;
}
/**
* Resolve entity IDs: CLI flag > config default > undefined.
*
* If any explicit ID is provided, only use explicit IDs (don't mix
* in defaults for other entity types which would over-filter).
* If no explicit IDs, fall back to all configured defaults.
*/
function resolveIds(
config: Mem0Config,
opts: {
userId?: string;
agentId?: string;
appId?: string;
runId?: string;
},
): { userId?: string; agentId?: string; appId?: string; runId?: string } {
const hasExplicit = !!(
opts.userId ||
opts.agentId ||
opts.appId ||
opts.runId
);
if (hasExplicit) {
return {
userId: opts.userId || undefined,
agentId: opts.agentId || undefined,
appId: opts.appId || undefined,
runId: opts.runId || undefined,
};
}
return {
userId: config.defaults.userId || undefined,
agentId: config.defaults.agentId || undefined,
appId: config.defaults.appId || undefined,
runId: config.defaults.runId || undefined,
};
}
// ── Main program ──────────────────────────────────────────────────────────
program
.name("mem0")
.description(
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
)
// Positional options: flags AFTER a subcommand name belong to that
// subcommand, not the global program. Without this, `mem0 init --agent`
// routes `--agent` to the program-level alias (for --json) and init's own
// `--agent` (Agent Mode bootstrap) silently never fires.
.enablePositionalOptions()
.option("--version", "Show version and exit.")
.on("option:version", () => {
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
process.exit(0);
})
.option("--json", "Output as JSON for agent/programmatic use.")
.option(
"--agent",
"Output as JSON for agent/programmatic use. (alias: --json) Place BEFORE the subcommand: `mem0 --agent <cmd>`. On `init`, `mem0 init --agent` is the Agent Mode bootstrap flag instead.",
)
.usage("<command> [options]")
.helpOption("--help", "Show this message and exit.")
.addHelpCommand(false)
.configureHelp({ formatHelp: richFormatHelp });
// ── Telemetry hook ───────────────────────────────────────────────────────
program.hook("preAction", (_thisCommand, actionCommand) => {
try {
const commandName = actionCommand.name();
const parentName = actionCommand.parent?.name();
const fullCommand =
parentName && parentName !== "mem0"
? `${parentName}.${commandName}`
: commandName;
// Stash the active command name in shared state so the JSON
// error envelope (printError) can report which command failed
// instead of an empty `"command": ""` field.
setCurrentCommand(fullCommand);
// init fires its own telemetry from runInit with full M1-M6 props
// (mode/agent_caller/signup_source/claimed_agent_mode); skip the
// auto-fire here so we don't double-count.
if (fullCommand === "init") return;
const isAgent = !!(program.opts().json || program.opts().agent);
captureEvent(
`cli.${fullCommand}`,
{
command: fullCommand,
is_agent: isAgent,
},
_validatedUserEmail,
);
} catch {
/* silently swallow */
}
});
// ── Init ──────────────────────────────────────────────────────────────────
program
.command("init")
.description("Interactive setup wizard for mem0 CLI.")
.option("--api-key <key>", "API key (skip prompt).")
.option("-u, --user-id <id>", "Default user ID (skip prompt).")
.option("--email <email>", "Login via email verification code.")
.option(
"--code <code>",
"Verification code (use with --email for non-interactive login).",
)
.option("--force", "Overwrite existing config without confirmation.", false)
.option(
"--agent",
"Bootstrap an unattended Agent Mode account (no email required).",
false,
)
.option(
"--source <channel>",
"Channel attribution for signup (e.g. github, hn, ph).",
)
.option(
"--agent-caller <name>",
"Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
)
// Accept `--json` at the init level too so the PRD-documented form
// `mem0 init --agent --json` works without requiring users to move it
// before the subcommand. Effect is identical to the global `--json`:
// flip agent-mode output state.
.option("--json", "Output as JSON (alias for global `--json`).", false)
.addHelpText(
"after",
"\nExamples:\n $ mem0 init\n $ mem0 init --api-key m0-xxx --user-id alice\n $ mem0 init --email you@example.com\n $ mem0 init --email you@example.com --code 123456\n $ mem0 init --agent # Bootstrap an Agent Mode account (unattended)\n $ mem0 init --email you@example.com # Claims an existing Agent Mode key when one is present",
)
.action(async (opts) => {
// `--json` at init level mirrors the global flag — flip agent_mode
// state so downstream formatters use JSON envelopes.
if (opts.json) setAgentMode(true);
const { runInit } = await import("./commands/init.js");
await runInit({
apiKey: opts.apiKey,
userId: opts.userId,
email: opts.email,
code: opts.code,
force: opts.force,
agent: opts.agent,
source: opts.source,
agentCaller: opts.agentCaller,
});
});
// ── Setup: identify (post-bootstrap agent self-tag) ──────────────────────
program
.command("identify <name>")
.description(
"Tag your active Agent Mode key with the AI agent that's using it (e.g. claude-code, cursor).",
)
.action(async (name: string) => {
const { runIdentify } = await import("./commands/identify.js");
await runIdentify(name);
});
// ── Setup: whoami (print active agent identifier) ────────────────────────
program
.command("whoami")
.description("Print the active agent's AGENTRUSH identifier.")
.action(async () => {
const { cmdWhoami } = await import("./commands/whoami.js");
await cmdWhoami();
});
// ── AGENTRUSH subcommand group ────────────────────────────────────────────
const agentRush = program
.command("agent-rush")
.description("AGENTRUSH game commands.")
.addHelpCommand(false)
.configureHelp({ formatHelp: richFormatHelp });
agentRush
.command("add <content...>")
.description("Submit a memory to AGENTRUSH.")
.addHelpText(
"after",
'\nExamples:\n $ mem0 agent-rush add "I used mem0 to build a coding agent"\n $ mem0 agent-rush add "Agents that remember are better agents"',
)
.action(async (parts: string[]) => {
const { cmdAgentRushAdd } = await import("./commands/agent-rush.js");
await cmdAgentRushAdd(parts.join(" "));
});
agentRush
.command("search <query...>")
.description("Search AGENTRUSH memories.")
.addHelpText(
"after",
'\nExamples:\n $ mem0 agent-rush search "agents and memory and tools"\n $ mem0 agent-rush search "coding assistant"',
)
.action(async (parts: string[]) => {
const { cmdAgentRushSearch } = await import("./commands/agent-rush.js");
await cmdAgentRushSearch(parts.join(" "));
});
// ── Memory: add ───────────────────────────────────────────────────────────
program
.command("add [text]")
.description("Add a memory from text, messages, file, or stdin.")
.option("-u, --user-id <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.")
.option("--run-id <id>", "Scope to run.")
.option("--messages <json>", "Conversation messages as JSON.")
.option("-f, --file <path>", "Read messages from JSON file.")
.option("-m, --metadata <json>", "Custom metadata as JSON.")
.option("--immutable", "Prevent future updates.", false)
.option("--no-infer", "Skip inference, store raw.")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--categories <value>", "Categories (JSON array or comma-separated).")
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
'\nExamples:\n $ mem0 add "I prefer dark mode" --user-id alice\n $ echo "text" | mem0 add -u alice\n $ mem0 add --file msgs.json -u alice -o json',
)
.action(async (text, opts) => {
const { cmdAdd } = await import("./commands/memory.js");
const isAgent = checkAgentMode();
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = resolveIds(config, opts);
const output = isAgent ? "agent" : opts.output;
await cmdAdd(backend, text, { ...ids, ...opts, output });
});
// ── Memory: search ────────────────────────────────────────────────────────
program
.command("search [query]")
.description(
"Query your memory store — semantic, keyword, or hybrid retrieval.",
)
.option("-u, --user-id <id>", "Filter by user.")
.option("--agent-id <id>", "Filter by agent.")
.option("--app-id <id>", "Filter by app.")
.option("--run-id <id>", "Filter by run.")
.option(
"-k, --top-k <n>",
"Number of results.",
(v) => Number.parseInt(v),
10,
)
.option(
"--threshold <n>",
"Minimum similarity score.",
(v) => Number.parseFloat(v),
0.3,
)
.option("--rerank", "Enable reranking (Platform only).", false)
.option("--keyword", "Use keyword search.", false)
.option("--filter <json>", "Advanced filter expression (JSON).")
.option("--fields <list>", "Specific fields to return (comma-separated).")
.option("-o, --output <format>", "Output: text, json, table.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
'\nExamples:\n $ mem0 search "preferences" --user-id alice\n $ mem0 search "tools" -u alice -o json -k 5\n $ echo "preferences" | mem0 search -u alice',
)
.action(async (query, opts) => {
let resolvedQuery = query;
if (!resolvedQuery && !process.stdin.isTTY) {
resolvedQuery = fs.readFileSync(0, "utf-8").trim();
}
if (!resolvedQuery) {
printError("No query provided. Pass a query argument or pipe via stdin.");
process.exit(1);
}
const { cmdSearch } = await import("./commands/memory.js");
const isAgent = checkAgentMode();
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = resolveIds(config, opts);
const output = isAgent ? "agent" : opts.output;
await cmdSearch(backend, resolvedQuery, {
...ids,
topK: opts.topK,
threshold: opts.threshold,
rerank: opts.rerank,
keyword: opts.keyword,
filterJson: opts.filter,
fields: opts.fields,
output,
});
});
// ── Memory: get ───────────────────────────────────────────────────────────
program
.command("get <memoryId>")
.description("Get a specific memory by ID.")
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 get abc-123-def-456\n $ mem0 get abc-123-def-456 -o json",
)
.action(async (memoryId, opts) => {
const { cmdGet } = await import("./commands/memory.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdGet(backend, memoryId, { output });
});
// ── Memory: list ──────────────────────────────────────────────────────────
program
.command("list")
.description("List memories with optional filters.")
.option("-u, --user-id <id>", "Filter by user.")
.option("--agent-id <id>", "Filter by agent.")
.option("--app-id <id>", "Filter by app.")
.option("--run-id <id>", "Filter by run.")
.option("--page <n>", "Page number.", (v) => Number.parseInt(v), 1)
.option(
"--page-size <n>",
"Results per page.",
(v) => Number.parseInt(v),
100,
)
.option("--category <name>", "Filter by category.")
.option("--after <date>", "Created after (YYYY-MM-DD).")
.option("--before <date>", "Created before (YYYY-MM-DD).")
.option("-o, --output <format>", "Output: text, json, table.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 list -u alice\n $ mem0 list --category prefs --after 2024-01-01 -o json",
)
.action(async (opts) => {
const { cmdList } = await import("./commands/memory.js");
const isAgent = checkAgentMode();
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = resolveIds(config, opts);
const output = isAgent ? "agent" : opts.output;
await cmdList(backend, {
...ids,
page: opts.page,
pageSize: opts.pageSize,
category: opts.category,
after: opts.after,
before: opts.before,
output,
});
});
// ── Memory: update ────────────────────────────────────────────────────────
program
.command("update <memoryId> [text]")
.description("Update a memory's text or metadata.")
.option("-m, --metadata <json>", "Update metadata (JSON).")
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
`\nExamples:\n $ mem0 update abc-123 "new text"\n $ mem0 update abc-123 --metadata '{"key":"val"}'\n $ echo "new text" | mem0 update abc-123`,
)
.action(async (memoryId, text, opts) => {
let resolvedText = text;
if (!resolvedText && !opts.metadata && !process.stdin.isTTY) {
resolvedText = fs.readFileSync(0, "utf-8").trim();
}
const { cmdUpdate } = await import("./commands/memory.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdUpdate(backend, memoryId, resolvedText, {
metadata: opts.metadata,
output,
});
});
// ── Memory: delete (consolidated) ─────────────────────────────────────────
program
.command("delete [memoryId]")
.description("Delete a memory, all memories matching a scope, or an entity.")
.option("--all", "Delete all memories matching scope filters.", false)
.option(
"--entity",
"Delete the entity itself and all its memories (cascade).",
false,
)
.option("--project", "With --all: delete ALL memories project-wide.", false)
.option("--dry-run", "Show what would be deleted without deleting.", false)
.option("--force", "Skip confirmation.", false)
.option("-u, --user-id <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.")
.option("--run-id <id>", "Scope to run.")
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
[
"\nExamples:",
" $ mem0 delete abc-123-def-456 # single memory",
" $ mem0 delete --all -u alice --force # all memories for user",
" $ mem0 delete --all --project --force # project-wide wipe",
" $ mem0 delete --entity -u alice --force # entity + all its memories",
].join("\n"),
)
.action(async (memoryId, opts) => {
const isAgent = checkAgentMode();
const output = isAgent ? "agent" : opts.output;
// ── Mutual-exclusion checks ──
if (memoryId && opts.all) {
printError("Cannot combine <memoryId> with --all. Use one or the other.");
process.exit(1);
}
if (memoryId && opts.entity) {
printError(
"Cannot combine <memoryId> with --entity. Use one or the other.",
);
process.exit(1);
}
if (opts.all && opts.entity) {
printError("Cannot combine --all with --entity. Use one or the other.");
process.exit(1);
}
if (!memoryId && !opts.all && !opts.entity) {
printError(
"Specify a memory ID, --all, or --entity.\n" +
" mem0 delete <id> Delete a single memory\n" +
" mem0 delete --all [scope] Delete all memories matching scope\n" +
" mem0 delete --entity [scope] Delete an entity and all its memories",
);
process.exit(1);
}
// ── Dispatch: single memory ──
if (memoryId) {
const { cmdDelete } = await import("./commands/memory.js");
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
await cmdDelete(backend, memoryId, {
output,
dryRun: opts.dryRun,
force: opts.force,
});
return;
}
// ── Dispatch: --all ──
if (opts.all) {
const { cmdDeleteAll } = await import("./commands/memory.js");
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = opts.project
? {
userId: undefined,
agentId: undefined,
appId: undefined,
runId: undefined,
}
: resolveIds(config, opts);
await cmdDeleteAll(backend, {
force: opts.force,
dryRun: opts.dryRun,
all: opts.project,
...ids,
output,
});
return;
}
// ── Dispatch: --entity ──
if (opts.entity) {
const { cmdEntitiesDelete } = await import("./commands/entities.js");
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
await cmdEntitiesDelete(backend, { ...opts, output });
return;
}
});
// ── Config subcommands ────────────────────────────────────────────────────
const configCmd = program
.command("config")
.description("Manage mem0 configuration.")
.addHelpCommand(false);
configCmd
.command("show")
.description("Display current configuration (secrets redacted).")
.option("-o, --output <format>", "Output: text, json.", "text")
.addHelpText(
"after",
"\nExamples:\n $ mem0 config show\n $ mem0 config show -o json",
)
.action(async (opts) => {
const { cmdConfigShow } = await import("./commands/config.js");
const isAgent = checkAgentMode();
const output = isAgent ? "agent" : opts.output;
cmdConfigShow({ output });
});
configCmd
.command("get <key>")
.description("Get a configuration value.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 config get platform.api_key\n $ mem0 config get defaults.user_id",
)
.action(async (key) => {
const { cmdConfigGet } = await import("./commands/config.js");
checkAgentMode();
cmdConfigGet(key);
});
configCmd
.command("set <key> <value>")
.description("Set a configuration value.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 config set defaults.user_id alice\n $ mem0 config set platform.base_url https://api.mem0.ai",
)
.action(async (key, value) => {
const { cmdConfigSet } = await import("./commands/config.js");
checkAgentMode();
cmdConfigSet(key, value);
});
// ── Entity subcommand group ───────────────────────────────────────────────
const entityCmd = program
.command("entity")
.description("Manage entities.")
.addHelpCommand(false)
.configureHelp({ formatHelp: richFormatHelp });
entityCmd
.command("list <entityType>")
.description("List all entities of a given type.")
.option("-o, --output <format>", "Output: table, json.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 entity list users\n $ mem0 entity list agents -o json",
)
.action(async (entityType, opts) => {
const { cmdEntitiesList } = await import("./commands/entities.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdEntitiesList(backend, entityType, { output });
});
entityCmd
.command("delete")
.description("Delete an entity and ALL its memories (cascade).")
.option("--dry-run", "Show what would be deleted without deleting.", false)
.option("-u, --user-id <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.")
.option("--run-id <id>", "Scope to run.")
.option("--force", "Skip confirmation.", false)
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 entity delete --user-id alice --force\n $ mem0 entity delete --user-id alice --dry-run",
)
.action(async (opts) => {
const { cmdEntitiesDelete } = await import("./commands/entities.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdEntitiesDelete(backend, { ...opts, output });
});
// ── Event subcommands ─────────────────────────────────────────────────────
const eventCmd = program
.command("event")
.description("Inspect background processing events.")
.addHelpCommand(false)
.configureHelp({ formatHelp: richFormatHelp });
eventCmd
.command("list")
.description("List recent background processing events.")
.option("-o, --output <format>", "Output: table, json.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 event list\n $ mem0 event list -o json",
)
.action(async (opts) => {
const { cmdEventList } = await import("./commands/events.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdEventList(backend, { output });
});
eventCmd
.command("status <eventId>")
.description("Check the status of a specific background event.")
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 event status <event-id>\n $ mem0 event status <event-id> -o json",
)
.action(async (eventId, opts) => {
const { cmdEventStatus } = await import("./commands/events.js");
const isAgent = checkAgentMode();
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
const output = isAgent ? "agent" : opts.output;
await cmdEventStatus(backend, eventId, { output });
});
// ── Utility commands ──────────────────────────────────────────────────────
program
.command("status")
.description("Check connectivity and authentication.")
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json")
.action(async (opts) => {
const { cmdStatus } = await import("./commands/utils.js");
const isAgent = checkAgentMode();
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const output = isAgent ? "agent" : opts.output;
await cmdStatus(backend, {
userId: config.defaults.userId || undefined,
agentId: config.defaults.agentId || undefined,
output,
});
});
program
.command("import <filePath>")
.description("Import memories from a JSON file.")
.option("-u, --user-id <id>", "Override user ID.")
.option("--agent-id <id>", "Override agent ID.")
.option("-o, --output <format>", "Output: text, json.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
.addHelpText(
"after",
"\nExamples:\n $ mem0 import data.json --user-id alice\n $ mem0 import data.json -u alice -o json",
)
.action(async (filePath, opts) => {
const { cmdImport } = await import("./commands/utils.js");
const isAgent = checkAgentMode();
const { backend, config } = await getBackendAndConfig(
opts.apiKey,
opts.baseUrl,
);
const ids = resolveIds(config, opts);
const output = isAgent ? "agent" : opts.output;
await cmdImport(backend, filePath, {
userId: ids.userId,
agentId: ids.agentId,
output,
});
});
// ── Help (machine-readable) ──────────────────────────────────────────────
program
.command("help")
.description(
"Show help. Use --json for machine-readable output (for LLM agents).",
)
.option("--json", "Output machine-readable JSON for LLM agents.", false)
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
.action((opts) => {
// opts.json is set when `mem0 help --json` is used (subcommand flag).
// program.opts().json is set when the root --json global flag was used first.
if (opts.json || program.opts().json) {
// Load spec from parent directory
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
if (fs.existsSync(specPath)) {
const spec = JSON.parse(fs.readFileSync(specPath, "utf-8"));
console.log(JSON.stringify(spec, null, 2));
} else {
console.log(
JSON.stringify(
{
name: "mem0",
version: CLI_VERSION,
description: "The Memory Layer for AI Agents",
},
null,
2,
),
);
}
} else {
const { brand: b } = colors;
console.log(
`${b("◆ Mem0 CLI")} v${CLI_VERSION} · Node.js SDK\n The Memory Layer for AI Agents\n`,
);
console.log("Usage: mem0 <command> [OPTIONS]\n");
console.log("Commands:");
console.log(
" add Add a memory from text, messages, file, or stdin",
);
console.log(
" search Query your memory store (semantic, keyword, hybrid)",
);
console.log(" get Get a specific memory by ID");
console.log(" list List memories with optional filters");
console.log(" update Update a memory's text or metadata");
console.log(
" delete Delete a memory, all memories, or an entity",
);
console.log(" import Import memories from a JSON file");
console.log(" config Manage configuration (show, get, set)");
console.log(" entity Manage entities (list, delete)");
console.log(
" event Inspect background events (list, status)",
);
console.log(" init Interactive setup wizard");
console.log(" status Check connectivity and authentication");
console.log();
console.log(" mem0 <command> --help Get help for a command");
console.log(
" mem0 help --json Machine-readable help (for LLM agents)",
);
console.log();
}
});
// ── Entrypoint ────────────────────────────────────────────────────────────
// Surface any unclaimed Agent Mode notice once per command, after the primary
// output. In JSON/agent mode the notice is folded into the envelope by
// formatJsonEnvelope, so skip the stderr banner there to avoid duplication.
function surfaceNotice(): void {
const notice = takeNotice();
if (notice && !isAgentMode()) {
process.stderr.write(`\n\x1b[33m🔔 ${notice}\x1b[0m\n\n`);
}
}
program.parseAsync().finally(() => {
surfaceNotice();
});
+397
View File
@@ -0,0 +1,397 @@
/**
* Output formatting for mem0 CLI — text, JSON, table, quiet modes.
*/
import boxen from "boxen";
import Table from "cli-table3";
import { colors, sym } from "./branding.js";
import { takeNotice } from "./state.js";
const { brand, accent, success, error: errorColor, dim } = colors;
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);
}
}
export function formatMemoriesText(
memories: Record<string, unknown>[],
title = "memories",
): void {
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;
}
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}`);
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: [] },
});
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]);
}
}
console.log();
console.log(table.toString());
console.log();
}
export function formatJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
export function formatSingleMemory(
mem: Record<string, unknown>,
output = "text",
): void {
if (output === "json") {
formatJson(mem);
return;
}
const memoryText = (mem.memory ?? mem.text ?? "") as string;
const memId = (mem.id ?? "") as string;
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}`);
}
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",
): void {
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]);
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;
}
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;
}
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;
}): 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;
// If the platform flagged this as an unclaimed Agent Mode account, surface
// the notice inside the JSON envelope so an agent consuming the output
// sees it without needing to inspect HTTP headers.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { takeNotice } = require("./state.js");
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
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);
// Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
// agent reading the JSON output sees it without inspecting HTTP headers.
const notice = takeNotice();
if (notice) envelope.mem0_notice = notice;
console.log(JSON.stringify(envelope, null, 2));
}
export function printResultSummary(opts: {
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`);
console.log(` ${dim(parts.join(" · "))}`);
console.log();
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Sync the active Mem0 API key into other ecosystem touchpoints.
*
* Why: the CLI canonical state is ~/.mem0/config.json. MCP servers
* (Claude Code plugin, Codex plugin) read MEM0_API_KEY from env or
* their own config files. Without a sync, agent-mode bootstrap mints a
* new key into config.json but the plugin's MCP keeps using the old
* key from env — silent surprise.
*
* Design:
* - Update ONLY entries that already exist; never create new ones
* - Preserve surrounding content, formatting, other keys
* - Atomic writes (tmp + rename) so a crash mid-write doesn't corrupt
* - Idempotent — re-running with the same key is a no-op
*
* Targets:
* - ~/.claude/settings.json::env::MEM0_API_KEY (Claude Code env injection)
* - ~/.zshrc / ~/.bashrc `export MEM0_API_KEY="..."` lines
*
* Out of scope: Codex / Cursor MCP configs and the plugin's own
* <plugin-dir>/.api_key file (plugin-managed, different schema).
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const CLAUDE_SETTINGS = path.join(os.homedir(), ".claude", "settings.json");
const SHELL_RCS = [
path.join(os.homedir(), ".zshrc"),
path.join(os.homedir(), ".bashrc"),
path.join(os.homedir(), ".bash_profile"),
];
// Use [ \t]* (not \s*) so a trailing newline at end-of-file is preserved
// when the MEM0_API_KEY export is the last line of the rc file.
const RC_LINE_RE =
/^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["']?)([^"'\n]*)(["']?)[ \t]*$/m;
export function syncApiKey(apiKey: string): string[] {
if (!apiKey) return [];
const updated: string[] = [];
if (updateClaudeSettings(CLAUDE_SETTINGS, apiKey)) {
updated.push(CLAUDE_SETTINGS);
}
for (const rc of SHELL_RCS) {
if (updateShellRc(rc, apiKey)) updated.push(rc);
}
return updated;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateClaudeSettings(
filePath: string,
apiKey: string,
): boolean {
if (!fs.existsSync(filePath)) return false;
let raw: string;
let data: Record<string, unknown>;
try {
raw = fs.readFileSync(filePath, "utf-8");
data = JSON.parse(raw);
} catch {
return false;
}
const env = data.env;
if (!env || typeof env !== "object" || !("MEM0_API_KEY" in env)) {
return false; // no existing entry — don't create one
}
const envObj = env as Record<string, string>;
if (envObj.MEM0_API_KEY === apiKey) return false; // already in sync
envObj.MEM0_API_KEY = apiKey;
atomicWriteText(filePath, `${JSON.stringify(data, null, 2)}\n`);
return true;
}
/** @internal — exported for unit tests; consumers should use {@link syncApiKey}. */
export function updateShellRc(filePath: string, apiKey: string): boolean {
if (!fs.existsSync(filePath)) return false;
let text: string;
try {
text = fs.readFileSync(filePath, "utf-8");
} catch {
return false;
}
const match = text.match(RC_LINE_RE);
if (!match) return false; // no existing line
if (match[3] === apiKey) return false;
const newText = text.replace(
RC_LINE_RE,
(_full, prefix) => `${prefix}"${apiKey}"`,
);
atomicWriteText(filePath, newText);
return true;
}
function atomicWriteText(filePath: string, content: string): void {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
try {
fs.writeFileSync(tmp, content, "utf-8");
// Preserve permissions if original existed.
if (fs.existsSync(filePath)) {
try {
const mode = fs.statSync(filePath).mode & 0o777;
fs.chmodSync(tmp, mode);
} catch {
/* best-effort */
}
}
fs.renameSync(tmp, filePath);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Agent mode state — set by the root program option handler,
* read by commands and branding functions.
*/
let _agentMode = false;
let _currentCommand = "";
let _pendingNotice = "";
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;
}
/**
* Stash a Mem0 backend notice (Agent Mode unclaimed reminder) for end-of-
* command surfacing. Called from the platform backend after each response so
* the notice prints once per command regardless of how many sub-requests
* fired. Last-write-wins is fine — the message text is identical.
*/
export function captureNotice(notice: string | null | undefined): void {
if (notice) _pendingNotice = notice;
}
export function takeNotice(): string {
const msg = _pendingNotice;
_pendingNotice = "";
return msg;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* CLI telemetry — anonymous usage tracking via PostHog.
*
* Sends fire-and-forget events by spawning a detached child process
* (telemetry-sender.cjs). The parent CLI process exits immediately;
* the child handles email resolution, caching, and the HTTP POST.
*
* Disable with: MEM0_TELEMETRY=false
*/
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CONFIG_FILE, loadConfig, saveConfig } from "./config.js";
import { CLI_VERSION } from "./version.js";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SENDER_SCRIPT = path.join(__dirname, "..", "telemetry-sender.cjs");
function isTelemetryEnabled(): boolean {
try {
return process.env.MEM0_TELEMETRY !== "false";
} catch {
return true;
}
}
/**
* Return a persistent per-machine anonymous ID, generating one if needed.
*
* Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that
* repeat runs on the same machine share one PostHog identity instead of
* collapsing into a single shared fallback string.
*/
function getOrCreateAnonymousId(): string {
const config = loadConfig();
if (config.telemetry.anonymousId) {
return config.telemetry.anonymousId;
}
const newId = `cli-anon-${randomUUID().replace(/-/g, "")}`;
config.telemetry.anonymousId = newId;
try {
saveConfig(config);
} catch {
/* ignore persistence failure — still return the generated ID */
}
return newId;
}
/**
* Return a stable anonymous identifier for the current user.
*
* Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
* persistent per-machine anonymous ID.
*/
function getDistinctId(): string {
try {
const config = loadConfig();
if (config.platform.userEmail) {
return config.platform.userEmail;
}
if (config.platform.apiKey) {
return createHash("md5").update(config.platform.apiKey).digest("hex");
}
} catch {
/* ignore */
}
try {
return getOrCreateAnonymousId();
} catch {
return `cli-anon-${randomUUID().replace(/-/g, "")}`;
}
}
/**
* Fire a PostHog event (non-blocking, returns void, never throws).
* Spawns telemetry-sender.cjs as a detached subprocess.
*
* When `preResolvedEmail` is provided (e.g. from an upfront ping
* validation), it is used directly as the PostHog distinct ID and the
* subprocess skips its own `/v1/ping/` call.
*/
export function captureEvent(
eventName: string,
properties: Record<string, unknown> = {},
preResolvedEmail?: string,
): void {
if (!isTelemetryEnabled()) return;
try {
const config = loadConfig();
const distinctId = preResolvedEmail || getDistinctId();
// Detect anonymous → identified transition. If a stored anonymous_id
// exists and we just resolved to a real identity, fire a one-shot
// $identify event so PostHog stitches the pre-signup history onto
// the authenticated profile. Clear the stored id so we don't re-alias.
let anonIdToAlias: string | null = null;
if (
distinctId &&
!distinctId.startsWith("cli-anon-") &&
config.telemetry.anonymousId
) {
anonIdToAlias = config.telemetry.anonymousId;
config.telemetry.anonymousId = "";
try {
saveConfig(config);
} catch {
/* ignore — alias may double-fire next run, harmless */
}
}
// M4: every cli.* event carries agent_mode based on the config flag
// (unclaimed Agent Mode key). This is the growth-doc property used to
// join init → add → search funnels in PostHog.
const payload = {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
event: eventName,
properties: {
source: "CLI",
language: "node",
cli_version: CLI_VERSION,
agent_mode: Boolean(config.platform.agentMode),
node_version: process.version,
os: process.platform,
...properties,
$process_person_profile: false,
$lib: "posthog-node",
},
};
const context = {
payload,
posthogHost: POSTHOG_HOST,
needsEmail: !distinctId || !distinctId.includes("@"),
mem0ApiKey: config.platform.apiKey || "",
mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai",
configPath: CONFIG_FILE,
anonDistinctIdToAlias: anonIdToAlias,
};
const child = spawn(process.execPath, [SENDER_SCRIPT], {
detached: true,
stdio: ["pipe", "ignore", "ignore"],
});
child.stdin?.end(JSON.stringify(context));
child.unref();
} catch {
/* silently swallow */
}
}
+10
View File
@@ -0,0 +1,10 @@
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;
+155
View File
@@ -0,0 +1,155 @@
/**
* Standalone telemetry sender — runs as a detached child process.
*
* Usage: node telemetry-sender.cjs (JSON context is read from stdin; a single
* argv argument is still accepted as a legacy fallback)
*
* This script is spawned by telemetry.captureEvent() and runs independently
* of the parent CLI process. It:
*
* 1. Resolves the user's email via /v1/ping/ if not already cached
* 2. Caches the email in ~/.mem0/config.json for future runs
* 3. Sends the PostHog event
*
* All errors are silently swallowed — this process must never produce output
* or affect the user experience.
*/
"use strict";
const https = require("https");
const fs = require("fs");
function loadContext() {
return new Promise((resolve, reject) => {
if (process.argv[2]) {
try {
resolve(JSON.parse(process.argv[2]));
} catch (err) {
reject(err);
}
return;
}
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (err) {
reject(err);
}
});
process.stdin.on("error", reject);
});
}
function httpsRequest(url, method, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const opts = {
hostname: u.hostname,
path: u.pathname + u.search,
method,
headers,
timeout: 10000,
};
const req = https.request(opts, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch {
resolve({});
}
});
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("timeout"));
});
if (body) {
req.end(body);
} else {
req.end();
}
});
}
async function resolveAndCacheEmail(ctx, payload) {
try {
const pingUrl = ctx.mem0BaseUrl.replace(/\/+$/, "") + "/v1/ping/";
const data = await httpsRequest(pingUrl, "GET", {
Authorization: "Token " + ctx.mem0ApiKey,
"Content-Type": "application/json",
});
if (data.user_email) {
payload.distinct_id = data.user_email;
cacheEmail(ctx.configPath, data.user_email);
}
} catch {
// silently swallow
}
}
function cacheEmail(configPath, email) {
if (!configPath) return;
try {
const raw = fs.readFileSync(configPath, "utf-8");
const cfg = JSON.parse(raw);
if (!cfg.platform) cfg.platform = {};
cfg.platform.user_email = email;
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
} catch {
// silently swallow
}
}
async function sendPosthogEvent(posthogHost, payload) {
try {
const body = JSON.stringify(payload);
await httpsRequest(posthogHost, "POST", {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
}, body);
} catch {
// silently swallow
}
}
async function sendIdentifyEvent(ctx, payload, anonId) {
const identifyPayload = {
api_key: payload.api_key,
event: "$identify",
distinct_id: payload.distinct_id,
properties: {
$anon_distinct_id: anonId,
$lib: (payload.properties && payload.properties.$lib) || "posthog-node",
},
};
await sendPosthogEvent(ctx.posthogHost, identifyPayload);
}
async function main() {
const ctx = await loadContext();
const payload = ctx.payload;
if (ctx.needsEmail && ctx.mem0ApiKey) {
await resolveAndCacheEmail(ctx, payload);
}
// Fire $identify *after* email resolution so PostHog links the stored
// anonymous id directly to the final identity (email, not the api-key
// hash). The regular event is sent next so it lands under the merged
// profile.
if (ctx.anonDistinctIdToAlias) {
await sendIdentifyEvent(ctx, payload, ctx.anonDistinctIdToAlias);
}
await sendPosthogEvent(ctx.posthogHost, payload);
}
main().catch(() => {});
+141
View File
@@ -0,0 +1,141 @@
/**
* Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
*
* Mirror of `cli/python/tests/test_agent_mode.py` — both files MUST stay
* in sync so that the Python and Node CLIs expose an identical surface
* for the Agent Mode entrypoint. If you add a flag here, add the same
* assertion on the Python side (and vice versa).
*
* Network-bound bootstrap is covered by the platform-side E2E suite
* (`backend/tests/e2e/test_05_agent_mode.py`); these tests only verify
* the CLI surface that ships in the binary.
*/
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function run(
args: string[],
opts: { home?: string; env?: Record<string, string> } = {},
): { stdout: string; stderr: string; exitCode: number } {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("MEM0_")) delete env[key];
}
if (opts.home) env.HOME = opts.home;
if (opts.env) Object.assign(env, opts.env);
try {
const stdout = execSync(`npx tsx src/index.ts ${args.join(" ")}`, {
cwd: path.join(__dirname, ".."),
env,
encoding: "utf-8",
timeout: 15000,
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: e.status ?? 1,
};
}
}
function cleanHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
}
describe("init flag surface", () => {
it("init --help lists --agent", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--agent");
});
it("init --help describes Agent Mode", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
// Description must mention what --agent actually does so an agent
// reading the help can self-discover the bootstrap entrypoint.
expect(
result.stdout.includes("Agent Mode") ||
result.stdout.toLowerCase().includes("unattended"),
).toBe(true);
});
it("init --help lists --source", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--source");
});
it("init --help lists --email and --code", () => {
const result = run(["init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--email");
expect(result.stdout).toContain("--code");
});
});
describe("argv preprocessing — --agent reaches init subcommand", () => {
// Regression for the bug where the global --agent JSON-alias swallowed
// the init-level --agent flag, making `mem0 init --agent` behave like
// the plain interactive wizard.
it("init --agent triggers bootstrap branch (not the wizard)", () => {
const home = cleanHome();
const result = run(["init", "--agent"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1", // blackhole
FORCE_COLOR: "0",
},
});
const combined = (result.stdout + result.stderr).toLowerCase();
// Either bootstrap-attempt error, or a connection/network error —
// both prove the --agent path executed (the wizard would prompt for
// input and succeed/hang, not surface a network error).
expect(
combined.includes("agent") ||
combined.includes("connect") ||
combined.includes("network") ||
combined.includes("fetch") ||
combined.includes("bootstrap"),
).toBe(true);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("JSON envelope on network failure", () => {
it("init --agent --json does not leak a stack trace when backend is unreachable", () => {
const home = cleanHome();
const result = run(["init", "--agent", "--json"], {
home,
env: {
MEM0_BASE_URL: "http://127.0.0.1:1",
FORCE_COLOR: "0",
},
});
const combined = result.stdout + result.stderr;
// No raw Node stack should escape the agent-mode handler.
expect(combined).not.toMatch(/at \w+\s*\(.+\.ts:\d+/);
expect(combined).not.toContain("UnhandledPromiseRejection");
expect(result.exitCode).not.toBe(0);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("top-level help lists init", () => {
// `mem0 --help` must list `init` so agents walking the top-level help
// can discover the Agent Mode entrypoint without prior knowledge.
it("--help lists init", () => {
const result = run(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("init");
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Tests for branding utilities.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
BRAND_COLOR,
SUCCESS_COLOR,
ERROR_COLOR,
TAGLINE,
LOGO_MINI,
printSuccess,
printError,
printWarning,
printInfo,
printScope,
} from "../src/branding.js";
let output: string;
let errOutput: string;
const originalLog = console.log;
const originalError = console.error;
beforeEach(() => {
output = "";
errOutput = "";
console.log = (...args: unknown[]) => {
output += args.map(String).join(" ") + "\n";
};
console.error = (...args: unknown[]) => {
errOutput += args.map(String).join(" ") + "\n";
};
});
afterEach(() => {
console.log = originalLog;
console.error = originalError;
});
describe("branding constants", () => {
it("has correct brand color", () => {
expect(BRAND_COLOR).toBe("#8b5cf6");
});
it("has correct tagline", () => {
expect(TAGLINE).toBe("The Memory Layer for AI Agents");
});
it("has correct logo mini", () => {
expect(LOGO_MINI).toBe("◆ mem0");
});
});
describe("printSuccess", () => {
it("prints success message", () => {
printSuccess("Operation completed");
expect(output).toContain("Operation completed");
});
});
describe("printError", () => {
it("prints error message to stderr", () => {
printError("Something failed");
expect(errOutput).toContain("Something failed");
});
it("prints hint when provided to stderr", () => {
printError("Failed", "Try again");
expect(errOutput).toContain("Try again");
});
});
describe("printWarning", () => {
it("prints warning message to stderr", () => {
printWarning("Be careful");
expect(errOutput).toContain("Be careful");
});
});
describe("printInfo", () => {
it("prints info message", () => {
printInfo("Important note");
expect(errOutput).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");
});
it("prints nothing when no IDs", () => {
printScope({});
expect(errOutput).toBe("");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Integration tests — invoke CLI as subprocess to test end-to-end.
*/
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function run(
args: string[],
opts: { home?: string; env?: Record<string, string> } = {},
): { stdout: string; stderr: string; exitCode: number } {
const env = { ...process.env };
// Strip MEM0_ env vars
for (const key of Object.keys(env)) {
if (key.startsWith("MEM0_")) delete env[key];
}
if (opts.home) env.HOME = opts.home;
if (opts.env) Object.assign(env, opts.env);
try {
const stdout = execSync(
`npx tsx src/index.ts ${args.join(" ")}`,
{ cwd: path.join(__dirname, ".."), env, encoding: "utf-8", timeout: 15000 },
);
return { stdout, stderr: "", exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: e.status ?? 1,
};
}
}
describe("CLI Integration — help and version", () => {
it("shows help with --help", () => {
const result = run(["--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("mem0");
expect(result.stdout).toContain("add");
expect(result.stdout).toContain("search");
});
it("help --json produces valid JSON", () => {
const result = run(["help", "--json"]);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout);
// spec may have cli.name or top-level name
const name = parsed.name ?? parsed.cli?.name;
expect(name).toBe("mem0");
});
it("shows add help", () => {
const result = run(["add", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("user-id");
expect(result.stdout).toContain("messages");
});
it("shows search help", () => {
const result = run(["search", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("top-k");
});
it("shows list help", () => {
const result = run(["list", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("page-size");
});
it("shows delete help with --all, --entity, --project", () => {
const result = run(["delete", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--all");
expect(result.stdout).toContain("--entity");
expect(result.stdout).toContain("--project");
expect(result.stdout).toContain("--force");
expect(result.stdout.toLowerCase()).toContain("memory");
});
it("delete with no args errors", () => {
const result = run(["delete"]);
expect(result.exitCode).not.toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).toContain("--all");
});
it("shows entity list help", () => {
const result = run(["entity", "list", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout.toLowerCase()).toContain("entitytype");
});
it("shows entity delete help", () => {
const result = run(["entity", "delete", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--user-id");
expect(result.stdout).toContain("--force");
});
it("shows import help", () => {
const result = run(["import", "--help"]);
expect(result.exitCode).toBe(0);
});
it("add help has --output flag", () => {
const result = run(["add", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--output");
});
it("search help has --rerank flag", () => {
const result = run(["search", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--rerank");
});
it("list help has --category flag", () => {
const result = run(["list", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("--category");
});
});
describe("CLI Integration — isolated (clean home)", () => {
function cleanHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
}
it("add without API key errors", () => {
const home = cleanHome();
const result = run(["add", "test", "--user-id", "alice"], { home });
expect(result.exitCode).not.toBe(0);
const combined = result.stdout + result.stderr;
expect(combined.toLowerCase()).toMatch(/api.key|error/i);
fs.rmSync(home, { recursive: true, force: true });
});
it("config show works with clean home", () => {
const home = cleanHome();
const result = run(["config", "show"], { home });
expect(result.exitCode).toBe(0);
fs.rmSync(home, { recursive: true, force: true });
});
});
+466
View File
@@ -0,0 +1,466 @@
/**
* Tests for CLI commands using mock backend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Command } from "commander";
import { createMockBackend } from "./setup.js";
import type { Backend } from "../src/backend/base.js";
import { setAgentMode } from "../src/state.js";
let mockBackend: Backend;
// Capture console.log and console.error output
let output: string;
let errOutput: string;
const originalLog = console.log;
const originalError = console.error;
beforeEach(() => {
mockBackend = createMockBackend();
output = "";
errOutput = "";
console.log = (...args: unknown[]) => {
output += args.map(String).join(" ") + "\n";
};
console.error = (...args: unknown[]) => {
errOutput += args.map(String).join(" ") + "\n";
};
});
// Restore after each test
import { afterEach } from "vitest";
afterEach(() => {
console.log = originalLog;
console.error = originalError;
setAgentMode(false);
});
describe("cmdAdd", () => {
it("adds text memory", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
await cmdAdd(mockBackend, "I prefer dark mode", {
userId: "alice",
immutable: false,
output: "text",
});
expect(mockBackend.add).toHaveBeenCalledOnce();
});
it("adds from messages JSON", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
await cmdAdd(mockBackend, undefined, {
userId: "alice",
messages: JSON.stringify([{ role: "user", content: "I love Python" }]),
immutable: false,
output: "text",
});
expect(mockBackend.add).toHaveBeenCalledOnce();
});
it("outputs json format", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
await cmdAdd(mockBackend, "test", {
userId: "alice",
immutable: false,
output: "json",
});
expect(output).toContain("results");
});
it("quiet mode produces no memory content", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
await cmdAdd(mockBackend, "test", {
userId: "alice",
immutable: false,
output: "quiet",
});
expect(output).not.toContain("dark mode");
});
});
describe("cmdAdd forwards --no-infer (regression for #5261)", () => {
it("forwards infer: false when --no-infer is set", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
// `infer: false` is the shape Commander produces for `--no-infer`.
await cmdAdd(mockBackend, "store me verbatim", {
userId: "alice",
immutable: false,
infer: false,
output: "text",
});
expect(mockBackend.add).toHaveBeenCalledWith(
"store me verbatim",
undefined,
expect.objectContaining({ infer: false }),
);
});
it("forwards infer: true by default (flag absent)", async () => {
const { cmdAdd } = await import("../src/commands/memory.js");
await cmdAdd(mockBackend, "infer me", {
userId: "alice",
immutable: false,
output: "text",
});
expect(mockBackend.add).toHaveBeenCalledWith(
"infer me",
undefined,
expect.objectContaining({ infer: true }),
);
});
it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => {
// Pins the assumption the fix relies on: Commander's `--no-X` option
// populates the positive camelCase key (`infer`), never `noInfer`.
const withFlag = new Command();
withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
withFlag.parse(["--no-infer"], { from: "user" });
expect(withFlag.opts().infer).toBe(false);
expect(withFlag.opts().noInfer).toBeUndefined();
const withoutFlag = new Command();
withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
withoutFlag.parse([], { from: "user" });
expect(withoutFlag.opts().infer).toBe(true);
});
});
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,
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,
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,
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");
await cmdSearch(mockBackend, "preferences", {
userId: "alice",
topK: 10,
threshold: 0.3,
rerank: false,
keyword: false,
output: "text",
});
expect(output).toContain("Found 2");
});
it("outputs json format", async () => {
const { cmdSearch } = await import("../src/commands/memory.js");
await cmdSearch(mockBackend, "preferences", {
userId: "alice",
topK: 10,
threshold: 0.3,
rerank: false,
keyword: false,
output: "json",
});
expect(output).toContain("memory");
});
it("shows no results message", async () => {
(mockBackend.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const { cmdSearch } = await import("../src/commands/memory.js");
await cmdSearch(mockBackend, "nonexistent", {
userId: "alice",
topK: 10,
threshold: 0.3,
rerank: false,
keyword: false,
output: "text",
});
expect(errOutput).toContain("No memories found");
});
});
describe("cmdGet", () => {
it("gets memory in text mode", async () => {
const { cmdGet } = await import("../src/commands/memory.js");
await cmdGet(mockBackend, "abc-123-def-456", { output: "text" });
expect(output).toContain("dark mode");
});
it("gets memory in json mode", async () => {
const { cmdGet } = await import("../src/commands/memory.js");
await cmdGet(mockBackend, "abc-123-def-456", { output: "json" });
expect(output).toContain("memory");
});
});
describe("cmdList", () => {
it("lists in table mode", async () => {
const { cmdList } = await import("../src/commands/memory.js");
await cmdList(mockBackend, {
userId: "alice",
page: 1,
pageSize: 100,
output: "table",
});
expect(output).toContain("dark mode");
});
it("shows empty message", async () => {
(mockBackend.listMemories as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const { cmdList } = await import("../src/commands/memory.js");
await cmdList(mockBackend, {
userId: "alice",
page: 1,
pageSize: 100,
output: "text",
});
expect(errOutput).toContain("No memories found");
});
});
describe("cmdUpdate", () => {
it("updates memory", async () => {
const { cmdUpdate } = await import("../src/commands/memory.js");
await cmdUpdate(mockBackend, "abc-123", "New text", { output: "text" });
expect(output.toLowerCase()).toContain("updated");
});
});
describe("cmdDelete", () => {
it("deletes memory", async () => {
const { cmdDelete } = await import("../src/commands/memory.js");
await cmdDelete(mockBackend, "abc-123", { output: "text" });
expect(output.toLowerCase()).toContain("deleted");
});
});
describe("cmdDeleteAll", () => {
it("deletes all with force", async () => {
const { cmdDeleteAll } = await import("../src/commands/memory.js");
await cmdDeleteAll(mockBackend, {
force: true,
userId: "alice",
output: "text",
});
expect(output.toLowerCase()).toContain("deleted");
});
});
describe("cmdEntitiesList", () => {
it("lists users in table mode", async () => {
const { cmdEntitiesList } = await import("../src/commands/entities.js");
await cmdEntitiesList(mockBackend, "users", { output: "table" });
expect(output).toContain("alice");
});
it("lists in json mode", async () => {
const { cmdEntitiesList } = await import("../src/commands/entities.js");
await cmdEntitiesList(mockBackend, "users", { output: "json" });
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,
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,
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,
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");
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Tests for configuration management.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createDefaultConfig,
loadConfig,
saveConfig,
redactKey,
getNestedValue,
setNestedValue,
CONFIG_DIR,
CONFIG_FILE,
} from "../src/config.js";
// Use a temp directory for config during tests
let origConfigDir: string;
let origConfigFile: string;
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
// Monkey-patch the module-level constants
// We'll use env vars and direct file manipulation instead
// Clear MEM0_ env vars
for (const key of Object.keys(process.env)) {
if (key.startsWith("MEM0_")) {
delete process.env[key];
}
}
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("redactKey", () => {
it("returns '(not set)' for empty key", () => {
expect(redactKey("")).toBe("(not set)");
});
it("redacts short key", () => {
expect(redactKey("abc")).toBe("ab***");
});
it("redacts normal key", () => {
const result = redactKey("m0-abcdefgh12345678");
expect(result).toBe("m0-a...5678");
expect(result).not.toContain("abcdefgh");
});
it("redacts exactly 8-char key as short", () => {
expect(redactKey("12345678")).toBe("12***");
});
});
describe("createDefaultConfig", () => {
it("has correct defaults", () => {
const config = createDefaultConfig();
expect(config.platform.baseUrl).toBe("https://api.mem0.ai");
expect(config.platform.apiKey).toBe("");
expect(config.defaults.userId).toBe("");
});
});
describe("getNestedValue", () => {
it("gets platform.api_key", () => {
const config = createDefaultConfig();
config.platform.apiKey = "test-key";
expect(getNestedValue(config, "platform.api_key")).toBe("test-key");
});
it("returns undefined for nonexistent key", () => {
const config = createDefaultConfig();
expect(getNestedValue(config, "nonexistent.key")).toBeUndefined();
});
it("gets defaults.user_id", () => {
const config = createDefaultConfig();
config.defaults.userId = "alice";
expect(getNestedValue(config, "defaults.user_id")).toBe("alice");
});
});
describe("setNestedValue", () => {
it("sets platform.api_key", () => {
const config = createDefaultConfig();
expect(setNestedValue(config, "platform.api_key", "new-key")).toBe(true);
expect(config.platform.apiKey).toBe("new-key");
});
it("returns false for nonexistent key", () => {
const config = createDefaultConfig();
expect(setNestedValue(config, "nonexistent.key", "val")).toBe(false);
});
it("sets defaults.user_id", () => {
const config = createDefaultConfig();
expect(setNestedValue(config, "defaults.user_id", "bob")).toBe(true);
expect(config.defaults.userId).toBe("bob");
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Unit tests for init internals — decision tree primitives + plugin sync.
*
* Mirror of `cli/python/tests/test_init_internals.py`. Both files MUST stay
* in sync — if you add a behavioral assertion here, mirror it on the Python
* side and vice versa.
*
* - `pingKey` must NOT treat network errors as "invalid key" (else a VPN
* flap silently mints a new shadow over a working key).
* - `plugin_sync` must only update entries that already exist, preserve
* trailing newlines, and never mangle other lines.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pingKey } from "../src/commands/init.js";
import { updateClaudeSettings, updateShellRc } from "../src/plugin-sync.js";
// ── pingKey ──────────────────────────────────────────────────────────────
describe("pingKey — network vs auth distinction", () => {
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
vi.restoreAllMocks();
});
it("returns true for 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns false for 401 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns false for 403 (definitively invalid)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 403 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(false);
});
it("returns true for 5xx (transient upstream — prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ status: 503 } as Response);
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on network error (prefer reuse over re-mint)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
it("returns true on timeout (prefer reuse)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("aborted"));
await expect(pingKey("k", "http://x")).resolves.toBe(true);
});
});
// ── updateShellRc ────────────────────────────────────────────────────────
describe("updateShellRc — exists-only contract", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("updates existing export and preserves trailing newline", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="old"\n');
expect(updateShellRc(rc, "newkey")).toBe(true);
expect(fs.readFileSync(rc, "utf-8")).toBe('export MEM0_API_KEY="newkey"\n');
});
it("does NOT create a new export when none exists", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, "alias ll='ls -la'\n");
expect(updateShellRc(rc, "newkey")).toBe(false);
expect(fs.readFileSync(rc, "utf-8")).toBe("alias ll='ls -la'\n");
});
it("preserves surrounding content", () => {
const rc = path.join(tmpDir, ".zshrc");
const original =
"# my zshrc\n" +
"alias ll='ls -la'\n" +
"export MEM0_API_KEY='old'\n" +
"export OTHER=keepme\n";
fs.writeFileSync(rc, original);
updateShellRc(rc, "newkey");
const after = fs.readFileSync(rc, "utf-8");
expect(after).toContain("alias ll='ls -la'\n");
expect(after).toContain("export OTHER=keepme\n");
expect(after).toContain("# my zshrc\n");
expect(after).toContain('export MEM0_API_KEY="newkey"\n');
});
it("is idempotent when value already matches", () => {
const rc = path.join(tmpDir, ".zshrc");
fs.writeFileSync(rc, 'export MEM0_API_KEY="same"\n');
expect(updateShellRc(rc, "same")).toBe(false);
});
it("is a no-op for missing files", () => {
const rc = path.join(tmpDir, ".zshrc"); // does not exist
expect(updateShellRc(rc, "x")).toBe(false);
});
});
// ── updateClaudeSettings ─────────────────────────────────────────────────
describe("updateClaudeSettings — never creates entries", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("does not create env block when none exists", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ otherKey: 1 }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
expect(JSON.parse(fs.readFileSync(settings, "utf-8"))).toEqual({
otherKey: 1,
});
});
it("does not create MEM0_API_KEY entry in existing env block", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, JSON.stringify({ env: { OTHER_KEY: "x" } }));
expect(updateClaudeSettings(settings, "newkey")).toBe(false);
});
it("updates existing entry and preserves siblings", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "old", OTHER: "y" } }, null, 2),
);
expect(updateClaudeSettings(settings, "fresh")).toBe(true);
const data = JSON.parse(fs.readFileSync(settings, "utf-8"));
expect(data.env.MEM0_API_KEY).toBe("fresh");
expect(data.env.OTHER).toBe("y");
});
it("is idempotent when value already matches", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(
settings,
JSON.stringify({ env: { MEM0_API_KEY: "same" } }),
);
expect(updateClaudeSettings(settings, "same")).toBe(false);
});
it("is a no-op for malformed JSON", () => {
const settings = path.join(tmpDir, "settings.json");
fs.writeFileSync(settings, "{ this is not json");
expect(updateClaudeSettings(settings, "x")).toBe(false);
});
});
+198
View File
@@ -0,0 +1,198 @@
/**
* Tests for output formatting.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
formatMemoriesText,
formatMemoriesTable,
formatJson,
formatSingleMemory,
formatAddResult,
printResultSummary,
sanitizeAgentData,
} from "../src/output.js";
let output: string;
const originalLog = console.log;
beforeEach(() => {
output = "";
console.log = (...args: unknown[]) => {
output += args.map(String).join(" ") + "\n";
};
});
afterEach(() => {
console.log = originalLog;
});
const sampleMemories = [
{
id: "abc-123-def-456",
memory: "User prefers dark mode",
score: 0.92,
created_at: "2026-02-15T10:30:00Z",
categories: ["preferences"],
},
{
id: "ghi-789-jkl-012",
memory: "User uses vim keybindings",
score: 0.78,
created_at: "2026-03-01T14:00:00Z",
categories: ["tools"],
},
];
describe("formatMemoriesText", () => {
it("shows count and memory content", () => {
formatMemoriesText(sampleMemories);
expect(output).toContain("Found 2");
expect(output).toContain("dark mode");
expect(output).toContain("vim keybindings");
});
it("shows scores and IDs", () => {
formatMemoriesText(sampleMemories);
expect(output).toContain("0.92");
expect(output).toContain("abc-123-");
});
});
describe("formatMemoriesTable", () => {
it("renders a table with memory content", () => {
formatMemoriesTable(sampleMemories);
expect(output).toContain("dark mode");
});
});
describe("formatJson", () => {
it("outputs valid JSON", () => {
formatJson({ key: "value" });
expect(JSON.parse(output)).toEqual({ key: "value" });
});
});
describe("formatSingleMemory", () => {
it("shows memory text in text mode", () => {
formatSingleMemory(sampleMemories[0], "text");
expect(output).toContain("dark mode");
});
it("outputs JSON in json mode", () => {
formatSingleMemory(sampleMemories[0], "json");
expect(output).toContain("memory");
});
});
describe("formatAddResult", () => {
it("shows ADD event", () => {
formatAddResult({
results: [{ id: "abc-123", memory: "Test", event: "ADD" }],
});
expect(output).toContain("Added");
});
it("shows PENDING event", () => {
formatAddResult({
results: [{ status: "PENDING", event_id: "evt-12345678" }],
});
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", () => {
it("shows count and duration", () => {
printResultSummary({ count: 5, durationSecs: 1.23 });
expect(output).toContain("5 results");
expect(output).toContain("1.23s");
});
it("handles singular", () => {
printResultSummary({ count: 1 });
expect(output).toContain("1 result");
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();
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Tests for the Platform backend (mem0 Platform API client).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlatformBackend } from "../src/backend/platform.js";
import { createDefaultConfig } from "../src/config.js";
function makeBackend(): PlatformBackend {
// apiKey/baseUrl only build request headers; every test spies on _request,
// so no real network calls are made.
return new PlatformBackend(createDefaultConfig().platform);
}
function mockFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue(null) },
json: vi.fn().mockResolvedValue({ message: "ok" }),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("deleteEntities", () => {
it("returns all results keyed by entity type for a multi-entity delete", async () => {
const backend = makeBackend();
const responses: Record<string, unknown> = {
"/v2/entities/user/alice/": { message: "user deleted" },
"/v2/entities/agent/bob/": { message: "agent deleted" },
};
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockImplementation(async (_method: string, path: string) => responses[path]);
const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" });
// Regression: previously only the last entity's response survived.
expect(result).toEqual({
user: { message: "user deleted" },
agent: { message: "agent deleted" },
});
expect(spy).toHaveBeenCalledTimes(2);
});
it("keys a single-entity delete by its type", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" });
const result = await backend.deleteEntities({ userId: "alice" });
expect(result).toEqual({ user: { message: "user deleted" } });
});
it("throws when no entity id is provided", async () => {
const backend = makeBackend();
await expect(backend.deleteEntities({})).rejects.toThrow(
"At least one entity ID is required",
);
});
});
describe("PlatformBackend path encoding", () => {
it("encodes memory IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
await backend.get("mem/a?b#c");
await backend.update("mem/a?b#c", "updated");
await backend.delete("mem/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
]);
});
it("encodes entity and event IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
await backend.deleteEntities({ userId: "org/team?active#frag" });
await backend.getEvent("evt/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
]);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Shared test helpers and mock factories for mem0 CLI tests.
*/
import { vi } from "vitest";
import type { Backend } from "../src/backend/base.js";
/** Create a mock backend with all methods stubbed with sensible defaults. */
export function createMockBackend(): Backend {
return {
add: vi.fn().mockResolvedValue({
results: [
{
id: "abc-123-def-456",
memory: "User prefers dark mode",
event: "ADD",
},
],
}),
search: vi.fn().mockResolvedValue([
{
id: "abc-123-def-456",
memory: "User prefers dark mode",
score: 0.92,
created_at: "2026-02-15T10:30:00Z",
categories: ["preferences"],
},
{
id: "ghi-789-jkl-012",
memory: "User uses vim keybindings",
score: 0.78,
created_at: "2026-03-01T14:00:00Z",
categories: ["tools"],
},
]),
get: vi.fn().mockResolvedValue({
id: "abc-123-def-456",
memory: "User prefers dark mode",
created_at: "2026-02-15T10:30:00Z",
updated_at: "2026-02-20T08:00:00Z",
metadata: { source: "onboarding" },
categories: ["preferences"],
}),
listMemories: vi.fn().mockResolvedValue([
{
id: "abc-123-def-456",
memory: "User prefers dark mode",
created_at: "2026-02-15T10:30:00Z",
categories: ["preferences"],
},
{
id: "ghi-789-jkl-012",
memory: "User uses vim keybindings",
created_at: "2026-03-01T14:00:00Z",
categories: ["tools"],
},
]),
update: vi.fn().mockResolvedValue({ id: "abc-123-def-456", memory: "Updated memory" }),
delete: vi.fn().mockResolvedValue({ status: "deleted" }),
status: vi.fn().mockResolvedValue({
connected: true,
backend: "platform",
base_url: "https://api.mem0.ai",
}),
deleteEntities: vi.fn().mockResolvedValue({ message: "Entity deleted" }),
entities: vi.fn().mockResolvedValue([
{ 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" },
},
],
}),
};
}
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockLoadConfig = vi.fn();
const mockSaveConfig = vi.fn();
const mockSpawn = vi.fn();
vi.mock("../src/config.js", () => ({
CONFIG_FILE: "/tmp/mem0-config.json",
loadConfig: mockLoadConfig,
saveConfig: mockSaveConfig,
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
describe("captureEvent", () => {
beforeEach(() => {
vi.resetModules();
mockLoadConfig.mockReset();
mockSaveConfig.mockReset();
mockSpawn.mockReset();
delete process.env.MEM0_TELEMETRY;
});
it("pipes the telemetry context through stdin instead of argv", async () => {
mockLoadConfig.mockReturnValue({
platform: {
apiKey: "m0-node-secret",
baseUrl: "https://api.mem0.ai",
userEmail: "",
},
telemetry: {
anonymousId: "cli-anon-node",
},
});
const stdin = { end: vi.fn() };
const child = { stdin, unref: vi.fn() };
mockSpawn.mockReturnValue(child);
const { captureEvent } = await import("../src/telemetry.js");
captureEvent("node_test_event", { case: "stdin-secret" });
expect(mockSpawn).toHaveBeenCalledTimes(1);
const [execPath, args, options] = mockSpawn.mock.calls[0];
expect(execPath).toBe(process.execPath);
expect(args).toHaveLength(1);
expect(String(args[0])).toContain("telemetry-sender.cjs");
expect(JSON.stringify(args)).not.toContain("m0-node-secret");
expect(options).toMatchObject({ detached: true, stdio: ["pipe", "ignore", "ignore"] });
expect(stdin.end).toHaveBeenCalledTimes(1);
const payload = JSON.parse(stdin.end.mock.calls[0][0]);
expect(payload.mem0ApiKey).toBe("m0-node-secret");
expect(payload.payload.event).toBe("node_test_event");
expect(child.unref).toHaveBeenCalledTimes(1);
});
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
+15
View File
@@ -0,0 +1,15 @@
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),
},
});
+17
View File
@@ -0,0 +1,17 @@
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),
},
test: {
// Integration tests spawn the CLI via `npx tsx` (15s subprocess
// timeout); the first spawn in a file pays a cold-start cost that can
// exceed vitest's 5s default on CI runners.
testTimeout: 30_000,
},
});
+43
View File
@@ -0,0 +1,43 @@
VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
.PHONY: install dev lint format test build clean publish publish-test shell
$(VENV)/bin/activate:
python3 -m venv $(VENV)
$(PIP) install -U pip
install: $(VENV)/bin/activate
$(PIP) install -e .
dev: $(VENV)/bin/activate
$(PIP) install -e ".[dev]"
lint: dev
$(VENV)/bin/ruff check .
$(VENV)/bin/ruff format --check .
format: dev
$(VENV)/bin/ruff check --fix .
$(VENV)/bin/ruff format .
test: dev
$(VENV)/bin/pytest
build: clean $(VENV)/bin/activate
$(PIP) install hatch
$(VENV)/bin/hatch build
clean:
rm -rf dist/
publish: build
$(VENV)/bin/hatch publish
publish-test: build
$(VENV)/bin/hatch publish --repo test
shell: $(VENV)/bin/activate
@echo "Spawning a new shell with the virtual environment activated..."
@VIRTUAL_ENV=$(CURDIR)/$(VENV) PATH=$(CURDIR)/$(VENV)/bin:$$PATH exec $(SHELL)
+349
View File
@@ -0,0 +1,349 @@
# mem0 CLI (Python)
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Python implementation.
> **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.
## Prerequisites
- Python **3.10+**
## Installation
### Using pipx (recommended)
```bash
pipx install mem0-cli
```
### Using pip
```bash
pip install mem0-cli
```
> **Note:** On macOS with Homebrew Python, `pip install` outside a virtual environment will fail with an `externally-managed-environment` error ([PEP 668](https://peps.python.org/pep-0668/)). Use `pipx` instead, or install inside a virtual environment.
## Quick start
```bash
# Interactive setup wizard
mem0 init
# Or login via email
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
# Search memories
mem0 search "What are Alice's preferences?" --user-id alice
# List all memories for a user
mem0 list --user-id alice
# Get a specific memory
mem0 get <memory-id>
# Update a memory
mem0 update <memory-id> "I switched to light mode"
# Delete a memory
mem0 delete <memory-id>
```
## Commands
### `mem0 init`
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 asks for confirmation before overwriting. Use `--force` to skip the prompt (useful in CI/CD).
```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.
```bash
mem0 add "I prefer dark mode" --user-id alice
mem0 add --file conversation.json --user-id alice
echo "Loves hiking on weekends" | mem0 add --user-id alice
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Scope to a user |
| `--agent-id` | Scope to an agent |
| `--messages` | Conversation messages as JSON |
| `-f, --file` | Read messages from a JSON file |
| `-m, --metadata` | Custom metadata as JSON |
| `--categories` | Categories (JSON array or comma-separated) |
| `--graph / --no-graph` | Enable or disable graph memory extraction |
| `-o, --output` | Output format: `text`, `json`, `quiet` |
### `mem0 search`
Search memories using natural language.
```bash
mem0 search "dietary restrictions" --user-id alice
mem0 search "preferred tools" --user-id alice --output json --top-k 5
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `-k, --top-k` | Number of results (default: 10) |
| `--threshold` | Minimum similarity score (default: 0.3) |
| `--rerank` | Enable reranking |
| `--keyword` | Use keyword search instead of semantic |
| `--filter` | Advanced filter expression (JSON) |
| `--graph / --no-graph` | Enable or disable graph in search |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 list`
List memories with optional filters and pagination.
```bash
mem0 list --user-id alice
mem0 list --user-id alice --category preferences --output json
mem0 list --user-id alice --after 2024-01-01 --page-size 50
```
| Flag | Description |
|------|-------------|
| `-u, --user-id` | Filter by user |
| `--page` | Page number (default: 1) |
| `--page-size` | Results per page (default: 100) |
| `--category` | Filter by category |
| `--after` | Created after date (YYYY-MM-DD) |
| `--before` | Created before date (YYYY-MM-DD) |
| `-o, --output` | Output format: `text`, `json`, `table` |
### `mem0 get`
Retrieve a specific memory by ID.
```bash
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789
mem0 get 7b3c1a2e-4d5f-6789-abcd-ef0123456789 --output json
```
### `mem0 update`
Update the text or metadata of an existing memory.
```bash
mem0 update <memory-id> "Updated preference text"
mem0 update <memory-id> --metadata '{"priority": "high"}'
echo "new text" | mem0 update <memory-id>
```
### `mem0 delete`
Delete a single memory, all memories for a scope, or an entire entity.
```bash
# Delete a single memory
mem0 delete <memory-id>
# Delete all memories for a user
mem0 delete --all --user-id alice --force
# Delete all memories project-wide
mem0 delete --all --project --force
# Preview what would be deleted
mem0 delete --all --user-id alice --dry-run
```
| Flag | Description |
|------|-------------|
| `--all` | Delete all memories matching scope filters |
| `--entity` | Delete the entity and all its memories |
| `--project` | With `--all`: delete all memories project-wide |
| `--dry-run` | Preview without deleting |
| `--force` | Skip confirmation prompt |
### `mem0 import`
Bulk import memories from a JSON file.
```bash
mem0 import data.json --user-id alice
```
The file should be a JSON array where each item has a `memory` (or `text` or `content`) field and optional `user_id`, `agent_id`, and `metadata` fields.
### `mem0 config`
View or modify the local CLI configuration.
```bash
mem0 config show # Display current config (secrets redacted)
mem0 config get api_key # Get a specific value
mem0 config set user_id bob # Set a value
```
### `mem0 entity`
List or delete entities (users, agents, apps, runs).
```bash
mem0 entity list users
mem0 entity list agents --output json
mem0 entity delete --user-id alice --force
```
### `mem0 event`
Inspect background processing events created by async operations (e.g. bulk deletes, large add jobs).
```bash
# List recent events
mem0 event list
# Check the status of a specific event
mem0 event status <event-id>
```
| Flag | Description |
|------|-------------|
| `-o, --output` | Output format: `text`, `json` |
### `mem0 status`
Verify your API connection and display the current project.
```bash
mem0 status
```
### `mem0 version`
Print the CLI version.
```bash
mem0 version
```
## 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
mem0 --agent delete --all --user-id alice --force
```
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`:
| Format | Description |
|--------|-------------|
| `text` | Human-readable with colors and formatting (default) |
| `json` | Structured JSON for piping to `jq` (raw API response) |
| `table` | Tabular format (default for `list`) |
| `quiet` | Minimal — just IDs or status codes |
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
## Global flags
These flags are available on all commands:
| Flag | Description |
|------|-------------|
| `--json` | Enable agent mode: structured JSON envelope output, no colors or spinners |
| `--agent` | Alias for `--json` |
| `--api-key` | Override the configured API key for this request |
| `--base-url` | Override the configured API base URL for this request |
| `-o, --output` | Set the output format |
## Environment variables
| Variable | Description |
|----------|-------------|
| `MEM0_API_KEY` | API key (overrides config file) |
| `MEM0_BASE_URL` | API base URL |
| `MEM0_USER_ID` | Default user ID |
| `MEM0_AGENT_ID` | Default agent ID |
| `MEM0_APP_ID` | Default app ID |
| `MEM0_RUN_ID` | Default run ID |
| `MEM0_ENABLE_GRAPH` | Enable graph memory (`true` / `false`) |
Environment variables take precedence over values in the config file, which take precedence over defaults.
## Development
```bash
cd cli/python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Run during development
python -m mem0_cli --help
mem0 add "test memory" --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.1`)
For a pre-release, use a beta version like `0.2.1b1` and check the **pre-release** checkbox.
## Documentation
Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli).
## License
Apache-2.0
+107
View File
@@ -0,0 +1,107 @@
# Development
## Prerequisites
- Python **3.10+**
- `make` (optional — you can use plain Python commands instead)
All commands below should be run from the `python/` directory:
```bash
cd python
```
## Setup
### Using Make (recommended)
All `make` targets automatically create a virtual environment (`.venv/`) and install the required dependencies — no manual setup needed.
```bash
# Install the CLI in editable mode
make install
# Install with dev tools (tests + linting)
make dev
```
### Using Python directly
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
# Install in editable mode
pip install -e .
# With dev tools
pip install -e ".[dev]"
```
## Make targets
| Target | Description |
| ------------------- | ------------------------------------------------ |
| `make install` | Create venv and install the CLI (editable mode) |
| `make dev` | Create venv and install CLI + dev dependencies |
| `make test` | Run all tests (installs dev deps if needed) |
| `make lint` | Run linter and format check |
| `make format` | Auto-fix lint issues and format code |
| `make build` | Build distribution packages |
| `make clean` | Remove `dist/` |
| `make publish` | Build and publish to PyPI |
| `make publish-test` | Build and publish to Test PyPI |
| `make shell` | Open a new shell with the venv activated |
## Run tests
```bash
# Using Make
make test
# Using Python directly
pytest
# Run a specific test file
pytest tests/test_cli_integration.py
# Run a single test
pytest -k test_help
```
## Run the CLI
```bash
# Using Make — drop into an activated shell
make shell
mem0 --help
# Using Python directly (with venv activated)
source .venv/bin/activate
mem0 --help
mem0 version
# Or run without activating
.venv/bin/mem0 --help
```
## Lint
```bash
# Using Make
make lint # check only
make format # auto-fix
# Using Python directly (with venv activated)
ruff check .
ruff format .
```
## Optional extras
### OSS integration
```bash
pip install -e ".[oss]"
```
+77
View File
@@ -0,0 +1,77 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mem0-cli"
version = "0.2.9"
description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [
{ name = "mem0.ai", email = "founders@mem0.ai" },
]
keywords = ["mem0", "memory", "ai", "agents", "cli"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries",
]
dependencies = [
"typer>=0.9.0",
"rich>=13.0.0",
"httpx>=0.24.0",
]
[project.optional-dependencies]
oss = ["mem0ai>=0.1.0"]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"ruff>=0.1.0",
]
[project.scripts]
mem0 = "mem0_cli.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/mem0_cli"]
[tool.hatch.build.targets.sdist]
include = ["src/mem0_cli"]
[tool.ruff]
target-version = "py310"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort (import sorting)
"W", # pycodestyle warnings
"UP", # pyupgrade (modern Python syntax)
"B", # flake8-bugbear (common bugs)
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long — handled by formatter
"B008", # function call in default arg — required by Typer's Option/Argument pattern
"SIM108", # ternary operator — sometimes less readable
]
[tool.ruff.lint.isort]
known-first-party = ["mem0_cli"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
+3
View File
@@ -0,0 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.9"
+5
View File
@@ -0,0 +1,5 @@
"""Allow running with `python -m mem0_cli`."""
from mem0_cli.app import main
main()
+36
View File
@@ -0,0 +1,36 @@
"""Detect whether the CLI is being invoked from inside an AI-agent context.
Used by `mem0 init` to auto-enter Agent Mode (Rule 3 bootstrap) when an
agent runtime env var is present. The return value is a context **trigger
only** — the canonical agent identity is self-declared by the agent via
``--agent-caller <name>`` (Proof Editor-style) and never sniffed from env
vars to fill the ``agent_caller`` field on the APIKey row.
Returns a short name or None. The list is curated, not exhaustive — env
vars we don't recognise fall through to None (caller treated as
non-agent). Honest reporting depends on ``--agent-caller``; this list is
just enough to enable the zero-friction auto-bootstrap UX.
"""
from __future__ import annotations
import os
_AGENT_CALLER_ENV: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
("cursor", ("CURSOR_AGENT", "CURSOR_SESSION_ID")),
("codex", ("CODEX_CLI", "OPENAI_CODEX")),
("cline", ("CLINE_AGENT", "CLINE")),
("continue", ("CONTINUE_AGENT", "CONTINUE_SESSION")),
("aider", ("AIDER_SESSION",)),
("goose", ("GOOSE_AGENT",)),
("windsurf", ("WINDSURF_AGENT",)),
)
def detect_agent_caller() -> str | None:
"""Return a canonical agent name if any agent env var is set, else None."""
for name, env_vars in _AGENT_CALLER_ENV:
if any(os.environ.get(v) for v in env_vars):
return name
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"""Backend abstraction layer for mem0 CLI."""
from mem0_cli.backend.base import Backend, get_backend
__all__ = ["Backend", "get_backend"]
+115
View File
@@ -0,0 +1,115 @@
"""Abstract backend interface and factory."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from mem0_cli.config import Mem0Config
class Backend(ABC):
"""Abstract interface for mem0 backends."""
@abstractmethod
def add(
self,
content: str | None = None,
messages: list[dict] | None = None,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
metadata: dict | None = None,
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
) -> dict: ...
@abstractmethod
def search(
self,
query: str,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
top_k: int = 10,
threshold: float = 0.3,
rerank: bool = False,
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
) -> list[dict]: ...
@abstractmethod
def get(self, memory_id: str) -> dict: ...
@abstractmethod
def list_memories(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
page: int = 1,
page_size: int = 100,
category: str | None = None,
after: str | None = None,
before: str | None = None,
) -> list[dict]: ...
@abstractmethod
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
) -> dict: ...
@abstractmethod
def delete(
self,
memory_id: str | None = None,
*,
all: bool = False,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict: ...
@abstractmethod
def delete_entities(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict: ...
@abstractmethod
def status(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
) -> dict[str, Any]: ...
@abstractmethod
def entities(self, entity_type: str) -> list[dict]: ...
@abstractmethod
def list_events(self) -> list[dict]: ...
@abstractmethod
def get_event(self, event_id: str) -> dict: ...
def get_backend(config: Mem0Config) -> Backend:
"""Return the Platform backend."""
from mem0_cli.backend.platform import PlatformBackend
return PlatformBackend(config.platform)
+382
View File
@@ -0,0 +1,382 @@
"""Platform (SaaS) backend — communicates with api.mem0.ai."""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
from mem0_cli import __version__
from mem0_cli.backend.base import Backend
from mem0_cli.config import PlatformConfig
def _encode_path_segment(value: Any) -> str:
return quote(str(value), safe="")
class PlatformBackend(Backend):
"""Backend that talks to the mem0 Platform API."""
def __init__(self, config: PlatformConfig) -> None:
self.config = config
self.base_url = config.base_url.rstrip("/")
self._client = httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Token {config.api_key}",
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Client-Language": "python",
"X-Mem0-Client-Version": __version__,
},
timeout=30.0,
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
from mem0_cli.state import capture_notice, is_agent_mode
self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user"
resp = self._client.request(method, path, **kwargs)
if resp.status_code == 401:
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
if resp.status_code == 404:
raise NotFoundError(f"Resource not found: {path}")
if resp.status_code == 400:
# Extract API error detail when available
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
raise APIError(f"Bad request to {path}: {detail}")
resp.raise_for_status()
if resp.status_code == 204:
return {}
data = resp.json()
# Pull the unclaimed-Agent-Mode notice out of the body (or the header
# fallback for endpoints that return non-dict / non-dict-leading
# payloads) and stash it for end-of-command surfacing.
notice = None
if isinstance(data, dict) and "mem0_notice" in data:
notice = data.pop("mem0_notice")
elif (
isinstance(data, list)
and data
and isinstance(data[0], dict)
and "mem0_notice" in data[0]
):
notice = data[0].pop("mem0_notice")
if notice is None:
notice = resp.headers.get("X-Mem0-Notice-Message") or None
capture_notice(notice)
return data
def add(
self,
content: str | None = None,
messages: list[dict] | None = None,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
metadata: dict | None = None,
immutable: bool = False,
infer: bool = True,
expires: str | None = None,
categories: list[str] | None = None,
) -> dict:
payload: dict[str, Any] = {}
if messages:
payload["messages"] = messages
elif content:
payload["messages"] = [{"role": "user", "content": content}]
if user_id:
payload["user_id"] = user_id
if agent_id:
payload["agent_id"] = agent_id
if app_id:
payload["app_id"] = app_id
if run_id:
payload["run_id"] = run_id
if metadata:
payload["metadata"] = metadata
if immutable:
payload["immutable"] = True
if not infer:
payload["infer"] = False
if expires:
payload["expiration_date"] = expires
if categories:
payload["categories"] = categories
payload["source"] = "CLI"
return self._request("POST", "/v3/memories/add/", json=payload)
def _build_filters(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
extra_filters: dict | None = None,
) -> dict | None:
"""Build a filters dict for v3 API endpoints.
Entity IDs are ANDed (all provided IDs must match).
Extra filters (date ranges, categories) are also ANDed.
"""
# If caller passed a pre-built filter structure (e.g. --filter from CLI), use it directly
if extra_filters and ("AND" in extra_filters or "OR" in extra_filters):
return extra_filters
# Build AND conditions for entity IDs
and_conditions: list[dict[str, Any]] = []
if user_id:
and_conditions.append({"user_id": user_id})
if agent_id:
and_conditions.append({"agent_id": agent_id})
if app_id:
and_conditions.append({"app_id": app_id})
if run_id:
and_conditions.append({"run_id": run_id})
# Append any extra filters (dates, categories)
if extra_filters:
for k, v in extra_filters.items():
and_conditions.append({k: v})
if len(and_conditions) == 1:
return and_conditions[0]
elif and_conditions:
return {"AND": and_conditions}
else:
return None
def search(
self,
query: str,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
top_k: int = 10,
threshold: float = 0.3,
rerank: bool = False,
keyword: bool = False,
filters: dict | None = None,
fields: list[str] | None = None,
) -> list[dict]:
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
api_filters = self._build_filters(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
extra_filters=filters,
)
if api_filters:
payload["filters"] = api_filters
if rerank:
payload["rerank"] = True
if keyword:
payload["keyword_search"] = True
if fields:
payload["fields"] = fields
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/search/", json=payload)
return (
result
if isinstance(result, list)
else result.get("results", result.get("memories", []))
)
def get(self, memory_id: str) -> dict:
return self._request(
"GET",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"},
)
def list_memories(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
page: int = 1,
page_size: int = 100,
category: str | None = None,
after: str | None = None,
before: str | None = None,
) -> list[dict]:
payload: dict[str, Any] = {}
params = {"page": str(page), "page_size": str(page_size)}
# Build filters — entity IDs and date filters go inside "filters"
extra: dict[str, Any] = {}
if category:
extra["categories"] = {"contains": category}
if after:
extra["created_at"] = {**(extra.get("created_at", {})), "gte": after}
if before:
extra["created_at"] = {**(extra.get("created_at", {})), "lte": before}
api_filters = self._build_filters(
user_id=user_id,
agent_id=agent_id,
app_id=app_id,
run_id=run_id,
extra_filters=extra if extra else None,
)
if api_filters:
payload["filters"] = api_filters
payload["source"] = "CLI"
result = self._request("POST", "/v3/memories/", json=payload, params=params)
return (
result
if isinstance(result, list)
else result.get("results", result.get("memories", []))
)
def update(
self, memory_id: str, content: str | None = None, metadata: dict | None = None
) -> dict:
payload: dict[str, Any] = {}
if content:
payload["text"] = content
if metadata:
payload["metadata"] = metadata
payload["source"] = "CLI"
return self._request(
"PUT",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
json=payload,
)
def delete(
self,
memory_id: str | None = None,
*,
all: bool = False,
user_id: str | None = None,
agent_id: str | None = None,
app_id: str | None = None,
run_id: str | None = None,
) -> dict:
if all:
params: dict[str, str] = {"source": "CLI"}
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
return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id:
return self._request(
"DELETE",
f"/v1/memories/{_encode_path_segment(memory_id)}/",
params={"source": "CLI"},
)
else:
raise ValueError("Either memory_id or --all is required")
def delete_entities(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
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:
raise ValueError("At least one entity ID is required for delete_entities.")
# Delete each provided entity via the v2 path-based endpoint. Key each
# response by entity type so a multi-entity delete (e.g. --user-id and
# --agent-id together) doesn't discard everything but the last result.
results: dict = {}
for entity_type, entity_id in entities.items():
results[entity_type] = self._request(
"DELETE",
f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/",
params={"source": "CLI"},
)
return results
def ping(self, timeout: float | None = None) -> dict:
"""Call the ping endpoint and return the raw response.
When *timeout* is given it overrides the client-level timeout so that
validation pings can fail fast without blocking the user.
"""
if timeout is not None:
resp = self._client.get("/v1/ping/", timeout=timeout)
if resp.status_code == 401:
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
resp.raise_for_status()
return resp.json()
return self._request("GET", "/v1/ping/")
def status(
self,
*,
user_id: str | None = None,
agent_id: str | None = None,
) -> dict[str, Any]:
"""Check connectivity using the ping endpoint."""
try:
self.ping()
return {"connected": True, "backend": "platform", "base_url": self.base_url}
except Exception as e:
return {"connected": False, "backend": "platform", "error": str(e)}
def entities(self, entity_type: str) -> list[dict]:
result = self._request("GET", "/v1/entities/")
items = result if isinstance(result, list) else result.get("results", [])
# Filter by entity type client-side (API returns all types)
type_map = {"users": "user", "agents": "agent", "apps": "app", "runs": "run"}
target_type = type_map.get(entity_type)
if target_type:
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/{_encode_path_segment(event_id)}/")
class AuthError(Exception):
pass
class NotFoundError(Exception):
pass
class APIError(Exception):
pass
+178
View File
@@ -0,0 +1,178 @@
"""Branding and ASCII art for mem0 CLI."""
import os
import sys
import time
from contextlib import contextmanager
from rich.console import Console
from rich.panel import Panel
from rich.status import Status
from rich.text import Text
# stderr console for spinners, errors, and timing messages
_err = Console(stderr=True)
LOGO = r"""
███╗ ███╗███████╗███╗ ███╗ ██████╗ ██████╗██╗ ██╗
████╗ ████║██╔════╝████╗ ████║██╔═████╗ ██╔════╝██║ ██║
██╔████╔██║█████╗ ██╔████╔██║██║██╔██║ ██║ ██║ ██║
██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║████╔╝██║ ██║ ██║ ██║
██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ╚██████╗███████╗██║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
"""
LOGO_MINI = "◆ mem0"
TAGLINE = "The Memory Layer for AI Agents"
BRAND_COLOR = "#8b5cf6" # Purple
ACCENT_COLOR = "#a78bfa"
SUCCESS_COLOR = "#22c55e"
ERROR_COLOR = "#ef4444"
WARNING_COLOR = "#f59e0b"
DIM_COLOR = "#6b7280"
def _sym(fancy: str, plain: str) -> str:
"""Return *fancy* when stdout is a TTY with colour, else *plain*."""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None:
return plain
return fancy
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}")
content = Text()
content.append_text(logo_text)
content.append_text(tagline)
panel = Panel(
content,
border_style=BRAND_COLOR,
padding=(0, 2),
subtitle=f"[{DIM_COLOR}]Python SDK · v{_get_version()}[/]",
subtitle_align="right",
)
console.print(panel)
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
from rich.markup import escape
sym = _sym("", "[error]")
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
if hint:
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
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}")
@contextmanager
def timed_status(console: Console, message: str):
"""Spinner with automatic timing. Yields a context object for setting the final message.
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):
self.success_msg = ""
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):
yield ctx
except Exception:
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?utm_source=oss&utm_medium=cli-python[/bold][/]"
)
raise
else:
elapsed = time.perf_counter() - start
if ctx.success_msg:
print_success(_err, f"{ctx.success_msg} ({elapsed:.2f}s)")
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}")
if parts:
scope_str = ", ".join(parts)
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
def _get_version() -> str:
from mem0_cli import __version__
return __version__

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