Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 805b466887 | |||
| 284547e2ab | |||
| 87a492c137 | |||
| 859c674956 | |||
| 4ca9d13c47 | |||
| abc57323c2 | |||
| 843ab82905 | |||
| 79793b0d2e | |||
| 5f7ace2aef | |||
| 219b1a6f3d | |||
| 57c8468ce6 | |||
| ddee5f8671 | |||
| fbce5fab14 | |||
| 6a1597c6fb | |||
| c9e8482a35 | |||
| e602923751 | |||
| 70bc9e51d5 | |||
| 0107fd53b8 | |||
| 54a03cc721 | |||
| e95de4ca50 | |||
| a623cfaf76 | |||
| 92491c00c2 | |||
| 9043fbf61e | |||
| c90cbc75a2 | |||
| 58304fc939 | |||
| 397f3414ee | |||
| a734e057cf | |||
| 0fdaa29b4a | |||
| 6d3486ca56 | |||
| ebb9bb2b15 | |||
| 594b4e65d6 | |||
| 1b95c99db4 | |||
| b66cf0f272 | |||
| 72dca1cdf5 | |||
| ece7ff6b84 | |||
| 30ce028a71 | |||
| bd9d27ff50 | |||
| 08b746c9be | |||
| 693e709389 | |||
| 553e275112 | |||
| 43dde3b186 | |||
| cca7551192 | |||
| 5be2630f5b | |||
| 2549a84e5c | |||
| 34ed122ef3 | |||
| db8ac61713 | |||
| 15feaa8ac4 | |||
| 282feaebf2 | |||
| f5dc825d47 | |||
| 32b74e18b7 | |||
| daa4495583 | |||
| cfb5f1776e | |||
| 573e5212a4 | |||
| 8ba225cec8 | |||
| 4b09943092 | |||
| 4e611e8dba | |||
| 5520226b5b | |||
| 00695e3113 | |||
| 7b6790bafb | |||
| 93da5ef8f7 | |||
| c1c5bd62f6 | |||
| 2ec3c4ab20 | |||
| 3fbc1c9aef | |||
| 0b14f75c05 | |||
| fb224083e4 | |||
| 30469aec17 | |||
| 50db9e428d | |||
| fb87349664 | |||
| 8827553576 | |||
| c8e20a9bb5 | |||
| 93a51f4763 | |||
| 86fe275f53 | |||
| e6d6276bb9 | |||
| 9692726db4 | |||
| d8d776636f | |||
| a5a688295e | |||
| 5d40592e42 | |||
| a488e19044 | |||
| 57f944e18a | |||
| fe3f7ae618 | |||
| 4a7e166f9a | |||
| 85768e78e7 | |||
| 7b395f3bf7 | |||
| 4180409b09 | |||
| 649e719ce6 | |||
| 1a53852d93 | |||
| ac9cdd4840 | |||
| cf530c4bec | |||
| 92b958c1cc | |||
| c239d8a483 | |||
| 9d6b79a14e | |||
| e44b46ef2e | |||
| 3882af7450 | |||
| d39ebad09f | |||
| 9d82e2329d | |||
| 789cc9d607 | |||
| e59e3d5f0c | |||
| d926f3697c | |||
| c996b0e7fa | |||
| 78ca85a260 | |||
| 88f696a60a | |||
| 081eca6d8f | |||
| 2434b9d550 | |||
| 3ffea554bc | |||
| 1ad8a59b0c | |||
| a670333d67 | |||
| 4c2db3e68b | |||
| 07f0d4f1e0 | |||
| 3565404eef | |||
| 144627c4ce | |||
| 6984958138 | |||
| b13748c446 | |||
| 4642a1d6e3 | |||
| 686d5e987d | |||
| c55447c1e4 | |||
| ee67602c58 | |||
| 0daa5d7d03 | |||
| cfb3f58e4a | |||
| 66230b3f1f | |||
| 1941cae031 | |||
| fcbb70ab3b | |||
| 33d2bc495d | |||
| c0cae68646 | |||
| 3b2f01796e | |||
| 9cd3d2cca8 | |||
| c53f1f126d | |||
| 0b7615fa87 | |||
| 66d34fab3c | |||
| 868b63af63 | |||
| 6cc1c15320 | |||
| 7a20da59ee | |||
| f89f7c7c81 | |||
| 5723136bed | |||
| b5345f8498 | |||
| 6577ae7616 | |||
| 5e00d5c452 | |||
| 3b152a3e85 | |||
| beca7cc873 | |||
| 30f242dc4c | |||
| 1bfaaf8750 | |||
| c250ccfb5c | |||
| e2b439c42a | |||
| c788d771d3 | |||
| 2acf9571b3 | |||
| 713dba5d0a | |||
| 8ae7a06220 | |||
| f94ea06588 | |||
| 215d8b5a71 | |||
| 82525dbf0f | |||
| 32c1ccba5b | |||
| 9bebcf45f7 | |||
| 93bd4e248c | |||
| af19495f66 | |||
| dbac83218f | |||
| 3618aeff22 | |||
| 25e25aaa2f | |||
| 5d30af9560 | |||
| 47969aaa6b | |||
| 7213e1b1a6 | |||
| 97291aa119 | |||
| b8a5ca1b70 | |||
| 431cba20e9 | |||
| 4482228383 | |||
| ae49edec44 | |||
| 4b7f51d194 | |||
| 41abb571a2 | |||
| e280665578 | |||
| 27a6e7863e | |||
| ae8e03c6b7 | |||
| 376be3b6d4 | |||
| cd2dd7cc54 | |||
| ece654811a | |||
| 13d42a99e9 | |||
| 3225e30859 | |||
| 88fd0e77d0 | |||
| c2698f2b71 | |||
| e87240d4d9 | |||
| 68cf4e118d | |||
| 12624555b4 | |||
| e8004b93db | |||
| 4e414e4015 | |||
| 16455789d4 | |||
| 3ac4e047de | |||
| a2ffca3266 | |||
| 3a9fcdbec2 | |||
| 515f87b6bd | |||
| 5edc0cc99f | |||
| 7fff26f374 | |||
| 1aecfadf45 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mem0-plugins",
|
||||
"interface": {
|
||||
"displayName": "Mem0 Plugins"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mem0",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./mem0-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
if: startsWith(github.event.release.tag_name, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -38,7 +39,7 @@ jobs:
|
||||
# packages_dir: dist/
|
||||
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages_dir: dist/
|
||||
|
||||
@@ -14,8 +14,51 @@ on:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- 'embedchain/**'
|
||||
- 'pyproject.toml'
|
||||
- 'cli/**'
|
||||
- 'docs/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Publish @mem0/cli 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/cli 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'cli-node-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli/node
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.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,100 @@
|
||||
name: CLI Node CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/node/**'
|
||||
- '.github/workflows/cli-node-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/node/**'
|
||||
- '.github/workflows/cli-node-ci.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
working-directory: cli/node
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Type check
|
||||
working-directory: cli/node
|
||||
run: pnpm run typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
working-directory: cli/node
|
||||
run: pnpm run test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: cli/node
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: cli/node
|
||||
run: pnpm run build
|
||||
|
||||
- name: Verify dist output
|
||||
run: |
|
||||
test -f cli/node/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0-cli 📦 to PyPI
|
||||
if: startsWith(github.event.release.tag_name, 'cli-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli/python
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: hatch build --clean
|
||||
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: cli/python/dist/
|
||||
@@ -0,0 +1,79 @@
|
||||
name: CLI Python CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/python/**'
|
||||
- '.github/workflows/cli-python-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/python/**'
|
||||
- '.github/workflows/cli-python-ci.yml'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: cli/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Lint with ruff
|
||||
working-directory: cli/python
|
||||
run: ruff check .
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: cli/python
|
||||
run: ruff format --check .
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dev dependencies
|
||||
working-directory: cli/python
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
working-directory: cli/python
|
||||
run: pytest
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
|
||||
- name: Build
|
||||
working-directory: cli/python
|
||||
run: hatch build --clean
|
||||
|
||||
- name: Verify dist output
|
||||
run: |
|
||||
ls cli/python/dist/*.whl || (echo "Wheel file missing" && exit 1)
|
||||
ls cli/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1)
|
||||
@@ -0,0 +1,45 @@
|
||||
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:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**/*.mdx'
|
||||
- 'docs/llms.txt'
|
||||
- 'scripts/check-llms-txt-coverage.py'
|
||||
- 'scripts/llms-txt-ignore.txt'
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Publish @mem0/openclaw-mem0 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/openclaw-mem0 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'openclaw-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: openclaw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.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,46 @@
|
||||
name: Publish mem0ai 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish mem0ai 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'ts-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mem0-ts
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: mem0-ts/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.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
|
||||
@@ -24,6 +24,42 @@ jobs:
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Publish @mem0/vercel-ai-provider 📦 to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
|
||||
if: startsWith(github.event.release.tag_name, 'vercel-ai-v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: vercel-ai-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: vercel-ai-sdk/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
if [ "${{ github.event.release.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
|
||||
+6
-2
@@ -4,6 +4,10 @@ __pycache__/
|
||||
*$py.class
|
||||
**/node_modules/
|
||||
|
||||
# Self-hosted server local runtime state
|
||||
server/history/
|
||||
server/.env
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
@@ -15,8 +19,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
# 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, documentation, and evaluation tooling.
|
||||
|
||||
### 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` |
|
||||
| `vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
|
||||
| `openclaw/` | `@mem0/openclaw-mem0` — OpenClaw plugin for Claude Code / AI editors |
|
||||
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||
| `mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills |
|
||||
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/` |
|
||||
| `docs/` | Documentation site (Mintlify) |
|
||||
| `tests/` | Python SDK tests (pytest) |
|
||||
| `evaluation/` | Benchmarking framework — LOCOMO evals, experiment runner, score generation |
|
||||
| `examples/` | Sample projects — demo apps, Chrome extension, multi-agent patterns |
|
||||
| `cookbooks/` | Jupyter notebooks — customer support chatbot, AutoGen integration |
|
||||
| `embedchain/` | Legacy Embedchain RAG framework (maintained separately, Poetry-based) |
|
||||
| `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)
|
||||
vercel-ai-sdk/ ──▶ ai, @ai-sdk/* providers
|
||||
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 vercel-ai-sdk && pnpm install # Vercel AI provider
|
||||
cd 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.9–3.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 (`vercel-ai-sdk/`)
|
||||
|
||||
```bash
|
||||
cd 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 (`openclaw/`)
|
||||
|
||||
```bash
|
||||
cd 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 (`evaluation/`)
|
||||
|
||||
```bash
|
||||
cd evaluation
|
||||
make run-mem0-add # Run mem0 add experiments
|
||||
make run-mem0-search # Run mem0 search experiments
|
||||
make run-mem0-plus-add # With graph memory
|
||||
make run-mem0-plus-search # With graph memory
|
||||
make run-rag # RAG baseline
|
||||
make run-full-context # Full context baseline
|
||||
make run-langmem # LangMem comparison
|
||||
make run-openai # OpenAI comparison
|
||||
```
|
||||
|
||||
## 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 `embedchain/` and `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 |
|
||||
| `vercel-ai-sdk/` | ESLint | Prettier | jest + vitest |
|
||||
| `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 `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
|
||||
|
||||
- `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.
|
||||
|
||||
### 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
|
||||
|
||||
## CI/CD
|
||||
|
||||
### CI Workflows (automated testing)
|
||||
|
||||
| Workflow | File | Triggers | Tests |
|
||||
|----------|------|----------|-------|
|
||||
| Python SDK | `ci.yml` | Push to main, PRs on `mem0/`, `tests/`, `pyproject.toml` | Ruff lint + pytest on Python 3.10, 3.11, 3.12 |
|
||||
| TypeScript SDK | `ts-sdk-ci.yml` | Push to main, PRs on `mem0-ts/` | Prettier + build + jest on Node 20, 22 |
|
||||
| Python CLI | `cli-python-ci.yml` | Push to `cli/python/`, PRs, manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||
| Node CLI | `cli-node-ci.yml` | Push to `cli/node/`, PRs, manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 |
|
||||
| OpenClaw | `openclaw-checks.yml` | Push to `openclaw/`, PRs, manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
|
||||
| Embedchain | `ci.yml` (shared) | PRs on `embedchain/` | Ruff + pytest + coverage on Python 3.9–3.12 |
|
||||
|
||||
### CD Workflows (automated publishing)
|
||||
|
||||
| Workflow | File | Tag Prefix | Target |
|
||||
|----------|------|------------|--------|
|
||||
| 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`) |
|
||||
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
- Modify `embedchain/` unless specifically working on that package — it has its own build system (Poetry).
|
||||
- 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/`.
|
||||
@@ -61,3 +61,31 @@ make test # After activating a shell with hatch shell test_XX
|
||||
Make sure that all tests pass across all supported Python versions before submitting a pull request.
|
||||
|
||||
We look forward to your pull requests and can't wait to see your contributions!
|
||||
|
||||
### 🚀 Releasing
|
||||
|
||||
All packages are published automatically via GitHub Actions when a GitHub Release is created with the correct tag prefix.
|
||||
|
||||
#### Tag Prefixes
|
||||
|
||||
| Package | Registry | Tag Prefix | Example |
|
||||
|---------|----------|------------|---------|
|
||||
| `mem0ai` (Python SDK) | PyPI | `v*` | `v0.1.31` |
|
||||
| `mem0-cli` (Python CLI) | PyPI | `cli-v*` | `cli-v0.2.1` |
|
||||
| `mem0ai` (TypeScript SDK) | npm | `ts-v*` | `ts-v2.4.6` |
|
||||
| `@mem0/cli` (Node CLI) | npm | `cli-node-v*` | `cli-node-v0.1.2` |
|
||||
| `@mem0/vercel-ai-provider` | npm | `vercel-ai-v*` | `vercel-ai-v2.0.6` |
|
||||
| `@mem0/openclaw-mem0` | npm | `openclaw-v*` | `openclaw-v1.0.1` |
|
||||
|
||||
#### How to Release
|
||||
|
||||
1. Bump the version in `pyproject.toml` (Python) or `package.json` (Node)
|
||||
2. Create a [GitHub Release](https://github.com/mem0ai/mem0/releases/new) with the matching tag prefix
|
||||
3. The correct workflow will trigger automatically — verify in the [Actions tab](https://github.com/mem0ai/mem0/actions)
|
||||
|
||||
#### Publishing Details
|
||||
|
||||
- **PyPI packages** use OIDC trusted publishing via `pypa/gh-action-pypi-publish`
|
||||
- **npm packages** use OIDC trusted publishing via npm CLI (>= 11.5.1) — no tokens or secrets required
|
||||
- All workflows require `permissions: id-token: write` for OIDC authentication
|
||||
- First publish of a new npm package must be done manually; OIDC works for subsequent versions
|
||||
|
||||
@@ -266,7 +266,7 @@ config = MemoryConfig(
|
||||
graph_store=GraphStoreConfig(provider="neo4j", config={...}), # optional
|
||||
history_db_path="~/.mem0/history.db",
|
||||
version="v1.1",
|
||||
custom_fact_extraction_prompt="Custom prompt...",
|
||||
custom_instructions="Custom prompt...",
|
||||
custom_update_memory_prompt="Custom prompt..."
|
||||
)
|
||||
```
|
||||
@@ -684,7 +684,7 @@ Conversation: {messages}
|
||||
"""
|
||||
|
||||
config = MemoryConfig(
|
||||
custom_fact_extraction_prompt=custom_extraction_prompt
|
||||
custom_instructions=custom_extraction_prompt
|
||||
)
|
||||
memory = Memory(config)
|
||||
```
|
||||
@@ -1313,7 +1313,7 @@ async def delete_memory(memory_id: str):
|
||||
- **Documentation**: https://docs.mem0.ai
|
||||
- **GitHub Repository**: https://github.com/mem0ai/mem0
|
||||
- **Discord Community**: https://mem0.dev/DiG
|
||||
- **Platform**: https://app.mem0.ai
|
||||
- **Platform**: https://app.mem0.ai?utm_source=oss&utm_medium=llm
|
||||
- **Research Paper**: https://mem0.ai/research
|
||||
- **Examples**: https://github.com/mem0ai/mem0/tree/main/examples
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ clean:
|
||||
test:
|
||||
hatch run test
|
||||
|
||||
test-py-3.9:
|
||||
hatch run dev_py_3_9:test
|
||||
|
||||
test-py-3.10:
|
||||
hatch run dev_py_3_10:test
|
||||
|
||||
|
||||
@@ -39,18 +39,33 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://mem0.ai/research"><strong>📄 Building Production-Ready AI Agents with Scalable Long-Term Memory →</strong></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<strong>⚡ +26% Accuracy vs. OpenAI Memory • 🚀 91% Faster • 💰 90% Fewer Tokens</strong>
|
||||
<a href="https://mem0.ai/research"><strong>📄 Benchmarking Mem0's token-efficient memory algorithm →</strong></a>
|
||||
</p>
|
||||
|
||||
> **🎉 mem0ai v1.0.0 is now available!** This major release includes API modernization, improved vector store support, and enhanced GCP integration. [See migration guide →](MIGRATION_GUIDE_v1.0.md)
|
||||
## New Memory Algorithm (April 2026)
|
||||
|
||||
## 🔥 Research Highlights
|
||||
- **+26% Accuracy** over OpenAI Memory on the LOCOMO benchmark
|
||||
- **91% Faster Responses** than full-context, ensuring low-latency at scale
|
||||
- **90% Lower Token Usage** than full-context, cutting costs without compromise
|
||||
| Benchmark | Old | New | Tokens | Latency p50 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
|
||||
| **LongMemEval** | 67.8 | **94.8** | 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).
|
||||
|
||||
**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
|
||||
- **91.6 on LoCoMo** -- +20 points over the previous algorithm
|
||||
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
|
||||
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
|
||||
- [Read the full paper](https://mem0.ai/research)
|
||||
|
||||
# Introduction
|
||||
@@ -71,31 +86,115 @@
|
||||
|
||||
## 🚀 Quickstart Guide <a name="quickstart"></a>
|
||||
|
||||
Choose between our hosted platform or self-hosted package:
|
||||
### Sign up as an agent
|
||||
|
||||
### Hosted Platform
|
||||
AI agents can mint a working Mem0 API key in under five seconds — no email, no dashboard, no OTP. Four commands end-to-end:
|
||||
|
||||
Get up and running in minutes with automatic updates, analytics, and enterprise security.
|
||||
```bash
|
||||
# 1. Install
|
||||
npm install -g @mem0/cli # or: pip install mem0-cli
|
||||
|
||||
1. Sign up on [Mem0 Platform](https://app.mem0.ai)
|
||||
2. Embed the memory layer via SDK or API keys
|
||||
# 2. Sign up as an agent (replace `claude-code` with your name)
|
||||
mem0 init --agent --agent-caller claude-code
|
||||
|
||||
### Self-Hosted (Open Source)
|
||||
# 3. Add a memory
|
||||
mem0 add "I am using mem0"
|
||||
|
||||
Install the sdk via pip:
|
||||
# 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
|
||||
```
|
||||
|
||||
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Mem0 requires an LLM to function, with `gpt-4.1-nano-2025-04-14 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 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:
|
||||
|
||||
@@ -108,13 +207,13 @@ memory = Memory()
|
||||
|
||||
def chat_with_memories(message: str, user_id: str = "default_user") -> str:
|
||||
# Retrieve relevant memories
|
||||
relevant_memories = memory.search(query=message, user_id=user_id, limit=3)
|
||||
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-4.1-nano-2025-04-14", messages=messages)
|
||||
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
|
||||
@@ -148,7 +247,7 @@ For detailed integration steps, see the [Quickstart](https://docs.mem0.ai/quicks
|
||||
## 📚 Documentation & Support
|
||||
|
||||
- Full docs: https://docs.mem0.ai
|
||||
- Community: [Discord](https://mem0.dev/DiG) · [Twitter](https://x.com/mem0ai)
|
||||
- Community: [Discord](https://mem0.dev/DiG) · [X (formerly Twitter)](https://x.com/mem0ai)
|
||||
- Contact: founders@mem0.ai
|
||||
|
||||
## Citation
|
||||
@@ -166,4 +265,4 @@ We now have a paper you can cite:
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
|
||||
Apache 2.0 — see the [LICENSE](https://github.com/mem0ai/mem0/blob/main/LICENSE) file for details.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+136
@@ -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
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `@mem0/cli` are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.7] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- `mem0 whoami` — print the active agent's `default_user_id` (the AGENTRUSH
|
||||
leaderboard identifier). Reads from local config, no network call.
|
||||
- `mem0 agent-rush <add | search>` — subcommand group that wraps the new
|
||||
`/v1/agent-rush/` platform endpoints for the 7-day AGENTRUSH game. Project
|
||||
routing is implicit (resolved server-side); no flags exposed. Pretty-prints
|
||||
platform error codes into actionable hints (e.g. `agentrush_search_first`
|
||||
→ "Run 3 'mem0 agent-rush search' commands before adding.").
|
||||
- PII safety prompt on first `mem0 agent-rush add`. Interactive runs require
|
||||
explicit `y` to acknowledge that AGENTRUSH memories are public; the
|
||||
acknowledgement is persisted in `~/.mem0/config.json` under
|
||||
`agent_rush.acknowledged_at` so the prompt only appears once per machine.
|
||||
Non-interactive (agent) invocations surface the warning to stderr without
|
||||
blocking.
|
||||
- New config schema field: `agent_rush.acknowledged_at` (ISO timestamp,
|
||||
empty until first interactive acknowledgement).
|
||||
|
||||
### Changed
|
||||
|
||||
- HTTP requests from the new agent-rush commands send `X-Mem0-Mode: agent-rush`
|
||||
in addition to the existing source headers, so platform telemetry can split
|
||||
game traffic from regular CLI usage.
|
||||
|
||||
## [0.2.6] and earlier
|
||||
|
||||
Unlogged historical releases. See git history under `cli/node/`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@mem0/cli",
|
||||
"version": "0.2.7",
|
||||
"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",
|
||||
"vitest": "^1.5.0",
|
||||
"@biomejs/biome": "^1.7.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+2066
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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/${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/${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/${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
|
||||
let result: Record<string, unknown> = {};
|
||||
for (const [entityType, entityId] of entities) {
|
||||
result = (await this._request(
|
||||
"DELETE",
|
||||
`/v2/entities/${entityType}/${entityId}/`,
|
||||
{ params: { source: "CLI" } },
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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/${eventId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}.`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
noInfer: 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.noInfer,
|
||||
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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, JSON.stringify(context)],
|
||||
{ detached: true, stdio: "ignore" },
|
||||
);
|
||||
child.unref();
|
||||
} catch {
|
||||
/* silently swallow */
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Standalone telemetry sender — runs as a detached child process.
|
||||
*
|
||||
* Usage: node telemetry-sender.cjs '<json context>'
|
||||
*
|
||||
* 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 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 = JSON.parse(process.argv[2]);
|
||||
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(() => {});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Tests for CLI commands using mock backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createMockBackend } from "./setup.js";
|
||||
import type { Backend } from "../src/backend/base.js";
|
||||
import { setAgentMode } from "../src/state.js";
|
||||
|
||||
let mockBackend: Backend;
|
||||
|
||||
// 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,
|
||||
noInfer: 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,
|
||||
noInfer: 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,
|
||||
noInfer: 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,
|
||||
noInfer: false,
|
||||
|
||||
output: "quiet",
|
||||
});
|
||||
expect(output).not.toContain("dark mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdAdd deduplicates PENDING", () => {
|
||||
const DUPLICATE_PENDING = {
|
||||
results: [
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
{ status: "PENDING", event_id: "evt-dup" },
|
||||
],
|
||||
};
|
||||
|
||||
it("text shows one pending block", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
|
||||
output: "text",
|
||||
});
|
||||
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("json shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
|
||||
output: "json",
|
||||
});
|
||||
const data = JSON.parse(output);
|
||||
const pending = data.results.filter((r: Record<string, unknown>) => r.status === "PENDING");
|
||||
expect(pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("agent shows one pending entry", async () => {
|
||||
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||
setAgentMode(true);
|
||||
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||
await cmdAdd(mockBackend, "test", {
|
||||
userId: "alice",
|
||||
immutable: false,
|
||||
noInfer: false,
|
||||
|
||||
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,
|
||||
noInfer: 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `mem0-cli` (Python) are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.7] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- `mem0 whoami` — print the active agent's `default_user_id` (the AGENTRUSH
|
||||
leaderboard identifier). Reads from local config, no network call.
|
||||
- `mem0 agent-rush <add | search>` — subcommand group that wraps the new
|
||||
`/v1/agent-rush/` platform endpoints for the 7-day AGENTRUSH game. Project
|
||||
routing is implicit (resolved server-side); no flags exposed. Pretty-prints
|
||||
platform error codes into actionable hints (e.g. `agentrush_search_first`
|
||||
→ "Run 3 'mem0 agent-rush search' commands before adding.").
|
||||
- PII safety prompt on first `mem0 agent-rush add`. Interactive runs require
|
||||
explicit `y` to acknowledge that AGENTRUSH memories are public; the
|
||||
acknowledgement is persisted in `~/.mem0/config.json` under
|
||||
`agent_rush.acknowledged_at` so the prompt only appears once per machine.
|
||||
Non-interactive (agent) invocations surface the warning to stderr without
|
||||
blocking.
|
||||
- New config schema field: `agent_rush.acknowledged_at` (ISO timestamp,
|
||||
empty until first interactive acknowledgement).
|
||||
|
||||
### Changed
|
||||
|
||||
- HTTP requests from the new agent-rush commands send `X-Mem0-Mode: agent-rush`
|
||||
in addition to the existing source headers, so platform telemetry can split
|
||||
game traffic from regular CLI usage.
|
||||
|
||||
## [0.2.6] and earlier
|
||||
|
||||
Unlogged historical releases. See git history under `cli/python/`.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "mem0-cli"
|
||||
version = "0.2.7"
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||
|
||||
__version__ = "0.2.4"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Allow running with `python -m mem0_cli`."""
|
||||
|
||||
from mem0_cli.app import main
|
||||
|
||||
main()
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Platform (SaaS) backend — communicates with api.mem0.ai."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.config import PlatformConfig
|
||||
|
||||
|
||||
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/{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/{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/{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
|
||||
result: dict = {}
|
||||
for entity_type, entity_id in entities.items():
|
||||
result = self._request(
|
||||
"DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"}
|
||||
)
|
||||
return result
|
||||
|
||||
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/{event_id}/")
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
pass
|
||||
@@ -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__
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI command modules."""
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Agent Mode commands — bootstrap (unattended signup) and claim (OTP-based human upgrade)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from mem0_cli.branding import (
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
print_error,
|
||||
print_success,
|
||||
)
|
||||
from mem0_cli.config import Mem0Config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
|
||||
def _validate_envelope(envelope: Any) -> None:
|
||||
"""Defend against partial/malformed backend responses.
|
||||
|
||||
A backend regression that returns ``{"api_key": null}`` would otherwise be
|
||||
silently persisted, producing confusing downstream errors far from the
|
||||
source. Fail fast with a clear message if the required fields are missing.
|
||||
"""
|
||||
if not isinstance(envelope, dict):
|
||||
print_error(err_console, "Bootstrap response was not a JSON object.")
|
||||
raise typer.Exit(1)
|
||||
for field in ("api_key", "default_user_id"):
|
||||
value = envelope.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
print_error(
|
||||
err_console,
|
||||
f"Bootstrap response missing required field {field!r} — please update the CLI.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def bootstrap_via_backend(
|
||||
config: Mem0Config,
|
||||
*,
|
||||
source: str | None = None,
|
||||
agent_caller: str | None = None,
|
||||
) -> None:
|
||||
"""POST /api/v1/auth/agent_mode/ and mutate config in place.
|
||||
|
||||
Args:
|
||||
config: Mem0Config mutated in place with the new platform values.
|
||||
source: ``--source`` flag passthrough (analytics tag, free-form).
|
||||
agent_caller: Self-declared agent identity passed via ``--agent-caller``
|
||||
(e.g. ``claude-code``, ``cursor``). May be None when the caller
|
||||
omitted the flag; the agent can backfill later via
|
||||
``mem0 identify <name>``. Sent to the backend in the request body
|
||||
and saved into ``platform.agent_caller`` for local introspection.
|
||||
|
||||
Raises typer.Exit(1) on failure.
|
||||
"""
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
body: dict[str, Any] = {}
|
||||
if source:
|
||||
body["source"] = source
|
||||
if agent_caller:
|
||||
body["agent_caller"] = agent_caller
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(
|
||||
f"{base_url}/api/v1/auth/agent_mode/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error contacting Mem0: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Rate-limited. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code == 503:
|
||||
print_error(err_console, "Agent Mode is temporarily disabled. Try again later.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
detail = resp.text
|
||||
try:
|
||||
err_body = resp.json()
|
||||
detail = err_body.get("error") or err_body.get("detail") or resp.text
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
pass
|
||||
# Backend's @ratelimit decorator raises PermissionDenied, which DRF
|
||||
# translates to a generic 403 "You do not have permission to perform
|
||||
# this action." That's opaque — surface as the rate-limit it actually is.
|
||||
if resp.status_code == 403 and "permission" in str(detail).lower():
|
||||
print_error(
|
||||
err_console,
|
||||
"Daily Agent Mode signup limit reached for this network (5/day). Try again from a different IP or after midnight UTC.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
print_error(err_console, f"Bootstrap failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
envelope = resp.json()
|
||||
_validate_envelope(envelope)
|
||||
config.platform.api_key = envelope["api_key"]
|
||||
config.platform.base_url = base_url
|
||||
config.platform.agent_mode = True
|
||||
config.platform.created_via = "agent_mode"
|
||||
config.platform.agent_caller = agent_caller or ""
|
||||
config.platform.claimed_at = ""
|
||||
config.platform.default_user_id = envelope["default_user_id"]
|
||||
# Adopt the slug-derived user_id as the default scope for memory ops.
|
||||
config.defaults.user_id = envelope["default_user_id"]
|
||||
save_config(config)
|
||||
|
||||
print_success(console, f"Agent Mode active. Default user_id: {envelope['default_user_id']}")
|
||||
notice = envelope.get("mem0_notice")
|
||||
if notice:
|
||||
console.print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
||||
else:
|
||||
# Fallback if the backend hasn't deployed the unified notice yet.
|
||||
claim_cmd = envelope.get("claim_command", "mem0 init --email <your-email>")
|
||||
console.print(f" [{DIM_COLOR}]To claim this account later: {claim_cmd}[/]")
|
||||
|
||||
if not agent_caller:
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]If you are an AI agent, identify yourself: "
|
||||
f"`mem0 identify <your-name>` (e.g. claude-code, cursor).[/]"
|
||||
)
|
||||
|
||||
|
||||
def claim_via_otp(config: Mem0Config, *, email: str, code: str | None = None) -> None:
|
||||
"""Claim an existing Agent Mode account via OTP — no browser, no polling.
|
||||
|
||||
Reuses the standard email-code flow (`/api/v1/auth/email_code/` then
|
||||
`/.../verify/`) and adds the local agent-mode API key in the verify body
|
||||
as `agent_mode_api_key`. Backend's `verify_email_code` runs the
|
||||
upgrade-in-place transaction inline and returns claim result.
|
||||
|
||||
On success: flips `platform.agent_mode=false`, sets `claimed_at`, stamps
|
||||
`user_email`. The api_key value itself never changes.
|
||||
"""
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
if not config.platform.api_key or not config.platform.agent_mode:
|
||||
print_error(
|
||||
err_console,
|
||||
"This command requires an active Agent Mode config. Run `mem0 init` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
raw_key = config.platform.api_key
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Step 1: request OTP (unless --code provided)
|
||||
if not code:
|
||||
send = client.post(
|
||||
f"{base_url}/api/v1/auth/email_code/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json={"email": email},
|
||||
)
|
||||
if send.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if send.status_code != 200:
|
||||
try:
|
||||
detail = send.json().get("error", send.text)
|
||||
except Exception:
|
||||
detail = send.text
|
||||
print_error(err_console, f"Failed to send code: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_success(console, f"Verification code sent to {email}. Check your inbox.")
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
print_error(
|
||||
err_console,
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
hint=f"Re-run: mem0 init --email {email} --code <code>",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print()
|
||||
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
||||
if not code:
|
||||
print_error(err_console, "Code is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Step 2: verify + claim in one shot
|
||||
verify = client.post(
|
||||
f"{base_url}/api/v1/auth/email_code/verify/",
|
||||
headers={**_SOURCE_HEADERS, "Content-Type": "application/json"},
|
||||
json={
|
||||
"email": email,
|
||||
"code": code.strip(),
|
||||
"agent_mode_api_key": raw_key,
|
||||
},
|
||||
)
|
||||
|
||||
if verify.status_code != 200:
|
||||
try:
|
||||
err_body = verify.json()
|
||||
detail = err_body.get("error", verify.text)
|
||||
code_str = err_body.get("code", "")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
detail = verify.text
|
||||
code_str = ""
|
||||
print_error(err_console, f"Claim failed: {detail}")
|
||||
if code_str == "email_already_claimed":
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]Tip: this email already has a Mem0 account. Sign in at app.mem0.ai with your existing credentials.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
claim_body = verify.json()
|
||||
if not claim_body.get("claimed"):
|
||||
print_error(err_console, f"Unexpected verify response: {claim_body}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.platform.agent_mode = False
|
||||
config.platform.claimed_at = claim_body.get("claimed_at") or _utcnow_iso()
|
||||
config.platform.user_email = email
|
||||
config.platform.created_via = "email"
|
||||
save_config(config)
|
||||
print_success(console, f"Agent claimed to {email}. Your API key is unchanged.")
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""mem0 agent-rush — AGENTRUSH game commands.
|
||||
|
||||
Wraps the platform's /v1/agent-rush/{memories/, memories/search/} endpoints.
|
||||
Hardcoded routing; no flags needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import print_error, print_success
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_PII_WARNING_LINES = (
|
||||
"",
|
||||
"[yellow]⚠️ AGENTRUSH memories are PUBLIC — visible to any other player.[/yellow]",
|
||||
"[yellow] Do not include real names, emails, secrets, work content, or PII.[/yellow]",
|
||||
"",
|
||||
)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
"X-Mem0-Mode": "agent-rush",
|
||||
}
|
||||
|
||||
_ERROR_HINTS = {
|
||||
"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.",
|
||||
}
|
||||
|
||||
|
||||
def _call(path: str, body: dict) -> dict:
|
||||
config = load_config()
|
||||
if not config.platform.api_key:
|
||||
print_error(err_console, "Not initialized. Run `mem0 init --agent` first.")
|
||||
raise typer.Exit(1)
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(
|
||||
f"{base_url}{path}",
|
||||
headers={
|
||||
**_SOURCE_HEADERS,
|
||||
"Authorization": f"Token {config.platform.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if resp.status_code >= 400:
|
||||
code = (
|
||||
(data.get("error") or {}).get("code", "unknown")
|
||||
if isinstance(data, dict)
|
||||
else "unknown"
|
||||
)
|
||||
print_error(err_console, f"AGENTRUSH error: {code}")
|
||||
hint = _ERROR_HINTS.get(code)
|
||||
if hint:
|
||||
console.print(f" [dim]{hint}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
return data
|
||||
|
||||
|
||||
def _ensure_warning_acknowledged() -> None:
|
||||
"""Block the first interactive add on the PII warning; pass-through for agents.
|
||||
|
||||
Interactive (TTY): show prompt, require explicit 'y', persist
|
||||
`agent_rush.acknowledged_at` so we never ask the same machine twice.
|
||||
|
||||
Non-interactive (no TTY — typical when an agent runs the CLI): surface
|
||||
the warning to stderr for the human reading the agent transcript and
|
||||
proceed without prompting (agents can't answer y/N).
|
||||
"""
|
||||
config = load_config()
|
||||
if config.agent_rush.acknowledged_at:
|
||||
return
|
||||
|
||||
is_tty = sys.stdin.isatty() and sys.stdout.isatty()
|
||||
if not is_tty:
|
||||
for line in _PII_WARNING_LINES:
|
||||
err_console.print(line)
|
||||
return
|
||||
|
||||
for line in _PII_WARNING_LINES:
|
||||
console.print(line)
|
||||
answer = typer.prompt(" Continue? [y/N]", default="N", show_default=False).strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
print_error(err_console, "Aborted.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.agent_rush.acknowledged_at = datetime.now(timezone.utc).isoformat()
|
||||
save_config(config)
|
||||
|
||||
|
||||
def run_agent_rush_add(content: str) -> None:
|
||||
_ensure_warning_acknowledged()
|
||||
result = _call("/v1/agent-rush/memories/", {"content": content})
|
||||
event_id = result.get("event_id", "?")
|
||||
print_success(console, f"Memory submitted (event_id: {event_id})")
|
||||
|
||||
|
||||
def run_agent_rush_search(query: str) -> None:
|
||||
result = _call("/v1/agent-rush/memories/search/", {"query": query})
|
||||
memories = result.get("results") or result.get("memories") or []
|
||||
if not memories:
|
||||
console.print("[dim](no results)[/dim]")
|
||||
return
|
||||
for i, m in enumerate(memories[:5], start=1):
|
||||
text = m.get("memory") if isinstance(m, dict) else str(m)
|
||||
console.print(f" {i}. {text}")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Config management commands: show, set, get."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from mem0_cli.branding import ACCENT_COLOR, BRAND_COLOR, DIM_COLOR, print_error, print_success
|
||||
from mem0_cli.config import (
|
||||
get_nested_value,
|
||||
load_config,
|
||||
redact_key,
|
||||
save_config,
|
||||
set_nested_value,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def cmd_config_show(*, output: str = "text") -> None:
|
||||
"""Display current configuration (secrets redacted)."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config show")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
|
||||
config = load_config()
|
||||
|
||||
if output in ("json", "agent"):
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="config show",
|
||||
data={
|
||||
"defaults": {
|
||||
"user_id": config.defaults.user_id or None,
|
||||
"agent_id": config.defaults.agent_id or None,
|
||||
"app_id": config.defaults.app_id or None,
|
||||
"run_id": config.defaults.run_id or None,
|
||||
},
|
||||
"platform": {
|
||||
"api_key": redact_key(config.platform.api_key),
|
||||
"base_url": config.platform.base_url,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print(f" [{BRAND_COLOR}]◆ mem0 Configuration[/]\n")
|
||||
|
||||
table = Table(border_style=BRAND_COLOR, header_style=f"bold {ACCENT_COLOR}", padding=(0, 2))
|
||||
table.add_column("Key", style="bold")
|
||||
table.add_column("Value")
|
||||
|
||||
# Defaults
|
||||
table.add_row(
|
||||
"defaults.user_id",
|
||||
config.defaults.user_id or f"[{DIM_COLOR}](not set)[/]",
|
||||
)
|
||||
table.add_row(
|
||||
"defaults.agent_id",
|
||||
config.defaults.agent_id or f"[{DIM_COLOR}](not set)[/]",
|
||||
)
|
||||
table.add_row(
|
||||
"defaults.app_id",
|
||||
config.defaults.app_id or f"[{DIM_COLOR}](not set)[/]",
|
||||
)
|
||||
table.add_row(
|
||||
"defaults.run_id",
|
||||
config.defaults.run_id or f"[{DIM_COLOR}](not set)[/]",
|
||||
)
|
||||
table.add_row("", "")
|
||||
|
||||
# Platform
|
||||
table.add_row("[bold]platform.api_key[/]", redact_key(config.platform.api_key))
|
||||
table.add_row("platform.base_url", config.platform.base_url)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_config_get(key: str) -> None:
|
||||
"""Get a config value."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config get")
|
||||
config = load_config()
|
||||
value = get_nested_value(config, key)
|
||||
|
||||
if value is None:
|
||||
print_error(err_console, f"Unknown config key: {key}")
|
||||
return
|
||||
|
||||
display_value = (
|
||||
redact_key(str(value)) if ("api_key" in key or "key" in key.split(".")[-1:]) else str(value)
|
||||
)
|
||||
|
||||
if is_agent_mode():
|
||||
format_agent_envelope(
|
||||
console, command="config get", data={"key": key, "value": display_value}
|
||||
)
|
||||
else:
|
||||
console.print(display_value)
|
||||
|
||||
|
||||
def cmd_config_set(key: str, value: str) -> None:
|
||||
"""Set a config value."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("config set")
|
||||
config = load_config()
|
||||
if set_nested_value(config, key, value):
|
||||
save_config(config)
|
||||
display = redact_key(value) if "key" in key else value
|
||||
if is_agent_mode():
|
||||
format_agent_envelope(
|
||||
console, command="config set", data={"key": key, "value": display}
|
||||
)
|
||||
else:
|
||||
print_success(console, f"{key} = {display}")
|
||||
else:
|
||||
print_error(err_console, f"Unknown config key: {key}")
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Entity management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time as _time
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.branding import (
|
||||
ACCENT_COLOR,
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
print_error,
|
||||
print_info,
|
||||
print_success,
|
||||
timed_status,
|
||||
)
|
||||
from mem0_cli.output import format_agent_envelope, format_json
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None:
|
||||
"""List entities of a given type."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("entity list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
valid_types = {"users", "agents", "apps", "runs"}
|
||||
if entity_type not in valid_types:
|
||||
print_error(
|
||||
err_console, f"Invalid entity type: {entity_type}. Use: {', '.join(valid_types)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, f"Fetching {entity_type}...") as _ts:
|
||||
try:
|
||||
results = backend.entities(entity_type)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e), hint="This feature may require the mem0 Platform.")
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="entity list",
|
||||
data=results,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
return
|
||||
|
||||
if not results:
|
||||
print_info(console, f"No {entity_type} found.")
|
||||
return
|
||||
|
||||
table = Table(border_style=BRAND_COLOR, header_style=f"bold {ACCENT_COLOR}", padding=(0, 1))
|
||||
table.add_column("Name / ID", style="bold")
|
||||
table.add_column("Created", max_width=12)
|
||||
|
||||
for entity in results:
|
||||
name = entity.get("name", entity.get("id", "—"))
|
||||
created = str(entity.get("created_at", "—"))[:10]
|
||||
table.add_row(str(name), created)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f" [{DIM_COLOR}]{len(results)} {entity_type} ({_elapsed:.2f}s)[/]")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_entities_delete(
|
||||
backend: Backend,
|
||||
*,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
app_id: str | None,
|
||||
run_id: str | None,
|
||||
force: bool,
|
||||
dry_run: bool = False,
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete an entity and all its memories (cascade delete)."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("entity delete")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if not force:
|
||||
print_error(err_console, "Destructive operation requires --force in agent mode.")
|
||||
raise typer.Exit(1)
|
||||
if not any([user_id, agent_id, app_id, run_id]):
|
||||
print_error(
|
||||
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
scope_parts = []
|
||||
if user_id:
|
||||
scope_parts.append(f"user={user_id}")
|
||||
if agent_id:
|
||||
scope_parts.append(f"agent={agent_id}")
|
||||
if app_id:
|
||||
scope_parts.append(f"app={app_id}")
|
||||
if run_id:
|
||||
scope_parts.append(f"run={run_id}")
|
||||
scope_str = ", ".join(scope_parts)
|
||||
|
||||
if dry_run:
|
||||
print_info(console, f"Would delete entity {scope_str} and all its memories.")
|
||||
print_info(console, "No changes made (dry run).")
|
||||
return
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(
|
||||
f"\n \u26a0 Delete entity {scope_str} AND all its memories? This cannot be undone."
|
||||
)
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Deleting entity...") as _ts:
|
||||
try:
|
||||
result = backend.delete_entities(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="entity delete",
|
||||
data={"deleted": True},
|
||||
scope=scope or None,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Event commands: list and status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.branding import (
|
||||
ACCENT_COLOR,
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
ERROR_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
WARNING_COLOR,
|
||||
print_info,
|
||||
timed_status,
|
||||
)
|
||||
from mem0_cli.output import format_agent_envelope, format_json
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_STATUS_STYLE = {
|
||||
"SUCCEEDED": f"[{SUCCESS_COLOR}]SUCCEEDED[/]",
|
||||
"PENDING": f"[{ACCENT_COLOR}]PENDING[/]",
|
||||
"FAILED": f"[{ERROR_COLOR}]FAILED[/]",
|
||||
"PROCESSING": f"[{WARNING_COLOR}]PROCESSING[/]",
|
||||
}
|
||||
|
||||
|
||||
def _status_styled(status: str) -> str:
|
||||
return _STATUS_STYLE.get(status.upper(), status)
|
||||
|
||||
|
||||
def cmd_event_list(backend: Backend, *, output: str = "table") -> None:
|
||||
"""List recent background events."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("event list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
import time as _time
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Fetching events...") as _ts:
|
||||
try:
|
||||
results = backend.list_events()
|
||||
except Exception as e:
|
||||
_ts.error_msg = str(e)
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="event list",
|
||||
data=results,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
return
|
||||
|
||||
if not results:
|
||||
console.print()
|
||||
print_info(console, "No events found.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
table = Table(
|
||||
border_style=BRAND_COLOR,
|
||||
header_style=f"bold {ACCENT_COLOR}",
|
||||
row_styles=["", "dim"],
|
||||
padding=(0, 1),
|
||||
)
|
||||
table.add_column("Event ID", style="dim", max_width=10, no_wrap=True)
|
||||
table.add_column("Type", max_width=14)
|
||||
table.add_column("Status", max_width=12)
|
||||
table.add_column("Latency", max_width=10, justify="right")
|
||||
table.add_column("Created", max_width=20)
|
||||
|
||||
for ev in results:
|
||||
ev_id = str(ev.get("id", ""))[:8]
|
||||
ev_type = str(ev.get("event_type", "—"))
|
||||
status = str(ev.get("status", "—"))
|
||||
latency = ev.get("latency")
|
||||
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—"
|
||||
created = str(ev.get("created_at", "—"))[:19].replace("T", " ")
|
||||
table.add_row(ev_id, ev_type, _status_styled(status), latency_str, created)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f" [{DIM_COLOR}]{len(results)} event{'s' if len(results) != 1 else ''}[/]")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_event_status(backend: Backend, event_id: str, *, output: str = "text") -> None:
|
||||
"""Get the status of a specific background event."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("event status")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
import time as _time
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Fetching event...") as _ts:
|
||||
try:
|
||||
ev = backend.get_event(event_id)
|
||||
except Exception as e:
|
||||
_ts.error_msg = str(e)
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="event status",
|
||||
data=ev,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, ev)
|
||||
return
|
||||
|
||||
status = str(ev.get("status", "—"))
|
||||
ev_type = str(ev.get("event_type", "—"))
|
||||
latency = ev.get("latency")
|
||||
latency_str = f"{latency:.0f}ms" if isinstance(latency, (int, float)) else "—"
|
||||
created = str(ev.get("created_at", "—"))[:19].replace("T", " ")
|
||||
updated = str(ev.get("updated_at", "—"))[:19].replace("T", " ")
|
||||
results = ev.get("results")
|
||||
|
||||
lines = []
|
||||
lines.append(f" [{DIM_COLOR}]Event ID:[/] {event_id}")
|
||||
lines.append(f" [{DIM_COLOR}]Type:[/] {ev_type}")
|
||||
lines.append(f" [{DIM_COLOR}]Status:[/] {_status_styled(status)}")
|
||||
lines.append(f" [{DIM_COLOR}]Latency:[/] {latency_str}")
|
||||
lines.append(f" [{DIM_COLOR}]Created:[/] {created}")
|
||||
lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}")
|
||||
|
||||
if results:
|
||||
lines.append("")
|
||||
lines.append(f" [{DIM_COLOR}]Results ({len(results)}):[/]")
|
||||
for r in results:
|
||||
mem_id = str(r.get("id", ""))[:8]
|
||||
data = r.get("data", {})
|
||||
memory = data.get("memory", "") if isinstance(data, dict) else str(data)
|
||||
ev_name = str(r.get("event", ""))
|
||||
user = str(r.get("user_id", ""))
|
||||
detail = f"{ev_name} {memory}"
|
||||
if user:
|
||||
detail += f" [{DIM_COLOR}](user_id={user})[/]"
|
||||
lines.append(f" [{SUCCESS_COLOR}]·[/] {detail} [{DIM_COLOR}]({mem_id})[/]")
|
||||
|
||||
content = "\n".join(lines)
|
||||
panel = Panel(
|
||||
content,
|
||||
title=f"[{BRAND_COLOR}]Event Status[/]",
|
||||
title_align="left",
|
||||
border_style=BRAND_COLOR,
|
||||
padding=(1, 1),
|
||||
)
|
||||
console.print()
|
||||
console.print(panel)
|
||||
console.print()
|
||||
@@ -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 — running it again just
|
||||
overwrites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import print_error, print_success
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
_SOURCE_HEADERS = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
|
||||
def run_identify(name: str) -> None:
|
||||
"""PATCH the active agent-mode key's agent_caller field."""
|
||||
config = load_config()
|
||||
if not config.platform.api_key:
|
||||
print_error(
|
||||
err_console,
|
||||
"No API key configured. Run `mem0 init --agent` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not config.platform.agent_mode:
|
||||
print_error(
|
||||
err_console,
|
||||
"This command only works on unclaimed agent-mode keys.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
print_error(err_console, "Agent name is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
base_url = (config.platform.base_url or "https://api.mem0.ai").rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.patch(
|
||||
f"{base_url}/api/v1/auth/agent_mode/caller/",
|
||||
headers={
|
||||
**_SOURCE_HEADERS,
|
||||
"Authorization": f"Token {config.platform.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"agent_caller": name},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print_error(err_console, f"Network error: {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Identify failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
canonical = resp.json().get("agent_caller", name)
|
||||
config.platform.agent_caller = canonical
|
||||
save_config(config)
|
||||
print_success(console, f"Identified as {canonical}.")
|
||||
@@ -0,0 +1,566 @@
|
||||
"""mem0 init — interactive setup wizard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from mem0_cli.branding import (
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
print_banner,
|
||||
print_error,
|
||||
print_info,
|
||||
print_success,
|
||||
)
|
||||
from mem0_cli.config import (
|
||||
CONFIG_FILE,
|
||||
DEFAULT_BASE_URL,
|
||||
Mem0Config,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _prompt_secret(label: str) -> str:
|
||||
"""Prompt for a secret value, echoing '*' for each character typed."""
|
||||
sys.stdout.write(label)
|
||||
sys.stdout.flush()
|
||||
|
||||
chars: list[str] = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
while True:
|
||||
ch = msvcrt.getwch()
|
||||
if ch in ("\r", "\n"):
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
break
|
||||
if ch == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
if ch in ("\x08", "\x7f"): # backspace
|
||||
if chars:
|
||||
chars.pop()
|
||||
sys.stdout.write("\b \b")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
chars.append(ch)
|
||||
sys.stdout.write("*")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
while True:
|
||||
ch = sys.stdin.read(1)
|
||||
if ch in ("\r", "\n"):
|
||||
sys.stdout.write("\r\n")
|
||||
sys.stdout.flush()
|
||||
break
|
||||
if ch == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
if ch in ("\x7f", "\x08"): # backspace/delete
|
||||
if chars:
|
||||
chars.pop()
|
||||
sys.stdout.write("\b \b")
|
||||
sys.stdout.flush()
|
||||
elif ch == "\x15": # Ctrl+U — clear line
|
||||
sys.stdout.write("\b \b" * len(chars))
|
||||
sys.stdout.flush()
|
||||
chars = []
|
||||
elif ch >= " ": # ignore other control characters
|
||||
chars.append(ch)
|
||||
sys.stdout.write("*")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _validate_email(email: str) -> None:
|
||||
"""Exit with an error if *email* doesn't look like a valid address."""
|
||||
if not _EMAIL_RE.match(email):
|
||||
print_error(err_console, f"Invalid email address: {email!r}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _ping_key(api_key: str, base_url: str, timeout: float = 5.0) -> bool:
|
||||
"""Validate api_key against /v1/ping/.
|
||||
|
||||
Returns False ONLY on a definitive "invalid key" signal (HTTP 401 / 403).
|
||||
Network errors, timeouts, and 5xx responses return True so we prefer
|
||||
reusing an existing key over silently minting a new shadow on a transient
|
||||
blip (which would also clobber config + plugin-sync targets).
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url.rstrip('/')}/v1/ping/",
|
||||
headers={"Authorization": f"Token {api_key}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return True # unknown — prefer reuse
|
||||
return resp.status_code not in (401, 403)
|
||||
|
||||
|
||||
def _email_login(
|
||||
email: str,
|
||||
code: str | None,
|
||||
base_url: str,
|
||||
) -> dict:
|
||||
"""Run the email verification code login flow.
|
||||
|
||||
Returns the parsed JSON response from the verify endpoint.
|
||||
The caller expects at minimum an ``api_key`` field.
|
||||
"""
|
||||
url = base_url.rstrip("/")
|
||||
_source_headers = {
|
||||
"X-Mem0-Source": "cli",
|
||||
"X-Mem0-Client-Language": "python",
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# If code is already provided, skip sending — user already has a code
|
||||
if not code:
|
||||
# Step 1: Request verification code
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/",
|
||||
json={"email": email},
|
||||
headers=_source_headers,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Failed to send code: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_success(console, "Verification code sent! Check your email.")
|
||||
|
||||
# Step 2: Get code from user
|
||||
if not sys.stdin.isatty():
|
||||
print_error(
|
||||
err_console,
|
||||
"No --code provided and terminal is non-interactive.",
|
||||
hint="Run: mem0 init --email <email> --code <code>",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print()
|
||||
code = Prompt.ask(f" [{BRAND_COLOR}]Verification Code[/]")
|
||||
if not code:
|
||||
print_error(err_console, "Code is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Step 3: Verify code
|
||||
resp = client.post(
|
||||
f"{url}/api/v1/auth/email_code/verify/",
|
||||
json={"email": email, "code": code.strip()},
|
||||
headers=_source_headers,
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||
raise typer.Exit(1)
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
print_error(err_console, f"Verification failed: {detail}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return resp.json()
|
||||
|
||||
|
||||
def run_init(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
user_id: str | None = None,
|
||||
email: str | None = None,
|
||||
code: str | None = None,
|
||||
force: bool = False,
|
||||
source: str | None = None,
|
||||
agent: bool = False,
|
||||
agent_caller: str | None = None,
|
||||
) -> None:
|
||||
"""Interactive setup wizard for mem0 CLI.
|
||||
|
||||
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
||||
(non-interactive mode). When running in a non-TTY without the required
|
||||
flags, an error message is printed.
|
||||
|
||||
Agent Mode dispatch (no email/api-key flags):
|
||||
- If existing config has an active API key → reuse (existing_key path).
|
||||
- Else if any positive agent signal (--agent, --json global, agent env
|
||||
var, or `agent` flag) → POST /api/v1/auth/agent_mode/ and write config.
|
||||
- Else fall through to the interactive wizard.
|
||||
|
||||
Claim dispatch:
|
||||
- If `--email` is set AND existing config has `agent_mode=true`, run the
|
||||
claim device-flow against the existing key instead of minting a new
|
||||
email-based key.
|
||||
"""
|
||||
from mem0_cli.agent_detect import detect_agent_caller
|
||||
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend, claim_via_otp
|
||||
from mem0_cli.state import is_agent_mode as _global_agent_mode
|
||||
from mem0_cli.telemetry import capture_event
|
||||
|
||||
def _fire_init(mode: str, *, claimed: bool = False) -> None:
|
||||
"""Fire cli.init telemetry with M1-M6 properties."""
|
||||
props: dict = {"command": "init", "mode": mode}
|
||||
if agent_caller:
|
||||
# Self-declared via --agent-caller; not sniffed from env vars.
|
||||
props["agent_caller"] = agent_caller
|
||||
if source:
|
||||
props["signup_source"] = source
|
||||
if claimed:
|
||||
props["claimed_agent_mode"] = True
|
||||
capture_event("cli.init", props)
|
||||
|
||||
config = Mem0Config()
|
||||
|
||||
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
||||
config.platform.base_url = base_url
|
||||
|
||||
if code and not email:
|
||||
print_error(err_console, "--code requires --email.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# ── Email + existing agent-mode config → claim flow ─────────────────
|
||||
if email and CONFIG_FILE.exists():
|
||||
existing = load_config()
|
||||
if existing.platform.agent_mode and existing.platform.api_key:
|
||||
email = email.strip().lower()
|
||||
_validate_email(email)
|
||||
print_info(console, f"Claiming Agent Mode account to {email}...")
|
||||
claim_via_otp(existing, email=email, code=code)
|
||||
_fire_init("email", claimed=True)
|
||||
return
|
||||
|
||||
# ── Agent Mode path runs BEFORE the existing-config guard ──────────
|
||||
# Rules 1/2 REUSE a valid existing key (not overwrite), so we must
|
||||
# short-circuit before the guard prompts. Rule 3 mints only when there
|
||||
# is no valid key to reuse — in that case overwriting is correct.
|
||||
_agent_ctx = agent or _global_agent_mode() or (detect_agent_caller() is not None)
|
||||
if not api_key and not email and _agent_ctx:
|
||||
from mem0_cli.output import format_json_envelope
|
||||
from mem0_cli.state import is_agent_mode as _is_json_mode
|
||||
|
||||
def _emit_reuse(source: str) -> None:
|
||||
if _is_json_mode():
|
||||
format_json_envelope(
|
||||
console,
|
||||
command="init",
|
||||
data={
|
||||
"api_key_saved": False,
|
||||
"api_key_source": source,
|
||||
"agent_mode": False,
|
||||
"message": "Existing Mem0 API key found and reused. No Agent Mode key was created.",
|
||||
},
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"Existing MEM0_API_KEY is valid; reusing it. No new Agent Mode key was minted."
|
||||
if source == "env"
|
||||
else "Existing API key in config is valid; reusing it. No new Agent Mode key was minted."
|
||||
)
|
||||
print_success(console, msg)
|
||||
|
||||
def _maybe_identify(key: str) -> None:
|
||||
"""Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
||||
reused key. Silent no-op on any failure — reuse must not break.
|
||||
"""
|
||||
if not agent_caller:
|
||||
return
|
||||
try:
|
||||
resp = httpx.patch(
|
||||
f"{base_url.rstrip('/')}/api/v1/auth/agent_mode/caller/",
|
||||
headers={
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"agent_caller": agent_caller},
|
||||
timeout=10.0,
|
||||
)
|
||||
# Also reflect in local config so introspection matches backend.
|
||||
if resp.status_code == 200 and CONFIG_FILE.exists():
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg.platform.agent_caller = resp.json().get("agent_caller", agent_caller)
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
|
||||
# Rule 1: env MEM0_API_KEY valid → reuse, no new key.
|
||||
_env_key = (os.environ.get("MEM0_API_KEY") or "").strip()
|
||||
if _env_key and _ping_key(_env_key, base_url):
|
||||
_maybe_identify(_env_key)
|
||||
_emit_reuse("env")
|
||||
_fire_init("existing_key")
|
||||
return
|
||||
# Rule 2: existing config api_key valid → reuse.
|
||||
if CONFIG_FILE.exists():
|
||||
_existing = load_config()
|
||||
if _existing.platform.api_key and _ping_key(_existing.platform.api_key, base_url):
|
||||
_maybe_identify(_existing.platform.api_key)
|
||||
_emit_reuse("config")
|
||||
_fire_init("existing_key")
|
||||
return
|
||||
# Rule 3: mint a fresh shadow (no valid key to reuse).
|
||||
# agent_caller is the agent's self-declared identity from --agent-caller
|
||||
# (Proof Editor-style). Env-var auto-detect is still used above to
|
||||
# decide we're in an agent context, but never to fill identity.
|
||||
bootstrap_via_backend(config, source=source, agent_caller=agent_caller)
|
||||
_fire_init("agent")
|
||||
return
|
||||
|
||||
# Warn if an existing config with an API key would be overwritten
|
||||
if not force and CONFIG_FILE.exists():
|
||||
existing = load_config()
|
||||
if existing.platform.api_key:
|
||||
from mem0_cli.config import redact_key
|
||||
|
||||
console.print(
|
||||
f"\n [{BRAND_COLOR}]Existing configuration found[/] "
|
||||
f"[{DIM_COLOR}](API key: {redact_key(existing.platform.api_key)})[/]"
|
||||
)
|
||||
if sys.stdin.isatty():
|
||||
confirm = typer.confirm(" Overwrite existing config? This cannot be undone.")
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled. Use --force to skip this check.")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
print_error(
|
||||
err_console,
|
||||
"Existing config would be overwritten.",
|
||||
hint="Use --force to overwrite.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# ── Email login flow ──────────────────────────────────────────────
|
||||
if email:
|
||||
if api_key:
|
||||
print_error(err_console, "Cannot use both --api-key and --email.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
email = email.strip().lower()
|
||||
_validate_email(email)
|
||||
|
||||
print_banner(console)
|
||||
console.print()
|
||||
print_info(console, f"Logging in as {email}...\n")
|
||||
|
||||
result = _email_login(email, code, base_url)
|
||||
|
||||
api_key_val = result.get("api_key")
|
||||
if not api_key_val:
|
||||
print_error(err_console, "Auth succeeded but no API key was returned. Contact support.")
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email
|
||||
config.platform.created_via = "email"
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
|
||||
save_config(config)
|
||||
|
||||
console.print()
|
||||
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
|
||||
console.print()
|
||||
console.print(f" [{DIM_COLOR}]Get started:[/]")
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
|
||||
console.print()
|
||||
return
|
||||
|
||||
# ── API key flow (existing) ───────────────────────────────────────
|
||||
# (Agent Mode branch runs earlier — see above, before the existing-config
|
||||
# guard, so Rules 1/2 can REUSE a valid key without prompting overwrite.)
|
||||
|
||||
# Non-TTY: resolve defaults so partial flags work in pipelines / CI
|
||||
if not sys.stdin.isatty():
|
||||
if not api_key:
|
||||
print_error(
|
||||
err_console,
|
||||
"Non-interactive terminal detected and --api-key is required.",
|
||||
hint="Run: mem0 init --api-key <key>, --email <addr>, or --agent for unattended Agent Mode bootstrap.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
user_id = user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
|
||||
# Fully non-interactive when both flags provided
|
||||
if api_key and user_id:
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
config.defaults.user_id = user_id
|
||||
_validate_platform(config)
|
||||
save_config(config)
|
||||
print_success(console, "Configuration saved to ~/.mem0/config.json")
|
||||
return
|
||||
|
||||
print_banner(console)
|
||||
console.print()
|
||||
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
|
||||
|
||||
# If no flags at all, ask user how they want to authenticate
|
||||
if not api_key:
|
||||
console.print(f" [{BRAND_COLOR}]How would you like to authenticate?[/]")
|
||||
console.print(f" [{DIM_COLOR}]1.[/] Login with email [{DIM_COLOR}](recommended)[/]")
|
||||
console.print(f" [{DIM_COLOR}]2.[/] Enter API key manually")
|
||||
console.print()
|
||||
choice = Prompt.ask(f" [{BRAND_COLOR}]Choose[/]", choices=["1", "2"], default="1")
|
||||
|
||||
if choice == "1":
|
||||
console.print()
|
||||
email_addr = Prompt.ask(f" [{BRAND_COLOR}]Email[/]")
|
||||
if not email_addr:
|
||||
print_error(err_console, "Email is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
email_addr = email_addr.strip().lower()
|
||||
_validate_email(email_addr)
|
||||
print_info(console, f"Logging in as {email_addr}...\n")
|
||||
|
||||
result = _email_login(email_addr, None, base_url)
|
||||
|
||||
api_key_val = result.get("api_key")
|
||||
if not api_key_val:
|
||||
print_error(
|
||||
err_console, "Auth succeeded but no API key was returned. Contact support."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
config.platform.api_key = api_key_val
|
||||
config.platform.base_url = base_url
|
||||
config.platform.user_email = email_addr
|
||||
config.platform.created_via = "email"
|
||||
config.defaults.user_id = (
|
||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
)
|
||||
|
||||
save_config(config)
|
||||
|
||||
console.print()
|
||||
print_success(console, "Authenticated! Configuration saved to ~/.mem0/config.json")
|
||||
console.print()
|
||||
console.print(f" [{DIM_COLOR}]Get started:[/]")
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
|
||||
console.print()
|
||||
return
|
||||
|
||||
# API key flow
|
||||
if api_key:
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
else:
|
||||
_setup_platform(config)
|
||||
|
||||
if user_id:
|
||||
config.defaults.user_id = user_id
|
||||
else:
|
||||
_setup_defaults(config)
|
||||
|
||||
_validate_platform(config)
|
||||
|
||||
save_config(config)
|
||||
console.print()
|
||||
print_success(console, "Configuration saved to ~/.mem0/config.json")
|
||||
console.print()
|
||||
console.print(f" [{DIM_COLOR}]Get started:[/]")
|
||||
if config.defaults.user_id:
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode"[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences"[/]')
|
||||
else:
|
||||
console.print(f' [{DIM_COLOR}] mem0 add "I prefer dark mode" --user-id alice[/]')
|
||||
console.print(f' [{DIM_COLOR}] mem0 search "preferences" --user-id alice[/]')
|
||||
console.print()
|
||||
|
||||
|
||||
def _setup_platform(config: Mem0Config) -> None:
|
||||
"""Platform setup flow."""
|
||||
console.print()
|
||||
console.print(
|
||||
f" [{DIM_COLOR}]Get your API key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
console.print(f" [{BRAND_COLOR}]API Key[/]: ", end="")
|
||||
api_key = _prompt_secret("")
|
||||
if not api_key:
|
||||
print_error(err_console, "API key is required.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.platform.api_key = api_key
|
||||
config.platform.created_via = "api_key"
|
||||
|
||||
|
||||
def _setup_defaults(config: Mem0Config) -> None:
|
||||
"""Collect default entity IDs."""
|
||||
console.print()
|
||||
print_info(console, "Set default entity IDs (press Enter to skip).\n")
|
||||
|
||||
_default_user = os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
||||
user_id = Prompt.ask(
|
||||
f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]",
|
||||
default=_default_user,
|
||||
)
|
||||
if user_id:
|
||||
config.defaults.user_id = user_id
|
||||
|
||||
|
||||
def _validate_platform(config: Mem0Config) -> None:
|
||||
"""Validate platform connection after all inputs are collected."""
|
||||
console.print()
|
||||
print_info(console, "Validating connection...")
|
||||
try:
|
||||
from mem0_cli.backend.platform import PlatformBackend
|
||||
|
||||
backend = PlatformBackend(config.platform)
|
||||
status = backend.status(
|
||||
user_id=config.defaults.user_id or None,
|
||||
agent_id=config.defaults.agent_id or None,
|
||||
)
|
||||
if status.get("connected"):
|
||||
print_success(console, "Connected to mem0 Platform!")
|
||||
# Cache user_email from ping response for telemetry distinct_id
|
||||
try:
|
||||
ping_data = backend.ping()
|
||||
user_email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
|
||||
if user_email:
|
||||
config.platform.user_email = user_email
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print_error(
|
||||
err_console,
|
||||
f"Could not connect: {status.get('error', 'Unknown error')}",
|
||||
hint="Visit https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python to get a new key, then run mem0 init again.",
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, f"Connection test failed: {e}")
|
||||
@@ -0,0 +1,671 @@
|
||||
"""Memory CRUD commands: add, search, get, list, update, delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat as _stat_mod
|
||||
import sys
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.branding import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_scope,
|
||||
print_success,
|
||||
timed_status,
|
||||
)
|
||||
from mem0_cli.output import (
|
||||
format_add_result,
|
||||
format_agent_envelope,
|
||||
format_json,
|
||||
format_memories_table,
|
||||
format_memories_text,
|
||||
format_single_memory,
|
||||
print_result_summary,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _stdin_is_piped() -> bool:
|
||||
"""Return True only when stdin is an actual pipe or file redirect."""
|
||||
from mem0_cli.state import is_agent_mode
|
||||
|
||||
if is_agent_mode():
|
||||
return False
|
||||
try:
|
||||
mode = os.fstat(sys.stdin.fileno()).st_mode
|
||||
return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_add(
|
||||
backend: Backend,
|
||||
text: str | None,
|
||||
*,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
app_id: str | None,
|
||||
run_id: str | None,
|
||||
messages: str | None,
|
||||
file: Path | None,
|
||||
metadata: str | None,
|
||||
immutable: bool,
|
||||
no_infer: bool,
|
||||
expires: str | None,
|
||||
categories: str | None,
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Add a memory."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("add")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
msgs = None
|
||||
content = text
|
||||
|
||||
# Read from file
|
||||
if file:
|
||||
try:
|
||||
raw = Path(file).read_text()
|
||||
msgs = json.loads(raw)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print_error(err_console, f"Failed to read file: {e}")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
# Parse messages JSON
|
||||
elif messages:
|
||||
try:
|
||||
msgs = json.loads(messages)
|
||||
except json.JSONDecodeError as e:
|
||||
print_error(err_console, f"Invalid JSON in --messages: {e}")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
# Read from stdin only if stdin is an actual pipe or file redirect
|
||||
elif not content and _stdin_is_piped():
|
||||
content = sys.stdin.read().strip()
|
||||
|
||||
if not content and not msgs:
|
||||
print_error(
|
||||
err_console, "No content provided. Pass text, --messages, --file, or pipe via stdin."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
meta = None
|
||||
if metadata:
|
||||
try:
|
||||
meta = json.loads(metadata)
|
||||
except json.JSONDecodeError:
|
||||
print_error(err_console, "Invalid JSON in --metadata.")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
cats = None
|
||||
if categories:
|
||||
try:
|
||||
cats = json.loads(categories)
|
||||
except json.JSONDecodeError:
|
||||
cats = [c.strip() for c in categories.split(",")]
|
||||
|
||||
# Validate --expires
|
||||
if expires:
|
||||
import re
|
||||
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", expires):
|
||||
print_error(
|
||||
err_console, "Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
from datetime import date
|
||||
|
||||
if date.fromisoformat(expires) <= date.today():
|
||||
print_error(err_console, "--expires date must be in the future.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with timed_status(err_console, "Adding memory...") as ts:
|
||||
try:
|
||||
result = backend.add(
|
||||
content=content,
|
||||
messages=msgs,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
metadata=meta,
|
||||
immutable=immutable,
|
||||
infer=not no_infer,
|
||||
expires=expires,
|
||||
categories=cats,
|
||||
)
|
||||
except Exception as e:
|
||||
ts.error_msg = str(e)
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
# Deduplicate PENDING entries sharing the same event_id across all output modes
|
||||
results_list = result if isinstance(result, list) else result.get("results", [result])
|
||||
seen_events: set[str] = set()
|
||||
deduped: list[dict] = []
|
||||
for r in results_list:
|
||||
if r.get("status") == "PENDING":
|
||||
eid = r.get("event_id", "")
|
||||
if eid and eid in seen_events:
|
||||
continue
|
||||
if eid:
|
||||
seen_events.add(eid)
|
||||
deduped.append(r)
|
||||
# Write back so downstream formatters see deduplicated data
|
||||
if isinstance(result, dict) and "results" in result:
|
||||
result = {**result, "results": deduped}
|
||||
else:
|
||||
result = deduped
|
||||
|
||||
if output == "agent":
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="add",
|
||||
data=deduped,
|
||||
scope=scope or None,
|
||||
count=len(deduped),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_add_result(console, result, output)
|
||||
return
|
||||
|
||||
console.print()
|
||||
print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||
count = len(deduped)
|
||||
all_pending = count > 0 and all(r.get("status") == "PENDING" for r in deduped)
|
||||
if all_pending:
|
||||
print_success(
|
||||
console,
|
||||
f"Memory queued — {count} event{'s' if count != 1 else ''} pending",
|
||||
)
|
||||
else:
|
||||
print_success(
|
||||
console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted"
|
||||
)
|
||||
format_add_result(console, result, output)
|
||||
|
||||
|
||||
def cmd_search(
|
||||
backend: Backend,
|
||||
query: str,
|
||||
*,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
app_id: str | None,
|
||||
run_id: str | None,
|
||||
top_k: int,
|
||||
threshold: float,
|
||||
rerank: bool,
|
||||
keyword: bool,
|
||||
filter_json: str | None,
|
||||
fields: str | None,
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Search memories."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("search")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
filters = None
|
||||
if filter_json:
|
||||
try:
|
||||
filters = json.loads(filter_json)
|
||||
except json.JSONDecodeError:
|
||||
print_error(err_console, "Invalid JSON in --filter.")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
field_list = None
|
||||
if fields:
|
||||
field_list = [f.strip() for f in fields.split(",")]
|
||||
|
||||
if top_k < 1:
|
||||
print_error(err_console, "--top-k must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
if not (0.0 <= threshold <= 1.0):
|
||||
print_error(err_console, "--threshold must be between 0.0 and 1.0.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Searching memories...") as _ts:
|
||||
try:
|
||||
results = backend.search(
|
||||
query,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
top_k=top_k,
|
||||
threshold=threshold,
|
||||
rerank=rerank,
|
||||
keyword=keyword,
|
||||
filters=filters,
|
||||
fields=field_list,
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
if output == "agent":
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="search",
|
||||
data=results,
|
||||
scope=scope or None,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
if output == "json":
|
||||
format_json(console, results)
|
||||
elif output == "table":
|
||||
if results:
|
||||
format_memories_table(console, results, show_score=True)
|
||||
print_result_summary(
|
||||
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
|
||||
)
|
||||
else:
|
||||
console.print()
|
||||
print_info(console, "No memories found matching your query.")
|
||||
console.print()
|
||||
else:
|
||||
if results:
|
||||
format_memories_text(console, results)
|
||||
print_result_summary(
|
||||
console, len(results), duration_secs=_elapsed, user_id=user_id, agent_id=agent_id
|
||||
)
|
||||
else:
|
||||
console.print()
|
||||
print_info(console, "No memories found matching your query.")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
||||
"""Get a specific memory by ID."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("get")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
with timed_status(err_console, "Fetching memory...") as _ts:
|
||||
try:
|
||||
result = backend.get(memory_id)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(console, command="get", data=result)
|
||||
else:
|
||||
format_single_memory(console, result, output)
|
||||
|
||||
|
||||
def cmd_list(
|
||||
backend: Backend,
|
||||
*,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
app_id: str | None,
|
||||
run_id: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
category: str | None,
|
||||
after: str | None,
|
||||
before: str | None,
|
||||
output: str = "table",
|
||||
) -> None:
|
||||
"""List memories."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("list")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if page_size < 1:
|
||||
print_error(err_console, "--page-size must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
if page < 1:
|
||||
print_error(err_console, "--page must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Listing memories...") as _ts:
|
||||
try:
|
||||
results = backend.list_memories(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
category=category,
|
||||
after=after,
|
||||
before=before,
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
if output in ("json", "agent"):
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="list",
|
||||
data=results,
|
||||
scope=scope or None,
|
||||
count=len(results),
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "table":
|
||||
if results:
|
||||
format_memories_table(console, results)
|
||||
print_result_summary(
|
||||
console,
|
||||
len(results),
|
||||
duration_secs=_elapsed,
|
||||
page=page,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
else:
|
||||
console.print()
|
||||
print_info(console, "No memories found.")
|
||||
console.print()
|
||||
else:
|
||||
if results:
|
||||
format_memories_text(console, results, title="memories")
|
||||
print_result_summary(
|
||||
console,
|
||||
len(results),
|
||||
duration_secs=_elapsed,
|
||||
page=page,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
else:
|
||||
console.print()
|
||||
print_info(console, "No memories found.")
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_update(
|
||||
backend: Backend,
|
||||
memory_id: str,
|
||||
text: str | None,
|
||||
*,
|
||||
metadata: str | None,
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Update a memory."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("update")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
meta = None
|
||||
if metadata:
|
||||
try:
|
||||
meta = json.loads(metadata)
|
||||
except json.JSONDecodeError:
|
||||
print_error(err_console, "Invalid JSON in --metadata.")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Updating memory...") as _ts:
|
||||
try:
|
||||
result = backend.update(memory_id, content=text, metadata=meta)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="update",
|
||||
data=result,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
|
||||
|
||||
|
||||
def cmd_delete(
|
||||
backend: Backend,
|
||||
memory_id: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete a single memory by ID."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("delete")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if dry_run:
|
||||
# Fetch and display what would be deleted
|
||||
try:
|
||||
mem = backend.get(memory_id)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
format_single_memory(console, mem, output)
|
||||
print_info(console, "No changes made (dry run).")
|
||||
return
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Deleting...") as _ts:
|
||||
try:
|
||||
result = backend.delete(memory_id=memory_id)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete",
|
||||
data={"id": memory_id, "deleted": True},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
|
||||
|
||||
|
||||
def cmd_delete_all(
|
||||
backend: Backend,
|
||||
*,
|
||||
force: bool,
|
||||
dry_run: bool = False,
|
||||
all_: bool = False,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
app_id: str | None,
|
||||
run_id: str | None,
|
||||
output: str,
|
||||
) -> None:
|
||||
"""Delete all memories matching a scope."""
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("delete-all")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
if not force:
|
||||
print_error(err_console, "Destructive operation requires --force in agent mode.")
|
||||
raise typer.Exit(1)
|
||||
if all_:
|
||||
# Project-wide wipe using wildcard entity IDs
|
||||
# Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
|
||||
|
||||
if not force:
|
||||
confirm = typer.confirm(
|
||||
"\n ⚠ Delete ALL memories across the ENTIRE project? This cannot be undone."
|
||||
)
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Deleting all memories project-wide...") as _ts:
|
||||
try:
|
||||
result = backend.delete(
|
||||
all=True,
|
||||
user_id="*",
|
||||
agent_id="*",
|
||||
app_id="*",
|
||||
run_id="*",
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete-all",
|
||||
data={"deleted": True, "scope": "project"},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
if isinstance(result, dict) and "message" in result:
|
||||
print_info(console, "Deletion started. Memories will be removed in the background.")
|
||||
else:
|
||||
print_success(console, f"All project memories deleted ({_elapsed:.2f}s)")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
# List matching memories and show count
|
||||
try:
|
||||
results = backend.list_memories(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
count = len(results)
|
||||
print_info(console, f"Would delete {count} memor{'y' if count == 1 else 'ies'}.")
|
||||
print_info(console, "No changes made (dry run).")
|
||||
return
|
||||
|
||||
if not force:
|
||||
scope_parts = []
|
||||
if user_id:
|
||||
scope_parts.append(f"user={user_id}")
|
||||
if agent_id:
|
||||
scope_parts.append(f"agent={agent_id}")
|
||||
if app_id:
|
||||
scope_parts.append(f"app={app_id}")
|
||||
if run_id:
|
||||
scope_parts.append(f"run={run_id}")
|
||||
scope = ", ".join(scope_parts) if scope_parts else "ALL entities"
|
||||
|
||||
confirm = typer.confirm(f"\n ⚠ Delete ALL memories for {scope}? This cannot be undone.")
|
||||
if not confirm:
|
||||
print_info(console, "Cancelled.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Deleting all memories...") as _ts:
|
||||
try:
|
||||
result = backend.delete(
|
||||
all=True,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
app_id=app_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print_error(err_console, str(e))
|
||||
raise typer.Exit(1) from None
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
scope = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"app_id": app_id,
|
||||
"run_id": run_id,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if output == "agent":
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="delete-all",
|
||||
data={"deleted": True},
|
||||
scope=scope or None,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
elif output == "json":
|
||||
format_json(console, result)
|
||||
elif output != "quiet":
|
||||
if isinstance(result, dict) and "message" in result:
|
||||
print_info(console, "Deletion started. Memories will be removed in the background.")
|
||||
else:
|
||||
print_success(console, f"All matching memories deleted ({_elapsed:.2f}s)")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Utility commands: status, version, import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time as _time
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import track
|
||||
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.branding import (
|
||||
BRAND_COLOR,
|
||||
DIM_COLOR,
|
||||
ERROR_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
print_error,
|
||||
print_success,
|
||||
timed_status,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def cmd_status(
|
||||
backend: Backend,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Check connectivity and auth."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("status")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
|
||||
_start = _time.perf_counter()
|
||||
with timed_status(err_console, "Checking connection...") as _ts:
|
||||
result = backend.status(user_id=user_id, agent_id=agent_id)
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output in ("json", "agent"):
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="status",
|
||||
data={
|
||||
"connected": result.get("connected", False),
|
||||
"backend": result.get("backend", "?"),
|
||||
"base_url": result.get("base_url", ""),
|
||||
},
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
lines = []
|
||||
if result.get("connected"):
|
||||
lines.append(f" [{SUCCESS_COLOR}]●[/] Connected")
|
||||
else:
|
||||
lines.append(f" [{ERROR_COLOR}]●[/] Disconnected")
|
||||
|
||||
lines.append(f" [{DIM_COLOR}]Backend:[/] {result.get('backend', '?')}")
|
||||
if result.get("base_url"):
|
||||
lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}")
|
||||
if result.get("error"):
|
||||
lines.append(f" [{ERROR_COLOR}]Error:[/] {result['error']}")
|
||||
if "Authentication failed" in str(result["error"]):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f" [{DIM_COLOR}]Run [bold]mem0 init[/bold] to reconfigure your API key[/]"
|
||||
)
|
||||
lines.append(
|
||||
f" [{DIM_COLOR}]Get a key at [bold]https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python[/bold][/]"
|
||||
)
|
||||
lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s")
|
||||
|
||||
content = "\n".join(lines)
|
||||
panel = Panel(
|
||||
content,
|
||||
title=f"[{BRAND_COLOR}]Connection Status[/]",
|
||||
title_align="left",
|
||||
border_style=BRAND_COLOR,
|
||||
padding=(1, 1),
|
||||
)
|
||||
console.print()
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def cmd_version() -> None:
|
||||
"""Show version."""
|
||||
console.print(f" [{BRAND_COLOR}]◆ Mem0[/] CLI v{__version__}")
|
||||
|
||||
|
||||
def cmd_import(
|
||||
backend: Backend,
|
||||
file_path: str,
|
||||
*,
|
||||
user_id: str | None,
|
||||
agent_id: str | None,
|
||||
output: str = "text",
|
||||
) -> None:
|
||||
"""Import memories from a JSON file."""
|
||||
from mem0_cli.output import format_agent_envelope
|
||||
from mem0_cli.state import is_agent_mode, set_current_command
|
||||
|
||||
set_current_command("import")
|
||||
if is_agent_mode():
|
||||
output = "agent"
|
||||
|
||||
try:
|
||||
data = json.loads(Path(file_path).read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print_error(err_console, f"Failed to read file: {e}")
|
||||
raise typer.Exit(1) from None
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = [data]
|
||||
|
||||
added = 0
|
||||
failed = 0
|
||||
_start = _time.perf_counter()
|
||||
for item in track(
|
||||
data, description=f"[{DIM_COLOR}]Importing memories...[/]", console=err_console
|
||||
):
|
||||
content = item.get("memory", item.get("text", item.get("content", "")))
|
||||
if not content:
|
||||
failed += 1
|
||||
continue
|
||||
try:
|
||||
backend.add(
|
||||
content=content,
|
||||
user_id=user_id or item.get("user_id"),
|
||||
agent_id=agent_id or item.get("agent_id"),
|
||||
metadata=item.get("metadata"),
|
||||
)
|
||||
added += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
_elapsed = _time.perf_counter() - _start
|
||||
|
||||
if output in ("json", "agent"):
|
||||
scope = {k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}
|
||||
format_agent_envelope(
|
||||
console,
|
||||
command="import",
|
||||
data={"added": added, "failed": failed},
|
||||
scope=scope or None,
|
||||
duration_ms=int(_elapsed * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
print_success(err_console, f"Imported {added} memories ({_elapsed:.2f}s)")
|
||||
if failed:
|
||||
print_error(err_console, f"{failed} memories failed to import.")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""mem0 whoami — print the active agent's default_user_id (AGENTRUSH identifier)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import BRAND_COLOR, print_error, print_info
|
||||
from mem0_cli.config import load_config
|
||||
|
||||
console = Console()
|
||||
err_console = Console(stderr=True)
|
||||
|
||||
|
||||
def run_whoami() -> None:
|
||||
config = load_config()
|
||||
session_id = config.platform.default_user_id if config.platform else None
|
||||
if not session_id:
|
||||
print_error(
|
||||
err_console,
|
||||
"No default_user_id found. Run `mem0 init --agent` first.",
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(f"Your AGENTRUSH identifier: [{BRAND_COLOR}]{session_id}[/{BRAND_COLOR}]")
|
||||
print_info(console, "Find your row at https://mem0.ai/agentrush")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""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
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CONFIG_DIR = Path.home() / ".mem0"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.mem0.ai"
|
||||
CONFIG_VERSION = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = DEFAULT_BASE_URL
|
||||
user_email: str = ""
|
||||
# Agent Mode (unclaimed-shadow signup)
|
||||
agent_mode: bool = False # True while the key is an unclaimed agent-mode key
|
||||
created_via: str = "" # "agent_mode" | "email" | "api_key" | "existing_key"
|
||||
agent_caller: str = (
|
||||
"" # canonical agent name when created_via == "agent_mode" (e.g. "claude-code")
|
||||
)
|
||||
claimed_at: str = "" # ISO timestamp once the agent has been claimed by a human
|
||||
default_user_id: str = "" # `user_<slug>` returned by bootstrap; used as auto-default
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultsConfig:
|
||||
user_id: str = ""
|
||||
agent_id: str = ""
|
||||
app_id: str = ""
|
||||
run_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelemetryConfig:
|
||||
anonymous_id: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRushConfig:
|
||||
# ISO timestamp the human acknowledged the "memories are public" warning.
|
||||
# Empty until first interactive `mem0 agent-rush add`.
|
||||
acknowledged_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mem0Config:
|
||||
version: int = CONFIG_VERSION
|
||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
||||
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
|
||||
agent_rush: AgentRushConfig = field(default_factory=AgentRushConfig)
|
||||
|
||||
|
||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||
"api_key": "platform.api_key",
|
||||
"base_url": "platform.base_url",
|
||||
"user_email": "platform.user_email",
|
||||
"user_id": "defaults.user_id",
|
||||
"agent_id": "defaults.agent_id",
|
||||
"app_id": "defaults.app_id",
|
||||
"run_id": "defaults.run_id",
|
||||
}
|
||||
|
||||
|
||||
def ensure_config_dir() -> Path:
|
||||
"""Create ~/.mem0 directory with secure permissions if it doesn't exist."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(CONFIG_DIR, stat.S_IRWXU) # 0700
|
||||
return CONFIG_DIR
|
||||
|
||||
|
||||
def load_config() -> Mem0Config:
|
||||
"""Load config from file, applying env var overrides."""
|
||||
config = Mem0Config()
|
||||
|
||||
if CONFIG_FILE.exists():
|
||||
with open(CONFIG_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
config.version = data.get("version", CONFIG_VERSION)
|
||||
|
||||
plat = data.get("platform", {})
|
||||
config.platform.api_key = plat.get("api_key", "")
|
||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
||||
config.platform.user_email = plat.get("user_email", "")
|
||||
config.platform.agent_mode = bool(plat.get("agent_mode", False))
|
||||
config.platform.created_via = plat.get("created_via", "")
|
||||
config.platform.agent_caller = plat.get("agent_caller", "")
|
||||
config.platform.claimed_at = plat.get("claimed_at", "")
|
||||
config.platform.default_user_id = plat.get("default_user_id", "")
|
||||
|
||||
defaults = data.get("defaults", {})
|
||||
config.defaults.user_id = defaults.get("user_id", "")
|
||||
config.defaults.agent_id = defaults.get("agent_id", "")
|
||||
config.defaults.app_id = defaults.get("app_id", "")
|
||||
config.defaults.run_id = defaults.get("run_id", "")
|
||||
telemetry = data.get("telemetry", {})
|
||||
config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
|
||||
|
||||
agent_rush = data.get("agent_rush", {})
|
||||
config.agent_rush.acknowledged_at = agent_rush.get("acknowledged_at", "")
|
||||
|
||||
# Environment variable overrides
|
||||
env_key = os.environ.get("MEM0_API_KEY")
|
||||
if env_key:
|
||||
config.platform.api_key = env_key
|
||||
|
||||
env_base = os.environ.get("MEM0_BASE_URL")
|
||||
if env_base:
|
||||
config.platform.base_url = env_base
|
||||
|
||||
env_user_id = os.environ.get("MEM0_USER_ID")
|
||||
if env_user_id:
|
||||
config.defaults.user_id = env_user_id
|
||||
|
||||
env_agent_id = os.environ.get("MEM0_AGENT_ID")
|
||||
if env_agent_id:
|
||||
config.defaults.agent_id = env_agent_id
|
||||
|
||||
env_app_id = os.environ.get("MEM0_APP_ID")
|
||||
if env_app_id:
|
||||
config.defaults.app_id = env_app_id
|
||||
|
||||
env_run_id = os.environ.get("MEM0_RUN_ID")
|
||||
if env_run_id:
|
||||
config.defaults.run_id = env_run_id
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: Mem0Config) -> None:
|
||||
"""Write config to disk with secure permissions."""
|
||||
ensure_config_dir()
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"version": config.version,
|
||||
"defaults": {
|
||||
"user_id": config.defaults.user_id,
|
||||
"agent_id": config.defaults.agent_id,
|
||||
"app_id": config.defaults.app_id,
|
||||
"run_id": config.defaults.run_id,
|
||||
},
|
||||
"platform": {
|
||||
"api_key": config.platform.api_key,
|
||||
"base_url": config.platform.base_url,
|
||||
"user_email": config.platform.user_email,
|
||||
"agent_mode": config.platform.agent_mode,
|
||||
"created_via": config.platform.created_via,
|
||||
"agent_caller": config.platform.agent_caller,
|
||||
"claimed_at": config.platform.claimed_at,
|
||||
"default_user_id": config.platform.default_user_id,
|
||||
},
|
||||
"telemetry": {
|
||||
"anonymous_id": config.telemetry.anonymous_id,
|
||||
},
|
||||
"agent_rush": {
|
||||
"acknowledged_at": config.agent_rush.acknowledged_at,
|
||||
},
|
||||
}
|
||||
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
||||
|
||||
# Propagate the active api_key to ecosystem touchpoints (Claude Code
|
||||
# plugin env injection, shell rc exports). Idempotent — only updates
|
||||
# EXISTING entries; never creates new ones. Best-effort: any IOError
|
||||
# in the sync is swallowed so config.json is always the authoritative
|
||||
# write, never blocked by plugin-state issues.
|
||||
if config.platform.api_key:
|
||||
try:
|
||||
from mem0_cli.plugin_sync import sync_api_key
|
||||
|
||||
sync_api_key(config.platform.api_key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display: m0-xxx...xxx"""
|
||||
if not key:
|
||||
return "(not set)"
|
||||
if len(key) <= 8:
|
||||
return key[:2] + "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
|
||||
|
||||
def get_nested_value(config: Mem0Config, dotted_key: str) -> Any:
|
||||
"""Get a config value by dotted path, e.g. 'platform.api_key' or short form 'api_key'."""
|
||||
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
|
||||
parts = dotted_key.split(".")
|
||||
obj: Any = config
|
||||
for part in parts:
|
||||
if hasattr(obj, part):
|
||||
obj = getattr(obj, part)
|
||||
else:
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
def set_nested_value(config: Mem0Config, dotted_key: str, value: str) -> bool:
|
||||
"""Set a config value by dotted path. Returns True on success."""
|
||||
dotted_key = SHORT_KEY_ALIASES.get(dotted_key, dotted_key)
|
||||
parts = dotted_key.split(".")
|
||||
obj: Any = config
|
||||
for part in parts[:-1]:
|
||||
if hasattr(obj, part):
|
||||
obj = getattr(obj, part)
|
||||
else:
|
||||
return False
|
||||
|
||||
final_key = parts[-1]
|
||||
if not hasattr(obj, final_key):
|
||||
return False
|
||||
|
||||
current = getattr(obj, final_key)
|
||||
# Type coercion
|
||||
if isinstance(current, bool):
|
||||
value = value.lower() in ("true", "1", "yes") # type: ignore[assignment]
|
||||
elif isinstance(current, int):
|
||||
value = int(value) # type: ignore[assignment]
|
||||
|
||||
setattr(obj, final_key, value)
|
||||
return True
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Output formatting for mem0 CLI — text, JSON, table, quiet modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from mem0_cli.branding import ACCENT_COLOR, BRAND_COLOR, DIM_COLOR, SUCCESS_COLOR, _sym
|
||||
|
||||
|
||||
def format_memories_text(console: Console, memories: list[dict], title: str = "memories") -> None:
|
||||
"""Render memories in human-friendly text mode."""
|
||||
count = len(memories)
|
||||
console.print(f"\n[{BRAND_COLOR}]Found {count} {title}:[/]\n")
|
||||
|
||||
for i, mem in enumerate(memories, 1):
|
||||
memory_text = mem.get("memory", mem.get("text", ""))
|
||||
mem_id = mem.get("id", "")[:8]
|
||||
score = mem.get("score")
|
||||
created = _format_date(mem.get("created_at"))
|
||||
category = mem.get("categories", [None])
|
||||
if isinstance(category, list):
|
||||
category = category[0] if category else None
|
||||
|
||||
line = Text()
|
||||
line.append(f" {i}. ", style="bold")
|
||||
line.append(memory_text, style="white")
|
||||
console.print(line)
|
||||
|
||||
details = []
|
||||
if score is not None:
|
||||
details.append(f"Score: {score:.2f}")
|
||||
if mem_id:
|
||||
details.append(f"ID: {mem_id}")
|
||||
if created:
|
||||
details.append(f"Created: {created}")
|
||||
if category:
|
||||
details.append(f"Category: {category}")
|
||||
|
||||
if details:
|
||||
detail_str = " · ".join(details)
|
||||
console.print(f" [{DIM_COLOR}]{detail_str}[/]")
|
||||
console.print()
|
||||
|
||||
|
||||
def format_memories_table(
|
||||
console: Console, memories: list[dict], *, show_score: bool = False
|
||||
) -> None:
|
||||
"""Render memories in a rich table."""
|
||||
table = Table(
|
||||
border_style=BRAND_COLOR,
|
||||
header_style=f"bold {ACCENT_COLOR}",
|
||||
row_styles=["", "dim"],
|
||||
padding=(0, 1),
|
||||
)
|
||||
table.add_column("ID", style="dim", max_width=38, no_wrap=True)
|
||||
if show_score:
|
||||
table.add_column("Score", max_width=7, justify="right")
|
||||
table.add_column("Memory", max_width=50, no_wrap=False)
|
||||
table.add_column("Category", max_width=14)
|
||||
table.add_column("Created", max_width=12)
|
||||
|
||||
for mem in memories:
|
||||
mem_id = mem.get("id", "")
|
||||
memory_text = mem.get("memory", mem.get("text", ""))
|
||||
if len(memory_text) > 60:
|
||||
memory_text = memory_text[:57] + "..."
|
||||
categories = mem.get("categories", [])
|
||||
if isinstance(categories, list) and categories:
|
||||
cat = (
|
||||
categories[0]
|
||||
if len(categories) == 1
|
||||
else f"{categories[0]} (+{len(categories) - 1})"
|
||||
)
|
||||
else:
|
||||
cat = "—"
|
||||
created = _format_date(mem.get("created_at")) or "—"
|
||||
if show_score:
|
||||
score = mem.get("score")
|
||||
score_str = f"{score:.2f}" if score is not None else "—"
|
||||
table.add_row(mem_id, score_str, memory_text, cat, created)
|
||||
else:
|
||||
table.add_row(mem_id, memory_text, cat, created)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
def format_json(console: Console, data: Any) -> None:
|
||||
"""Output data as pretty-printed JSON."""
|
||||
console.print_json(json.dumps(data, default=str))
|
||||
|
||||
|
||||
def format_single_memory(console: Console, mem: dict, output: str = "text") -> None:
|
||||
"""Format a single memory for display."""
|
||||
if output == "json":
|
||||
format_json(console, mem)
|
||||
return
|
||||
|
||||
memory_text = mem.get("memory", mem.get("text", ""))
|
||||
mem_id = mem.get("id", "")
|
||||
|
||||
lines = []
|
||||
lines.append(f" [white bold]{memory_text}[/]")
|
||||
lines.append("")
|
||||
|
||||
if mem_id:
|
||||
lines.append(f" [{DIM_COLOR}]ID:[/] {mem_id}")
|
||||
created = _format_date(mem.get("created_at"))
|
||||
if created:
|
||||
lines.append(f" [{DIM_COLOR}]Created:[/] {created}")
|
||||
updated = _format_date(mem.get("updated_at"))
|
||||
if updated:
|
||||
lines.append(f" [{DIM_COLOR}]Updated:[/] {updated}")
|
||||
meta = mem.get("metadata")
|
||||
if meta:
|
||||
lines.append(f" [{DIM_COLOR}]Metadata:[/] {json.dumps(meta)}")
|
||||
categories = mem.get("categories")
|
||||
if categories:
|
||||
cat_str = ", ".join(categories) if isinstance(categories, list) else categories
|
||||
lines.append(f" [{DIM_COLOR}]Categories:[/] {cat_str}")
|
||||
|
||||
content = "\n".join(lines)
|
||||
panel = Panel(
|
||||
content,
|
||||
title=f"[{BRAND_COLOR}]Memory[/]",
|
||||
title_align="left",
|
||||
border_style=BRAND_COLOR,
|
||||
padding=(1, 1),
|
||||
)
|
||||
console.print()
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
|
||||
def format_add_result(console: Console, result: dict | list, output: str = "text") -> None:
|
||||
"""Format the result of an add operation."""
|
||||
if output == "json":
|
||||
format_json(console, result)
|
||||
return
|
||||
if output == "quiet":
|
||||
return
|
||||
|
||||
# result from API is typically {"results": [...]}
|
||||
results = result if isinstance(result, list) else result.get("results", [result])
|
||||
if not results:
|
||||
console.print(f" [{DIM_COLOR}]No memories extracted.[/]")
|
||||
return
|
||||
|
||||
console.print()
|
||||
seen_pending_events: set[str] = set()
|
||||
for r in results:
|
||||
# Detect async PENDING response from Platform API
|
||||
if r.get("status") == "PENDING":
|
||||
event_id = r.get("event_id", "")
|
||||
# Deduplicate PENDING entries with the same event_id
|
||||
if event_id and event_id in seen_pending_events:
|
||||
continue
|
||||
if event_id:
|
||||
seen_pending_events.add(event_id)
|
||||
icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]"
|
||||
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
|
||||
parts.append("[white]Processing in background[/]")
|
||||
console.print(" ".join(parts))
|
||||
if event_id:
|
||||
console.print(f" [{DIM_COLOR}] event_id: {event_id}[/]")
|
||||
console.print(f" [{DIM_COLOR}] → Check status: mem0 event status {event_id}[/]")
|
||||
continue
|
||||
|
||||
event = r.get("event", "ADD")
|
||||
memory = r.get("memory") or r.get("text") or r.get("content") or r.get("data") or ""
|
||||
mem_id = (r.get("id") or r.get("memory_id") or "")[:8]
|
||||
|
||||
if event == "ADD":
|
||||
icon = f"[{SUCCESS_COLOR}]+[/]"
|
||||
label = "Added"
|
||||
elif event == "UPDATE":
|
||||
icon = f"[{ACCENT_COLOR}]~[/]"
|
||||
label = "Updated"
|
||||
elif event == "DELETE":
|
||||
icon = "[red]-[/]"
|
||||
label = "Deleted"
|
||||
elif event == "NOOP":
|
||||
icon = f"[{DIM_COLOR}]·[/]"
|
||||
label = "No change"
|
||||
else:
|
||||
icon = f"[{DIM_COLOR}]?[/]"
|
||||
label = event
|
||||
|
||||
# Build the display line
|
||||
parts = [f" {icon} [{DIM_COLOR}]{label:<10}[/]"]
|
||||
if memory:
|
||||
parts.append(f"[white]{memory}[/]")
|
||||
if mem_id:
|
||||
parts.append(f"[{DIM_COLOR}]({mem_id})[/]")
|
||||
console.print(" ".join(parts))
|
||||
console.print()
|
||||
|
||||
|
||||
def format_json_envelope(
|
||||
console: Console,
|
||||
*,
|
||||
command: str,
|
||||
data: Any,
|
||||
duration_ms: int | None = None,
|
||||
scope: dict | None = None,
|
||||
count: int | None = None,
|
||||
status: str = "success",
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Output structured JSON envelope for AI agent consumption."""
|
||||
envelope: dict[str, Any] = {
|
||||
"status": status,
|
||||
"command": command,
|
||||
}
|
||||
if duration_ms is not None:
|
||||
envelope["duration_ms"] = duration_ms
|
||||
if scope is not None:
|
||||
envelope["scope"] = scope
|
||||
if count is not None:
|
||||
envelope["count"] = count
|
||||
if error:
|
||||
envelope["error"] = error
|
||||
envelope["data"] = data
|
||||
|
||||
# If the platform flagged this as an unclaimed Agent Mode account, surface
|
||||
# the notice inside the JSON envelope so an agent consuming the output
|
||||
# sees it without needing to inspect HTTP headers.
|
||||
from mem0_cli.state import take_notice
|
||||
|
||||
notice = take_notice()
|
||||
if notice:
|
||||
envelope["mem0_notice"] = notice
|
||||
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
def sanitize_agent_data(command: str, data: Any) -> Any:
|
||||
"""Project API response data to minimal relevant fields for agent consumption."""
|
||||
|
||||
def pick(obj: dict, keys: list) -> dict:
|
||||
return {k: obj[k] for k in keys if k in obj}
|
||||
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
if command == "add":
|
||||
items = data if isinstance(data, list) else [data]
|
||||
result = []
|
||||
for item in items:
|
||||
if item.get("status") == "PENDING":
|
||||
result.append(pick(item, ["status", "event_id"]))
|
||||
else:
|
||||
result.append(pick(item, ["id", "memory", "event"]))
|
||||
return result
|
||||
|
||||
if command == "search":
|
||||
return [pick(r, ["id", "memory", "score", "created_at", "categories"]) for r in data]
|
||||
|
||||
if command == "list":
|
||||
return [pick(r, ["id", "memory", "created_at", "categories"]) for r in data]
|
||||
|
||||
if command == "get":
|
||||
return pick(data, ["id", "memory", "created_at", "updated_at", "categories", "metadata"])
|
||||
|
||||
if command == "update":
|
||||
return pick(data, ["id", "memory"])
|
||||
|
||||
if command in ("delete", "delete-all", "entity delete"):
|
||||
return data
|
||||
|
||||
if command == "entity list":
|
||||
result = []
|
||||
for r in data:
|
||||
item = pick(r, ["type", "count"])
|
||||
item["name"] = r.get("name") or r.get("id", "")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
if command == "event list":
|
||||
return [pick(r, ["id", "event_type", "status", "latency", "created_at"]) for r in data]
|
||||
|
||||
if command == "event status":
|
||||
ev = data
|
||||
raw_results = ev.get("results") or []
|
||||
sanitized_results = []
|
||||
for r in raw_results:
|
||||
nested = r.get("data") or {}
|
||||
memory = nested.get("memory") if isinstance(nested, dict) else None
|
||||
sanitized_results.append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"event": r.get("event"),
|
||||
"user_id": r.get("user_id"),
|
||||
"memory": memory,
|
||||
}
|
||||
)
|
||||
result = pick(ev, ["id", "event_type", "status", "latency", "created_at", "updated_at"])
|
||||
result["results"] = sanitized_results
|
||||
return result
|
||||
|
||||
# Pass-through: status, import, config show/get/set
|
||||
return data
|
||||
|
||||
|
||||
def format_agent_envelope(
|
||||
console: Console,
|
||||
*,
|
||||
command: str,
|
||||
data: Any,
|
||||
duration_ms: int | None = None,
|
||||
scope: dict | None = None,
|
||||
count: int | None = None,
|
||||
) -> None:
|
||||
"""Output structured JSON envelope for agent/programmatic use (--json/--agent mode)."""
|
||||
envelope: dict[str, Any] = {
|
||||
"status": "success",
|
||||
"command": command,
|
||||
}
|
||||
if duration_ms is not None:
|
||||
envelope["duration_ms"] = duration_ms
|
||||
if scope:
|
||||
filtered = {k: v for k, v in scope.items() if v}
|
||||
if filtered:
|
||||
envelope["scope"] = filtered
|
||||
if count is not None:
|
||||
envelope["count"] = count
|
||||
envelope["data"] = sanitize_agent_data(command, data)
|
||||
|
||||
# Surface the unclaimed-Agent-Mode notice (if any) in the envelope so an
|
||||
# agent reading the JSON output sees it without inspecting HTTP headers.
|
||||
from mem0_cli.state import take_notice
|
||||
|
||||
notice = take_notice()
|
||||
if notice:
|
||||
envelope["mem0_notice"] = notice
|
||||
|
||||
console.print_json(json.dumps(envelope, default=str))
|
||||
|
||||
|
||||
def print_result_summary(
|
||||
console: Console,
|
||||
count: int,
|
||||
*,
|
||||
duration_secs: float | None = None,
|
||||
page: int | None = None,
|
||||
**scope_ids: str | None,
|
||||
) -> None:
|
||||
"""Print a summary footer after result lists."""
|
||||
parts = [f"{count} result{'s' if count != 1 else ''}"]
|
||||
if page is not None:
|
||||
parts.append(f"page {page}")
|
||||
scope_parts = [f"{k}={v}" for k, v in scope_ids.items() if v]
|
||||
if scope_parts:
|
||||
parts.append(", ".join(scope_parts))
|
||||
if duration_secs is not None:
|
||||
parts.append(f"{duration_secs:.2f}s")
|
||||
|
||||
summary = " · ".join(parts)
|
||||
console.print(f" [{DIM_COLOR}]{summary}[/]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _format_date(dt_str: str | None) -> str | None:
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, AttributeError):
|
||||
return str(dt_str)[:10] if dt_str else None
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Sync the active Mem0 API key into other ecosystem touchpoints.
|
||||
|
||||
Why this exists:
|
||||
The CLI canonical state lives in ``~/.mem0/config.json``. But MCP servers
|
||||
(Claude Code plugin, Codex plugin, etc.) read ``MEM0_API_KEY`` from env
|
||||
vars or their own config files. Without a sync, an agent-mode bootstrap
|
||||
mints a new key into config.json but the plugin's MCP keeps using the
|
||||
old key from env — silent surprise.
|
||||
|
||||
Design:
|
||||
- Update ONLY entries that already exist (never create new ones)
|
||||
- Preserve all surrounding content / formatting / other keys
|
||||
- Atomic writes (tmpfile + rename) so a crash mid-write doesn't corrupt
|
||||
- Idempotent — re-running with the same key is a no-op
|
||||
- Skip on dry_run
|
||||
|
||||
Targets currently handled:
|
||||
- ``~/.claude/settings.json::env::MEM0_API_KEY`` (Claude Code env injection)
|
||||
- ``~/.zshrc`` / ``~/.bashrc`` ``export MEM0_API_KEY="..."`` lines
|
||||
|
||||
Out of scope (deliberately not touched):
|
||||
- Codex / Cursor MCP configs — would require schema-aware edits and
|
||||
those tools don't have mem0 entries by default
|
||||
- Plugin's own ``<plugin-dir>/.api_key`` file — plugin-managed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Files we know how to update safely.
|
||||
_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
||||
_SHELL_RCS = [Path.home() / ".zshrc", Path.home() / ".bashrc", Path.home() / ".bash_profile"]
|
||||
|
||||
|
||||
def sync_api_key(api_key: str) -> list[str]:
|
||||
"""Propagate ``api_key`` into known ecosystem touchpoints.
|
||||
|
||||
Returns the list of paths actually updated. Empty list means nothing
|
||||
needed updating (either targets didn't exist or already had this value).
|
||||
"""
|
||||
if not api_key:
|
||||
return []
|
||||
updated: list[str] = []
|
||||
if _update_claude_settings(_CLAUDE_SETTINGS, api_key):
|
||||
updated.append(str(_CLAUDE_SETTINGS))
|
||||
for rc in _SHELL_RCS:
|
||||
if _update_shell_rc(rc, api_key):
|
||||
updated.append(str(rc))
|
||||
return updated
|
||||
|
||||
|
||||
def _update_claude_settings(path: Path, api_key: str) -> bool:
|
||||
"""Update ``env.MEM0_API_KEY`` in path. Returns True if file was changed."""
|
||||
if not path.is_file():
|
||||
return False
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
env = data.get("env")
|
||||
if not isinstance(env, dict) or "MEM0_API_KEY" not in env:
|
||||
# No existing entry — don't create one.
|
||||
return False
|
||||
if env["MEM0_API_KEY"] == api_key:
|
||||
return False # already in sync
|
||||
env["MEM0_API_KEY"] = api_key
|
||||
_atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
||||
return True
|
||||
|
||||
|
||||
# Match `export MEM0_API_KEY="..."` (or single quotes, or no quotes).
|
||||
# Use [ \t]* (not \s*) for trailing whitespace so a trailing newline at
|
||||
# end-of-file is preserved when MEM0_API_KEY is the last line.
|
||||
_RC_LINE = re.compile(
|
||||
r'^([ \t]*export[ \t]+MEM0_API_KEY[ \t]*=[ \t]*)(["\']?)([^"\'\n]*)(["\']?)[ \t]*$',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _update_shell_rc(path: Path, api_key: str) -> bool:
|
||||
"""Update an existing ``export MEM0_API_KEY=...`` line in path."""
|
||||
if not path.is_file():
|
||||
return False
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
match = _RC_LINE.search(text)
|
||||
if not match:
|
||||
return False # no existing line
|
||||
if match.group(3) == api_key:
|
||||
return False
|
||||
new_text = _RC_LINE.sub(lambda m: f'{m.group(1)}"{api_key}"', text, count=1)
|
||||
_atomic_write_text(path, new_text)
|
||||
return True
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, content: str) -> None:
|
||||
"""Write content to path atomically (temp + rename)."""
|
||||
dirname = path.parent
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=dirname)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
# Preserve mode if the original existed.
|
||||
if path.exists():
|
||||
os.chmod(tmp_path, path.stat().st_mode & 0o777)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Agent mode state — set by the root callback, read by commands and branding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_agent_mode: bool = False
|
||||
_current_command: str = ""
|
||||
_pending_notice: str = ""
|
||||
|
||||
|
||||
def is_agent_mode() -> bool:
|
||||
return _agent_mode
|
||||
|
||||
|
||||
def set_agent_mode(val: bool) -> None:
|
||||
global _agent_mode
|
||||
_agent_mode = val
|
||||
|
||||
|
||||
def get_current_command() -> str:
|
||||
return _current_command
|
||||
|
||||
|
||||
def set_current_command(name: str) -> None:
|
||||
global _current_command
|
||||
_current_command = name
|
||||
|
||||
|
||||
def capture_notice(notice: str | None) -> None:
|
||||
"""Stash a Mem0 backend notice for end-of-command surfacing.
|
||||
|
||||
Called from the platform backend after each response so the notice can
|
||||
be printed once per command (regardless of how many sub-requests fired).
|
||||
Last-write-wins is fine — the message text is identical across requests.
|
||||
"""
|
||||
global _pending_notice
|
||||
if notice:
|
||||
_pending_notice = notice
|
||||
|
||||
|
||||
def take_notice() -> str:
|
||||
"""Return and clear the pending notice."""
|
||||
global _pending_notice
|
||||
msg = _pending_notice
|
||||
_pending_notice = ""
|
||||
return msg
|
||||
@@ -0,0 +1,148 @@
|
||||
"""CLI telemetry — anonymous usage tracking via PostHog.
|
||||
|
||||
Sends fire-and-forget events to PostHog by spawning a detached subprocess
|
||||
(telemetry_sender.py). The parent CLI process exits immediately; the
|
||||
subprocess handles email resolution, caching, and the HTTP POST.
|
||||
|
||||
Disable with: MEM0_TELEMETRY=false
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||
|
||||
|
||||
def _is_telemetry_enabled() -> bool:
|
||||
val = os.environ.get("MEM0_TELEMETRY", "true").lower()
|
||||
return val not in ("false", "0", "no")
|
||||
|
||||
|
||||
def _get_or_create_anonymous_id() -> str:
|
||||
"""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.
|
||||
"""
|
||||
from mem0_cli.config import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
if config.telemetry.anonymous_id:
|
||||
return config.telemetry.anonymous_id
|
||||
|
||||
new_id = f"cli-anon-{uuid.uuid4().hex}"
|
||||
config.telemetry.anonymous_id = new_id
|
||||
with contextlib.suppress(Exception):
|
||||
save_config(config)
|
||||
return new_id
|
||||
|
||||
|
||||
def _get_distinct_id() -> str:
|
||||
"""Return a stable anonymous identifier for the current user.
|
||||
|
||||
Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
|
||||
persistent per-machine anonymous ID.
|
||||
"""
|
||||
try:
|
||||
from mem0_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
if config.platform.user_email:
|
||||
return config.platform.user_email
|
||||
if config.platform.api_key:
|
||||
return hashlib.md5(config.platform.api_key.encode()).hexdigest()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _get_or_create_anonymous_id()
|
||||
except Exception:
|
||||
return f"cli-anon-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def capture_event(
|
||||
event_name: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
pre_resolved_email: str | None = None,
|
||||
) -> None:
|
||||
"""Fire a PostHog event via a detached subprocess (non-blocking).
|
||||
|
||||
When *pre_resolved_email* 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.
|
||||
"""
|
||||
if not _is_telemetry_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
from mem0_cli import __version__
|
||||
from mem0_cli.config import CONFIG_FILE, load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
distinct_id = pre_resolved_email or _get_distinct_id()
|
||||
|
||||
# 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.
|
||||
anon_id_to_alias: str | None = None
|
||||
if (
|
||||
distinct_id
|
||||
and not distinct_id.startswith("cli-anon-")
|
||||
and config.telemetry.anonymous_id
|
||||
):
|
||||
anon_id_to_alias = config.telemetry.anonymous_id
|
||||
config.telemetry.anonymous_id = ""
|
||||
with contextlib.suppress(Exception):
|
||||
save_config(config)
|
||||
|
||||
# M4: every cli.* event carries agent_mode based on the config flag
|
||||
# (unclaimed Agent Mode key). This is the growth-doc property used to
|
||||
# join init → add → search funnels in PostHog.
|
||||
payload = {
|
||||
"api_key": POSTHOG_API_KEY,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event_name,
|
||||
"properties": {
|
||||
"source": "CLI",
|
||||
"language": "python",
|
||||
"cli_version": __version__,
|
||||
"agent_mode": bool(config.platform.agent_mode),
|
||||
"python_version": sys.version,
|
||||
"os": sys.platform,
|
||||
"os_version": platform.version(),
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(properties or {}),
|
||||
},
|
||||
}
|
||||
|
||||
context = {
|
||||
"payload": payload,
|
||||
"posthog_host": POSTHOG_HOST,
|
||||
"needs_email": not distinct_id or "@" not in distinct_id,
|
||||
"mem0_api_key": config.platform.api_key or "",
|
||||
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
|
||||
"config_path": str(CONFIG_FILE),
|
||||
"anon_distinct_id_to_alias": anon_id_to_alias,
|
||||
}
|
||||
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
close_fds=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Standalone telemetry sender — runs as a detached subprocess.
|
||||
|
||||
Usage: python -m mem0_cli.telemetry_sender '<json context>'
|
||||
|
||||
This module is spawned by telemetry.capture_event() 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ctx = json.loads(sys.argv[1])
|
||||
payload = ctx["payload"]
|
||||
|
||||
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
||||
_resolve_and_cache_email(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.
|
||||
anon_id = ctx.get("anon_distinct_id_to_alias")
|
||||
if anon_id:
|
||||
_send_identify_event(ctx, payload, anon_id)
|
||||
|
||||
_send_posthog_event(ctx["posthog_host"], payload)
|
||||
|
||||
|
||||
def _send_identify_event(ctx: dict, payload: dict, anon_id: str) -> None:
|
||||
"""Send a PostHog $identify event aliasing anon_id → payload['distinct_id']."""
|
||||
identify_payload = {
|
||||
"api_key": payload["api_key"],
|
||||
"event": "$identify",
|
||||
"distinct_id": payload["distinct_id"],
|
||||
"properties": {
|
||||
"$anon_distinct_id": anon_id,
|
||||
"$lib": payload.get("properties", {}).get("$lib", "posthog-python"),
|
||||
},
|
||||
}
|
||||
_send_posthog_event(ctx["posthog_host"], identify_payload)
|
||||
|
||||
|
||||
def _resolve_and_cache_email(ctx: dict, payload: dict) -> None:
|
||||
"""Call /v1/ping/ to get the user's email, update the payload, and cache it."""
|
||||
try:
|
||||
ping_url = ctx["mem0_base_url"].rstrip("/") + "/v1/ping/"
|
||||
req = urllib.request.Request(
|
||||
ping_url,
|
||||
headers={
|
||||
"Authorization": "Token " + ctx["mem0_api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read())
|
||||
email = data.get("user_email")
|
||||
if email:
|
||||
payload["distinct_id"] = email
|
||||
_cache_email(ctx.get("config_path"), email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cache_email(config_path: str | None, email: str) -> None:
|
||||
"""Write user_email into the config file for future runs."""
|
||||
if not config_path:
|
||||
return
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
cfg.setdefault("platform", {})["user_email"] = email
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _send_posthog_event(posthog_host: str, payload: dict) -> None:
|
||||
"""POST the event to PostHog."""
|
||||
try:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
posthog_host,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Shared fixtures for mem0 CLI tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.config import Mem0Config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_config(tmp_path, monkeypatch):
|
||||
"""Redirect config to a temp directory so tests don't touch real config."""
|
||||
fake_config_dir = tmp_path / ".mem0"
|
||||
fake_config_file = fake_config_dir / "config.json"
|
||||
monkeypatch.setattr("mem0_cli.config.CONFIG_DIR", fake_config_dir)
|
||||
monkeypatch.setattr("mem0_cli.config.CONFIG_FILE", fake_config_file)
|
||||
# Also patch the commands that import config
|
||||
monkeypatch.setattr("mem0_cli.commands.config_cmd.CONFIG_DIR", fake_config_dir, raising=False)
|
||||
# Clear any MEM0 env vars
|
||||
for key in list(os.environ.keys()):
|
||||
if key.startswith("MEM0_"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
return fake_config_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_backend():
|
||||
"""Return a mock backend with all methods stubbed."""
|
||||
backend = MagicMock(spec=Backend)
|
||||
|
||||
# Default return values
|
||||
backend.add.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "abc-123-def-456",
|
||||
"memory": "User prefers dark mode",
|
||||
"event": "ADD",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
backend.search.return_value = [
|
||||
{
|
||||
"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"],
|
||||
},
|
||||
]
|
||||
|
||||
backend.get.return_value = {
|
||||
"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"],
|
||||
}
|
||||
|
||||
backend.list_memories.return_value = [
|
||||
{
|
||||
"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"],
|
||||
},
|
||||
]
|
||||
|
||||
backend.update.return_value = {"id": "abc-123-def-456", "memory": "Updated memory"}
|
||||
backend.delete.return_value = {"status": "deleted"}
|
||||
backend.status.return_value = {
|
||||
"connected": True,
|
||||
"backend": "platform",
|
||||
"base_url": "https://api.mem0.ai",
|
||||
}
|
||||
backend.delete_entities.return_value = {"message": "Entity deleted"}
|
||||
backend.entities.return_value = [
|
||||
{"name": "alice", "count": 5},
|
||||
{"name": "bob", "count": 3},
|
||||
]
|
||||
backend.list_events.return_value = [
|
||||
{
|
||||
"id": "evt-abc-123-def-456",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"graph_status": None,
|
||||
"latency": 1234.5,
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-01T10:00:01Z",
|
||||
},
|
||||
{
|
||||
"id": "evt-def-456-ghi-789",
|
||||
"event_type": "SEARCH",
|
||||
"status": "PENDING",
|
||||
"graph_status": None,
|
||||
"latency": None,
|
||||
"created_at": "2026-04-01T10:01:00Z",
|
||||
"updated_at": "2026-04-01T10:01:00Z",
|
||||
},
|
||||
]
|
||||
backend.get_event.return_value = {
|
||||
"id": "evt-abc-123-def-456",
|
||||
"event_type": "ADD",
|
||||
"status": "SUCCEEDED",
|
||||
"graph_status": "SUCCEEDED",
|
||||
"latency": 1234.5,
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-01T10:00:01Z",
|
||||
"results": [
|
||||
{
|
||||
"id": "mem-abc-123",
|
||||
"event": "ADD",
|
||||
"user_id": "alice",
|
||||
"data": {"memory": "User prefers dark mode"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config():
|
||||
"""Return a sample config object."""
|
||||
config = Mem0Config()
|
||||
config.platform.api_key = "m0-test-key-12345678"
|
||||
config.platform.base_url = "https://api.mem0.ai"
|
||||
return config
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Parity tests for `mem0 init --agent` (Agent Mode bootstrap).
|
||||
|
||||
Mirror of ``cli/node/tests/agent-mode.test.ts`` — both files MUST stay in
|
||||
sync so that the Python and Node CLIs expose an identical surface for the
|
||||
Agent Mode entrypoint. If you add a flag here, add the same assertion on
|
||||
the Node side (and vice versa).
|
||||
|
||||
Network-bound bootstrap is covered by the platform-side E2E suite
|
||||
(``backend/tests/e2e/test_05_agent_mode.py``); these tests only verify
|
||||
the CLI surface that ships in the binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def _run(args: list[str], home_dir: str | None = None) -> subprocess.CompletedProcess:
|
||||
env = os.environ.copy()
|
||||
for key in list(env.keys()):
|
||||
if key.startswith("MEM0_"):
|
||||
del env[key]
|
||||
env.pop("FORCE_COLOR", None)
|
||||
if home_dir:
|
||||
env["HOME"] = home_dir
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=15,
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=result.args,
|
||||
returncode=result.returncode,
|
||||
stdout=_strip_ansi(result.stdout),
|
||||
stderr=_strip_ansi(result.stderr),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_home(tmp_path):
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
class TestInitFlagSurface:
|
||||
"""`mem0 init --help` must expose the Agent Mode flags."""
|
||||
|
||||
def test_init_help_lists_agent_flag(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--agent" in result.stdout
|
||||
|
||||
def test_init_help_describes_agent_mode(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
# Description must mention what --agent actually does so an agent
|
||||
# reading the help can self-discover the bootstrap entrypoint.
|
||||
assert "Agent Mode" in result.stdout or "unattended" in result.stdout.lower()
|
||||
|
||||
def test_init_help_lists_source_flag(self):
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--source" in result.stdout
|
||||
|
||||
def test_init_help_lists_email_and_code(self):
|
||||
# Claim flow flags must remain present alongside Agent Mode flags.
|
||||
result = _run(["init", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--email" in result.stdout
|
||||
assert "--code" in result.stdout
|
||||
|
||||
|
||||
class TestArgvPreprocessing:
|
||||
"""`--agent` on `init` must reach init_cmd, not be eaten by the global preprocessor.
|
||||
|
||||
Regression for the bug where the top-level `--agent` JSON-alias was
|
||||
stripped from ``sys.argv`` before Typer could bind it to the init
|
||||
subcommand, making ``mem0 init --agent`` indistinguishable from a
|
||||
plain ``mem0 init`` (interactive wizard).
|
||||
"""
|
||||
|
||||
def test_init_with_agent_reaches_subcommand(self, clean_home):
|
||||
# We can't hit a real backend in unit tests, so we point the CLI at
|
||||
# a guaranteed-dead URL and assert the failure is the bootstrap
|
||||
# request failing — proving the --agent flag was honored and the
|
||||
# bootstrap branch ran, not the interactive wizard.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", "init", "--agent"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
||||
"HOME": clean_home,
|
||||
"MEM0_BASE_URL": "http://127.0.0.1:1", # blackhole
|
||||
"FORCE_COLOR": "0",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
combined = _strip_ansi(result.stdout + result.stderr).lower()
|
||||
# Either we got a connection/network error from the bootstrap POST,
|
||||
# or the CLI surfaced an Agent Mode-specific failure message.
|
||||
assert (
|
||||
"agent" in combined
|
||||
or "connect" in combined
|
||||
or "network" in combined
|
||||
or "fetch" in combined
|
||||
or "bootstrap" in combined
|
||||
), f"Expected bootstrap attempt, got: {combined!r}"
|
||||
|
||||
|
||||
class TestJsonEnvelopeParity:
|
||||
"""`mem0 init --agent --json` should produce a JSON envelope on success.
|
||||
|
||||
Without a live backend we can only assert the failure shape: when the
|
||||
backend is unreachable, the CLI must still exit non-zero AND not crash
|
||||
on a Python traceback (which would mean we leaked an exception past
|
||||
the agent-mode handler).
|
||||
"""
|
||||
|
||||
def test_init_agent_json_no_traceback_on_network_failure(self, clean_home):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", "init", "--agent", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**{k: v for k, v in os.environ.items() if not k.startswith("MEM0_")},
|
||||
"HOME": clean_home,
|
||||
"MEM0_BASE_URL": "http://127.0.0.1:1",
|
||||
"FORCE_COLOR": "0",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
combined = _strip_ansi(result.stdout + result.stderr)
|
||||
assert "Traceback (most recent call last)" not in combined
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
class TestInitInCommandList:
|
||||
"""`mem0 --help` must list `init` so agents walking the top-level help
|
||||
can discover the Agent Mode entrypoint without prior knowledge."""
|
||||
|
||||
def test_top_level_help_lists_init(self):
|
||||
result = _run(["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "init" in result.stdout
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for branding and output helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from mem0_cli.branding import print_banner, print_error, print_info, print_success, print_warning
|
||||
|
||||
|
||||
def _make_console() -> tuple[Console, StringIO]:
|
||||
buf = StringIO()
|
||||
return Console(file=buf, force_terminal=False, no_color=True, width=80), buf
|
||||
|
||||
|
||||
class TestBranding:
|
||||
def test_print_banner(self):
|
||||
console, buf = _make_console()
|
||||
print_banner(console)
|
||||
output = buf.getvalue()
|
||||
# Banner contains the mem0 ASCII art and tagline
|
||||
assert "Memory Layer" in output or "mem" in output.lower()
|
||||
|
||||
def test_print_success(self):
|
||||
console, buf = _make_console()
|
||||
print_success(console, "It worked!")
|
||||
output = buf.getvalue()
|
||||
assert "It worked!" in output
|
||||
|
||||
def test_print_error(self):
|
||||
console, buf = _make_console()
|
||||
print_error(console, "Something failed", hint="Try this fix")
|
||||
output = buf.getvalue()
|
||||
assert "Something failed" in output
|
||||
assert "Try this fix" in output
|
||||
|
||||
def test_print_error_no_hint(self):
|
||||
console, buf = _make_console()
|
||||
print_error(console, "Failed")
|
||||
output = buf.getvalue()
|
||||
assert "Failed" in output
|
||||
|
||||
def test_print_warning(self):
|
||||
console, buf = _make_console()
|
||||
print_warning(console, "Watch out")
|
||||
output = buf.getvalue()
|
||||
assert "Watch out" in output
|
||||
|
||||
def test_print_info(self):
|
||||
console, buf = _make_console()
|
||||
print_info(console, "FYI")
|
||||
output = buf.getvalue()
|
||||
assert "FYI" in output
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Integration tests — invoke CLI as subprocess to test end-to-end.
|
||||
|
||||
These tests launch the CLI as a real subprocess, so they must manage
|
||||
environment isolation themselves (monkeypatch doesn't cross process
|
||||
boundaries).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[mKJHABCDfsu]")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes so substring checks work regardless of color mode."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def _run(
|
||||
args: list[str],
|
||||
env_override: dict | None = None,
|
||||
home_dir: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run mem0 CLI command and capture output.
|
||||
|
||||
Args:
|
||||
args: CLI arguments.
|
||||
env_override: Extra env vars to set.
|
||||
home_dir: If provided, set HOME to this path so the subprocess
|
||||
reads config from ``<home_dir>/.mem0/config.json`` instead
|
||||
of the user's real config. This is critical for tests that
|
||||
depend on a clean (no API key) or custom config state.
|
||||
|
||||
Returns a CompletedProcess whose stdout/stderr have ANSI escape codes
|
||||
stripped. GitHub Actions sets FORCE_COLOR=1 which causes Rich/Typer to
|
||||
fragment option names like --user-id into separately-styled ANSI segments,
|
||||
making plain ``in`` checks fail. Stripping here is version-agnostic and
|
||||
ensures all assertions see the same plain text regardless of terminal env.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
# Strip all MEM0_ env vars so tests start clean
|
||||
for key in list(env.keys()):
|
||||
if key.startswith("MEM0_"):
|
||||
del env[key]
|
||||
env.pop("FORCE_COLOR", None)
|
||||
if home_dir:
|
||||
env["HOME"] = home_dir
|
||||
if env_override:
|
||||
env.update(env_override)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "mem0_cli", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=result.args,
|
||||
returncode=result.returncode,
|
||||
stdout=_strip_ansi(result.stdout),
|
||||
stderr=_strip_ansi(result.stderr),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_home(tmp_path):
|
||||
"""Return a temp directory to use as HOME, ensuring no ~/.mem0 exists."""
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
class TestCLIIntegration:
|
||||
"""Tests that only inspect help text / version — no config needed."""
|
||||
|
||||
def test_help(self):
|
||||
result = _run(["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "mem0" in result.stdout
|
||||
assert "add" in result.stdout
|
||||
assert "search" in result.stdout
|
||||
|
||||
def test_add_help(self):
|
||||
result = _run(["add", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "user-id" in result.stdout
|
||||
assert "messages" in result.stdout
|
||||
|
||||
def test_add_help_has_scope_panel(self):
|
||||
"""Verify rich_help_panel grouping shows in help output."""
|
||||
result = _run(["add", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "Scope" in result.stdout
|
||||
|
||||
def test_search_help(self):
|
||||
result = _run(["search", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "top-k" in result.stdout
|
||||
|
||||
def test_list_help(self):
|
||||
result = _run(["list", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "page-size" in result.stdout
|
||||
|
||||
def test_delete_help(self):
|
||||
result = _run(["delete", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--all" in result.stdout
|
||||
assert "--entity" in result.stdout
|
||||
assert "--project" in result.stdout
|
||||
assert "--force" in result.stdout
|
||||
assert "--dry-run" in result.stdout
|
||||
|
||||
def test_entity_list_help(self):
|
||||
result = _run(["entity", "list", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "entity-type" in result.stdout.lower() or "entity_type" in result.stdout.lower()
|
||||
|
||||
def test_entity_delete_help(self):
|
||||
result = _run(["entity", "delete", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--user-id" in result.stdout
|
||||
assert "--force" in result.stdout
|
||||
|
||||
def test_import_help(self):
|
||||
result = _run(["import", "--help"])
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_no_args_shows_help(self):
|
||||
"""no_args_is_help=True makes Typer print help and exit with code 2."""
|
||||
result = _run([])
|
||||
# Typer returns exit code 2 for "no command given" — this is standard
|
||||
# Click/Typer behaviour and not an error.
|
||||
assert result.returncode in (0, 2)
|
||||
assert "Usage" in result.stdout
|
||||
|
||||
|
||||
class TestCLIIsolated:
|
||||
"""Tests that need a clean HOME to avoid reading the user's real config."""
|
||||
|
||||
def test_add_no_key_errors(self, clean_home):
|
||||
"""Without an API key, `mem0 add` must fail with a helpful message."""
|
||||
result = _run(
|
||||
["add", "test", "--user-id", "alice"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert "API key" in combined or "api" in combined.lower() or "Error" in combined
|
||||
|
||||
def test_search_no_key_errors(self, clean_home):
|
||||
"""Without an API key, `mem0 search` must fail."""
|
||||
result = _run(
|
||||
["search", "preferences", "--user-id", "alice"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert "API key" in combined or "Error" in combined
|
||||
|
||||
def test_list_no_key_errors(self, clean_home):
|
||||
"""Without an API key, `mem0 list` must fail."""
|
||||
result = _run(["list"], home_dir=clean_home)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert "API key" in combined or "Error" in combined
|
||||
|
||||
def test_delete_no_id_no_all_errors(self, clean_home):
|
||||
"""Delete without memory_id, --all, or --entity must fail."""
|
||||
result = _run(
|
||||
["delete", "--api-key", "m0-fake-key"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert (
|
||||
"memory ID" in combined.lower()
|
||||
or "--all" in combined
|
||||
or "--entity" in combined
|
||||
or "Error" in combined
|
||||
)
|
||||
|
||||
def test_config_show_clean(self, clean_home):
|
||||
"""config show with no config should still work."""
|
||||
result = _run(["config", "show"], home_dir=clean_home)
|
||||
assert result.returncode == 0
|
||||
assert "backend" in result.stdout.lower() or "platform" in result.stdout.lower()
|
||||
|
||||
def test_config_set_and_get_roundtrip(self, clean_home):
|
||||
"""config set then config get should return the set value."""
|
||||
_run(
|
||||
["config", "set", "defaults.user_id", "integration-test-user"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
result = _run(
|
||||
["config", "get", "defaults.user_id"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "integration-test-user" in result.stdout
|
||||
|
||||
def test_import_nonexistent_file(self, clean_home):
|
||||
"""Importing a nonexistent file should fail gracefully."""
|
||||
result = _run(
|
||||
["import", "/nonexistent/file.json", "--api-key", "m0-fake"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert "Failed" in combined or "Error" in combined or "error" in combined
|
||||
|
||||
def test_add_no_content_errors(self, clean_home):
|
||||
"""add with no text/messages/file should fail."""
|
||||
result = _run(
|
||||
["add", "--user-id", "alice", "--api-key", "m0-fake"],
|
||||
home_dir=clean_home,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stderr + result.stdout
|
||||
assert "No content" in combined or "Error" in combined
|
||||
|
||||
|
||||
class TestCLINewFeatures:
|
||||
"""Tests for MCP parity features: --limit, entities delete."""
|
||||
|
||||
def test_search_help_has_limit(self):
|
||||
result = _run(["search", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--limit" in result.stdout
|
||||
|
||||
def test_delete_entity_via_delete_flag(self):
|
||||
"""delete --entity should appear in help output."""
|
||||
result = _run(["delete", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--entity" in result.stdout
|
||||
|
||||
def test_entity_delete_has_scope_options(self):
|
||||
"""entity delete should expose scope options."""
|
||||
result = _run(["entity", "delete", "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--user-id" in result.stdout
|
||||
assert "--force" in result.stdout
|
||||
assert "--app-id" in result.stdout
|
||||
assert "--run-id" in result.stdout
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user