Compare commits
236
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ac9ce149 | ||
|
|
9315e3036f | ||
|
|
b3ede5b7c0 | ||
|
|
3553fc79dd | ||
|
|
f322cf82b9 | ||
|
|
73c975ba68 | ||
|
|
931d579ba5 | ||
|
|
f4773a0baf | ||
|
|
06d33f6cc4 | ||
|
|
8f3b60f3e1 | ||
|
|
a6e27dcc9c | ||
|
|
4f10c986b5 | ||
|
|
821152bd14 | ||
|
|
f48b133101 | ||
|
|
1d56f85705 | ||
|
|
ced852033b | ||
|
|
b9ad8fa8b2 | ||
|
|
e3f5ce7b41 | ||
|
|
f681889b14 | ||
|
|
b5ec46be5b | ||
|
|
168ad358d5 | ||
|
|
2c796d144f | ||
|
|
c676c2c458 | ||
|
|
b36847622d | ||
|
|
32c8849044 | ||
|
|
2dd2872c08 | ||
|
|
cf268da19d | ||
|
|
7a5df64746 | ||
|
|
4c41f6deeb | ||
|
|
f84aa1eb31 | ||
|
|
9226ee2229 | ||
|
|
8399b088a5 | ||
|
|
437f0b5495 | ||
|
|
0ffaffa88c | ||
|
|
433ff494f1 | ||
|
|
de03c52ed3 | ||
|
|
b4a50e3dc8 | ||
|
|
b819d95d18 | ||
|
|
3ac1c9452c | ||
|
|
d6347f6660 | ||
|
|
e769502baa | ||
|
|
652193d599 | ||
|
|
a86c87236d | ||
|
|
2274b5acad | ||
|
|
9b0705c345 | ||
|
|
d31fa168eb | ||
|
|
f32eb4406b | ||
|
|
366945965d | ||
|
|
6702fa3e3e | ||
|
|
a44855af9e | ||
|
|
d817aa9c12 | ||
|
|
7ac8ab154b | ||
|
|
b00a1a1065 | ||
|
|
2e90ed4f78 | ||
|
|
069ea0887c | ||
|
|
ae7f406265 | ||
|
|
90f2d24e83 | ||
|
|
64b9646e7d | ||
|
|
866888df41 | ||
|
|
95b6f95f7b | ||
|
|
74771b4e76 | ||
|
|
8e65ce915d | ||
|
|
a3154d59e5 | ||
|
|
1019f0e17c | ||
|
|
9328c36a46 | ||
|
|
eb4afc6ef7 | ||
|
|
fea748d7e6 | ||
|
|
e83297f150 | ||
|
|
eaca45dcdb | ||
|
|
add6aad40b | ||
|
|
116c439b1d | ||
|
|
49b7953c44 | ||
|
|
3e6ab39429 | ||
|
|
75a37ec93d | ||
|
|
88934304c6 | ||
|
|
098a599579 | ||
|
|
5a2201d76b | ||
|
|
ad736d9a06 | ||
|
|
7f6d46050e | ||
|
|
f9c52baf21 | ||
|
|
0da3359a1a | ||
|
|
6b9707fee9 | ||
|
|
99beb007ab | ||
|
|
16a7702d09 | ||
|
|
53a3998873 | ||
|
|
08aa143db3 | ||
|
|
ac141fdafe | ||
|
|
b1188d6044 | ||
|
|
0d61af60c2 | ||
|
|
58696e4bd4 | ||
|
|
8b11e0787a | ||
|
|
09dc74d61a | ||
|
|
606ede7c0a | ||
|
|
edd1b3e2f2 | ||
|
|
74d043731b | ||
|
|
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 |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "mem0-plugins",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Mem0 Plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "mem0",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./integrations/mem0-plugin"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./mem0-plugin",
|
"source": "./integrations/mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||||
"version": "0.1.0"
|
"version": "0.2.10"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "mem0-plugins",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Mem0 Plugins"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "mem0",
|
||||||
|
"source": {
|
||||||
|
"source": "local",
|
||||||
|
"path": "./integrations/mem0-plugin"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"installation": "AVAILABLE",
|
||||||
|
"authentication": "ON_INSTALL"
|
||||||
|
},
|
||||||
|
"category": "Productivity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./mem0-plugin",
|
"source": "./integrations/mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||||
"version": "0.1.0"
|
"version": "0.2.10"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged v* is
|
||||||
|
# published. Can also be dispatched manually to re-publish a tag.
|
||||||
on:
|
on:
|
||||||
release:
|
workflow_dispatch:
|
||||||
types: [published]
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. v1.2.3)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-n-publish:
|
build-n-publish:
|
||||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||||
|
# Pure SDK version tags only (v1.2.3) — excludes package-prefixed tags
|
||||||
|
# like vercel-ai-v* that also start with 'v'
|
||||||
|
if: startsWith(inputs.tag, 'v') && !contains(inputs.tag, '-v')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
@@ -38,7 +54,6 @@ jobs:
|
|||||||
# packages_dir: dist/
|
# packages_dir: dist/
|
||||||
|
|
||||||
- name: Publish distribution 📦 to PyPI
|
- name: Publish distribution 📦 to PyPI
|
||||||
if: startsWith(github.ref, 'refs/tags')
|
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
packages_dir: dist/
|
packages_dir: dist/
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
name: CI Gate
|
||||||
|
|
||||||
|
# Single required status check for all PRs.
|
||||||
|
#
|
||||||
|
# Path-filtered CI workflows can't be marked as required in branch
|
||||||
|
# protection: on a PR that doesn't touch their paths they never report, and
|
||||||
|
# the required check hangs at "Expected" forever. This gate solves that. It
|
||||||
|
# runs on every PR, detects which packages changed, calls only the relevant
|
||||||
|
# package CI workflows (as reusable workflows), and the final "CI Gate" job
|
||||||
|
# reports the aggregate result — success when every invoked pipeline passed
|
||||||
|
# (skipped pipelines are fine), failure when any failed.
|
||||||
|
#
|
||||||
|
# Branch protection should require exactly one status check: "CI Gate".
|
||||||
|
#
|
||||||
|
# Package CI workflows keep their own push-to-main and workflow_dispatch
|
||||||
|
# triggers; only their pull_request triggers moved here. To wire in a new
|
||||||
|
# package: add a filter under the `changes` job, a call job that `uses:` the
|
||||||
|
# package workflow, and list the call job in the gate's `needs`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-gate-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
name: Detect changed packages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
python_sdk: ${{ steps.filter.outputs.python_sdk }}
|
||||||
|
ts_sdk: ${{ steps.filter.outputs.ts_sdk }}
|
||||||
|
cli_python: ${{ steps.filter.outputs.cli_python }}
|
||||||
|
cli_node: ${{ steps.filter.outputs.cli_node }}
|
||||||
|
openclaw: ${{ steps.filter.outputs.openclaw }}
|
||||||
|
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
|
||||||
|
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
|
||||||
|
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
|
||||||
|
steps:
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: filter
|
||||||
|
with:
|
||||||
|
# Each filter mirrors the package workflow's old pull_request
|
||||||
|
# paths, plus the package workflow file itself and this gate file
|
||||||
|
# (changing either must re-exercise the pipeline).
|
||||||
|
filters: |
|
||||||
|
python_sdk:
|
||||||
|
- 'mem0/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- '.github/workflows/ci.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
ts_sdk:
|
||||||
|
- 'mem0-ts/**'
|
||||||
|
- '.github/workflows/ts-sdk-ci.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
cli_python:
|
||||||
|
- 'cli/python/**'
|
||||||
|
- '.github/workflows/cli-python-ci.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
cli_node:
|
||||||
|
- 'cli/node/**'
|
||||||
|
- '.github/workflows/cli-node-ci.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
openclaw:
|
||||||
|
- 'integrations/openclaw/**'
|
||||||
|
- '.github/workflows/openclaw-checks.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
opencode_plugin:
|
||||||
|
- 'integrations/mem0-plugin/.opencode-plugin/**'
|
||||||
|
- '.github/workflows/opencode-plugin-checks.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
pi_agent_plugin:
|
||||||
|
- 'integrations/pi-agent-plugin/**'
|
||||||
|
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
docs_llms_txt:
|
||||||
|
- 'docs/**/*.mdx'
|
||||||
|
- 'docs/llms.txt'
|
||||||
|
- 'scripts/check-llms-txt-coverage.py'
|
||||||
|
- 'scripts/llms-txt-ignore.txt'
|
||||||
|
- '.github/workflows/docs-llms-txt-check.yml'
|
||||||
|
- '.github/workflows/ci-gate.yml'
|
||||||
|
|
||||||
|
python-sdk:
|
||||||
|
name: Python SDK
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.python_sdk == 'true'
|
||||||
|
uses: ./.github/workflows/ci.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
ts-sdk:
|
||||||
|
name: TypeScript SDK
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.ts_sdk == 'true'
|
||||||
|
uses: ./.github/workflows/ts-sdk-ci.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
cli-python:
|
||||||
|
name: Python CLI
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.cli_python == 'true'
|
||||||
|
uses: ./.github/workflows/cli-python-ci.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
cli-node:
|
||||||
|
name: Node CLI
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.cli_node == 'true'
|
||||||
|
uses: ./.github/workflows/cli-node-ci.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
openclaw:
|
||||||
|
name: OpenClaw
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.openclaw == 'true'
|
||||||
|
uses: ./.github/workflows/openclaw-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
opencode-plugin:
|
||||||
|
name: OpenCode Plugin
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.opencode_plugin == 'true'
|
||||||
|
uses: ./.github/workflows/opencode-plugin-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
pi-agent-plugin:
|
||||||
|
name: Pi Agent Plugin
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.pi_agent_plugin == 'true'
|
||||||
|
uses: ./.github/workflows/pi-agent-plugin-checks.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
docs-llms-txt:
|
||||||
|
name: docs llms.txt
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.docs_llms_txt == 'true'
|
||||||
|
uses: ./.github/workflows/docs-llms-txt-check.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
gate:
|
||||||
|
name: CI Gate
|
||||||
|
needs:
|
||||||
|
- changes
|
||||||
|
- python-sdk
|
||||||
|
- ts-sdk
|
||||||
|
- cli-python
|
||||||
|
- cli-node
|
||||||
|
- openclaw
|
||||||
|
- opencode-plugin
|
||||||
|
- pi-agent-plugin
|
||||||
|
- docs-llms-txt
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Evaluate pipeline results
|
||||||
|
env:
|
||||||
|
NEEDS: ${{ toJSON(needs) }}
|
||||||
|
run: |
|
||||||
|
echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
|
||||||
|
failed=$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")')
|
||||||
|
if [ -n "$failed" ]; then
|
||||||
|
echo "::error::Failing pipelines: $failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All pipelines relevant to this change passed."
|
||||||
+57
-58
@@ -1,28 +1,58 @@
|
|||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main runs remain standalone.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
workflow_call:
|
||||||
- 'mem0/**'
|
|
||||||
- 'tests/**'
|
|
||||||
- 'embedchain/**'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'mem0/**'
|
|
||||||
- 'tests/**'
|
|
||||||
- 'embedchain/**'
|
|
||||||
|
|
||||||
jobs:
|
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:
|
check_changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
mem0_changed: ${{ steps.filter.outputs.mem0 }}
|
mem0_changed: ${{ steps.filter.outputs.mem0 }}
|
||||||
embedchain_changed: ${{ steps.filter.outputs.embedchain }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: dorny/paths-filter@v2
|
- uses: dorny/paths-filter@v2
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
@@ -30,25 +60,28 @@ jobs:
|
|||||||
mem0:
|
mem0:
|
||||||
- 'mem0/**'
|
- 'mem0/**'
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/ci.yml'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
embedchain:
|
|
||||||
- 'embedchain/**'
|
|
||||||
|
|
||||||
build_mem0:
|
build_mem0:
|
||||||
needs: check_changes
|
needs: check_changes
|
||||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Skip — no relevant changes
|
||||||
|
if: needs.check_changes.outputs.mem0_changed != 'true'
|
||||||
|
run: echo "No changes in mem0/, tests/, pyproject.toml, or ci.yml — skipping"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Clean up disk space
|
- name: Clean up disk space
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
df -h
|
df -h
|
||||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||||
@@ -56,61 +89,27 @@ jobs:
|
|||||||
sudo docker builder prune -a
|
sudo docker builder prune -a
|
||||||
df -h
|
df -h
|
||||||
- name: Install Hatch
|
- name: Install Hatch
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
run: pip install hatch
|
run: pip install hatch
|
||||||
- name: Load cached venv
|
- name: Load cached venv
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
id: cached-hatch-dependencies
|
id: cached-hatch-dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: .venv
|
path: .venv
|
||||||
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||||
- name: Install GEOS Libraries
|
- name: Install GEOS Libraries
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
run: sudo apt-get update && sudo apt-get install -y libgeos-dev
|
run: sudo apt-get update && sudo apt-get install -y libgeos-dev
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true' && steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -e ".[test,graph,vector_stores,llms,extras]"
|
pip install -e ".[test,graph,vector_stores,llms,extras]"
|
||||||
pip install ruff
|
pip install ruff
|
||||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
|
||||||
- name: Run Linting
|
- name: Run Linting
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
run: make lint
|
run: make lint
|
||||||
- name: Run tests and generate coverage report
|
- name: Run tests and generate coverage report
|
||||||
|
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
build_embedchain:
|
|
||||||
needs: check_changes
|
|
||||||
if: needs.check_changes.outputs.embedchain_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
- name: Install Hatch
|
|
||||||
run: pip install hatch
|
|
||||||
- name: Load cached venv
|
|
||||||
id: cached-hatch-dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: .venv
|
|
||||||
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd embedchain && make install_all
|
|
||||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
|
||||||
- name: Run Formatting
|
|
||||||
run: |
|
|
||||||
mkdir -p embedchain/.ruff_cache && chmod -R 777 embedchain/.ruff_cache
|
|
||||||
cd embedchain && hatch run format
|
|
||||||
- name: Lint with ruff
|
|
||||||
run: cd embedchain && make lint
|
|
||||||
- name: Run tests and generate coverage report
|
|
||||||
run: cd embedchain && make coverage
|
|
||||||
- name: Upload coverage reports to Codecov
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
file: coverage.xml
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Publish @mem0/cli 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged
|
||||||
|
# cli-node-v* is published. Can also be dispatched manually to re-publish
|
||||||
|
# a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. cli-node-v0.2.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish @mem0/cli 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'cli-node-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: cli/node
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: cli/node/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
name: CLI Node CI
|
name: CLI Node CI
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main and manual runs remain standalone.
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -7,10 +9,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'cli/node/**'
|
- 'cli/node/**'
|
||||||
- '.github/workflows/cli-node-ci.yml'
|
- '.github/workflows/cli-node-ci.yml'
|
||||||
pull_request:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- 'cli/node/**'
|
|
||||||
- '.github/workflows/cli-node-ci.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Publish mem0-cli 🐍 distributions 📦 to PyPI
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged cli-v* is
|
||||||
|
# published. Can also be dispatched manually to re-publish a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. cli-v0.2.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish mem0-cli 📦 to PyPI
|
||||||
|
if: startsWith(inputs.tag, 'cli-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: cli/python
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install Hatch
|
||||||
|
run: pip install hatch
|
||||||
|
|
||||||
|
- name: Build a binary wheel and a source tarball
|
||||||
|
run: hatch build --clean
|
||||||
|
|
||||||
|
- name: Publish distribution 📦 to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
packages-dir: cli/python/dist/
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
name: CLI Python CI
|
name: CLI Python CI
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main and manual runs remain standalone.
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
@@ -7,10 +9,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'cli/python/**'
|
- 'cli/python/**'
|
||||||
- '.github/workflows/cli-python-ci.yml'
|
- '.github/workflows/cli-python-ci.yml'
|
||||||
pull_request:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- 'cli/python/**'
|
|
||||||
- '.github/workflows/cli-python-ci.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: docs - llms.txt check
|
||||||
|
|
||||||
|
# Blocks PRs that introduce new .mdx pages without a matching entry in
|
||||||
|
# docs/llms.txt, or that link to pages that no longer exist. Contributors
|
||||||
|
# must update docs/llms.txt in the same PR. Run locally with:
|
||||||
|
# python scripts/check-llms-txt-coverage.py # read-only
|
||||||
|
# python scripts/check-llms-txt-coverage.py --write # scaffold placeholders
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# manual runs remain standalone.
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-llms-txt:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
timeout-minutes: 2
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Verify docs/llms.txt coverage
|
||||||
|
run: |
|
||||||
|
if ! python3 scripts/check-llms-txt-coverage.py; then
|
||||||
|
echo ""
|
||||||
|
echo "::error title=llms.txt out of sync::docs/llms.txt does not match docs/**/*.mdx."
|
||||||
|
echo ""
|
||||||
|
echo "To fix:"
|
||||||
|
echo " 1. Run locally: python scripts/check-llms-txt-coverage.py --write"
|
||||||
|
echo " This appends placeholder entries under '## Unclassified - needs triage'."
|
||||||
|
echo " 2. For each placeholder:"
|
||||||
|
echo " - replace [TODO: Platform|OSS|Both] with the correct scope tag"
|
||||||
|
echo " - rewrite the description as 'Use when ...'"
|
||||||
|
echo " - move the entry into the appropriate section"
|
||||||
|
echo " - delete the '## Unclassified - needs triage' heading once empty"
|
||||||
|
echo " 3. Resolve any stale URLs listed above by updating or removing the link."
|
||||||
|
echo " 4. Commit the updated docs/llms.txt to this PR."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Publish @mem0/openclaw-mem0 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged
|
||||||
|
# openclaw-v* is published. Can also be dispatched manually to re-publish
|
||||||
|
# a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. openclaw-v0.5.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish @mem0/openclaw-mem0 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'openclaw-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: integrations/openclaw
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
name: openclaw checks
|
name: openclaw checks
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main and manual runs remain standalone.
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'openclaw/**'
|
- 'integrations/openclaw/**'
|
||||||
- '.github/workflows/openclaw-checks.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'openclaw/**'
|
|
||||||
- '.github/workflows/openclaw-checks.yml'
|
- '.github/workflows/openclaw-checks.yml'
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -28,13 +27,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd openclaw && pnpm install --frozen-lockfile
|
run: cd integrations/openclaw && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: cd openclaw && pnpm exec tsc --noEmit
|
run: cd integrations/openclaw && pnpm exec tsc --noEmit
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -54,20 +53,20 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd openclaw && pnpm install --frozen-lockfile
|
run: cd integrations/openclaw && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: cd openclaw && pnpm exec vitest run --coverage
|
run: cd integrations/openclaw && pnpm exec vitest run --coverage
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.node-version == 20
|
if: matrix.node-version == 20
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
flags: openclaw
|
flags: openclaw
|
||||||
directory: openclaw/coverage
|
directory: integrations/openclaw/coverage
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -86,15 +85,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: openclaw/pnpm-lock.yaml
|
cache-dependency-path: integrations/openclaw/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd openclaw && pnpm install --frozen-lockfile
|
run: cd integrations/openclaw && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cd openclaw && pnpm build
|
run: cd integrations/openclaw && pnpm build
|
||||||
|
|
||||||
- name: Verify dist output exists
|
- name: Verify dist output exists
|
||||||
run: |
|
run: |
|
||||||
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
test -f integrations/openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||||
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
test -f integrations/openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Publish @mem0/opencode-plugin 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged
|
||||||
|
# opencode-v* is published. Can also be dispatched manually to re-publish
|
||||||
|
# a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. opencode-v0.2.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish @mem0/opencode-plugin 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'opencode-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: integrations/mem0-plugin/.opencode-plugin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: opencode-plugin checks
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main and manual runs remain standalone.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'integrations/mem0-plugin/.opencode-plugin/**'
|
||||||
|
- '.github/workflows/opencode-plugin-checks.yml'
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: integrations/mem0-plugin/.opencode-plugin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: bun run type-check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Verify dist output exists
|
||||||
|
run: |
|
||||||
|
test -f dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Publish @mem0/pi-agent-plugin 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged
|
||||||
|
# pi-agent-v* is published. Can also be dispatched manually to re-publish
|
||||||
|
# a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. pi-agent-v0.1.1)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish @mem0/pi-agent-plugin 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'pi-agent-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: integrations/pi-agent-plugin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: pi-agent-plugin checks
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main and manual runs remain standalone.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'integrations/pi-agent-plugin/**'
|
||||||
|
- '.github/workflows/pi-agent-plugin-checks.yml'
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm exec tsc --noEmit
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [20, 22]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm exec vitest run
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/pi-agent-plugin/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cd integrations/pi-agent-plugin && pnpm build
|
||||||
|
|
||||||
|
- name: Verify dist output exists
|
||||||
|
run: |
|
||||||
|
test -f integrations/pi-agent-plugin/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
|
||||||
|
test -f integrations/pi-agent-plugin/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
|
||||||
|
test -f integrations/pi-agent-plugin/dist/entry.js || (echo "Build output missing: dist/entry.js" && exit 1)
|
||||||
|
test -f integrations/pi-agent-plugin/dist/entry.d.ts || (echo "Build output missing: dist/entry.d.ts" && exit 1)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Release Router 🚦
|
||||||
|
|
||||||
|
# Single entry point for all release publishing.
|
||||||
|
#
|
||||||
|
# Package CD workflows no longer listen to release events themselves — this
|
||||||
|
# router inspects the release tag and dispatches only the matching pipeline,
|
||||||
|
# so each release produces one routed run instead of one real run plus seven
|
||||||
|
# skipped ones.
|
||||||
|
#
|
||||||
|
# Re-publishing a release (e.g. after fixing registry settings) does NOT
|
||||||
|
# require deleting and recreating it anymore — manually dispatch the
|
||||||
|
# package's CD workflow from the tag instead:
|
||||||
|
#
|
||||||
|
# gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>
|
||||||
|
#
|
||||||
|
# Note: dispatching runs the workflow file as it exists at the given ref, so
|
||||||
|
# this router can only dispatch tags created after the workflow_dispatch
|
||||||
|
# conversion landed on main. For older tags, dispatch manually from main.
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
route:
|
||||||
|
name: Route ${{ github.event.release.tag_name }} to its CD pipeline
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Match tag prefix to CD workflow
|
||||||
|
id: match
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.event.release.tag_name }}
|
||||||
|
run: |
|
||||||
|
# Specific package prefixes first; the bare v* (Python SDK) arm
|
||||||
|
# must stay last so prefixed tags that also start with 'v'
|
||||||
|
# (vercel-ai-v*) can never be routed to the Python pipeline.
|
||||||
|
case "$TAG" in
|
||||||
|
ts-v*) workflow="ts-sdk-cd.yml" ;;
|
||||||
|
cli-node-v*) workflow="cli-node-cd.yml" ;;
|
||||||
|
cli-v*) workflow="cli-python-cd.yml" ;;
|
||||||
|
vercel-ai-v*) workflow="vercel-ai-cd.yml" ;;
|
||||||
|
openclaw-v*) workflow="openclaw-cd.yml" ;;
|
||||||
|
opencode-v*) workflow="opencode-plugin-cd.yml" ;;
|
||||||
|
pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;;
|
||||||
|
v*) workflow="cd.yml" ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "workflow=$workflow" >> "$GITHUB_OUTPUT"
|
||||||
|
echo ":outbox_tray: Routed \`$TAG\` → \`$workflow\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Dispatch ${{ steps.match.outputs.workflow }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.event.release.tag_name }}
|
||||||
|
run: |
|
||||||
|
# --ref points at the tag so the dispatched run builds (and signs
|
||||||
|
# provenance for) the exact tagged commit.
|
||||||
|
gh workflow run "${{ steps.match.outputs.workflow }}" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--ref "refs/tags/$TAG" \
|
||||||
|
-f tag="$TAG" \
|
||||||
|
-f prerelease="${{ github.event.release.prerelease }}"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Publish mem0ai 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged ts-v* is
|
||||||
|
# published. Can also be dispatched manually to re-publish a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. ts-v2.1.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish mem0ai 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'ts-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mem0-ts
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: mem0-ts/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
name: TypeScript SDK CI
|
name: TypeScript SDK CI
|
||||||
|
|
||||||
|
# On PRs this is invoked by ci-gate.yml (the single required check);
|
||||||
|
# push-to-main runs remain standalone.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'mem0-ts/**'
|
- 'mem0-ts/**'
|
||||||
- '.github/workflows/ts-sdk-ci.yml'
|
- '.github/workflows/ts-sdk-ci.yml'
|
||||||
pull_request:
|
workflow_call:
|
||||||
paths:
|
|
||||||
- 'mem0-ts/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_changes:
|
check_changes:
|
||||||
@@ -24,6 +24,42 @@ jobs:
|
|||||||
ts_sdk:
|
ts_sdk:
|
||||||
- 'mem0-ts/**'
|
- '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:
|
build_ts_sdk:
|
||||||
needs: check_changes
|
needs: check_changes
|
||||||
if: needs.check_changes.outputs.ts_sdk_changed == 'true'
|
if: needs.check_changes.outputs.ts_sdk_changed == 'true'
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Publish @mem0/vercel-ai-provider 📦 to npm
|
||||||
|
|
||||||
|
# Dispatched by release.yml (Release Router) when a release tagged
|
||||||
|
# vercel-ai-v* is published. Can also be dispatched manually to re-publish
|
||||||
|
# a tag.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build and publish (e.g. vercel-ai-v2.0.7)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Publish under the version preid dist-tag instead of latest'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-n-publish:
|
||||||
|
name: Build and publish @mem0/vercel-ai-provider 📦 to npm
|
||||||
|
if: startsWith(inputs.tag, 'vercel-ai-v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: integrations/vercel-ai-sdk
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: integrations/vercel-ai-sdk/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.prerelease }}" = "true" ]; then
|
||||||
|
PREID=$(node -p "require('./package.json').version.split('-')[1].split('.')[0]")
|
||||||
|
npx npm@latest publish --provenance --access public --tag "$PREID"
|
||||||
|
else
|
||||||
|
npx npm@latest publish --provenance --access public
|
||||||
|
fi
|
||||||
+6
-3
@@ -4,6 +4,10 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
|
# Self-hosted server local runtime state
|
||||||
|
server/history/
|
||||||
|
server/.env
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
@@ -15,8 +19,8 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
/lib/
|
||||||
lib64/
|
/lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -166,7 +170,6 @@ cython_debug/
|
|||||||
# Database
|
# Database
|
||||||
db
|
db
|
||||||
test-db
|
test-db
|
||||||
!embedchain/embedchain/core/db/
|
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "evaluation"]
|
||||||
|
path = evaluation
|
||||||
|
url = https://github.com/mem0ai/memory-benchmarks
|
||||||
|
branch = main
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides context for AI coding assistants (Claude Code, Cursor, GitHub Copilot, Codex, etc.) working with the Mem0 repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Mem0** ("mem-zero") is an intelligent memory layer for AI agents and assistants. It provides persistent, personalized memory via both a hosted platform API and self-hosted open-source SDKs.
|
||||||
|
|
||||||
|
- **Repository**: https://github.com/mem0ai/mem0
|
||||||
|
- **Documentation**: https://docs.mem0.ai
|
||||||
|
- **License**: Apache-2.0
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs, servers, plugins, and documentation.
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `mem0/` | Core Python SDK (`mem0ai` on PyPI) — memory, LLMs, embeddings, vector stores, graphs, rerankers |
|
||||||
|
| `mem0-ts/` | TypeScript SDK (`mem0ai` on npm) — client + OSS memory |
|
||||||
|
| `cli/python/` | Python CLI (`mem0-cli` on PyPI) — Typer-based, entry point `mem0` |
|
||||||
|
| `cli/node/` | Node CLI (`@mem0/cli` on npm) — Commander-based, entry point `mem0` |
|
||||||
|
| `integrations/` | **Agent & editor integrations**, one directory per integration (see "Adding a New Integration") |
|
||||||
|
| `integrations/mem0-plugin/` | AI editor plugins (Claude Code, Cursor, Codex) — MCP server connection, lifecycle hooks, skills. Contains nested `.opencode-plugin/` (`@mem0/opencode-plugin`) |
|
||||||
|
| `integrations/openclaw/` | `@mem0/openclaw-mem0` — OpenClaw plugin for Claude Code / AI editors |
|
||||||
|
| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin |
|
||||||
|
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
|
||||||
|
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||||
|
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||||
|
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` |
|
||||||
|
| `docs/` | Documentation site (Mintlify) |
|
||||||
|
| `tests/` | Python SDK tests (pytest) |
|
||||||
|
| `evaluation/` | Submodule → [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) — benchmarking (LOCOMO, LongMemEval, BEAM) lives in that repo |
|
||||||
|
| `examples/` | Sample projects & runnable demos — apps, Chrome extension, multi-agent patterns, and Jupyter notebooks (`notebooks/`) |
|
||||||
|
| `pr-reviews/` | Pull request review materials |
|
||||||
|
| `scripts/` | Repo-wide utility scripts (e.g., `check-llms-txt-coverage.py` for docs/llms.txt sync) |
|
||||||
|
|
||||||
|
### Core Package Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
mem0 (Python SDK) mem0-ts (TypeScript SDK)
|
||||||
|
├── mem0/memory/ ├── src/client/ (MemoryClient — hosted)
|
||||||
|
├── mem0/llms/ └── src/oss/ (Memory — self-hosted)
|
||||||
|
├── mem0/embeddings/ ├── src/llms/
|
||||||
|
├── mem0/vector_stores/ ├── src/embeddings/
|
||||||
|
├── mem0/graphs/ ├── src/vector_stores/
|
||||||
|
└── mem0/reranker/ └── src/graphs/
|
||||||
|
|
||||||
|
cli/python/ ──▶ mem0ai (optional, for OSS mode)
|
||||||
|
cli/node/ ──▶ mem0ai (npm, for API calls)
|
||||||
|
integrations/vercel-ai-sdk/ ──▶ ai, @ai-sdk/* providers
|
||||||
|
integrations/openclaw/ ──▶ mem0ai (npm)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- **Python**: 3.9+ (3.10+ for CLI)
|
||||||
|
- **Node.js**: v18+ (v20 or v22 recommended)
|
||||||
|
- **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages
|
||||||
|
- **Hatch**: Python build/environment tool (`pip install hatch`)
|
||||||
|
- **Docker**: Required for `server/` and `openmemory/` development
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python SDK
|
||||||
|
hatch shell dev_py_3_11 # creates environment with all deps
|
||||||
|
pre-commit install # install git hooks
|
||||||
|
|
||||||
|
# TypeScript packages
|
||||||
|
cd mem0-ts && pnpm install # TS SDK
|
||||||
|
cd cli/node && pnpm install # Node CLI
|
||||||
|
cd integrations/vercel-ai-sdk && pnpm install # Vercel AI provider
|
||||||
|
cd integrations/openclaw && pnpm install # OpenClaw plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build, Lint, and Test Commands
|
||||||
|
|
||||||
|
### Python SDK (`mem0/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Environment setup (uses Hatch)
|
||||||
|
hatch shell dev_py_3_11 # or dev_py_3_9, dev_py_3_10, dev_py_3_12
|
||||||
|
|
||||||
|
# Linting and formatting
|
||||||
|
make lint # ruff check
|
||||||
|
make format # ruff format
|
||||||
|
make sort # isort mem0/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
make test # pytest tests/
|
||||||
|
make test-py-3.9 # test specific Python version (3.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 (`integrations/vercel-ai-sdk/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd integrations/vercel-ai-sdk
|
||||||
|
pnpm install
|
||||||
|
pnpm run build # tsup
|
||||||
|
pnpm run lint # eslint
|
||||||
|
pnpm run type-check # tsc --noEmit
|
||||||
|
pnpm run prettier-check # prettier --check
|
||||||
|
pnpm run test # jest
|
||||||
|
pnpm run test:edge # vitest (edge runtime)
|
||||||
|
pnpm run test:node # vitest (node runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Build:** tsup (CJS + ESM)
|
||||||
|
- **Lint:** ESLint + Prettier
|
||||||
|
- **Test:** jest + vitest (edge/node configs)
|
||||||
|
|
||||||
|
### OpenClaw Plugin (`integrations/openclaw/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd integrations/openclaw
|
||||||
|
pnpm install
|
||||||
|
pnpm run build # tsup
|
||||||
|
pnpm run test # vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Build:** tsup (ESM)
|
||||||
|
- **Test:** vitest (with Codecov in CI)
|
||||||
|
- **Plugin manifest:** `openclaw.plugin.json`
|
||||||
|
|
||||||
|
### Server (`server/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker production build
|
||||||
|
cd server
|
||||||
|
make build # docker build -t mem0-api-server .
|
||||||
|
make run_local # docker run -p 8000:8000 with .env
|
||||||
|
|
||||||
|
# Docker Compose development (FastAPI + PostgreSQL/pgvector + Neo4j)
|
||||||
|
cd server
|
||||||
|
docker-compose up # starts all 3 services
|
||||||
|
# mem0 API: localhost:8888
|
||||||
|
# PostgreSQL: localhost:8432
|
||||||
|
# Neo4j HTTP: localhost:8474, Bolt: localhost:8687
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Framework:** FastAPI with uvicorn (auto-reload in dev)
|
||||||
|
- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin
|
||||||
|
- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes
|
||||||
|
|
||||||
|
### OpenMemory (`openmemory/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full stack via Docker Compose
|
||||||
|
cd openmemory
|
||||||
|
docker-compose up
|
||||||
|
# Qdrant: localhost:6333
|
||||||
|
# API (MCP): localhost:8765
|
||||||
|
# UI: localhost:3000
|
||||||
|
|
||||||
|
# Individual development
|
||||||
|
cd openmemory/api && uvicorn main:app --reload # FastAPI backend
|
||||||
|
cd openmemory/ui && npm run dev # Next.js frontend
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol)
|
||||||
|
- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts
|
||||||
|
- **Vector store:** Qdrant
|
||||||
|
|
||||||
|
### Documentation (`docs/`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docs # or: cd docs && mintlify dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Framework:** Mintlify
|
||||||
|
- **API spec:** `docs/openapi.json`
|
||||||
|
- **Structure:** `api-reference/`, `open-source/`, `platform/`, `integrations/`, `cookbooks/`, `core-concepts/`
|
||||||
|
|
||||||
|
### Evaluation / Benchmarking
|
||||||
|
|
||||||
|
Benchmarking lives in the external [`mem0ai/memory-benchmarks`](https://github.com/mem0ai/memory-benchmarks) repo (LOCOMO + LongMemEval + BEAM). The in-repo `evaluation/` path is a **git submodule** pinned to that repo's `main` — populate it with `git submodule update --init evaluation` (or clone mem0 with `--recurse-submodules`), or clone the benchmarks repo standalone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mem0ai/memory-benchmarks.git
|
||||||
|
cd memory-benchmarks
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run a benchmark (Mem0 Cloud; use docker compose for OSS)
|
||||||
|
python -m benchmarks.locomo.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY
|
||||||
|
python -m benchmarks.longmemeval.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --all-questions
|
||||||
|
python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-key $MEM0_API_KEY --chat-sizes 100K --conversations 0-9
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core APIs
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
| Function / Class | Purpose | Import |
|
||||||
|
|-----------------|---------|--------|
|
||||||
|
| `Memory` | Self-hosted memory (sync) | `from mem0 import Memory` |
|
||||||
|
| `AsyncMemory` | Self-hosted memory (async) | `from mem0 import AsyncMemory` |
|
||||||
|
| `MemoryClient` | Hosted platform client (sync) | `from mem0 import MemoryClient` |
|
||||||
|
| `AsyncMemoryClient` | Hosted platform client (async) | `from mem0 import AsyncMemoryClient` |
|
||||||
|
|
||||||
|
**Key `Memory` / `MemoryClient` methods:**
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `add(messages, *, user_id, agent_id, run_id, metadata)` | Store a new memory |
|
||||||
|
| `search(query, *, user_id, agent_id, run_id, limit, filters)` | Search memories |
|
||||||
|
| `get(memory_id)` | Retrieve a single memory by ID |
|
||||||
|
| `get_all(*, user_id, agent_id, run_id, limit)` | List all memories |
|
||||||
|
| `update(memory_id, data)` | Update a memory |
|
||||||
|
| `delete(memory_id)` | Delete a memory |
|
||||||
|
| `delete_all(*, user_id, agent_id, run_id)` | Delete all memories |
|
||||||
|
| `history(memory_id)` | Get change history for a memory |
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
| Export | Purpose | Import |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `MemoryClient` | Hosted platform client | `import { MemoryClient } from 'mem0ai'` |
|
||||||
|
| `Memory` | Self-hosted OSS memory | `import { Memory } from 'mem0ai/oss'` |
|
||||||
|
|
||||||
|
## Import Patterns
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
| What | Import |
|
||||||
|
|------|--------|
|
||||||
|
| Core memory classes | `from mem0 import Memory, AsyncMemory` |
|
||||||
|
| Platform client | `from mem0 import MemoryClient, AsyncMemoryClient` |
|
||||||
|
| Configuration | `from mem0.configs.base import MemoryConfig` |
|
||||||
|
| LLM providers | `from mem0.llms.<provider> import <ProviderLLM>` |
|
||||||
|
| Embedding providers | `from mem0.embeddings.<provider> import <ProviderEmbedding>` |
|
||||||
|
| Vector store providers | `from mem0.vector_stores.<provider> import <ProviderVectorStore>` |
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
| What | Import |
|
||||||
|
|------|--------|
|
||||||
|
| Hosted client | `import { MemoryClient } from 'mem0ai'` |
|
||||||
|
| OSS memory | `import { Memory } from 'mem0ai/oss'` |
|
||||||
|
| Specific providers (OSS) | `import { OpenAIEmbedding } from 'mem0ai/oss'` |
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### File Naming Conventions
|
||||||
|
|
||||||
|
- **Python source files:** `snake_case.py` (e.g., `azure_openai.py`, `cohere_reranker.py`)
|
||||||
|
- **Python test files:** `test_<module>.py` (e.g., `test_memory.py`, `test_main.py`)
|
||||||
|
- **TypeScript source files:** `snake_case.ts` (e.g., `azure_ai_search.ts`)
|
||||||
|
- **TypeScript test files:** `<module>.test.ts` (e.g., `memory.test.ts`)
|
||||||
|
- **Config/manifest files:** `kebab-case` (e.g., `openclaw.plugin.json`, `jest.config.js`)
|
||||||
|
|
||||||
|
### Python Conventions
|
||||||
|
|
||||||
|
- **Provider pattern:** All providers (LLMs, embeddings, vector stores, graphs, rerankers) inherit from a `base.py` abstract class in their directory. Config classes live in `configs.py`.
|
||||||
|
- **Pydantic v2** for all data models and configuration.
|
||||||
|
- **Ruff** is the single linting and formatting tool — no black, no flake8.
|
||||||
|
- Root SDK: line length **120**
|
||||||
|
- Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF)
|
||||||
|
- **isort** with `profile = "black"` for import sorting.
|
||||||
|
- Ruff excludes `openmemory/` from root config.
|
||||||
|
|
||||||
|
### TypeScript Conventions
|
||||||
|
|
||||||
|
- **Build:** tsup across all packages.
|
||||||
|
- **Package manager:** pnpm everywhere (no npm, no yarn).
|
||||||
|
- **TypeScript strict mode** across all packages.
|
||||||
|
- **Linting varies by package:**
|
||||||
|
|
||||||
|
| Package | Linter | Formatter | Test Framework |
|
||||||
|
|---------|--------|-----------|---------------|
|
||||||
|
| `mem0-ts/` | — | Prettier | jest |
|
||||||
|
| `cli/node/` | Biome | Biome | vitest |
|
||||||
|
| `integrations/vercel-ai-sdk/` | ESLint | Prettier | jest + vitest |
|
||||||
|
| `integrations/openclaw/` | — | — | vitest |
|
||||||
|
|
||||||
|
### Type Checking
|
||||||
|
|
||||||
|
Always run type checking after modifying TypeScript code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <package> && pnpm run typecheck # or: tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Provider Pattern
|
||||||
|
|
||||||
|
The SDK uses a consistent plugin architecture across 5 categories. Each category has a `base.py` abstract class and concrete provider implementations:
|
||||||
|
|
||||||
|
| Category | Count | Examples |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| **LLMs** | 24 | OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, Gemini, Groq, Ollama, Together, DeepSeek, vLLM, LiteLLM, LM Studio, xAI |
|
||||||
|
| **Vector Stores** | 30 | Qdrant, Pinecone, Chroma, Weaviate, Milvus, MongoDB, Redis, Elasticsearch, pgvector, Supabase, Faiss, S3 Vectors |
|
||||||
|
| **Embeddings** | 15 | OpenAI, Azure OpenAI, Gemini, HuggingFace, FastEmbed, Together, AWS Bedrock, Ollama, Vertex AI |
|
||||||
|
| **Graph Stores** | 4 | Neo4j, Memgraph, Kuzu, Apache AGE |
|
||||||
|
| **Rerankers** | 5 | Cohere, HuggingFace, LLM-based, Sentence Transformer, Zero Entropy |
|
||||||
|
|
||||||
|
### Two Usage Modes
|
||||||
|
|
||||||
|
Self-hosted `Memory` / `AsyncMemory` classes and hosted-platform `MemoryClient` — both in Python and TypeScript.
|
||||||
|
|
||||||
|
### Graph Memory
|
||||||
|
|
||||||
|
Optional layer on top of vector memory for relationship-aware retrieval. Configured via the `graph` section of `MemoryConfig`.
|
||||||
|
|
||||||
|
### MCP Integration
|
||||||
|
|
||||||
|
Model Context Protocol support in multiple places:
|
||||||
|
|
||||||
|
- **Remote:** MCP server at `mcp.mem0.ai`
|
||||||
|
- **Local:** MCP server in `openmemory/api/` (FastAPI-based)
|
||||||
|
- **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`
|
||||||
|
|
||||||
|
### Plugin & Skills System
|
||||||
|
|
||||||
|
- `integrations/mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
|
||||||
|
- `skills/` contains structured skill definitions for AI agents, split into two categories:
|
||||||
|
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
|
||||||
|
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch (the two are loosely coupled via `.mem0-integration/` artifacts); `mem0-oss-to-platform` migrates an existing project from Mem0 OSS to the hosted Platform SDK (plan, then execute on approval).
|
||||||
|
|
||||||
|
### Adding a New Provider
|
||||||
|
|
||||||
|
To add a new LLM, embedding, vector store, or reranker provider:
|
||||||
|
|
||||||
|
1. Create `mem0/<category>/<provider_name>.py`
|
||||||
|
2. Inherit from the abstract base class in `mem0/<category>/base.py`
|
||||||
|
3. Add configuration to `mem0/<category>/configs.py` (if the category uses one)
|
||||||
|
4. Register the provider in `mem0/<category>/__init__.py`
|
||||||
|
5. Add tests in `tests/<category>/<provider_name>/`
|
||||||
|
6. Add any new dependencies to the appropriate optional group in `pyproject.toml` (never to core `dependencies`)
|
||||||
|
7. Follow the exact pattern of existing providers in the same category — match method signatures, error handling, and config structure
|
||||||
|
|
||||||
|
### Adding a New Integration
|
||||||
|
|
||||||
|
Agent/editor integrations live under `integrations/`. Each is a self-contained directory (its own `package.json`/lockfile, build, and tests). To add one:
|
||||||
|
|
||||||
|
1. Create `integrations/<name>/` and build the integration there.
|
||||||
|
2. If it publishes to a registry, set `repository.directory: "integrations/<name>"` in its `package.json` so npm provenance links to the correct subdirectory.
|
||||||
|
3. Add CI/CD under `.github/workflows/` (`<name>-checks.yml`, `<name>-cd.yml`). Use `integrations/<name>` in `paths:` triggers, `working-directory`, and `cache-dependency-path`. Register the release tag prefix in the `case` block in `release.yml` (keep the bare `v*` arm last). Keep workflow **filenames** stable — npm OIDC trusted publishing is pinned to repo + workflow filename.
|
||||||
|
4. If it is a Claude Code / editor marketplace plugin, register its path in the five `marketplace.json` files (root + `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.agents/plugins/`).
|
||||||
|
5. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`.
|
||||||
|
6. Add rows to the "Key Directories" table and the CI/CD tables in this file.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### CI Workflows (automated testing)
|
||||||
|
|
||||||
|
PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)** runs on every PR, detects which packages changed, and invokes only the relevant package workflows below as reusable workflows (`workflow_call`). Its final **`CI Gate`** job aggregates the results (skipped pipelines pass; failed or cancelled ones fail) and is the **only status check that needs to be required** in branch protection. Package workflows keep their own push-to-main and manual triggers; their `pull_request` triggers moved into the gate's path filters.
|
||||||
|
|
||||||
|
| Workflow | File | Standalone Triggers | Tests |
|
||||||
|
|----------|------|---------------------|-------|
|
||||||
|
| CI Gate | `ci-gate.yml` | All PRs | Routes to and aggregates the workflows below |
|
||||||
|
| Python SDK | `ci.yml` | Push to main | Ruff lint + pytest on Python 3.10, 3.11, 3.12 |
|
||||||
|
| TypeScript SDK | `ts-sdk-ci.yml` | Push to main (on `mem0-ts/`) | Prettier + build + jest on Node 20, 22 |
|
||||||
|
| Python CLI | `cli-python-ci.yml` | Push to main (on `cli/python/`), manual | Ruff lint + pytest + hatch build on Python 3.10, 3.11, 3.12 |
|
||||||
|
| Node CLI | `cli-node-ci.yml` | Push to main (on `cli/node/`), manual | Biome lint + tsc + vitest + tsup build on Node 20, 22 |
|
||||||
|
| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
|
||||||
|
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check |
|
||||||
|
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
|
||||||
|
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check |
|
||||||
|
|
||||||
|
When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` — a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
|
||||||
|
|
||||||
|
### CD Workflows (automated publishing)
|
||||||
|
|
||||||
|
Publishing is routed through a single entry point: **`release.yml` (Release Router)** is the only workflow that listens to `release: published` events. It matches the release tag prefix and dispatches the corresponding package workflow via `workflow_dispatch`, so each release produces exactly one routed run (no skipped runs from the other pipelines).
|
||||||
|
|
||||||
|
| Workflow | File | Tag Prefix | Target |
|
||||||
|
|----------|------|------------|--------|
|
||||||
|
| Release Router | `release.yml` | (all releases) | dispatches the matching workflow below |
|
||||||
|
| Python SDK | `cd.yml` | `v*` | PyPI (`mem0ai`) |
|
||||||
|
| TypeScript SDK | `ts-sdk-cd.yml` | `ts-v*` | npm (`mem0ai`) |
|
||||||
|
| Python CLI | `cli-python-cd.yml` | `cli-v*` | PyPI (`mem0-cli`) |
|
||||||
|
| Node CLI | `cli-node-cd.yml` | `cli-node-v*` | npm (`@mem0/cli`) |
|
||||||
|
| Vercel AI SDK | `vercel-ai-cd.yml` | `vercel-ai-v*` | npm (`@mem0/vercel-ai-provider`) |
|
||||||
|
| OpenClaw | `openclaw-cd.yml` | `openclaw-v*` | npm (`@mem0/openclaw-mem0`) |
|
||||||
|
| OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) |
|
||||||
|
| Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) |
|
||||||
|
|
||||||
|
- Package CD workflows are `workflow_dispatch`-only (inputs: `tag`, `prerelease`); they check out and build the given tag. Registry trusted-publisher settings stay pinned to each package's own workflow filename.
|
||||||
|
- All publishing uses **OIDC trusted publishing** — no tokens or secrets required.
|
||||||
|
- First publish of a new npm package must be done manually; OIDC works for subsequent versions.
|
||||||
|
- To re-publish a release (e.g. after a registry settings fix), do **not** delete/recreate the GitHub release — manually dispatch the package workflow instead: `gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`.
|
||||||
|
- When adding a new package: add its CD workflow (`workflow_dispatch` with `tag`/`prerelease` inputs), then register its tag prefix in the `case` block in `release.yml`. Keep the bare `v*` arm last.
|
||||||
|
|
||||||
|
### Utility Workflows
|
||||||
|
|
||||||
|
| Workflow | File | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| Issue Labeler | `issue-labeler.yml` | Automatic issue labeling |
|
||||||
|
| Stale Bot | `stale.yml` | Marks stale issues and PRs |
|
||||||
|
| llms.txt Check | `docs-llms-txt-check.yml` | Blocks PRs touching `docs/**/*.mdx` when `docs/llms.txt` is out of sync. Fix locally with `python scripts/check-llms-txt-coverage.py --write`. |
|
||||||
|
|
||||||
|
## Task Completion Guidelines
|
||||||
|
|
||||||
|
These guidelines outline typical artifacts for different task types. Use judgment to adapt based on scope and context.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
1. **Unit tests**: Add tests that would fail without the fix (regression tests)
|
||||||
|
2. **Implementation**: Fix the bug
|
||||||
|
3. **Manual verification**: Run the relevant test suite to confirm the fix
|
||||||
|
4. **Lint**: Run the appropriate linter for the package you modified
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
1. **Implementation**: Build the feature following existing patterns
|
||||||
|
2. **Unit tests**: Comprehensive test coverage for new functionality
|
||||||
|
3. **Documentation**: Update relevant docs in `docs/` for public APIs
|
||||||
|
4. **Examples**: Add usage examples if the feature introduces new user-facing behavior
|
||||||
|
5. **llms.txt**: Any new `.mdx` page under `docs/` must be linked in `docs/llms.txt` with a scope tag (`[Platform]` / `[OSS]` / `[Both]`) and a `Use when ...` description. The `docs-llms-txt-check.yml` workflow runs on every PR that touches docs and **fails the check** if the index is out of sync. To fix: run `python scripts/check-llms-txt-coverage.py --write` locally to scaffold placeholders under `## Unclassified - needs triage`, then replace the `[TODO: ...]` tags, rewrite descriptions as `Use when ...`, move entries into the right section, and delete the triage heading when empty.
|
||||||
|
|
||||||
|
### New Provider (LLM / Embedding / Vector Store / Reranker)
|
||||||
|
|
||||||
|
1. **Implementation**: Follow the "Adding a New Provider" steps above
|
||||||
|
2. **Tests**: Add unit tests matching the pattern of existing providers
|
||||||
|
3. **Configuration**: Add to the appropriate `configs.py` and `__init__.py`
|
||||||
|
4. **Dependencies**: Add to the correct optional group in `pyproject.toml`
|
||||||
|
5. **Documentation**: Add an integration guide in `docs/integrations/`
|
||||||
|
|
||||||
|
### Refactoring / Internal Changes
|
||||||
|
|
||||||
|
- Unit tests for any changed behavior
|
||||||
|
- No documentation needed for internal-only changes
|
||||||
|
- Ensure all existing tests still pass
|
||||||
|
|
||||||
|
### When to Deviate
|
||||||
|
|
||||||
|
These are guidelines, not rigid rules. Adjust based on:
|
||||||
|
|
||||||
|
- **Scope**: Trivial fixes (typos, comments) may not need tests
|
||||||
|
- **Visibility**: Internal changes may not need documentation
|
||||||
|
- **Context**: Some changes span multiple categories — use judgment
|
||||||
|
|
||||||
|
When uncertain about expected artifacts, ask for clarification.
|
||||||
|
|
||||||
|
## Contributing Guidelines
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. Fork and clone the repository.
|
||||||
|
2. Create a feature branch from `main` (e.g., `feature/my-new-feature`).
|
||||||
|
3. Make your changes — add tests, docs, and examples as appropriate.
|
||||||
|
4. Run linting and tests for every package you modified (see commands above).
|
||||||
|
5. Run `pre-commit install` on first setup — hooks run ruff + isort automatically.
|
||||||
|
6. Commit with a clear message following [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`, `refactor:`).
|
||||||
|
7. Push and open a Pull Request against `main`.
|
||||||
|
|
||||||
|
### Pull Request Requirements
|
||||||
|
|
||||||
|
Every PR must follow the repo's PR template (`.github/PULL_REQUEST_TEMPLATE.md`):
|
||||||
|
|
||||||
|
1. **Linked Issue** — Reference the issue with `Closes #<number>`. If no issue exists, create one first or explain why in the description.
|
||||||
|
2. **Description** — Explain what the PR does and why it's needed.
|
||||||
|
3. **Type of Change** — Check the appropriate box:
|
||||||
|
- Bug fix / New feature / Breaking change / Refactor / Documentation update
|
||||||
|
4. **Breaking Changes** — If applicable, describe what breaks and the migration path.
|
||||||
|
5. **Test Coverage** — Check what applies:
|
||||||
|
- Added/updated unit tests
|
||||||
|
- Added/updated integration tests
|
||||||
|
- Tested manually (describe how)
|
||||||
|
- No tests needed (explain why)
|
||||||
|
6. **Checklist** — All must be checked before merge:
|
||||||
|
- [ ] Code follows the project's style guidelines
|
||||||
|
- [ ] Self-review performed
|
||||||
|
- [ ] Tests added that prove the fix/feature works
|
||||||
|
- [ ] New and existing tests pass locally
|
||||||
|
- [ ] Documentation updated if needed
|
||||||
|
|
||||||
|
### PR Description Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Linked Issue
|
||||||
|
|
||||||
|
Closes #<!-- issue number -->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- What does this PR do? Why is it needed? -->
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change that adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||||
|
- [ ] Refactor (no functional changes)
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
N/A
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- [ ] I added/updated unit tests
|
||||||
|
- [ ] I added/updated integration tests
|
||||||
|
- [ ] I tested manually (describe below)
|
||||||
|
- [ ] No tests needed (explain why)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the project's style guidelines
|
||||||
|
- [ ] I have performed a self-review of my code
|
||||||
|
- [ ] I have added tests that prove my fix/feature works
|
||||||
|
- [ ] New and existing tests pass locally
|
||||||
|
- [ ] I have updated documentation if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### General Rules
|
||||||
|
|
||||||
|
- Follow existing code patterns — don't introduce new frameworks or abstractions without discussion.
|
||||||
|
- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript).
|
||||||
|
- For `server/` and `openmemory/` work, use Docker Compose for local development.
|
||||||
|
- Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`).
|
||||||
|
|
||||||
|
### Contributing Guides
|
||||||
|
|
||||||
|
| Task | Guide |
|
||||||
|
|------|-------|
|
||||||
|
| Code contributions | `docs/contributing/development.mdx` |
|
||||||
|
| Documentation contributions | `docs/contributing/documentation.mdx` |
|
||||||
|
| PR template | `.github/PULL_REQUEST_TEMPLATE.md` |
|
||||||
|
| Bug reports | `.github/ISSUE_TEMPLATE/bug_report.yml` |
|
||||||
|
| Feature requests | `.github/ISSUE_TEMPLATE/feature_request.yml` |
|
||||||
|
| Documentation issues | `.github/ISSUE_TEMPLATE/documentation_issue.yml` |
|
||||||
|
|
||||||
|
## Do NOT
|
||||||
|
|
||||||
|
- Modify CI/CD workflows without explicit approval.
|
||||||
|
- Add new Python dependencies to the core `dependencies` list in `pyproject.toml` without discussion — use optional dependency groups instead.
|
||||||
|
- Commit `.env` files, API keys, or credentials.
|
||||||
|
- Skip pre-commit hooks.
|
||||||
|
- Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively.
|
||||||
|
- Use `require()` for imports in TypeScript — use ES module `import` syntax.
|
||||||
|
- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff).
|
||||||
|
- Modify `openmemory/` database migrations without understanding the Alembic migration chain.
|
||||||
|
- Change public APIs without updating documentation in `docs/`.
|
||||||
@@ -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.
|
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!
|
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
|
graph_store=GraphStoreConfig(provider="neo4j", config={...}), # optional
|
||||||
history_db_path="~/.mem0/history.db",
|
history_db_path="~/.mem0/history.db",
|
||||||
version="v1.1",
|
version="v1.1",
|
||||||
custom_fact_extraction_prompt="Custom prompt...",
|
custom_instructions="Custom prompt...",
|
||||||
custom_update_memory_prompt="Custom prompt..."
|
custom_update_memory_prompt="Custom prompt..."
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -684,7 +684,7 @@ Conversation: {messages}
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
config = MemoryConfig(
|
config = MemoryConfig(
|
||||||
custom_fact_extraction_prompt=custom_extraction_prompt
|
custom_instructions=custom_extraction_prompt
|
||||||
)
|
)
|
||||||
memory = Memory(config)
|
memory = Memory(config)
|
||||||
```
|
```
|
||||||
@@ -1026,7 +1026,8 @@ def get_user_preferences(user_id: str):
|
|||||||
### AutoGen Integration
|
### AutoGen Integration
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from cookbooks.helper.mem0_teachability import Mem0Teachability
|
# Mem0Teachability lives in examples/notebooks/helper/ — see examples/notebooks/mem0-autogen.ipynb
|
||||||
|
from helper.mem0_teachability import Mem0Teachability
|
||||||
from mem0 import Memory
|
from mem0 import Memory
|
||||||
|
|
||||||
# Add memory capability to AutoGen agents
|
# Add memory capability to AutoGen agents
|
||||||
@@ -1313,7 +1314,7 @@ async def delete_memory(memory_id: str):
|
|||||||
- **Documentation**: https://docs.mem0.ai
|
- **Documentation**: https://docs.mem0.ai
|
||||||
- **GitHub Repository**: https://github.com/mem0ai/mem0
|
- **GitHub Repository**: https://github.com/mem0ai/mem0
|
||||||
- **Discord Community**: https://mem0.dev/DiG
|
- **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
|
- **Research Paper**: https://mem0.ai/research
|
||||||
- **Examples**: https://github.com/mem0ai/mem0/tree/main/examples
|
- **Examples**: https://github.com/mem0ai/mem0/tree/main/examples
|
||||||
|
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
# Migration Guide: Upgrading to mem0 1.0.0
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
**What changed?** We simplified the API by removing confusing version parameters. Now everything returns a consistent format: `{"results": [...]}`.
|
|
||||||
|
|
||||||
**What you need to do:**
|
|
||||||
1. Upgrade: `pip install mem0ai==1.0.0`
|
|
||||||
2. Remove `version` and `output_format` parameters from your code
|
|
||||||
3. Update response handling to use `result["results"]` instead of treating responses as lists
|
|
||||||
|
|
||||||
**Time needed:** ~5-10 minutes for most projects
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Migration Guide
|
|
||||||
|
|
||||||
### 1. Install the Update
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install mem0ai==1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update Your Code
|
|
||||||
|
|
||||||
**If you're using the Memory API:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Before
|
|
||||||
memory = Memory(config=MemoryConfig(version="v1.1"))
|
|
||||||
result = memory.add("I like pizza")
|
|
||||||
|
|
||||||
# After
|
|
||||||
memory = Memory() # That's it - version is automatic now
|
|
||||||
result = memory.add("I like pizza")
|
|
||||||
```
|
|
||||||
|
|
||||||
**If you're using the Client API:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Before
|
|
||||||
client.add(messages, output_format="v1.1")
|
|
||||||
client.search(query, version="v2", output_format="v1.1")
|
|
||||||
|
|
||||||
# After
|
|
||||||
client.add(messages) # Just remove those extra parameters
|
|
||||||
client.search(query)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update How You Handle Responses
|
|
||||||
|
|
||||||
All responses now use the same format: a dictionary with `"results"` key.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Before - you might have done this
|
|
||||||
result = memory.add("I like pizza")
|
|
||||||
for item in result: # Treating it as a list
|
|
||||||
print(item)
|
|
||||||
|
|
||||||
# After - do this instead
|
|
||||||
result = memory.add("I like pizza")
|
|
||||||
for item in result["results"]: # Access the results key
|
|
||||||
print(item)
|
|
||||||
|
|
||||||
# Graph relations (if you use them)
|
|
||||||
if "relations" in result:
|
|
||||||
for relation in result["relations"]:
|
|
||||||
print(relation)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enhanced Message Handling
|
|
||||||
|
|
||||||
The platform client (MemoryClient) now supports the same flexible message formats as the OSS version:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mem0 import MemoryClient
|
|
||||||
|
|
||||||
client = MemoryClient(api_key="your-key")
|
|
||||||
|
|
||||||
# All three formats now work:
|
|
||||||
|
|
||||||
# 1. Single string (automatically converted to user message)
|
|
||||||
client.add("I like pizza", user_id="alice")
|
|
||||||
|
|
||||||
# 2. Single message dictionary
|
|
||||||
client.add({"role": "user", "content": "I like pizza"}, user_id="alice")
|
|
||||||
|
|
||||||
# 3. List of messages (conversation)
|
|
||||||
client.add([
|
|
||||||
{"role": "user", "content": "I like pizza"},
|
|
||||||
{"role": "assistant", "content": "I'll remember that!"}
|
|
||||||
], user_id="alice")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Async Mode Configuration
|
|
||||||
|
|
||||||
The `async_mode` parameter now defaults to `True` but can be configured:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Default behavior (async_mode=True)
|
|
||||||
client.add(messages, user_id="alice")
|
|
||||||
|
|
||||||
# Explicitly set async mode
|
|
||||||
client.add(messages, user_id="alice", async_mode=True)
|
|
||||||
|
|
||||||
# Disable async mode if needed
|
|
||||||
client.add(messages, user_id="alice", async_mode=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** `async_mode=True` provides better performance for most use cases. Only set it to `False` if you have specific synchronous processing requirements.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## That's It!
|
|
||||||
|
|
||||||
For most users, that's all you need to know. The changes are:
|
|
||||||
- ✅ No more `version` or `output_format` parameters
|
|
||||||
- ✅ Consistent `{"results": [...]}` response format
|
|
||||||
- ✅ Cleaner, simpler API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
**Getting `KeyError: 'results'`?**
|
|
||||||
|
|
||||||
Your code is still treating the response as a list. Update it:
|
|
||||||
```python
|
|
||||||
# Change this:
|
|
||||||
for memory in response:
|
|
||||||
|
|
||||||
# To this:
|
|
||||||
for memory in response["results"]:
|
|
||||||
```
|
|
||||||
|
|
||||||
**Getting `TypeError: unexpected keyword argument`?**
|
|
||||||
|
|
||||||
You're still passing old parameters. Remove them:
|
|
||||||
```python
|
|
||||||
# Change this:
|
|
||||||
client.add(messages, output_format="v1.1")
|
|
||||||
|
|
||||||
# To this:
|
|
||||||
client.add(messages)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Seeing deprecation warnings?**
|
|
||||||
|
|
||||||
Remove any explicit `version="v1.0"` from your config:
|
|
||||||
```python
|
|
||||||
# Change this:
|
|
||||||
memory = Memory(config=MemoryConfig(version="v1.0"))
|
|
||||||
|
|
||||||
# To this:
|
|
||||||
memory = Memory()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's New in 1.0.0
|
|
||||||
|
|
||||||
- **Better vector stores:** Fixed OpenSearch and improved reliability across all stores
|
|
||||||
- **Cleaner API:** One way to do things, no more confusing options
|
|
||||||
- **Enhanced GCP support:** Better Vertex AI configuration options
|
|
||||||
- **Flexible message input:** Platform client now accepts strings, dicts, and lists (aligned with OSS)
|
|
||||||
- **Configurable async_mode:** Now defaults to `True` but users can override if needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Need Help?
|
|
||||||
|
|
||||||
- Check [GitHub Issues](https://github.com/mem0ai/mem0/issues)
|
|
||||||
- Read the [documentation](https://docs.mem0.ai/)
|
|
||||||
- Open a new issue if you're stuck
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced: Configuration Changes
|
|
||||||
|
|
||||||
**If you configured vector stores with version:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Before
|
|
||||||
config = MemoryConfig(
|
|
||||||
version="v1.1",
|
|
||||||
vector_store=VectorStoreConfig(...)
|
|
||||||
)
|
|
||||||
|
|
||||||
# After
|
|
||||||
config = MemoryConfig(
|
|
||||||
vector_store=VectorStoreConfig(...)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Your Migration
|
|
||||||
|
|
||||||
Quick sanity check:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mem0 import Memory
|
|
||||||
|
|
||||||
memory = Memory()
|
|
||||||
|
|
||||||
# Add should return a dict with "results"
|
|
||||||
result = memory.add("I like pizza", user_id="test")
|
|
||||||
assert "results" in result
|
|
||||||
|
|
||||||
# Search should return a dict with "results"
|
|
||||||
search = memory.search("food", user_id="test")
|
|
||||||
assert "results" in search
|
|
||||||
|
|
||||||
# Get all should return a dict with "results"
|
|
||||||
all_memories = memory.get_all(user_id="test")
|
|
||||||
assert "results" in all_memories
|
|
||||||
|
|
||||||
print("✅ Migration successful!")
|
|
||||||
```
|
|
||||||
@@ -42,9 +42,6 @@ clean:
|
|||||||
test:
|
test:
|
||||||
hatch run test
|
hatch run test
|
||||||
|
|
||||||
test-py-3.9:
|
|
||||||
hatch run dev_py_3_9:test
|
|
||||||
|
|
||||||
test-py-3.10:
|
test-py-3.10:
|
||||||
hatch run dev_py_3_10:test
|
hatch run dev_py_3_10:test
|
||||||
|
|
||||||
|
|||||||
@@ -39,18 +39,33 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://mem0.ai/research"><strong>📄 Building Production-Ready AI Agents with Scalable Long-Term Memory →</strong></a>
|
<a href="https://mem0.ai/research"><strong>📄 Benchmarking Mem0's token-efficient memory algorithm →</strong></a>
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<strong>⚡ +26% Accuracy vs. OpenAI Memory • 🚀 91% Faster • 💰 90% Fewer Tokens</strong>
|
|
||||||
</p>
|
</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
|
| Benchmark | Old | New | Tokens | Latency p50 |
|
||||||
- **+26% Accuracy** over OpenAI Memory on the LOCOMO benchmark
|
| --- | --- | --- | --- | --- |
|
||||||
- **91% Faster Responses** than full-context, ensuring low-latency at scale
|
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
|
||||||
- **90% Lower Token Usage** than full-context, cutting costs without compromise
|
| **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)
|
- [Read the full paper](https://mem0.ai/research)
|
||||||
|
|
||||||
# Introduction
|
# Introduction
|
||||||
@@ -71,28 +86,75 @@
|
|||||||
|
|
||||||
## 🚀 Quickstart Guide <a name="quickstart"></a>
|
## 🚀 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. Sign up as an agent (replace `claude-code` with your name)
|
||||||
2. Embed the memory layer via SDK or API keys
|
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
|
```bash
|
||||||
pip install mem0ai
|
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:
|
Install sdk via npm:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install mem0ai
|
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
|
### CLI
|
||||||
|
|
||||||
Manage memories from your terminal:
|
Manage memories from your terminal:
|
||||||
@@ -107,9 +169,33 @@ mem0 search "What does Alice prefer?" --user-id alice
|
|||||||
|
|
||||||
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
|
See the [CLI documentation](https://docs.mem0.ai/platform/cli) for the full command reference.
|
||||||
|
|
||||||
|
### Agent Skills
|
||||||
|
|
||||||
|
Teach your AI coding assistant (Claude Code, Codex, Cursor, Windsurf, OpenCode, OpenClaw, and any tool that supports the skills standard) how to build with Mem0. Two categories:
|
||||||
|
|
||||||
|
**Reference skills — always on** (SDK knowledge loaded into the assistant's context):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0-cli
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pipeline skills — run on demand** (execute an end-to-end workflow in an existing repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
|
||||||
|
npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. Use `/mem0-oss-to-platform` to migrate an existing project from Mem0 OSS to the hosted Platform SDK. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
|
||||||
|
|
||||||
### Basic Usage
|
### 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:
|
First step is to instantiate the memory:
|
||||||
|
|
||||||
@@ -122,13 +208,13 @@ memory = Memory()
|
|||||||
|
|
||||||
def chat_with_memories(message: str, user_id: str = "default_user") -> str:
|
def chat_with_memories(message: str, user_id: str = "default_user") -> str:
|
||||||
# Retrieve relevant memories
|
# 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"])
|
memories_str = "\n".join(f"- {entry['memory']}" for entry in relevant_memories["results"])
|
||||||
|
|
||||||
# Generate Assistant response
|
# Generate Assistant response
|
||||||
system_prompt = f"You are a helpful AI. Answer the question based on query and memories.\nUser Memories:\n{memories_str}"
|
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}]
|
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
|
assistant_response = response.choices[0].message.content
|
||||||
|
|
||||||
# Create new memories from the conversation
|
# Create new memories from the conversation
|
||||||
|
|||||||
+175
-15
@@ -28,7 +28,7 @@ mem0 CLI is the official command-line interface for [mem0](https://mem0.ai) -- t
|
|||||||
### Who is it for?
|
### Who is it for?
|
||||||
|
|
||||||
- Developers integrating mem0 into their workflows
|
- Developers integrating mem0 into their workflows
|
||||||
- AI agents that need persistent memory (the CLI is designed with `--output json` and `help --json` specifically for machine consumption)
|
- AI agents that need persistent memory (the CLI is designed with `--json`/`--agent` global flags and `help --json` specifically for machine consumption)
|
||||||
- DevOps/CI pipelines that need to manage memories programmatically
|
- DevOps/CI pipelines that need to manage memories programmatically
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -79,6 +79,7 @@ Apache-2.0
|
|||||||
│ ├── init_cmd.py # run_init (interactive wizard)
|
│ ├── init_cmd.py # run_init (interactive wizard)
|
||||||
│ ├── config_cmd.py # cmd_config_show, cmd_config_get, cmd_config_set
|
│ ├── config_cmd.py # cmd_config_show, cmd_config_get, cmd_config_set
|
||||||
│ ├── entities.py # cmd_entities_list, cmd_entities_delete
|
│ ├── entities.py # cmd_entities_list, cmd_entities_delete
|
||||||
|
│ ├── events_cmd.py # cmd_event_list, cmd_event_status
|
||||||
│ └── utils.py # cmd_status, cmd_version, cmd_import
|
│ └── utils.py # cmd_status, cmd_version, cmd_import
|
||||||
└── node/
|
└── node/
|
||||||
├── package.json # Node package config (tsup build)
|
├── package.json # Node package config (tsup build)
|
||||||
@@ -88,6 +89,7 @@ Apache-2.0
|
|||||||
├── config.ts # Config loading/saving, env var overrides
|
├── config.ts # Config loading/saving, env var overrides
|
||||||
├── branding.ts # Colors, icons, banner, timedStatus, print helpers
|
├── branding.ts # Colors, icons, banner, timedStatus, print helpers
|
||||||
├── output.ts # Output formatting (text, json, table, quiet)
|
├── output.ts # Output formatting (text, json, table, quiet)
|
||||||
|
├── state.ts # Agent mode flag (setAgentMode, isAgentMode)
|
||||||
├── help.ts # Rich-style help formatter (panels, command ordering)
|
├── help.ts # Rich-style help formatter (panels, command ordering)
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── index.ts # Re-exports
|
│ ├── index.ts # Re-exports
|
||||||
@@ -98,6 +100,7 @@ Apache-2.0
|
|||||||
├── init.ts # runInit (interactive wizard)
|
├── init.ts # runInit (interactive wizard)
|
||||||
├── config.ts # cmdConfigShow, cmdConfigGet, cmdConfigSet
|
├── config.ts # cmdConfigShow, cmdConfigGet, cmdConfigSet
|
||||||
├── entities.ts # cmdEntitiesList, cmdEntitiesDelete
|
├── entities.ts # cmdEntitiesList, cmdEntitiesDelete
|
||||||
|
├── events.ts # cmdEventList, cmdEventStatus
|
||||||
└── utils.ts # cmdStatus, cmdVersion, cmdImport
|
└── utils.ts # cmdStatus, cmdVersion, cmdImport
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -155,9 +158,15 @@ Interactive setup wizard for mem0 CLI.
|
|||||||
| `-u, --user-id` | string | No | - | Default user ID (skip prompt). |
|
| `-u, --user-id` | string | No | - | Default user ID (skip prompt). |
|
||||||
| `--email` | string | No | - | Login via email verification code. |
|
| `--email` | string | No | - | Login via email verification code. |
|
||||||
| `--code` | string | No | - | Verification code (use with --email for non-interactive login). |
|
| `--code` | string | No | - | Verification code (use with --email for non-interactive login). |
|
||||||
|
| `--force` | bool | No | false | Overwrite existing config without confirmation. |
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
|
*Existing config protection:*
|
||||||
|
- If `~/.mem0/config.json` exists with an API key, the CLI warns and asks for confirmation before overwriting.
|
||||||
|
- In non-TTY mode, this is a hard error unless `--force` is passed.
|
||||||
|
- `--force` skips the confirmation in both TTY and non-TTY modes.
|
||||||
|
|
||||||
*Email login flow (when `--email` is provided):*
|
*Email login flow (when `--email` is provided):*
|
||||||
- Sends a 6-digit verification code to the email via `POST /api/v1/auth/email_code/`.
|
- Sends a 6-digit verification code to the email via `POST /api/v1/auth/email_code/`.
|
||||||
- If `--code` is also provided, verifies immediately (fully non-interactive).
|
- If `--code` is also provided, verifies immediately (fully non-interactive).
|
||||||
@@ -175,6 +184,7 @@ Interactive setup wizard for mem0 CLI.
|
|||||||
```bash
|
```bash
|
||||||
mem0 init
|
mem0 init
|
||||||
mem0 init --api-key m0-xxx --user-id alice
|
mem0 init --api-key m0-xxx --user-id alice
|
||||||
|
mem0 init --api-key m0-xxx --user-id alice --force
|
||||||
mem0 init --email alice@company.com
|
mem0 init --email alice@company.com
|
||||||
mem0 init --email alice@company.com --code 482901
|
mem0 init --email alice@company.com --code 482901
|
||||||
```
|
```
|
||||||
@@ -700,7 +710,114 @@ mem0 entity delete --user-id alice --dry-run
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.14 `status`
|
### 3.14 `event list`
|
||||||
|
|
||||||
|
List recent background processing events.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|------------------|-------|
|
||||||
|
| Usage | `mem0 event list [OPTIONS]` |
|
||||||
|
| needsBackend | Yes |
|
||||||
|
| needsConfig | Yes |
|
||||||
|
| resolveIds | No |
|
||||||
|
| resolveGraph | No |
|
||||||
|
| confirmDangerous | No |
|
||||||
|
| Output formats | text (table), json |
|
||||||
|
| Default output | table |
|
||||||
|
| API endpoint | `GET /v1/events/` |
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Type | Default | Panel | Help |
|
||||||
|
|----------------|--------|---------|------------|------|
|
||||||
|
| `-o, --output` | string | "table" | Output | Output: text, json. |
|
||||||
|
| `--api-key` | string | - | Connection | Override API key. |
|
||||||
|
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||||
|
|
||||||
|
**Behavior:** Fetches all background events for the project. Displays as a table with columns: Event ID (first 8 chars), Type, Status (color-coded), Latency, Created. Status values: `PENDING` (accent), `SUCCEEDED` (green), `FAILED` (red), `PROCESSING` (yellow).
|
||||||
|
|
||||||
|
**JSON output envelope:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"command": "event list",
|
||||||
|
"count": 3,
|
||||||
|
"duration_ms": 87,
|
||||||
|
"data": [
|
||||||
|
{ "id": "evt-abc", "event_type": "ADD", "status": "SUCCEEDED", "latency": 412.0, "created_at": "2026-01-01T10:00:00Z" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
mem0 event list
|
||||||
|
mem0 event list --output json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.15 `event status`
|
||||||
|
|
||||||
|
Get the status and results of a specific background event.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|------------------|-------|
|
||||||
|
| Usage | `mem0 event status <event_id> [OPTIONS]` |
|
||||||
|
| needsBackend | Yes |
|
||||||
|
| needsConfig | Yes |
|
||||||
|
| resolveIds | No |
|
||||||
|
| resolveGraph | No |
|
||||||
|
| confirmDangerous | No |
|
||||||
|
| Output formats | text, json |
|
||||||
|
| Default output | text |
|
||||||
|
| API endpoint | `GET /v1/events/{event_id}/` |
|
||||||
|
|
||||||
|
**Arguments:**
|
||||||
|
|
||||||
|
| Name | Type | Required | Help |
|
||||||
|
|------------|--------|----------|------|
|
||||||
|
| `event_id` | string | Yes | Event ID to inspect. |
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Type | Default | Panel | Help |
|
||||||
|
|----------------|--------|---------|------------|------|
|
||||||
|
| `-o, --output` | string | "text" | Output | Output: text, json. |
|
||||||
|
| `--api-key` | string | - | Connection | Override API key. |
|
||||||
|
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||||
|
|
||||||
|
**Behavior:** Fetches the event by ID and displays: Event ID, Type, Status (color-coded), Latency, Created, Updated, and a numbered list of result memories (event type, memory text, user_id, truncated memory ID). Displayed in a boxed panel (text) or JSON envelope.
|
||||||
|
|
||||||
|
**JSON output envelope:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"command": "event status",
|
||||||
|
"duration_ms": 65,
|
||||||
|
"data": {
|
||||||
|
"id": "evt-abc",
|
||||||
|
"event_type": "ADD",
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"latency": 412.0,
|
||||||
|
"created_at": "2026-01-01T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T10:00:01Z",
|
||||||
|
"results": [
|
||||||
|
{ "id": "mem-xyz", "event": "ADD", "user_id": "alice", "memory": "User prefers dark mode" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
mem0 event status evt-abc-123
|
||||||
|
mem0 event status evt-abc-123 --output json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.16 `status`
|
||||||
|
|
||||||
Check connectivity and authentication.
|
Check connectivity and authentication.
|
||||||
|
|
||||||
@@ -721,20 +838,19 @@ Check connectivity and authentication.
|
|||||||
| `--api-key` | string | - | Connection | Override API key. |
|
| `--api-key` | string | - | Connection | Override API key. |
|
||||||
| `--base-url` | string | - | Connection | Override API base URL. |
|
| `--base-url` | string | - | Connection | Override API base URL. |
|
||||||
|
|
||||||
**Behavior:** If config has a default `user_id` or `agent_id`, validates by making a minimal `POST /v2/memories/` with `page=1&page_size=1`. Otherwise validates via `GET /v1/entities/`. Displays connection status in a boxed panel (text) or JSON envelope.
|
**Behavior:** Validates connectivity by calling `GET /v1/ping/`. Displays connection status in a boxed panel (text) or JSON envelope. The ping endpoint is lightweight and does not require any entity scope.
|
||||||
|
|
||||||
**JSON output:**
|
**JSON output:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"command": "status",
|
"command": "status",
|
||||||
|
"duration_ms": 112,
|
||||||
"data": {
|
"data": {
|
||||||
"connected": true,
|
"connected": true,
|
||||||
"backend": "platform",
|
"backend": "platform",
|
||||||
"base_url": "https://api.mem0.ai",
|
"base_url": "https://api.mem0.ai"
|
||||||
"latency_ms": 245
|
}
|
||||||
},
|
|
||||||
"duration_ms": 245
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -746,7 +862,7 @@ mem0 status -o json
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.15 `help`
|
### 3.17 `help`
|
||||||
|
|
||||||
Show help. Use `--json` for machine-readable output (for LLM agents).
|
Show help. Use `--json` for machine-readable output (for LLM agents).
|
||||||
|
|
||||||
@@ -806,6 +922,9 @@ The auth header name is `Authorization` and the scheme is `Token` (not Bearer).
|
|||||||
| Delete all | `DELETE` | `/v1/memories/` | - | entity ID params |
|
| Delete all | `DELETE` | `/v1/memories/` | - | entity ID params |
|
||||||
| List entities | `GET` | `/v1/entities/` | - | - |
|
| List entities | `GET` | `/v1/entities/` | - | - |
|
||||||
| Delete entities | `DELETE` | `/v1/entities/` | - | entity ID params |
|
| Delete entities | `DELETE` | `/v1/entities/` | - | entity ID params |
|
||||||
|
| List events | `GET` | `/v1/events/` | - | - |
|
||||||
|
| Get event | `GET` | `/v1/events/{event_id}/` | - | - |
|
||||||
|
| Ping (status) | `GET` | `/v1/ping/` | - | - |
|
||||||
|
|
||||||
### How Filters Are Built (`_buildFilters` / `_build_filters`)
|
### How Filters Are Built (`_buildFilters` / `_build_filters`)
|
||||||
|
|
||||||
@@ -1108,6 +1227,8 @@ For `PENDING` events, displays "Processing in background" with the event ID.
|
|||||||
|
|
||||||
### 7.1 Supported Modes Per Command
|
### 7.1 Supported Modes Per Command
|
||||||
|
|
||||||
|
All commands also support `agent` mode via the global `--json`/`--agent` flag, which wraps output in a structured JSON envelope with sanitized fields.
|
||||||
|
|
||||||
| Command | text | json | table | quiet |
|
| Command | text | json | table | quiet |
|
||||||
|----------------|------|------|-------|-------|
|
|----------------|------|------|-------|-------|
|
||||||
| add | Y | Y | - | Y |
|
| add | Y | Y | - | Y |
|
||||||
@@ -1122,12 +1243,16 @@ For `PENDING` events, displays "Processing in background" with the event ID.
|
|||||||
| config set | (success msg) | - | - | - |
|
| config set | (success msg) | - | - | - |
|
||||||
| entity list | - | Y | Y (default) | - |
|
| entity list | - | Y | Y (default) | - |
|
||||||
| entity delete | Y | Y | - | Y |
|
| entity delete | Y | Y | - | Y |
|
||||||
|
| event list | Y (table) | Y | - | - |
|
||||||
|
| event status | Y | Y | - | - |
|
||||||
| status | Y | Y | - | - |
|
| status | Y | Y | - | - |
|
||||||
| help | Y | Y (--json) | - | - |
|
| help | Y | Y (--json) | - | - |
|
||||||
|
|
||||||
### 7.2 JSON Envelope Format (`formatJsonEnvelope`)
|
### 7.2 JSON Envelope Format
|
||||||
|
|
||||||
Used by `config show`, `status`, and `import` for structured JSON output:
|
There are two related envelope formats:
|
||||||
|
|
||||||
|
**`formatJsonEnvelope`** — used by `config show`, `status`, and `import` for `--output json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1141,14 +1266,39 @@ Used by `config show`, `status`, and `import` for structured JSON output:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`formatAgentEnvelope`** — used by all commands in agent mode (`--json`/`--agent`). Same structure, but `data` is passed through `sanitizeAgentData(command, data)` to project only the most relevant fields:
|
||||||
|
|
||||||
|
| Command | Fields in `data` |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| add | `[{id, memory, event}]` or `[{status, event_id}]` for PENDING |
|
||||||
|
| search | `[{id, memory, score, created_at, categories}]` |
|
||||||
|
| list | `[{id, memory, created_at, categories}]` |
|
||||||
|
| get | `{id, memory, created_at, updated_at, categories, metadata}` |
|
||||||
|
| update | `{id, memory}` |
|
||||||
|
| delete | (raw API response) |
|
||||||
|
| entity list | `[{name, type, count}]` |
|
||||||
|
| event list | `[{id, event_type, status, latency, created_at}]` |
|
||||||
|
| event status | `{id, event_type, status, latency, created_at, updated_at, results: [{id, event, user_id, memory}]}` |
|
||||||
|
| status/config/import | (pass-through) |
|
||||||
|
|
||||||
|
Error envelopes (on non-zero exit):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"command": "<command_name>",
|
||||||
|
"error": "Authentication failed. Your API key may be invalid or expired.",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
- `status`: Always `"success"` (errors go to stderr before exit).
|
- `status`: `"success"` or `"error"`.
|
||||||
- `command`: The command name (e.g. `"status"`, `"config show"`, `"import"`).
|
- `command`: The command name.
|
||||||
- `duration_ms`: Optional, elapsed time in milliseconds.
|
- `duration_ms`: Optional, elapsed time in milliseconds.
|
||||||
- `scope`: Optional, active entity scope.
|
- `scope`: Optional, active entity scope (omitted if empty).
|
||||||
- `count`: Optional, result count.
|
- `count`: Optional, result count.
|
||||||
- `error`: Optional, error message string.
|
- `error`: Only present when `status` is `"error"`.
|
||||||
- `data`: The primary payload.
|
- `data`: The primary payload (sanitized in agent mode).
|
||||||
|
|
||||||
### 7.3 Text Output
|
### 7.3 Text Output
|
||||||
|
|
||||||
@@ -1219,6 +1369,16 @@ Destructive commands (`delete --all`, `delete --entity`, `entity delete`) requir
|
|||||||
- CI/CD pipelines
|
- CI/CD pipelines
|
||||||
- Scripting
|
- Scripting
|
||||||
|
|
||||||
|
### Why `--json`/`--agent` global flags exist
|
||||||
|
|
||||||
|
The `--json` and `--agent` flags (aliases of each other) activate agent mode globally. When set:
|
||||||
|
1. All output becomes a structured JSON envelope (`{status, command, duration_ms, scope, count, data}`).
|
||||||
|
2. The `data` field is sanitized via `sanitizeAgentData` — only the most relevant fields are included per command, reducing noise for agents parsing the output.
|
||||||
|
3. All human-readable output (spinners, colors, banners, timing lines) is suppressed.
|
||||||
|
4. Errors are emitted as JSON to stdout with a non-zero exit code, not to stderr as text.
|
||||||
|
|
||||||
|
This is distinct from `--output json`, which returns the raw API response without sanitization.
|
||||||
|
|
||||||
### Why `--output json` is on every command
|
### Why `--output json` is on every command
|
||||||
|
|
||||||
Every data-returning command supports `--output json` (or `--json` for `help`). This enables machine consumption by AI agents and scripts. JSON output goes to stdout while human-readable spinners/timing go to stderr, so piping `mem0 list -o json | jq .` works cleanly.
|
Every data-returning command supports `--output json` (or `--json` for `help`). This enables machine consumption by AI agents and scripts. JSON output goes to stdout while human-readable spinners/timing go to stderr, so piping `mem0 list -o json | jq .` works cleanly.
|
||||||
|
|||||||
+38
-6
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. Works with the Mem0 Platform API. Available in Python and Node.js.
|
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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -55,12 +57,45 @@ mem0 delete <memory-id>
|
|||||||
| `mem0 delete` | Delete a memory, all memories for a scope, or an entity |
|
| `mem0 delete` | Delete a memory, all memories for a scope, or an entity |
|
||||||
| `mem0 import` | Bulk import memories from a JSON file |
|
| `mem0 import` | Bulk import memories from a JSON file |
|
||||||
| `mem0 config` | View or modify CLI configuration |
|
| `mem0 config` | View or modify CLI configuration |
|
||||||
| `mem0 entities` | List or delete entities (users, agents, apps) |
|
| `mem0 entity` | List or delete entities (users, agents, apps, runs) |
|
||||||
|
| `mem0 event` | Inspect background processing events (bulk deletes, large add jobs) |
|
||||||
| `mem0 status` | Verify API connection and display current project |
|
| `mem0 status` | Verify API connection and display current project |
|
||||||
| `mem0 version` | Print the CLI version |
|
| `mem0 version` | Print the CLI version |
|
||||||
|
|
||||||
Run `mem0 <command> --help` for detailed usage on any command.
|
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
|
## Output formats
|
||||||
|
|
||||||
Control how results are displayed with `--output`:
|
Control how results are displayed with `--output`:
|
||||||
@@ -68,13 +103,10 @@ Control how results are displayed with `--output`:
|
|||||||
| Format | Description |
|
| Format | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `text` | Human-readable with colors and formatting (default) |
|
| `text` | Human-readable with colors and formatting (default) |
|
||||||
| `json` | Structured JSON for piping to `jq` or agent consumption |
|
| `json` | Structured JSON for piping to `jq` (raw API response) |
|
||||||
| `table` | Tabular format (default for `list`) |
|
| `table` | Tabular format (default for `list`) |
|
||||||
| `quiet` | Minimal — just IDs or status codes |
|
| `quiet` | Minimal — just IDs or status codes |
|
||||||
|
| `agent` | Structured JSON envelope with sanitized fields (set by `--agent`/`--json`) |
|
||||||
```bash
|
|
||||||
mem0 search "preferences" --user-id alice --output json | jq '.data.results[].memory'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -503,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "init",
|
"name": "init",
|
||||||
"description": "Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
|
"description": "Setup wizard for mem0 CLI. Supports Agent Mode bootstrap (--agent), email login (--email), or manual API key (--api-key).",
|
||||||
"usage": "mem0 init [OPTIONS]",
|
"usage": "mem0 init [OPTIONS]",
|
||||||
"needsBackend": false,
|
"needsBackend": false,
|
||||||
"needsConfig": false,
|
"needsConfig": false,
|
||||||
@@ -515,7 +515,10 @@
|
|||||||
{ "name": "api-key", "flags": ["--api-key"], "type": "string", "default": null, "help": "API key (skip prompt)." },
|
{ "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": "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": "email", "flags": ["--email"], "type": "string", "default": null, "help": "Login via email verification code." },
|
||||||
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." }
|
{ "name": "code", "flags": ["--code"], "type": "string", "default": null, "help": "Verification code (use with --email for non-interactive login)." },
|
||||||
|
{ "name": "force", "flags": ["--force"], "type": "boolean", "default": false, "help": "Overwrite existing config without confirmation." },
|
||||||
|
{ "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)." }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 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.8] — 2026-06-01
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Pinned transitive dependencies via pnpm overrides to remediate high-severity CVEs:
|
||||||
|
- `jws` → 4.0.1 (CVE-2025-65945)
|
||||||
|
- `langsmith` → ^0.6.0 (CVE-2026-45134)
|
||||||
|
- `tar-fs` → ^2.1.4 (CVE-2025-48387, CVE-2025-59343)
|
||||||
|
- `picomatch` → ^2.3.2 (CVE-2026-33671)
|
||||||
|
- `minimatch` → ^3.1.3 / ^5.1.8 / ^9.0.7 (CVE-2026-27903, CVE-2026-27904, CVE-2026-26996)
|
||||||
|
- `path-to-regexp` → ^8.4.0 (CVE-2026-4926)
|
||||||
|
- `rollup` → ^4.59.0 (CVE-2026-27606)
|
||||||
|
- `glob` → ^10.5.0 (CVE-2025-64756)
|
||||||
|
- `@modelcontextprotocol/sdk` → ^1.25.4 (CVE-2025-66414, CVE-2026-0621)
|
||||||
|
|
||||||
|
## [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/`.
|
||||||
+299
-37
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents. TypeScript implementation.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js **18+**
|
- Node.js **18+**
|
||||||
- pnpm (`npm install -g pnpm`)
|
- pnpm (`npm install -g pnpm`) — for development only
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -13,22 +15,307 @@ The official command-line interface for [mem0](https://mem0.ai) — the memory l
|
|||||||
npm install -g @mem0/cli
|
npm install -g @mem0/cli
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from source:
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd node
|
# Interactive setup wizard
|
||||||
pnpm install
|
mem0 init
|
||||||
pnpm build
|
|
||||||
pnpm link --global
|
|
||||||
|
|
||||||
# Now use it like a normal CLI
|
# Or login via email
|
||||||
mem0 --help
|
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>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running during development
|
## Commands
|
||||||
|
|
||||||
|
### `mem0 init`
|
||||||
|
|
||||||
|
Interactive setup wizard. Prompts for your API key and default user ID.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd node
|
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
|
pnpm install
|
||||||
|
|
||||||
# Development mode (runs TypeScript directly, no build needed)
|
# Development mode (runs TypeScript directly, no build needed)
|
||||||
@@ -39,36 +326,11 @@ pnpm dev search "test" --user-id alice
|
|||||||
# Or build first, then run the compiled JS
|
# Or build first, then run the compiled JS
|
||||||
pnpm build
|
pnpm build
|
||||||
node dist/index.js --help
|
node dist/index.js --help
|
||||||
node dist/index.js add "test memory" --user-id alice
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Documentation
|
||||||
|
|
||||||
```bash
|
Full documentation is available at [docs.mem0.ai/platform/cli](https://docs.mem0.ai/platform/cli).
|
||||||
# Set up your configuration
|
|
||||||
mem0 init
|
|
||||||
|
|
||||||
# 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
|
|
||||||
mem0 list --user-id alice
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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) |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/cli",
|
"name": "@mem0/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.2.8",
|
||||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -20,6 +20,11 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"author": "mem0.ai <founders@mem0.ai>",
|
"author": "mem0.ai <founders@mem0.ai>",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mem0ai/mem0",
|
||||||
|
"directory": "cli/node"
|
||||||
|
},
|
||||||
"keywords": ["mem0", "memory", "ai", "agents", "cli"],
|
"keywords": ["mem0", "memory", "ai", "agents", "cli"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
@@ -35,7 +40,8 @@
|
|||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"vitest": "^1.5.0",
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
"@biomejs/biome": "^1.7.0",
|
"@biomejs/biome": "^1.7.0",
|
||||||
"@types/node": "^20.0.0"
|
"@types/node": "^20.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+364
-493
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@biomejs/biome"
|
||||||
|
- esbuild
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
jws@4.0.0: 4.0.1
|
||||||
|
langsmith@<0.6.0: ^0.6.0
|
||||||
|
tar-fs@>=2.0.0 <2.1.4: ^2.1.4
|
||||||
|
picomatch@<2.3.2: ^2.3.2
|
||||||
|
"postcss@<8.5.10": ">=8.5.10"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ export interface AddOptions {
|
|||||||
infer?: boolean;
|
infer?: boolean;
|
||||||
expires?: string;
|
expires?: string;
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
enableGraph?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchOptions {
|
export interface SearchOptions {
|
||||||
@@ -29,7 +28,6 @@ export interface SearchOptions {
|
|||||||
keyword?: boolean;
|
keyword?: boolean;
|
||||||
filters?: Record<string, unknown>;
|
filters?: Record<string, unknown>;
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
enableGraph?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListOptions {
|
export interface ListOptions {
|
||||||
@@ -42,7 +40,6 @@ export interface ListOptions {
|
|||||||
category?: string;
|
category?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
enableGraph?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteOptions {
|
export interface DeleteOptions {
|
||||||
@@ -89,11 +86,17 @@ export interface Backend {
|
|||||||
|
|
||||||
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
deleteEntities(opts: EntityIds): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
ping(): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
status(opts?: { userId?: string; agentId?: string }): Promise<
|
status(opts?: { userId?: string; agentId?: string }): Promise<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
entities(entityType: 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 {
|
export class AuthError extends Error {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlatformConfig } from "../config.js";
|
import type { PlatformConfig } from "../config.js";
|
||||||
|
import { captureNotice, isAgentMode } from "../state.js";
|
||||||
|
import { CLI_VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
APIError,
|
APIError,
|
||||||
type AddOptions,
|
type AddOptions,
|
||||||
@@ -24,6 +26,9 @@ export class PlatformBackend implements Backend {
|
|||||||
this.headers = {
|
this.headers = {
|
||||||
Authorization: `Token ${config.apiKey}`,
|
Authorization: `Token ${config.apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "node",
|
||||||
|
"X-Mem0-Client-Version": CLI_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +43,14 @@ export class PlatformBackend implements Backend {
|
|||||||
url += `?${qs}`;
|
url += `?${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...this.headers,
|
||||||
|
"X-Mem0-Caller-Type": isAgentMode() ? "agent" : "user",
|
||||||
|
};
|
||||||
|
|
||||||
const fetchOpts: RequestInit = {
|
const fetchOpts: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: this.headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
};
|
};
|
||||||
if (opts?.json) {
|
if (opts?.json) {
|
||||||
@@ -80,7 +90,39 @@ export class PlatformBackend implements Backend {
|
|||||||
if (resp.status === 204) {
|
if (resp.status === 204) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return resp.json();
|
|
||||||
|
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(
|
async add(
|
||||||
@@ -105,9 +147,9 @@ export class PlatformBackend implements Backend {
|
|||||||
if (opts.infer === false) payload.infer = false;
|
if (opts.infer === false) payload.infer = false;
|
||||||
if (opts.expires) payload.expiration_date = opts.expires;
|
if (opts.expires) payload.expiration_date = opts.expires;
|
||||||
if (opts.categories) payload.categories = opts.categories;
|
if (opts.categories) payload.categories = opts.categories;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
payload.source = "CLI";
|
||||||
|
|
||||||
return (await this._request("POST", "/v1/memories/", {
|
return (await this._request("POST", "/v3/memories/add/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -165,9 +207,9 @@ export class PlatformBackend implements Backend {
|
|||||||
if (opts.rerank) payload.rerank = true;
|
if (opts.rerank) payload.rerank = true;
|
||||||
if (opts.keyword) payload.keyword_search = true;
|
if (opts.keyword) payload.keyword_search = true;
|
||||||
if (opts.fields) payload.fields = opts.fields;
|
if (opts.fields) payload.fields = opts.fields;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v2/memories/search/", {
|
const result = (await this._request("POST", "/v3/memories/search/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
})) as unknown;
|
})) as unknown;
|
||||||
if (Array.isArray(result)) return result;
|
if (Array.isArray(result)) return result;
|
||||||
@@ -176,10 +218,9 @@ export class PlatformBackend implements Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||||
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record<
|
return (await this._request("GET", `/v1/memories/${memoryId}/`, {
|
||||||
string,
|
params: { source: "CLI" },
|
||||||
unknown
|
})) as Record<string, unknown>;
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMemories(
|
async listMemories(
|
||||||
@@ -216,9 +257,9 @@ export class PlatformBackend implements Backend {
|
|||||||
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
|
||||||
});
|
});
|
||||||
if (apiFilters) payload.filters = apiFilters;
|
if (apiFilters) payload.filters = apiFilters;
|
||||||
if (opts.enableGraph) payload.enable_graph = true;
|
payload.source = "CLI";
|
||||||
|
|
||||||
const result = (await this._request("POST", "/v2/memories/", {
|
const result = (await this._request("POST", "/v3/memories/", {
|
||||||
json: payload,
|
json: payload,
|
||||||
params,
|
params,
|
||||||
})) as unknown;
|
})) as unknown;
|
||||||
@@ -235,6 +276,7 @@ export class PlatformBackend implements Backend {
|
|||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (content) payload.text = content;
|
if (content) payload.text = content;
|
||||||
if (metadata) payload.metadata = metadata;
|
if (metadata) payload.metadata = metadata;
|
||||||
|
payload.source = "CLI";
|
||||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||||
json: payload,
|
json: payload,
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
@@ -245,7 +287,7 @@ export class PlatformBackend implements Backend {
|
|||||||
opts: DeleteOptions = {},
|
opts: DeleteOptions = {},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (opts.all) {
|
if (opts.all) {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = { source: "CLI" };
|
||||||
if (opts.userId) params.user_id = opts.userId;
|
if (opts.userId) params.user_id = opts.userId;
|
||||||
if (opts.agentId) params.agent_id = opts.agentId;
|
if (opts.agentId) params.agent_id = opts.agentId;
|
||||||
if (opts.appId) params.app_id = opts.appId;
|
if (opts.appId) params.app_id = opts.appId;
|
||||||
@@ -255,50 +297,46 @@ export class PlatformBackend implements Backend {
|
|||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
if (memoryId) {
|
if (memoryId) {
|
||||||
return (await this._request(
|
return (await this._request("DELETE", `/v1/memories/${memoryId}/`, {
|
||||||
"DELETE",
|
params: { source: "CLI" },
|
||||||
`/v1/memories/${memoryId}/`,
|
})) as Record<string, unknown>;
|
||||||
)) as Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
throw new Error("Either memoryId or --all is required");
|
throw new Error("Either memoryId or --all is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
async deleteEntities(opts: EntityIds): Promise<Record<string, unknown>> {
|
||||||
const params: Record<string, string> = {};
|
// v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
|
||||||
if (opts.userId) params.user_id = opts.userId;
|
const typeMap: [string, string | undefined][] = [
|
||||||
if (opts.agentId) params.agent_id = opts.agentId;
|
["user", opts.userId],
|
||||||
if (opts.appId) params.app_id = opts.appId;
|
["agent", opts.agentId],
|
||||||
if (opts.runId) params.run_id = opts.runId;
|
["app", opts.appId],
|
||||||
if (Object.keys(params).length === 0) {
|
["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.");
|
throw new Error("At least one entity ID is required for deleteEntities.");
|
||||||
}
|
}
|
||||||
return (await this._request("DELETE", "/v1/entities/", {
|
// Delete each provided entity via the v2 path-based endpoint
|
||||||
params,
|
let result: Record<string, unknown> = {};
|
||||||
})) as 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(
|
async status(
|
||||||
opts: { userId?: string; agentId?: string } = {},
|
opts: { userId?: string; agentId?: string } = {},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
if (opts.userId || opts.agentId) {
|
await this.ping();
|
||||||
const payload: Record<string, unknown> = {};
|
|
||||||
const statusParams: Record<string, string> = {
|
|
||||||
page: "1",
|
|
||||||
page_size: "1",
|
|
||||||
};
|
|
||||||
const apiFilters = this._buildFilters({
|
|
||||||
userId: opts.userId,
|
|
||||||
agentId: opts.agentId,
|
|
||||||
});
|
|
||||||
if (apiFilters) payload.filters = apiFilters;
|
|
||||||
await this._request("POST", "/v2/memories/", {
|
|
||||||
json: payload,
|
|
||||||
params: statusParams,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this._request("GET", "/v1/entities/");
|
|
||||||
}
|
|
||||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
@@ -335,4 +373,20 @@ export class PlatformBackend implements Backend {
|
|||||||
}
|
}
|
||||||
return items;
|
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
|
||||||
|
>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import ora, { type Ora } from "ora";
|
import ora, { type Ora } from "ora";
|
||||||
|
import { getCurrentCommand, isAgentMode } from "./state.js";
|
||||||
import { CLI_VERSION } from "./version.js";
|
import { CLI_VERSION } from "./version.js";
|
||||||
|
|
||||||
export const LOGO = `
|
export const LOGO = `
|
||||||
@@ -42,6 +43,7 @@ export function sym(fancy: string, plain: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function printBanner(): void {
|
export function printBanner(): void {
|
||||||
|
if (isAgentMode()) return;
|
||||||
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
|
const pad = 3; // horizontal padding each side (matches Rich's padding=(0, 2))
|
||||||
const logoLines = LOGO.trimEnd().split("\n");
|
const logoLines = LOGO.trimEnd().split("\n");
|
||||||
const tagline = ` ${TAGLINE}`;
|
const tagline = ` ${TAGLINE}`;
|
||||||
@@ -75,13 +77,29 @@ export function printBanner(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function printSuccess(message: string): void {
|
export function printSuccess(message: string): void {
|
||||||
|
if (isAgentMode()) return;
|
||||||
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
|
console.log(`${success(sym("✓", "[ok]"))} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function printError(message: string, hint?: string): void {
|
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}`);
|
console.error(`${error(`${sym("✗", "[error]")} Error:`)} ${message}`);
|
||||||
if (hint) {
|
const resolvedHint =
|
||||||
console.error(` ${dim(hint)}`);
|
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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,15 +108,16 @@ export function printWarning(message: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function printInfo(message: string): void {
|
export function printInfo(message: string): void {
|
||||||
|
if (isAgentMode()) return;
|
||||||
console.error(`${brand(sym("◆", "*"))} ${message}`);
|
console.error(`${brand(sym("◆", "*"))} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function printScope(ids: Record<string, string | undefined>): void {
|
export function printScope(ids: Record<string, string | undefined>): void {
|
||||||
|
if (isAgentMode()) return;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const [key, val] of Object.entries(ids)) {
|
for (const [key, val] of Object.entries(ids)) {
|
||||||
if (val) {
|
if (val) {
|
||||||
const label = key.replace(/_/g, " ").replace("id", "ID").trim();
|
parts.push(`${key}=${val}`);
|
||||||
parts.push(`${label}=${val}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
@@ -119,10 +138,14 @@ export async function timedStatus<T>(
|
|||||||
message: string,
|
message: string,
|
||||||
fn: (ctx: TimedStatusContext) => Promise<T>,
|
fn: (ctx: TimedStatusContext) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
if (isAgentMode()) {
|
||||||
|
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||||
|
return fn(ctx);
|
||||||
|
}
|
||||||
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
const ctx: TimedStatusContext = { successMsg: "", errorMsg: "" };
|
||||||
const spinner = ora({
|
const spinner = ora({
|
||||||
text: dim(message),
|
text: dim(message),
|
||||||
color: "magenta",
|
color: "yellow",
|
||||||
stream: process.stderr,
|
stream: process.stderr,
|
||||||
}).start();
|
}).start();
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -139,7 +162,7 @@ export async function timedStatus<T>(
|
|||||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||||
spinner.stop();
|
spinner.stop();
|
||||||
if (ctx.errorMsg) {
|
if (ctx.errorMsg) {
|
||||||
console.error(`${error("✗ Error:")} ${ctx.errorMsg} (${elapsed}s)`);
|
printError(`${ctx.errorMsg} (${elapsed}s)`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,15 +11,17 @@ import {
|
|||||||
saveConfig,
|
saveConfig,
|
||||||
setNestedValue,
|
setNestedValue,
|
||||||
} from "../config.js";
|
} from "../config.js";
|
||||||
import { formatJsonEnvelope } from "../output.js";
|
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
||||||
|
import { isAgentMode, setCurrentCommand } from "../state.js";
|
||||||
|
|
||||||
const { brand, accent, dim } = colors;
|
const { brand, accent, dim } = colors;
|
||||||
|
|
||||||
export function cmdConfigShow(opts: { output?: string } = {}): void {
|
export function cmdConfigShow(opts: { output?: string } = {}): void {
|
||||||
|
setCurrentCommand("config show");
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent" || opts.output === "json") {
|
||||||
formatJsonEnvelope({
|
formatAgentEnvelope({
|
||||||
command: "config show",
|
command: "config show",
|
||||||
data: {
|
data: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -27,7 +29,6 @@ export function cmdConfigShow(opts: { output?: string } = {}): void {
|
|||||||
agent_id: config.defaults.agentId || null,
|
agent_id: config.defaults.agentId || null,
|
||||||
app_id: config.defaults.appId || null,
|
app_id: config.defaults.appId || null,
|
||||||
run_id: config.defaults.runId || null,
|
run_id: config.defaults.runId || null,
|
||||||
enable_graph: config.defaults.enableGraph,
|
|
||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
api_key: redactKey(config.platform.apiKey),
|
api_key: redactKey(config.platform.apiKey),
|
||||||
@@ -54,7 +55,6 @@ export function cmdConfigShow(opts: { output?: string } = {}): void {
|
|||||||
]);
|
]);
|
||||||
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
|
table.push(["defaults.app_id", config.defaults.appId || dim("(not set)")]);
|
||||||
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
|
table.push(["defaults.run_id", config.defaults.runId || dim("(not set)")]);
|
||||||
table.push(["defaults.enable_graph", String(config.defaults.enableGraph)]);
|
|
||||||
table.push(["", ""]);
|
table.push(["", ""]);
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
@@ -66,6 +66,7 @@ export function cmdConfigShow(opts: { output?: string } = {}): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cmdConfigGet(key: string): void {
|
export function cmdConfigGet(key: string): void {
|
||||||
|
setCurrentCommand("config get");
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const value = getNestedValue(config, key);
|
const value = getNestedValue(config, key);
|
||||||
|
|
||||||
@@ -73,20 +74,35 @@ export function cmdConfigGet(key: string): void {
|
|||||||
printError(`Unknown config key: ${key}`);
|
printError(`Unknown config key: ${key}`);
|
||||||
} else {
|
} else {
|
||||||
// Redact secrets
|
// Redact secrets
|
||||||
if (key.includes("api_key") || key.split(".").pop() === "key") {
|
const displayValue =
|
||||||
console.log(redactKey(String(value)));
|
key.includes("api_key") || key.split(".").pop() === "key"
|
||||||
|
? redactKey(String(value))
|
||||||
|
: String(value);
|
||||||
|
if (isAgentMode()) {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "config get",
|
||||||
|
data: { key, value: displayValue },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(String(value));
|
console.log(displayValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cmdConfigSet(key: string, value: string): void {
|
export function cmdConfigSet(key: string, value: string): void {
|
||||||
|
setCurrentCommand("config set");
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
if (setNestedValue(config, key, value)) {
|
if (setNestedValue(config, key, value)) {
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
const display = key.includes("key") ? redactKey(value) : value;
|
const display = key.includes("key") ? redactKey(value) : value;
|
||||||
printSuccess(`${key} = ${display}`);
|
if (isAgentMode()) {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "config set",
|
||||||
|
data: { key, value: display },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
printSuccess(`${key} = ${display}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
printError(`Unknown config key: ${key}`);
|
printError(`Unknown config key: ${key}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
printSuccess,
|
printSuccess,
|
||||||
timedStatus,
|
timedStatus,
|
||||||
} from "../branding.js";
|
} from "../branding.js";
|
||||||
import { formatJson } from "../output.js";
|
import { formatAgentEnvelope, formatJson } from "../output.js";
|
||||||
|
import { setCurrentCommand } from "../state.js";
|
||||||
|
|
||||||
const { brand, accent, dim } = colors;
|
const { brand, accent, dim } = colors;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export async function cmdEntitiesList(
|
|||||||
entityType: string,
|
entityType: string,
|
||||||
opts: { output: string },
|
opts: { output: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("entity list");
|
||||||
if (!VALID_TYPES.has(entityType)) {
|
if (!VALID_TYPES.has(entityType)) {
|
||||||
printError(
|
printError(
|
||||||
`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
|
`Invalid entity type: ${entityType}. Use: ${[...VALID_TYPES].join(", ")}`,
|
||||||
@@ -45,8 +47,13 @@ export async function cmdEntitiesList(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent" || opts.output === "json") {
|
||||||
formatJson(results);
|
formatAgentEnvelope({
|
||||||
|
command: "entity list",
|
||||||
|
data: results,
|
||||||
|
count: results.length,
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +93,12 @@ export async function cmdEntitiesDelete(
|
|||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): 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) {
|
if (!opts.userId && !opts.agentId && !opts.appId && !opts.runId) {
|
||||||
printError(
|
printError(
|
||||||
"Provide at least one of --user-id, --agent-id, --app-id, --run-id.",
|
"Provide at least one of --user-id, --agent-id, --app-id, --run-id.",
|
||||||
@@ -93,27 +106,20 @@ export async function cmdEntitiesDelete(
|
|||||||
process.exit(1);
|
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) {
|
if (opts.dryRun) {
|
||||||
const scopeParts: string[] = [];
|
printInfo(`Would delete entity ${scope} and all its memories.`);
|
||||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
|
||||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
|
||||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
|
||||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
|
||||||
printInfo(
|
|
||||||
`Would delete entity ${scopeParts.join(", ")} and all its memories.`,
|
|
||||||
);
|
|
||||||
printInfo("No changes made.");
|
printInfo("No changes made.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opts.force) {
|
if (!opts.force) {
|
||||||
const scopeParts: string[] = [];
|
|
||||||
if (opts.userId) scopeParts.push(`user=${opts.userId}`);
|
|
||||||
if (opts.agentId) scopeParts.push(`agent=${opts.agentId}`);
|
|
||||||
if (opts.appId) scopeParts.push(`app=${opts.appId}`);
|
|
||||||
if (opts.runId) scopeParts.push(`run=${opts.runId}`);
|
|
||||||
const scope = scopeParts.join(", ");
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
output: process.stdout,
|
output: process.stdout,
|
||||||
@@ -148,7 +154,13 @@ export async function cmdEntitiesDelete(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "entity delete",
|
||||||
|
data: { deleted: true },
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
|
} else if (opts.output === "json") {
|
||||||
formatJson(result);
|
formatJson(result);
|
||||||
} else if (opts.output !== "quiet") {
|
} else if (opts.output !== "quiet") {
|
||||||
printSuccess(`Entity deleted with all memories (${elapsed.toFixed(2)}s)`);
|
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}.`);
|
||||||
|
}
|
||||||
+245
-16
@@ -2,6 +2,7 @@
|
|||||||
* mem0 init — interactive setup wizard.
|
* mem0 init — interactive setup wizard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { PlatformBackend } from "../backend/platform.js";
|
import { PlatformBackend } from "../backend/platform.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +13,16 @@ import {
|
|||||||
printSuccess,
|
printSuccess,
|
||||||
} from "../branding.js";
|
} from "../branding.js";
|
||||||
import {
|
import {
|
||||||
|
CONFIG_FILE,
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
type Mem0Config,
|
type Mem0Config,
|
||||||
createDefaultConfig,
|
createDefaultConfig,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
redactKey,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
} from "../config.js";
|
} from "../config.js";
|
||||||
|
import { formatJsonEnvelope } from "../output.js";
|
||||||
|
import { isAgentMode } from "../state.js";
|
||||||
|
|
||||||
const { brand, dim } = colors;
|
const { brand, dim } = colors;
|
||||||
|
|
||||||
@@ -30,6 +35,65 @@ function validateEmail(email: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal — exported for unit tests. */
|
||||||
|
export async function pingKey(
|
||||||
|
apiKey: string,
|
||||||
|
baseUrl: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Returns false ONLY on a definitive "invalid key" signal (HTTP 401/403).
|
||||||
|
// Network errors, timeouts, and 5xx responses return true so we prefer
|
||||||
|
// reusing an existing key over silently minting a new shadow on a transient
|
||||||
|
// blip (which would also clobber config + plugin-sync targets).
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/ping/`, {
|
||||||
|
headers: { Authorization: `Token ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
return resp.status !== 401 && resp.status !== 403;
|
||||||
|
} catch {
|
||||||
|
return true; // unknown — prefer reuse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeIdentify(
|
||||||
|
key: string,
|
||||||
|
baseUrl: string,
|
||||||
|
agentCaller: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
// Best-effort PATCH agent_caller when --agent-caller is supplied on a
|
||||||
|
// reused key. Silent no-op on any failure — reuse must not break.
|
||||||
|
if (!agentCaller) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${baseUrl.replace(/\/+$/, "")}/api/v1/auth/agent_mode/caller/`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${key}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ agent_caller: agentCaller }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (resp.ok) {
|
||||||
|
try {
|
||||||
|
const body = (await resp.json()) as { agent_caller?: string };
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
cfg.platform.agentCaller = body.agent_caller ?? agentCaller;
|
||||||
|
saveConfig(cfg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* swallow — best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* swallow — best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function emailLogin(
|
async function emailLogin(
|
||||||
email: string,
|
email: string,
|
||||||
code: string | undefined,
|
code: string | undefined,
|
||||||
@@ -38,10 +102,16 @@ async function emailLogin(
|
|||||||
const url = baseUrl.replace(/\/+$/, "");
|
const url = baseUrl.replace(/\/+$/, "");
|
||||||
let codeValue = code;
|
let codeValue = code;
|
||||||
|
|
||||||
|
const sourceHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "node",
|
||||||
|
};
|
||||||
|
|
||||||
if (!codeValue) {
|
if (!codeValue) {
|
||||||
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: sourceHeaders,
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
@@ -82,7 +152,7 @@ async function emailLogin(
|
|||||||
|
|
||||||
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: sourceHeaders,
|
||||||
body: JSON.stringify({ email, code: codeValue.trim() }),
|
body: JSON.stringify({ email, code: codeValue.trim() }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
@@ -176,7 +246,7 @@ function promptLine(label: string, defaultValue?: string): Promise<string> {
|
|||||||
async function setupPlatform(config: Mem0Config): Promise<void> {
|
async function setupPlatform(config: Mem0Config): Promise<void> {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(
|
console.log(
|
||||||
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys")}`,
|
` ${dim("Get your API key at https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-node")}`,
|
||||||
);
|
);
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
@@ -187,15 +257,17 @@ async function setupPlatform(config: Mem0Config): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
config.platform.apiKey = apiKey;
|
config.platform.apiKey = apiKey;
|
||||||
|
config.platform.createdVia = "api_key";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupDefaults(config: Mem0Config): Promise<void> {
|
async function setupDefaults(config: Mem0Config): Promise<void> {
|
||||||
console.log();
|
console.log();
|
||||||
printInfo("Set default entity IDs (press Enter to skip).\n");
|
printInfo("Set default entity IDs (press Enter to skip).\n");
|
||||||
|
|
||||||
|
const _systemUser = process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
const userId = await promptLine(
|
const userId = await promptLine(
|
||||||
` ${brand("Default User ID")} ${dim("(recommended)")}`,
|
` ${brand("Default User ID")} ${dim("(recommended)")}`,
|
||||||
"mem0-cli",
|
_systemUser,
|
||||||
);
|
);
|
||||||
if (userId) config.defaults.userId = userId;
|
if (userId) config.defaults.userId = userId;
|
||||||
}
|
}
|
||||||
@@ -211,10 +283,20 @@ async function validatePlatform(config: Mem0Config): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (status.connected) {
|
if (status.connected) {
|
||||||
printSuccess("Connected to mem0 Platform!");
|
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 {
|
} else {
|
||||||
printError(
|
printError(
|
||||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||||
"Check your API key and try again.",
|
"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) {
|
} catch (e) {
|
||||||
@@ -228,14 +310,36 @@ export async function runInit(
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
|
force?: boolean;
|
||||||
|
agent?: boolean;
|
||||||
|
source?: string;
|
||||||
|
agentCaller?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): 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 config = createDefaultConfig();
|
||||||
const savedConfig = loadConfig();
|
const savedConfig = loadConfig();
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
process.env.MEM0_BASE_URL ||
|
process.env.MEM0_BASE_URL ||
|
||||||
savedConfig.platform.baseUrl ||
|
savedConfig.platform.baseUrl ||
|
||||||
DEFAULT_BASE_URL;
|
DEFAULT_BASE_URL;
|
||||||
|
config.platform.baseUrl = baseUrl;
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
if (opts.code && !opts.email) {
|
if (opts.code && !opts.email) {
|
||||||
@@ -247,6 +351,118 @@ export async function runInit(
|
|||||||
process.exit(1);
|
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 ──────────────────────────────────────────────────────
|
// ── Email login flow ──────────────────────────────────────────────────────
|
||||||
if (opts.email) {
|
if (opts.email) {
|
||||||
const email = opts.email.trim().toLowerCase();
|
const email = opts.email.trim().toLowerCase();
|
||||||
@@ -268,7 +484,10 @@ export async function runInit(
|
|||||||
|
|
||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
config.defaults.userId = opts.userId || "mem0-cli";
|
config.platform.userEmail = email;
|
||||||
|
config.platform.createdVia = "email";
|
||||||
|
config.defaults.userId =
|
||||||
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
console.log();
|
console.log();
|
||||||
@@ -282,10 +501,26 @@ export async function runInit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── API key flow ──────────────────────────────────────────────────────────
|
// ── 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
|
// Non-interactive: both flags provided
|
||||||
if (opts.apiKey && opts.userId) {
|
if (opts.apiKey && opts.userId) {
|
||||||
config.platform.apiKey = opts.apiKey;
|
config.platform.apiKey = opts.apiKey;
|
||||||
|
config.platform.createdVia = "api_key";
|
||||||
config.defaults.userId = opts.userId;
|
config.defaults.userId = opts.userId;
|
||||||
await validatePlatform(config);
|
await validatePlatform(config);
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
@@ -293,15 +528,6 @@ export async function runInit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-TTY without full flags: error with usage hint
|
|
||||||
if (!process.stdin.isTTY && (!opts.apiKey || !opts.userId)) {
|
|
||||||
printError(
|
|
||||||
"Non-interactive terminal detected and missing required flags.",
|
|
||||||
"Usage: mem0 init --api-key <key> --user-id <id>",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
printBanner();
|
printBanner();
|
||||||
console.log();
|
console.log();
|
||||||
printInfo("Welcome! Let's set up your mem0 CLI.\n");
|
printInfo("Welcome! Let's set up your mem0 CLI.\n");
|
||||||
@@ -341,7 +567,10 @@ export async function runInit(
|
|||||||
|
|
||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
config.defaults.userId = opts.userId || "mem0-cli";
|
config.platform.userEmail = email;
|
||||||
|
config.platform.createdVia = "email";
|
||||||
|
config.defaults.userId =
|
||||||
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
+138
-35
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../branding.js";
|
} from "../branding.js";
|
||||||
import {
|
import {
|
||||||
formatAddResult,
|
formatAddResult,
|
||||||
|
formatAgentEnvelope,
|
||||||
formatJson,
|
formatJson,
|
||||||
formatJsonEnvelope,
|
formatJsonEnvelope,
|
||||||
formatMemoriesTable,
|
formatMemoriesTable,
|
||||||
@@ -20,6 +21,18 @@ import {
|
|||||||
formatSingleMemory,
|
formatSingleMemory,
|
||||||
printResultSummary,
|
printResultSummary,
|
||||||
} from "../output.js";
|
} 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(
|
export async function cmdAdd(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
@@ -33,13 +46,13 @@ export async function cmdAdd(
|
|||||||
file?: string;
|
file?: string;
|
||||||
metadata?: string;
|
metadata?: string;
|
||||||
immutable: boolean;
|
immutable: boolean;
|
||||||
noInfer: boolean;
|
infer?: boolean;
|
||||||
expires?: string;
|
expires?: string;
|
||||||
categories?: string;
|
categories?: string;
|
||||||
enableGraph: boolean;
|
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("add");
|
||||||
let msgs: Record<string, unknown>[] | undefined;
|
let msgs: Record<string, unknown>[] | undefined;
|
||||||
let content = text;
|
let content = text;
|
||||||
|
|
||||||
@@ -64,8 +77,8 @@ export async function cmdAdd(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Read from stdin if piped
|
// Read from stdin only if stdin is an actual pipe or file redirect
|
||||||
else if (!content && !process.stdin.isTTY) {
|
else if (!content && _stdinIsPiped()) {
|
||||||
content = fs.readFileSync(0, "utf-8").trim();
|
content = fs.readFileSync(0, "utf-8").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +136,9 @@ export async function cmdAdd(
|
|||||||
runId: opts.runId,
|
runId: opts.runId,
|
||||||
metadata: meta,
|
metadata: meta,
|
||||||
immutable: opts.immutable,
|
immutable: opts.immutable,
|
||||||
infer: !opts.noInfer,
|
infer: opts.infer !== false,
|
||||||
expires: opts.expires,
|
expires: opts.expires,
|
||||||
categories: cats,
|
categories: cats,
|
||||||
enableGraph: opts.enableGraph,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -136,8 +148,43 @@ export async function cmdAdd(
|
|||||||
|
|
||||||
if (opts.output === "quiet") return;
|
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") {
|
if (opts.output === "json") {
|
||||||
formatAddResult(result, opts.output);
|
formatAddResult(dedupedResult, opts.output);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,14 +195,18 @@ export async function cmdAdd(
|
|||||||
app_id: opts.appId,
|
app_id: opts.appId,
|
||||||
run_id: opts.runId,
|
run_id: opts.runId,
|
||||||
});
|
});
|
||||||
const results = Array.isArray(result)
|
const count = deduped.length;
|
||||||
? result
|
const allPending = count > 0 && deduped.every((r) => r.status === "PENDING");
|
||||||
: ((result.results as unknown[]) ?? [result]);
|
if (allPending) {
|
||||||
const count = results.length;
|
printSuccess(
|
||||||
printSuccess(
|
`Memory queued — ${count} event${count !== 1 ? "s" : ""} pending`,
|
||||||
`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`,
|
);
|
||||||
);
|
} else {
|
||||||
formatAddResult(result, opts.output);
|
printSuccess(
|
||||||
|
`Memory processed — ${count} memor${count === 1 ? "y" : "ies"} extracted`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
formatAddResult(dedupedResult, opts.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdSearch(
|
export async function cmdSearch(
|
||||||
@@ -172,10 +223,10 @@ export async function cmdSearch(
|
|||||||
keyword: boolean;
|
keyword: boolean;
|
||||||
filterJson?: string;
|
filterJson?: string;
|
||||||
fields?: string;
|
fields?: string;
|
||||||
enableGraph: boolean;
|
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("search");
|
||||||
if (!query) {
|
if (!query) {
|
||||||
printError("No query provided. Pass a query argument or pipe via stdin.");
|
printError("No query provided. Pass a query argument or pipe via stdin.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -220,7 +271,6 @@ export async function cmdSearch(
|
|||||||
keyword: opts.keyword,
|
keyword: opts.keyword,
|
||||||
filters,
|
filters,
|
||||||
fields: fieldList,
|
fields: fieldList,
|
||||||
enableGraph: opts.enableGraph,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -231,6 +281,23 @@ export async function cmdSearch(
|
|||||||
|
|
||||||
if (opts.output === "quiet") return;
|
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") {
|
if (opts.output === "json") {
|
||||||
formatJson(results);
|
formatJson(results);
|
||||||
} else if (opts.output === "table") {
|
} else if (opts.output === "table") {
|
||||||
@@ -267,6 +334,7 @@ export async function cmdGet(
|
|||||||
memoryId: string,
|
memoryId: string,
|
||||||
opts: { output: string },
|
opts: { output: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("get");
|
||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
result = await timedStatus("Fetching memory...", async () => {
|
result = await timedStatus("Fetching memory...", async () => {
|
||||||
@@ -277,7 +345,11 @@ export async function cmdGet(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
formatSingleMemory(result, opts.output);
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({ command: "get", data: result });
|
||||||
|
} else {
|
||||||
|
formatSingleMemory(result, opts.output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdList(
|
export async function cmdList(
|
||||||
@@ -292,10 +364,10 @@ export async function cmdList(
|
|||||||
category?: string;
|
category?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
enableGraph: boolean;
|
|
||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("list");
|
||||||
if (opts.pageSize < 1) {
|
if (opts.pageSize < 1) {
|
||||||
printError("--page-size must be >= 1.");
|
printError("--page-size must be >= 1.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -319,7 +391,6 @@ export async function cmdList(
|
|||||||
category: opts.category,
|
category: opts.category,
|
||||||
after: opts.after,
|
after: opts.after,
|
||||||
before: opts.before,
|
before: opts.before,
|
||||||
enableGraph: opts.enableGraph,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -330,12 +401,19 @@ export async function cmdList(
|
|||||||
|
|
||||||
if (opts.output === "quiet") return;
|
if (opts.output === "quiet") return;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent" || opts.output === "json") {
|
||||||
formatJsonEnvelope({
|
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",
|
command: "list",
|
||||||
data: results,
|
data: results,
|
||||||
|
scope,
|
||||||
count: results.length,
|
count: results.length,
|
||||||
scope: { user_id: opts.userId, agent_id: opts.agentId },
|
durationMs: Math.round(elapsed * 1000),
|
||||||
});
|
});
|
||||||
} else if (opts.output === "table") {
|
} else if (opts.output === "table") {
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
@@ -374,6 +452,7 @@ export async function cmdUpdate(
|
|||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
opts: { metadata?: string; output: string },
|
opts: { metadata?: string; output: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("update");
|
||||||
let meta: Record<string, unknown> | undefined;
|
let meta: Record<string, unknown> | undefined;
|
||||||
if (opts.metadata) {
|
if (opts.metadata) {
|
||||||
try {
|
try {
|
||||||
@@ -396,7 +475,13 @@ export async function cmdUpdate(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "update",
|
||||||
|
data: result,
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
|
} else if (opts.output === "json") {
|
||||||
formatJson(result);
|
formatJson(result);
|
||||||
} else if (opts.output !== "quiet") {
|
} else if (opts.output !== "quiet") {
|
||||||
printSuccess(
|
printSuccess(
|
||||||
@@ -410,6 +495,7 @@ export async function cmdDelete(
|
|||||||
memoryId: string,
|
memoryId: string,
|
||||||
opts: { output: string; dryRun?: boolean; force?: boolean },
|
opts: { output: string; dryRun?: boolean; force?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("delete");
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
let mem: Record<string, unknown>;
|
let mem: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -436,7 +522,13 @@ export async function cmdDelete(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "delete",
|
||||||
|
data: { id: memoryId, deleted: true },
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
|
} else if (opts.output === "json") {
|
||||||
formatJson(result);
|
formatJson(result);
|
||||||
} else if (opts.output !== "quiet") {
|
} else if (opts.output !== "quiet") {
|
||||||
printSuccess(
|
printSuccess(
|
||||||
@@ -458,16 +550,15 @@ export async function cmdDeleteAll(
|
|||||||
output: string;
|
output: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): 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) {
|
if (opts.all) {
|
||||||
// Project-wide wipe using wildcard entity IDs
|
// Project-wide wipe using wildcard entity IDs
|
||||||
if (opts.dryRun) {
|
// Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
|
||||||
printInfo("Would delete ALL memories project-wide.");
|
|
||||||
printInfo(
|
|
||||||
"Run without --dry-run to see the actual count of deleted memories.",
|
|
||||||
);
|
|
||||||
printInfo("No changes made.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opts.force) {
|
if (!opts.force) {
|
||||||
const readline = await import("node:readline");
|
const readline = await import("node:readline");
|
||||||
@@ -509,7 +600,13 @@ export async function cmdDeleteAll(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "delete-all",
|
||||||
|
data: result,
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
|
} else if (opts.output === "json") {
|
||||||
formatJson(result);
|
formatJson(result);
|
||||||
} else if (opts.output !== "quiet") {
|
} else if (opts.output !== "quiet") {
|
||||||
if (result.message) {
|
if (result.message) {
|
||||||
@@ -586,7 +683,13 @@ export async function cmdDeleteAll(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent") {
|
||||||
|
formatAgentEnvelope({
|
||||||
|
command: "delete-all",
|
||||||
|
data: result,
|
||||||
|
durationMs: Math.round(elapsed * 1000),
|
||||||
|
});
|
||||||
|
} else if (opts.output === "json") {
|
||||||
formatJson(result);
|
formatJson(result);
|
||||||
} else if (opts.output !== "quiet") {
|
} else if (opts.output !== "quiet") {
|
||||||
if (result.message) {
|
if (result.message) {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import fs from "node:fs";
|
|||||||
import boxen from "boxen";
|
import boxen from "boxen";
|
||||||
import type { Backend } from "../backend/base.js";
|
import type { Backend } from "../backend/base.js";
|
||||||
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
|
import { colors, printError, printSuccess, timedStatus } from "../branding.js";
|
||||||
import { formatJsonEnvelope } from "../output.js";
|
import { formatAgentEnvelope, formatJsonEnvelope } from "../output.js";
|
||||||
|
import { setCurrentCommand } from "../state.js";
|
||||||
import { CLI_VERSION } from "../version.js";
|
import { CLI_VERSION } from "../version.js";
|
||||||
|
|
||||||
const { brand, dim, success, error: errorColor } = colors;
|
const { brand, dim, success, error: errorColor } = colors;
|
||||||
@@ -15,6 +16,7 @@ export async function cmdStatus(
|
|||||||
backend: Backend,
|
backend: Backend,
|
||||||
opts: { userId?: string; agentId?: string; output?: string } = {},
|
opts: { userId?: string; agentId?: string; output?: string } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("status");
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
let result: Record<string, unknown>;
|
let result: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -29,14 +31,13 @@ export async function cmdStatus(
|
|||||||
}
|
}
|
||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent" || opts.output === "json") {
|
||||||
formatJsonEnvelope({
|
formatAgentEnvelope({
|
||||||
command: "status",
|
command: "status",
|
||||||
data: {
|
data: {
|
||||||
connected: result.connected,
|
connected: result.connected,
|
||||||
backend: result.backend ?? null,
|
backend: result.backend ?? null,
|
||||||
base_url: result.base_url ?? null,
|
base_url: result.base_url ?? null,
|
||||||
latency_ms: Math.round(elapsed * 1000),
|
|
||||||
},
|
},
|
||||||
durationMs: Math.round(elapsed * 1000),
|
durationMs: Math.round(elapsed * 1000),
|
||||||
});
|
});
|
||||||
@@ -56,6 +57,15 @@ export async function cmdStatus(
|
|||||||
}
|
}
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
lines.push(` ${errorColor("Error:")} ${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`);
|
lines.push(` ${dim("Latency:")} ${elapsed.toFixed(2)}s`);
|
||||||
|
|
||||||
@@ -81,6 +91,7 @@ export async function cmdImport(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
opts: { userId?: string; agentId?: string; output?: string },
|
opts: { userId?: string; agentId?: string; output?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
setCurrentCommand("import");
|
||||||
let data: Record<string, unknown>[];
|
let data: Record<string, unknown>[];
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(filePath, "utf-8");
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
@@ -125,13 +136,12 @@ export async function cmdImport(
|
|||||||
const elapsed = (performance.now() - start) / 1000;
|
const elapsed = (performance.now() - start) / 1000;
|
||||||
console.log(); // Clear progress line
|
console.log(); // Clear progress line
|
||||||
|
|
||||||
if (opts.output === "json") {
|
if (opts.output === "agent" || opts.output === "json") {
|
||||||
formatJsonEnvelope({
|
formatAgentEnvelope({
|
||||||
command: "import",
|
command: "import",
|
||||||
data: {
|
data: {
|
||||||
added,
|
added,
|
||||||
failed,
|
failed,
|
||||||
duration_s: Number.parseFloat(elapsed.toFixed(2)),
|
|
||||||
},
|
},
|
||||||
durationMs: Math.round(elapsed * 1000),
|
durationMs: Math.round(elapsed * 1000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
+69
-12
@@ -20,6 +20,13 @@ export const CONFIG_VERSION = 1;
|
|||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: 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 {
|
export interface DefaultsConfig {
|
||||||
@@ -27,13 +34,24 @@ export interface DefaultsConfig {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
appId: string;
|
appId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
enableGraph: boolean;
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface Mem0Config {
|
||||||
version: number;
|
version: number;
|
||||||
defaults: DefaultsConfig;
|
defaults: DefaultsConfig;
|
||||||
platform: PlatformConfig;
|
platform: PlatformConfig;
|
||||||
|
telemetry: TelemetryConfig;
|
||||||
|
agentRush: AgentRushConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultConfig(): Mem0Config {
|
export function createDefaultConfig(): Mem0Config {
|
||||||
@@ -44,11 +62,22 @@ export function createDefaultConfig(): Mem0Config {
|
|||||||
agentId: "",
|
agentId: "",
|
||||||
appId: "",
|
appId: "",
|
||||||
runId: "",
|
runId: "",
|
||||||
enableGraph: false,
|
|
||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
|
userEmail: "",
|
||||||
|
agentMode: false,
|
||||||
|
createdVia: "",
|
||||||
|
agentCaller: "",
|
||||||
|
claimedAt: "",
|
||||||
|
defaultUserId: "",
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
anonymousId: "",
|
||||||
|
},
|
||||||
|
agentRush: {
|
||||||
|
acknowledgedAt: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,13 +99,22 @@ export function loadConfig(): Mem0Config {
|
|||||||
const plat = data.platform ?? {};
|
const plat = data.platform ?? {};
|
||||||
config.platform.apiKey = plat.api_key ?? "";
|
config.platform.apiKey = plat.api_key ?? "";
|
||||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
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 ?? {};
|
const defaults = data.defaults ?? {};
|
||||||
config.defaults.userId = defaults.user_id ?? "";
|
config.defaults.userId = defaults.user_id ?? "";
|
||||||
config.defaults.agentId = defaults.agent_id ?? "";
|
config.defaults.agentId = defaults.agent_id ?? "";
|
||||||
config.defaults.appId = defaults.app_id ?? "";
|
config.defaults.appId = defaults.app_id ?? "";
|
||||||
config.defaults.runId = defaults.run_id ?? "";
|
config.defaults.runId = defaults.run_id ?? "";
|
||||||
config.defaults.enableGraph = defaults.enable_graph ?? false;
|
const telemetry = data.telemetry ?? {};
|
||||||
|
config.telemetry.anonymousId = telemetry.anonymous_id ?? "";
|
||||||
|
const agentRush = data.agent_rush ?? {};
|
||||||
|
config.agentRush.acknowledgedAt = agentRush.acknowledged_at ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variable overrides
|
// Environment variable overrides
|
||||||
@@ -90,12 +128,6 @@ export function loadConfig(): Mem0Config {
|
|||||||
config.defaults.agentId = 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_APP_ID) config.defaults.appId = process.env.MEM0_APP_ID;
|
||||||
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
|
if (process.env.MEM0_RUN_ID) config.defaults.runId = process.env.MEM0_RUN_ID;
|
||||||
if (process.env.MEM0_ENABLE_GRAPH) {
|
|
||||||
config.defaults.enableGraph = ["true", "1", "yes"].includes(
|
|
||||||
process.env.MEM0_ENABLE_GRAPH.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,16 +141,41 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
agent_id: config.defaults.agentId,
|
agent_id: config.defaults.agentId,
|
||||||
app_id: config.defaults.appId,
|
app_id: config.defaults.appId,
|
||||||
run_id: config.defaults.runId,
|
run_id: config.defaults.runId,
|
||||||
enable_graph: config.defaults.enableGraph,
|
|
||||||
},
|
},
|
||||||
platform: {
|
platform: {
|
||||||
api_key: config.platform.apiKey,
|
api_key: config.platform.apiKey,
|
||||||
base_url: config.platform.baseUrl,
|
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.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
|
||||||
fs.chmodSync(CONFIG_FILE, 0o600);
|
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 {
|
export function redactKey(key: string): string {
|
||||||
@@ -131,19 +188,19 @@ export function redactKey(key: string): string {
|
|||||||
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||||
"platform.api_key": ["platform", "apiKey"],
|
"platform.api_key": ["platform", "apiKey"],
|
||||||
"platform.base_url": ["platform", "baseUrl"],
|
"platform.base_url": ["platform", "baseUrl"],
|
||||||
|
"platform.user_email": ["platform", "userEmail"],
|
||||||
"defaults.user_id": ["defaults", "userId"],
|
"defaults.user_id": ["defaults", "userId"],
|
||||||
"defaults.agent_id": ["defaults", "agentId"],
|
"defaults.agent_id": ["defaults", "agentId"],
|
||||||
"defaults.app_id": ["defaults", "appId"],
|
"defaults.app_id": ["defaults", "appId"],
|
||||||
"defaults.run_id": ["defaults", "runId"],
|
"defaults.run_id": ["defaults", "runId"],
|
||||||
"defaults.enable_graph": ["defaults", "enableGraph"],
|
|
||||||
// Short-form aliases
|
// Short-form aliases
|
||||||
api_key: ["platform", "apiKey"],
|
api_key: ["platform", "apiKey"],
|
||||||
base_url: ["platform", "baseUrl"],
|
base_url: ["platform", "baseUrl"],
|
||||||
|
user_email: ["platform", "userEmail"],
|
||||||
user_id: ["defaults", "userId"],
|
user_id: ["defaults", "userId"],
|
||||||
agent_id: ["defaults", "agentId"],
|
agent_id: ["defaults", "agentId"],
|
||||||
app_id: ["defaults", "appId"],
|
app_id: ["defaults", "appId"],
|
||||||
run_id: ["defaults", "runId"],
|
run_id: ["defaults", "runId"],
|
||||||
enable_graph: ["defaults", "enableGraph"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
|
export function getNestedValue(config: Mem0Config, dottedKey: string): unknown {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const COMMAND_GROUPS: { panel: string; commands: string[] }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
panel: "Management",
|
panel: "Management",
|
||||||
commands: ["init", "status", "import", "help", "entity", "config"],
|
commands: ["init", "status", "import", "help", "entity", "event", "config"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+323
-74
@@ -8,21 +8,32 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { type Backend, getBackend } from "./backend/index.js";
|
import { AuthError, type Backend, getBackend } from "./backend/index.js";
|
||||||
import { colors, printError } from "./branding.js";
|
import { colors, printError, printWarning } from "./branding.js";
|
||||||
import type { Mem0Config } from "./config.js";
|
import type { Mem0Config } from "./config.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig, saveConfig } from "./config.js";
|
||||||
import { richFormatHelp } from "./help.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";
|
import { CLI_VERSION } from "./version.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
// ── Validated user identity (set by getBackendAndConfig) ─────────────────
|
||||||
|
|
||||||
|
let _validatedUserEmail: string | undefined;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getBackendAndConfig(
|
async function getBackendAndConfig(
|
||||||
apiKey?: string,
|
apiKey?: string,
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): { backend: Backend; config: Mem0Config } {
|
): Promise<{ backend: Backend; config: Mem0Config }> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if (apiKey) config.platform.apiKey = apiKey;
|
if (apiKey) config.platform.apiKey = apiKey;
|
||||||
@@ -36,11 +47,58 @@ function getBackendAndConfig(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { backend: getBackend(config), config };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackendOnly(apiKey?: string, baseUrl?: string): Backend {
|
async function getBackendOnly(
|
||||||
return getBackendAndConfig(apiKey, baseUrl).backend;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,18 +139,6 @@ function resolveIds(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve graph tri-state: --no-graph > --graph > config default.
|
|
||||||
*/
|
|
||||||
function resolveGraph(
|
|
||||||
config: Mem0Config,
|
|
||||||
opts: { graph?: boolean; noGraph?: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (opts.noGraph) return false;
|
|
||||||
if (opts.graph) return true;
|
|
||||||
return config.defaults.enableGraph;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main program ──────────────────────────────────────────────────────────
|
// ── Main program ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -100,23 +146,63 @@ program
|
|||||||
.description(
|
.description(
|
||||||
`◆ Mem0 CLI v${CLI_VERSION} · Node.js SDK\n\nThe Memory Layer for AI Agents`,
|
`◆ 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.")
|
.option("--version", "Show version and exit.")
|
||||||
.on("option:version", () => {
|
.on("option:version", () => {
|
||||||
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
console.log(` ${colors.brand("◆ Mem0")} CLI v${CLI_VERSION}`);
|
||||||
process.exit(0);
|
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]")
|
.usage("<command> [options]")
|
||||||
.helpOption("--help", "Show this message and exit.")
|
.helpOption("--help", "Show this message and exit.")
|
||||||
.addHelpCommand(false)
|
.addHelpCommand(false)
|
||||||
.configureHelp({ formatHelp: richFormatHelp });
|
.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 ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("init")
|
.command("init")
|
||||||
.description(
|
.description("Interactive setup wizard for mem0 CLI.")
|
||||||
"Setup wizard for mem0 CLI. Supports email login (--email) or manual API key (--api-key).",
|
|
||||||
)
|
|
||||||
.option("--api-key <key>", "API key (skip prompt).")
|
.option("--api-key <key>", "API key (skip prompt).")
|
||||||
.option("-u, --user-id <id>", "Default user ID (skip prompt).")
|
.option("-u, --user-id <id>", "Default user ID (skip prompt).")
|
||||||
.option("--email <email>", "Login via email verification code.")
|
.option("--email <email>", "Login via email verification code.")
|
||||||
@@ -124,20 +210,100 @@ program
|
|||||||
"--code <code>",
|
"--code <code>",
|
||||||
"Verification code (use with --email for non-interactive login).",
|
"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(
|
.addHelpText(
|
||||||
"after",
|
"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",
|
"\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) => {
|
.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");
|
const { runInit } = await import("./commands/init.js");
|
||||||
await runInit({
|
await runInit({
|
||||||
apiKey: opts.apiKey,
|
apiKey: opts.apiKey,
|
||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
email: opts.email,
|
email: opts.email,
|
||||||
code: opts.code,
|
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 ───────────────────────────────────────────────────────────
|
// ── Memory: add ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
@@ -154,8 +320,6 @@ program
|
|||||||
.option("--no-infer", "Skip inference, store raw.")
|
.option("--no-infer", "Skip inference, store raw.")
|
||||||
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
|
||||||
.option("--categories <value>", "Categories (JSON array or comma-separated).")
|
.option("--categories <value>", "Categories (JSON array or comma-separated).")
|
||||||
.option("--graph", "Enable graph memory extraction.", false)
|
|
||||||
.option("--no-graph", "Disable graph memory extraction.")
|
|
||||||
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
|
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -165,17 +329,23 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (text, opts) => {
|
.action(async (text, opts) => {
|
||||||
const { cmdAdd } = await import("./commands/memory.js");
|
const { cmdAdd } = await import("./commands/memory.js");
|
||||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const { backend, config } = await getBackendAndConfig(
|
||||||
|
opts.apiKey,
|
||||||
|
opts.baseUrl,
|
||||||
|
);
|
||||||
const ids = resolveIds(config, opts);
|
const ids = resolveIds(config, opts);
|
||||||
const enableGraph = resolveGraph(config, opts);
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdAdd(backend, text, { ...ids, ...opts, enableGraph });
|
await cmdAdd(backend, text, { ...ids, ...opts, output });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Memory: search ────────────────────────────────────────────────────────
|
// ── Memory: search ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("search [query]")
|
.command("search [query]")
|
||||||
.description("Search memories by semantic query.")
|
.description(
|
||||||
|
"Query your memory store — semantic, keyword, or hybrid retrieval.",
|
||||||
|
)
|
||||||
.option("-u, --user-id <id>", "Filter by user.")
|
.option("-u, --user-id <id>", "Filter by user.")
|
||||||
.option("--agent-id <id>", "Filter by agent.")
|
.option("--agent-id <id>", "Filter by agent.")
|
||||||
.option("--app-id <id>", "Filter by app.")
|
.option("--app-id <id>", "Filter by app.")
|
||||||
@@ -196,8 +366,6 @@ program
|
|||||||
.option("--keyword", "Use keyword search.", false)
|
.option("--keyword", "Use keyword search.", false)
|
||||||
.option("--filter <json>", "Advanced filter expression (JSON).")
|
.option("--filter <json>", "Advanced filter expression (JSON).")
|
||||||
.option("--fields <list>", "Specific fields to return (comma-separated).")
|
.option("--fields <list>", "Specific fields to return (comma-separated).")
|
||||||
.option("--graph", "Enable graph in search.", false)
|
|
||||||
.option("--no-graph", "Disable graph in search.")
|
|
||||||
.option("-o, --output <format>", "Output: text, json, table.", "text")
|
.option("-o, --output <format>", "Output: text, json, table.", "text")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -215,9 +383,13 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const { cmdSearch } = await import("./commands/memory.js");
|
const { cmdSearch } = await import("./commands/memory.js");
|
||||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const { backend, config } = await getBackendAndConfig(
|
||||||
|
opts.apiKey,
|
||||||
|
opts.baseUrl,
|
||||||
|
);
|
||||||
const ids = resolveIds(config, opts);
|
const ids = resolveIds(config, opts);
|
||||||
const enableGraph = resolveGraph(config, opts);
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdSearch(backend, resolvedQuery, {
|
await cmdSearch(backend, resolvedQuery, {
|
||||||
...ids,
|
...ids,
|
||||||
topK: opts.topK,
|
topK: opts.topK,
|
||||||
@@ -226,8 +398,7 @@ program
|
|||||||
keyword: opts.keyword,
|
keyword: opts.keyword,
|
||||||
filterJson: opts.filter,
|
filterJson: opts.filter,
|
||||||
fields: opts.fields,
|
fields: opts.fields,
|
||||||
enableGraph,
|
output,
|
||||||
output: opts.output,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,8 +416,10 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (memoryId, opts) => {
|
.action(async (memoryId, opts) => {
|
||||||
const { cmdGet } = await import("./commands/memory.js");
|
const { cmdGet } = await import("./commands/memory.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
await cmdGet(backend, memoryId, { output: opts.output });
|
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
|
await cmdGet(backend, memoryId, { output });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Memory: list ──────────────────────────────────────────────────────────
|
// ── Memory: list ──────────────────────────────────────────────────────────
|
||||||
@@ -268,8 +441,6 @@ program
|
|||||||
.option("--category <name>", "Filter by category.")
|
.option("--category <name>", "Filter by category.")
|
||||||
.option("--after <date>", "Created after (YYYY-MM-DD).")
|
.option("--after <date>", "Created after (YYYY-MM-DD).")
|
||||||
.option("--before <date>", "Created before (YYYY-MM-DD).")
|
.option("--before <date>", "Created before (YYYY-MM-DD).")
|
||||||
.option("--graph", "Enable graph in listing.", false)
|
|
||||||
.option("--no-graph", "Disable graph in listing.")
|
|
||||||
.option("-o, --output <format>", "Output: text, json, table.", "table")
|
.option("-o, --output <format>", "Output: text, json, table.", "table")
|
||||||
.option("--api-key <key>", "Override API key.")
|
.option("--api-key <key>", "Override API key.")
|
||||||
.option("--base-url <url>", "Override API base URL.")
|
.option("--base-url <url>", "Override API base URL.")
|
||||||
@@ -279,9 +450,13 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const { cmdList } = await import("./commands/memory.js");
|
const { cmdList } = await import("./commands/memory.js");
|
||||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const { backend, config } = await getBackendAndConfig(
|
||||||
|
opts.apiKey,
|
||||||
|
opts.baseUrl,
|
||||||
|
);
|
||||||
const ids = resolveIds(config, opts);
|
const ids = resolveIds(config, opts);
|
||||||
const enableGraph = resolveGraph(config, opts);
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdList(backend, {
|
await cmdList(backend, {
|
||||||
...ids,
|
...ids,
|
||||||
page: opts.page,
|
page: opts.page,
|
||||||
@@ -289,8 +464,7 @@ program
|
|||||||
category: opts.category,
|
category: opts.category,
|
||||||
after: opts.after,
|
after: opts.after,
|
||||||
before: opts.before,
|
before: opts.before,
|
||||||
enableGraph,
|
output,
|
||||||
output: opts.output,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,10 +487,12 @@ program
|
|||||||
resolvedText = fs.readFileSync(0, "utf-8").trim();
|
resolvedText = fs.readFileSync(0, "utf-8").trim();
|
||||||
}
|
}
|
||||||
const { cmdUpdate } = await import("./commands/memory.js");
|
const { cmdUpdate } = await import("./commands/memory.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdUpdate(backend, memoryId, resolvedText, {
|
await cmdUpdate(backend, memoryId, resolvedText, {
|
||||||
metadata: opts.metadata,
|
metadata: opts.metadata,
|
||||||
output: opts.output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,6 +528,8 @@ program
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
.action(async (memoryId, opts) => {
|
.action(async (memoryId, opts) => {
|
||||||
|
const isAgent = checkAgentMode();
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
// ── Mutual-exclusion checks ──
|
// ── Mutual-exclusion checks ──
|
||||||
if (memoryId && opts.all) {
|
if (memoryId && opts.all) {
|
||||||
printError("Cannot combine <memoryId> with --all. Use one or the other.");
|
printError("Cannot combine <memoryId> with --all. Use one or the other.");
|
||||||
@@ -380,9 +558,9 @@ program
|
|||||||
// ── Dispatch: single memory ──
|
// ── Dispatch: single memory ──
|
||||||
if (memoryId) {
|
if (memoryId) {
|
||||||
const { cmdDelete } = await import("./commands/memory.js");
|
const { cmdDelete } = await import("./commands/memory.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||||
await cmdDelete(backend, memoryId, {
|
await cmdDelete(backend, memoryId, {
|
||||||
output: opts.output,
|
output,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
force: opts.force,
|
force: opts.force,
|
||||||
});
|
});
|
||||||
@@ -392,7 +570,7 @@ program
|
|||||||
// ── Dispatch: --all ──
|
// ── Dispatch: --all ──
|
||||||
if (opts.all) {
|
if (opts.all) {
|
||||||
const { cmdDeleteAll } = await import("./commands/memory.js");
|
const { cmdDeleteAll } = await import("./commands/memory.js");
|
||||||
const { backend, config } = getBackendAndConfig(
|
const { backend, config } = await getBackendAndConfig(
|
||||||
opts.apiKey,
|
opts.apiKey,
|
||||||
opts.baseUrl,
|
opts.baseUrl,
|
||||||
);
|
);
|
||||||
@@ -409,7 +587,7 @@ program
|
|||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
all: opts.project,
|
all: opts.project,
|
||||||
...ids,
|
...ids,
|
||||||
output: opts.output,
|
output,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -417,8 +595,8 @@ program
|
|||||||
// ── Dispatch: --entity ──
|
// ── Dispatch: --entity ──
|
||||||
if (opts.entity) {
|
if (opts.entity) {
|
||||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||||
await cmdEntitiesDelete(backend, opts);
|
await cmdEntitiesDelete(backend, { ...opts, output });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -440,7 +618,9 @@ configCmd
|
|||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const { cmdConfigShow } = await import("./commands/config.js");
|
const { cmdConfigShow } = await import("./commands/config.js");
|
||||||
cmdConfigShow({ output: opts.output });
|
const isAgent = checkAgentMode();
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
|
cmdConfigShow({ output });
|
||||||
});
|
});
|
||||||
|
|
||||||
configCmd
|
configCmd
|
||||||
@@ -452,6 +632,7 @@ configCmd
|
|||||||
)
|
)
|
||||||
.action(async (key) => {
|
.action(async (key) => {
|
||||||
const { cmdConfigGet } = await import("./commands/config.js");
|
const { cmdConfigGet } = await import("./commands/config.js");
|
||||||
|
checkAgentMode();
|
||||||
cmdConfigGet(key);
|
cmdConfigGet(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,6 +645,7 @@ configCmd
|
|||||||
)
|
)
|
||||||
.action(async (key, value) => {
|
.action(async (key, value) => {
|
||||||
const { cmdConfigSet } = await import("./commands/config.js");
|
const { cmdConfigSet } = await import("./commands/config.js");
|
||||||
|
checkAgentMode();
|
||||||
cmdConfigSet(key, value);
|
cmdConfigSet(key, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -487,8 +669,10 @@ entityCmd
|
|||||||
)
|
)
|
||||||
.action(async (entityType, opts) => {
|
.action(async (entityType, opts) => {
|
||||||
const { cmdEntitiesList } = await import("./commands/entities.js");
|
const { cmdEntitiesList } = await import("./commands/entities.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
await cmdEntitiesList(backend, entityType, { output: opts.output });
|
const backend = await getBackendOnly(opts.apiKey, opts.baseUrl);
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
|
await cmdEntitiesList(backend, entityType, { output });
|
||||||
});
|
});
|
||||||
|
|
||||||
entityCmd
|
entityCmd
|
||||||
@@ -509,8 +693,54 @@ entityCmd
|
|||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
const { cmdEntitiesDelete } = await import("./commands/entities.js");
|
||||||
const backend = getBackendOnly(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
await cmdEntitiesDelete(backend, opts);
|
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 ──────────────────────────────────────────────────────
|
// ── Utility commands ──────────────────────────────────────────────────────
|
||||||
@@ -524,11 +754,16 @@ program
|
|||||||
.addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json")
|
.addHelpText("after", "\nExamples:\n $ mem0 status\n $ mem0 status -o json")
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const { cmdStatus } = await import("./commands/utils.js");
|
const { cmdStatus } = await import("./commands/utils.js");
|
||||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const { backend, config } = await getBackendAndConfig(
|
||||||
|
opts.apiKey,
|
||||||
|
opts.baseUrl,
|
||||||
|
);
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdStatus(backend, {
|
await cmdStatus(backend, {
|
||||||
userId: config.defaults.userId || undefined,
|
userId: config.defaults.userId || undefined,
|
||||||
agentId: config.defaults.agentId || undefined,
|
agentId: config.defaults.agentId || undefined,
|
||||||
output: opts.output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,12 +781,17 @@ program
|
|||||||
)
|
)
|
||||||
.action(async (filePath, opts) => {
|
.action(async (filePath, opts) => {
|
||||||
const { cmdImport } = await import("./commands/utils.js");
|
const { cmdImport } = await import("./commands/utils.js");
|
||||||
const { backend, config } = getBackendAndConfig(opts.apiKey, opts.baseUrl);
|
const isAgent = checkAgentMode();
|
||||||
|
const { backend, config } = await getBackendAndConfig(
|
||||||
|
opts.apiKey,
|
||||||
|
opts.baseUrl,
|
||||||
|
);
|
||||||
const ids = resolveIds(config, opts);
|
const ids = resolveIds(config, opts);
|
||||||
|
const output = isAgent ? "agent" : opts.output;
|
||||||
await cmdImport(backend, filePath, {
|
await cmdImport(backend, filePath, {
|
||||||
userId: ids.userId,
|
userId: ids.userId,
|
||||||
agentId: ids.agentId,
|
agentId: ids.agentId,
|
||||||
output: opts.output,
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -565,7 +805,9 @@ program
|
|||||||
.option("--json", "Output machine-readable JSON for LLM agents.", false)
|
.option("--json", "Output machine-readable JSON for LLM agents.", false)
|
||||||
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
|
.addHelpText("after", "\nExamples:\n $ mem0 help\n $ mem0 help --json")
|
||||||
.action((opts) => {
|
.action((opts) => {
|
||||||
if (opts.json) {
|
// opts.json is set when `mem0 help --json` is used (subcommand flag).
|
||||||
|
// program.opts().json is set when the root --json global flag was used first.
|
||||||
|
if (opts.json || program.opts().json) {
|
||||||
// Load spec from parent directory
|
// Load spec from parent directory
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
|
const specPath = path.join(__dirname, "..", "..", "cli-spec.json");
|
||||||
@@ -595,7 +837,9 @@ program
|
|||||||
console.log(
|
console.log(
|
||||||
" add Add a memory from text, messages, file, or stdin",
|
" add Add a memory from text, messages, file, or stdin",
|
||||||
);
|
);
|
||||||
console.log(" search Search memories by semantic query");
|
console.log(
|
||||||
|
" search Query your memory store (semantic, keyword, hybrid)",
|
||||||
|
);
|
||||||
console.log(" get Get a specific memory by ID");
|
console.log(" get Get a specific memory by ID");
|
||||||
console.log(" list List memories with optional filters");
|
console.log(" list List memories with optional filters");
|
||||||
console.log(" update Update a memory's text or metadata");
|
console.log(" update Update a memory's text or metadata");
|
||||||
@@ -605,6 +849,9 @@ program
|
|||||||
console.log(" import Import memories from a JSON file");
|
console.log(" import Import memories from a JSON file");
|
||||||
console.log(" config Manage configuration (show, get, set)");
|
console.log(" config Manage configuration (show, get, set)");
|
||||||
console.log(" entity Manage entities (list, delete)");
|
console.log(" entity Manage entities (list, delete)");
|
||||||
|
console.log(
|
||||||
|
" event Inspect background events (list, status)",
|
||||||
|
);
|
||||||
console.log(" init Interactive setup wizard");
|
console.log(" init Interactive setup wizard");
|
||||||
console.log(" status Check connectivity and authentication");
|
console.log(" status Check connectivity and authentication");
|
||||||
console.log();
|
console.log();
|
||||||
@@ -616,16 +863,18 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Version ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
program
|
|
||||||
.command("version")
|
|
||||||
.description("Show version.")
|
|
||||||
.action(async () => {
|
|
||||||
const { cmdVersion } = await import("./commands/utils.js");
|
|
||||||
cmdVersion();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Entrypoint ────────────────────────────────────────────────────────────
|
// ── Entrypoint ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program.parse();
|
// 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();
|
||||||
|
});
|
||||||
|
|||||||
+140
-3
@@ -5,6 +5,7 @@
|
|||||||
import boxen from "boxen";
|
import boxen from "boxen";
|
||||||
import Table from "cli-table3";
|
import Table from "cli-table3";
|
||||||
import { colors, sym } from "./branding.js";
|
import { colors, sym } from "./branding.js";
|
||||||
|
import { takeNotice } from "./state.js";
|
||||||
|
|
||||||
const { brand, accent, success, error: errorColor, dim } = colors;
|
const { brand, accent, success, error: errorColor, dim } = colors;
|
||||||
|
|
||||||
@@ -169,17 +170,26 @@ export function formatAddResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
|
const seenPendingEvents = new Set<string>();
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
// Detect async PENDING response
|
// Detect async PENDING response
|
||||||
if (r.status === "PENDING") {
|
if (r.status === "PENDING") {
|
||||||
const eventId = ((r.event_id as string) ?? "").slice(0, 8);
|
const eventId = (r.event_id as string) ?? "";
|
||||||
|
// Deduplicate PENDING entries with the same event_id
|
||||||
|
if (eventId && seenPendingEvents.has(eventId)) continue;
|
||||||
|
if (eventId) seenPendingEvents.add(eventId);
|
||||||
const icon = accent(sym("⧗", "..."));
|
const icon = accent(sym("⧗", "..."));
|
||||||
const parts = [
|
const parts = [
|
||||||
` ${icon} ${dim("Queued".padEnd(10))}`,
|
` ${icon} ${dim("Queued".padEnd(10))}`,
|
||||||
"Processing in background",
|
"Processing in background",
|
||||||
];
|
];
|
||||||
if (eventId) parts.push(dim(`(event ${eventId})`));
|
|
||||||
console.log(parts.join(" "));
|
console.log(parts.join(" "));
|
||||||
|
if (eventId) {
|
||||||
|
console.log(` ${dim(` event_id: ${eventId}`)}`);
|
||||||
|
console.log(
|
||||||
|
` ${dim(` → Check status: mem0 event status ${eventId}`)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +245,133 @@ export function formatJsonEnvelope(opts: {
|
|||||||
if (opts.count !== undefined) envelope.count = opts.count;
|
if (opts.count !== undefined) envelope.count = opts.count;
|
||||||
if (opts.error) envelope.error = opts.error;
|
if (opts.error) envelope.error = opts.error;
|
||||||
envelope.data = opts.data;
|
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));
|
console.log(JSON.stringify(envelope, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +386,7 @@ export function printResultSummary(opts: {
|
|||||||
if (opts.scopeIds) {
|
if (opts.scopeIds) {
|
||||||
const scopeParts = Object.entries(opts.scopeIds)
|
const scopeParts = Object.entries(opts.scopeIds)
|
||||||
.filter(([, v]) => v)
|
.filter(([, v]) => v)
|
||||||
.map(([k, v]) => `${k.replace(/_/g, " ")}=${v}`);
|
.map(([k, v]) => `${k}=${v}`);
|
||||||
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
|
if (scopeParts.length > 0) parts.push(scopeParts.join(", "));
|
||||||
}
|
}
|
||||||
if (opts.durationSecs !== undefined)
|
if (opts.durationSecs !== undefined)
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,13 +44,6 @@ describe("CLI Integration — help and version", () => {
|
|||||||
expect(result.stdout).toContain("search");
|
expect(result.stdout).toContain("search");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows version with --version", () => {
|
|
||||||
const result = run(["--version"]);
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
expect(result.stdout).toContain("0.1.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it("help --json produces valid JSON", () => {
|
it("help --json produces valid JSON", () => {
|
||||||
const result = run(["help", "--json"]);
|
const result = run(["help", "--json"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
@@ -114,22 +107,22 @@ describe("CLI Integration — help and version", () => {
|
|||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("add help has --graph flag", () => {
|
it("add help has --output flag", () => {
|
||||||
const result = run(["add", "--help"]);
|
const result = run(["add", "--help"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(result.stdout).toContain("--graph");
|
expect(result.stdout).toContain("--output");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("search help has --graph flag", () => {
|
it("search help has --rerank flag", () => {
|
||||||
const result = run(["search", "--help"]);
|
const result = run(["search", "--help"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(result.stdout).toContain("--graph");
|
expect(result.stdout).toContain("--rerank");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("list help has --graph flag", () => {
|
it("list help has --category flag", () => {
|
||||||
const result = run(["list", "--help"]);
|
const result = run(["list", "--help"]);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(result.stdout).toContain("--graph");
|
expect(result.stdout).toContain("--category");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+265
-20
@@ -3,8 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { Command } from "commander";
|
||||||
import { createMockBackend } from "./setup.js";
|
import { createMockBackend } from "./setup.js";
|
||||||
import type { Backend } from "../src/backend/base.js";
|
import type { Backend } from "../src/backend/base.js";
|
||||||
|
import { setAgentMode } from "../src/state.js";
|
||||||
|
|
||||||
let mockBackend: Backend;
|
let mockBackend: Backend;
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ import { afterEach } from "vitest";
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
console.log = originalLog;
|
console.log = originalLog;
|
||||||
console.error = originalError;
|
console.error = originalError;
|
||||||
|
setAgentMode(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cmdAdd", () => {
|
describe("cmdAdd", () => {
|
||||||
@@ -39,8 +42,6 @@ describe("cmdAdd", () => {
|
|||||||
await cmdAdd(mockBackend, "I prefer dark mode", {
|
await cmdAdd(mockBackend, "I prefer dark mode", {
|
||||||
userId: "alice",
|
userId: "alice",
|
||||||
immutable: false,
|
immutable: false,
|
||||||
noInfer: false,
|
|
||||||
enableGraph: false,
|
|
||||||
output: "text",
|
output: "text",
|
||||||
});
|
});
|
||||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||||
@@ -52,8 +53,6 @@ describe("cmdAdd", () => {
|
|||||||
userId: "alice",
|
userId: "alice",
|
||||||
messages: JSON.stringify([{ role: "user", content: "I love Python" }]),
|
messages: JSON.stringify([{ role: "user", content: "I love Python" }]),
|
||||||
immutable: false,
|
immutable: false,
|
||||||
noInfer: false,
|
|
||||||
enableGraph: false,
|
|
||||||
output: "text",
|
output: "text",
|
||||||
});
|
});
|
||||||
expect(mockBackend.add).toHaveBeenCalledOnce();
|
expect(mockBackend.add).toHaveBeenCalledOnce();
|
||||||
@@ -64,8 +63,6 @@ describe("cmdAdd", () => {
|
|||||||
await cmdAdd(mockBackend, "test", {
|
await cmdAdd(mockBackend, "test", {
|
||||||
userId: "alice",
|
userId: "alice",
|
||||||
immutable: false,
|
immutable: false,
|
||||||
noInfer: false,
|
|
||||||
enableGraph: false,
|
|
||||||
output: "json",
|
output: "json",
|
||||||
});
|
});
|
||||||
expect(output).toContain("results");
|
expect(output).toContain("results");
|
||||||
@@ -76,14 +73,106 @@ describe("cmdAdd", () => {
|
|||||||
await cmdAdd(mockBackend, "test", {
|
await cmdAdd(mockBackend, "test", {
|
||||||
userId: "alice",
|
userId: "alice",
|
||||||
immutable: false,
|
immutable: false,
|
||||||
noInfer: false,
|
|
||||||
enableGraph: false,
|
|
||||||
output: "quiet",
|
output: "quiet",
|
||||||
});
|
});
|
||||||
expect(output).not.toContain("dark mode");
|
expect(output).not.toContain("dark mode");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cmdAdd forwards --no-infer (regression for #5261)", () => {
|
||||||
|
it("forwards infer: false when --no-infer is set", async () => {
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
// `infer: false` is the shape Commander produces for `--no-infer`.
|
||||||
|
await cmdAdd(mockBackend, "store me verbatim", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
infer: false,
|
||||||
|
output: "text",
|
||||||
|
});
|
||||||
|
expect(mockBackend.add).toHaveBeenCalledWith(
|
||||||
|
"store me verbatim",
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ infer: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards infer: true by default (flag absent)", async () => {
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
await cmdAdd(mockBackend, "infer me", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
output: "text",
|
||||||
|
});
|
||||||
|
expect(mockBackend.add).toHaveBeenCalledWith(
|
||||||
|
"infer me",
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ infer: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Commander stores --no-infer as opts.infer, not opts.noInfer", () => {
|
||||||
|
// Pins the assumption the fix relies on: Commander's `--no-X` option
|
||||||
|
// populates the positive camelCase key (`infer`), never `noInfer`.
|
||||||
|
const withFlag = new Command();
|
||||||
|
withFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
|
||||||
|
withFlag.parse(["--no-infer"], { from: "user" });
|
||||||
|
expect(withFlag.opts().infer).toBe(false);
|
||||||
|
expect(withFlag.opts().noInfer).toBeUndefined();
|
||||||
|
|
||||||
|
const withoutFlag = new Command();
|
||||||
|
withoutFlag.option("--no-infer", "Skip inference, store raw.").action(() => {});
|
||||||
|
withoutFlag.parse([], { from: "user" });
|
||||||
|
expect(withoutFlag.opts().infer).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdAdd deduplicates PENDING", () => {
|
||||||
|
const DUPLICATE_PENDING = {
|
||||||
|
results: [
|
||||||
|
{ status: "PENDING", event_id: "evt-dup" },
|
||||||
|
{ status: "PENDING", event_id: "evt-dup" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("text shows one pending block", async () => {
|
||||||
|
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
await cmdAdd(mockBackend, "test", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
output: "text",
|
||||||
|
});
|
||||||
|
expect(output.match(/Queued/g)?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("json shows one pending entry", async () => {
|
||||||
|
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
await cmdAdd(mockBackend, "test", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
output: "json",
|
||||||
|
});
|
||||||
|
const data = JSON.parse(output);
|
||||||
|
const pending = data.results.filter((r: Record<string, unknown>) => r.status === "PENDING");
|
||||||
|
expect(pending).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent shows one pending entry", async () => {
|
||||||
|
(mockBackend.add as ReturnType<typeof vi.fn>).mockResolvedValue(DUPLICATE_PENDING);
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
await cmdAdd(mockBackend, "test", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
output: "agent",
|
||||||
|
});
|
||||||
|
const data = JSON.parse(output);
|
||||||
|
expect(data.count).toBe(1);
|
||||||
|
expect(data.data).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("cmdSearch", () => {
|
describe("cmdSearch", () => {
|
||||||
it("searches and shows results in text mode", async () => {
|
it("searches and shows results in text mode", async () => {
|
||||||
const { cmdSearch } = await import("../src/commands/memory.js");
|
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||||
@@ -93,7 +182,7 @@ describe("cmdSearch", () => {
|
|||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
rerank: false,
|
rerank: false,
|
||||||
keyword: false,
|
keyword: false,
|
||||||
enableGraph: false,
|
|
||||||
output: "text",
|
output: "text",
|
||||||
});
|
});
|
||||||
expect(output).toContain("Found 2");
|
expect(output).toContain("Found 2");
|
||||||
@@ -107,7 +196,7 @@ describe("cmdSearch", () => {
|
|||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
rerank: false,
|
rerank: false,
|
||||||
keyword: false,
|
keyword: false,
|
||||||
enableGraph: false,
|
|
||||||
output: "json",
|
output: "json",
|
||||||
});
|
});
|
||||||
expect(output).toContain("memory");
|
expect(output).toContain("memory");
|
||||||
@@ -122,7 +211,7 @@ describe("cmdSearch", () => {
|
|||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
rerank: false,
|
rerank: false,
|
||||||
keyword: false,
|
keyword: false,
|
||||||
enableGraph: false,
|
|
||||||
output: "text",
|
output: "text",
|
||||||
});
|
});
|
||||||
expect(errOutput).toContain("No memories found");
|
expect(errOutput).toContain("No memories found");
|
||||||
@@ -150,7 +239,7 @@ describe("cmdList", () => {
|
|||||||
userId: "alice",
|
userId: "alice",
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
enableGraph: false,
|
|
||||||
output: "table",
|
output: "table",
|
||||||
});
|
});
|
||||||
expect(output).toContain("dark mode");
|
expect(output).toContain("dark mode");
|
||||||
@@ -163,7 +252,7 @@ describe("cmdList", () => {
|
|||||||
userId: "alice",
|
userId: "alice",
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
enableGraph: false,
|
|
||||||
output: "text",
|
output: "text",
|
||||||
});
|
});
|
||||||
expect(errOutput).toContain("No memories found");
|
expect(errOutput).toContain("No memories found");
|
||||||
@@ -198,13 +287,6 @@ describe("cmdDeleteAll", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cmdVersion", () => {
|
|
||||||
it("shows version", async () => {
|
|
||||||
const { cmdVersion } = await import("../src/commands/utils.js");
|
|
||||||
cmdVersion();
|
|
||||||
expect(output).toContain("0.1.0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cmdEntitiesList", () => {
|
describe("cmdEntitiesList", () => {
|
||||||
it("lists users in table mode", async () => {
|
it("lists users in table mode", async () => {
|
||||||
@@ -219,3 +301,166 @@ describe("cmdEntitiesList", () => {
|
|||||||
expect(output).toContain("alice");
|
expect(output).toContain("alice");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cmdEventList", () => {
|
||||||
|
it("lists events in table mode", async () => {
|
||||||
|
const { cmdEventList } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventList(mockBackend, { output: "table" });
|
||||||
|
expect(output).toContain("evt-abc-");
|
||||||
|
expect(output).toContain("ADD");
|
||||||
|
expect(output).toContain("SUCCEEDED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists events in json mode", async () => {
|
||||||
|
const { cmdEventList } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventList(mockBackend, { output: "json" });
|
||||||
|
expect(output).toContain("evt-abc-123-def-456");
|
||||||
|
expect(output).toContain("evt-def-456-ghi-789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty message when no events", async () => {
|
||||||
|
(mockBackend.listEvents as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||||
|
const { cmdEventList } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventList(mockBackend, { output: "table" });
|
||||||
|
expect((output + errOutput).toLowerCase()).toContain("no events");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdEventStatus", () => {
|
||||||
|
it("shows event details in text mode", async () => {
|
||||||
|
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "text" });
|
||||||
|
expect(output).toContain("evt-abc-123-def-456");
|
||||||
|
expect(output).toContain("SUCCEEDED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows event details in json mode", async () => {
|
||||||
|
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "json" });
|
||||||
|
expect(output).toContain("evt-abc-123-def-456");
|
||||||
|
expect(output).toContain("ADD");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent mode", () => {
|
||||||
|
it("cmdAdd outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdAdd } = await import("../src/commands/memory.js");
|
||||||
|
await cmdAdd(mockBackend, "test preference", {
|
||||||
|
userId: "alice",
|
||||||
|
immutable: false,
|
||||||
|
output: "agent",
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("add");
|
||||||
|
expect(parsed.data).toBeDefined();
|
||||||
|
expect(parsed.scope).toMatchObject({ user_id: "alice" });
|
||||||
|
expect(Object.keys(parsed.data[0]).sort()).toEqual(["event", "id", "memory"].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdSearch outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdSearch } = await import("../src/commands/memory.js");
|
||||||
|
await cmdSearch(mockBackend, "preferences", {
|
||||||
|
userId: "alice",
|
||||||
|
topK: 10,
|
||||||
|
threshold: 0.3,
|
||||||
|
rerank: false,
|
||||||
|
keyword: false,
|
||||||
|
|
||||||
|
output: "agent",
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("search");
|
||||||
|
expect(Array.isArray(parsed.data)).toBe(true);
|
||||||
|
expect(parsed.count).toBe(2);
|
||||||
|
const keys = Object.keys(parsed.data[0]);
|
||||||
|
expect(keys).toContain("id");
|
||||||
|
expect(keys).toContain("memory");
|
||||||
|
expect(keys).toContain("score");
|
||||||
|
expect(keys).toContain("created_at");
|
||||||
|
expect(keys).toContain("categories");
|
||||||
|
expect(keys).not.toContain("user_id");
|
||||||
|
expect(keys).not.toContain("agent_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdList outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdList } = await import("../src/commands/memory.js");
|
||||||
|
await cmdList(mockBackend, {
|
||||||
|
userId: "alice",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
|
||||||
|
output: "agent",
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("list");
|
||||||
|
expect(Array.isArray(parsed.data)).toBe(true);
|
||||||
|
expect(parsed.count).toBe(2);
|
||||||
|
expect(Object.keys(parsed.data[0]).sort()).toEqual(["categories", "created_at", "id", "memory"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdGet outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdGet } = await import("../src/commands/memory.js");
|
||||||
|
await cmdGet(mockBackend, "abc-123-def-456", { output: "agent" });
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("get");
|
||||||
|
expect(parsed.data).toBeDefined();
|
||||||
|
expect(parsed.data).toMatchObject({ id: "abc-123-def-456" });
|
||||||
|
expect(Object.keys(parsed.data)).not.toContain("user_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdUpdate outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdUpdate } = await import("../src/commands/memory.js");
|
||||||
|
await cmdUpdate(mockBackend, "abc-123", "Updated text", { output: "agent" });
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("update");
|
||||||
|
expect(parsed.data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdDelete outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdDelete } = await import("../src/commands/memory.js");
|
||||||
|
await cmdDelete(mockBackend, "abc-123", { output: "agent" });
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("delete");
|
||||||
|
expect(parsed.data).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdEventList outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdEventList } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventList(mockBackend, { output: "agent" });
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("event list");
|
||||||
|
expect(Array.isArray(parsed.data)).toBe(true);
|
||||||
|
expect(parsed.count).toBe(2);
|
||||||
|
expect(Object.keys(parsed.data[0]).sort()).toEqual(
|
||||||
|
["created_at", "event_type", "id", "latency", "status"],
|
||||||
|
);
|
||||||
|
expect(Object.keys(parsed.data[0])).not.toContain("updated_at");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdEventStatus outputs JSON envelope", async () => {
|
||||||
|
setAgentMode(true);
|
||||||
|
const { cmdEventStatus } = await import("../src/commands/events.js");
|
||||||
|
await cmdEventStatus(mockBackend, "evt-abc-123-def-456", { output: "agent" });
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.status).toBe("success");
|
||||||
|
expect(parsed.command).toBe("event status");
|
||||||
|
expect(parsed.data).toBeDefined();
|
||||||
|
expect(parsed.data).toMatchObject({ id: "evt-abc-123-def-456" });
|
||||||
|
expect(parsed.data.results[0]).toHaveProperty("memory");
|
||||||
|
expect(parsed.data.results[0]).not.toHaveProperty("data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ describe("createDefaultConfig", () => {
|
|||||||
expect(config.platform.baseUrl).toBe("https://api.mem0.ai");
|
expect(config.platform.baseUrl).toBe("https://api.mem0.ai");
|
||||||
expect(config.platform.apiKey).toBe("");
|
expect(config.platform.apiKey).toBe("");
|
||||||
expect(config.defaults.userId).toBe("");
|
expect(config.defaults.userId).toBe("");
|
||||||
expect(config.defaults.enableGraph).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,9 +104,4 @@ describe("setNestedValue", () => {
|
|||||||
expect(config.defaults.userId).toBe("bob");
|
expect(config.defaults.userId).toBe("bob");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces boolean for enable_graph", () => {
|
|
||||||
const config = createDefaultConfig();
|
|
||||||
expect(setNestedValue(config, "defaults.enable_graph", "true")).toBe(true);
|
|
||||||
expect(config.defaults.enableGraph).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
formatSingleMemory,
|
formatSingleMemory,
|
||||||
formatAddResult,
|
formatAddResult,
|
||||||
printResultSummary,
|
printResultSummary,
|
||||||
|
sanitizeAgentData,
|
||||||
} from "../src/output.js";
|
} from "../src/output.js";
|
||||||
|
|
||||||
let output: string;
|
let output: string;
|
||||||
@@ -98,6 +99,18 @@ describe("formatAddResult", () => {
|
|||||||
});
|
});
|
||||||
expect(output).toContain("Queued");
|
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", () => {
|
describe("printResultSummary", () => {
|
||||||
@@ -113,3 +126,73 @@ describe("printResultSummary", () => {
|
|||||||
expect(output).not.toContain("results");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,5 +71,42 @@ export function createMockBackend(): Backend {
|
|||||||
{ name: "alice", count: 5 },
|
{ name: "alice", count: 5 },
|
||||||
{ name: "bob", count: 3 },
|
{ 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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,10 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
// Integration tests spawn the CLI via `npx tsx` (15s subprocess
|
||||||
|
// timeout); the first spawn in a file pays a cold-start cost that can
|
||||||
|
// exceed vitest's 5s default on CI runners.
|
||||||
|
testTimeout: 30_000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/`.
|
||||||
+315
-5
@@ -1,6 +1,12 @@
|
|||||||
# mem0 CLI
|
# mem0 CLI (Python)
|
||||||
|
|
||||||
The official command-line interface for [mem0](https://mem0.ai) — the memory layer for AI agents.
|
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
|
## Installation
|
||||||
|
|
||||||
@@ -18,22 +24,326 @@ 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.
|
> **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
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set up your configuration
|
# Interactive setup wizard
|
||||||
mem0 init
|
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
|
# Add a memory
|
||||||
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
|
mem0 add "I prefer dark mode and use vim keybindings" --user-id alice
|
||||||
|
|
||||||
# Search memories
|
# Search memories
|
||||||
mem0 search "What are Alice's preferences?" --user-id alice
|
mem0 search "What are Alice's preferences?" --user-id alice
|
||||||
|
|
||||||
# List all memories
|
# List all memories for a user
|
||||||
mem0 list --user-id alice
|
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
|
## License
|
||||||
|
|
||||||
Apache-2.0
|
Apache-2.0
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0-cli"
|
name = "mem0-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.7"
|
||||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.4"
|
||||||
|
|||||||
@@ -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
|
||||||
+341
-58
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import os
|
||||||
|
import stat as _stat_mod
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,7 +13,7 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from mem0_cli import __version__
|
from mem0_cli import __version__
|
||||||
from mem0_cli.branding import BRAND_COLOR, print_error
|
from mem0_cli.branding import BRAND_COLOR, print_error, print_warning
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
err_console = Console(stderr=True)
|
err_console = Console(stderr=True)
|
||||||
@@ -43,7 +46,52 @@ entity_app = typer.Typer(
|
|||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
rich_markup_mode="rich",
|
rich_markup_mode="rich",
|
||||||
)
|
)
|
||||||
# entity_app registered after Memory commands to control panel ordering
|
|
||||||
|
event_app = typer.Typer(
|
||||||
|
name="event",
|
||||||
|
help="Inspect background processing events.",
|
||||||
|
no_args_is_help=True,
|
||||||
|
rich_markup_mode="rich",
|
||||||
|
)
|
||||||
|
# entity_app and event_app registered after Memory commands to control panel ordering
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validated user identity (set by _get_backend_and_config) ──────────────
|
||||||
|
|
||||||
|
_validated_user_email: str | None = None
|
||||||
|
|
||||||
|
# ── Telemetry helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_telemetry(command_name: str, extra: dict | None = None) -> None:
|
||||||
|
"""Fire a PostHog telemetry event (non-blocking, never fails)."""
|
||||||
|
try:
|
||||||
|
from mem0_cli.telemetry import capture_event
|
||||||
|
|
||||||
|
props = {"command": command_name}
|
||||||
|
if extra:
|
||||||
|
props.update(extra)
|
||||||
|
capture_event(f"cli.{command_name}", props, pre_resolved_email=_validated_user_email)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@config_app.callback(invoke_without_command=True)
|
||||||
|
def _config_callback(ctx: typer.Context) -> None:
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
_fire_telemetry(f"config.{ctx.invoked_subcommand}")
|
||||||
|
|
||||||
|
|
||||||
|
@entity_app.callback(invoke_without_command=True)
|
||||||
|
def _entity_callback(ctx: typer.Context) -> None:
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
_fire_telemetry(f"entity.{ctx.invoked_subcommand}")
|
||||||
|
|
||||||
|
|
||||||
|
@event_app.callback(invoke_without_command=True)
|
||||||
|
def _event_callback(ctx: typer.Context) -> None:
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
_fire_telemetry(f"event.{ctx.invoked_subcommand}")
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
@@ -53,9 +101,16 @@ def _get_backend_and_config(
|
|||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
):
|
):
|
||||||
"""Build and return the Platform backend plus the loaded config."""
|
"""Build and return the Platform backend plus the loaded config.
|
||||||
|
|
||||||
|
Validates the API key upfront via ``/v1/ping/`` and caches the
|
||||||
|
resolved user email for telemetry.
|
||||||
|
"""
|
||||||
|
global _validated_user_email
|
||||||
|
|
||||||
from mem0_cli.backend import get_backend
|
from mem0_cli.backend import get_backend
|
||||||
from mem0_cli.config import load_config
|
from mem0_cli.backend.platform import AuthError
|
||||||
|
from mem0_cli.config import load_config, save_config
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
@@ -72,7 +127,29 @@ def _get_backend_and_config(
|
|||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
return get_backend(config), config
|
backend = get_backend(config)
|
||||||
|
|
||||||
|
# Validate the API key upfront with a fast timeout
|
||||||
|
try:
|
||||||
|
ping_data = backend.ping(timeout=5.0)
|
||||||
|
email = ping_data.get("user_email") if isinstance(ping_data, dict) else None
|
||||||
|
if email:
|
||||||
|
_validated_user_email = email
|
||||||
|
if config.platform.user_email != email:
|
||||||
|
config.platform.user_email = email
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
save_config(config)
|
||||||
|
except AuthError:
|
||||||
|
print_error(
|
||||||
|
err_console,
|
||||||
|
"Invalid or expired API key.",
|
||||||
|
hint="Run 'mem0 init' or set MEM0_API_KEY environment variable.",
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
except Exception:
|
||||||
|
print_warning(err_console, "Could not validate API key (network issue). Proceeding anyway.")
|
||||||
|
|
||||||
|
return backend, config
|
||||||
|
|
||||||
|
|
||||||
def _get_backend(
|
def _get_backend(
|
||||||
@@ -114,9 +191,22 @@ def _resolve_ids(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stdin_is_piped() -> bool:
|
||||||
|
"""Return True only when stdin is an actual pipe or file redirect — not a bare open fd."""
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
|
if is_agent_mode():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mode = os.fstat(sys.stdin.fileno()).st_mode
|
||||||
|
return _stat_mod.S_ISFIFO(mode) or _stat_mod.S_ISREG(mode)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _read_stdin() -> str | None:
|
def _read_stdin() -> str | None:
|
||||||
"""Read from stdin if it is piped (not a TTY)."""
|
"""Read from stdin if it is an actual pipe or file redirect (not a TTY, not agent mode)."""
|
||||||
if not sys.stdin.isatty():
|
if _stdin_is_piped():
|
||||||
return sys.stdin.read().strip() or None
|
return sys.stdin.read().strip() or None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -128,12 +218,34 @@ def _read_stdin() -> str | None:
|
|||||||
def main_callback(
|
def main_callback(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
version: bool = typer.Option(False, "--version", help="Show version and exit."),
|
version: bool = typer.Option(False, "--version", help="Show version and exit."),
|
||||||
|
json_agent: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--json",
|
||||||
|
"--agent",
|
||||||
|
help="Output as JSON for agent/programmatic use.",
|
||||||
|
is_eager=False,
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if json_agent:
|
||||||
|
from mem0_cli.state import set_agent_mode
|
||||||
|
|
||||||
|
set_agent_mode(True)
|
||||||
if version:
|
if version:
|
||||||
from mem0_cli.commands.utils import cmd_version
|
from mem0_cli.commands.utils import cmd_version
|
||||||
|
|
||||||
|
_fire_telemetry("version")
|
||||||
cmd_version()
|
cmd_version()
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
# Stash the active subcommand name so the JSON error envelope
|
||||||
|
# (print_error in agent mode) can report which command failed
|
||||||
|
# instead of an empty `"command": ""` field.
|
||||||
|
from mem0_cli.state import set_current_command
|
||||||
|
|
||||||
|
set_current_command(ctx.invoked_subcommand)
|
||||||
|
if ctx.invoked_subcommand and ctx.invoked_subcommand != "init":
|
||||||
|
# init fires its own telemetry from init_cmd.run_init with full M1-M6 props.
|
||||||
|
_fire_telemetry(ctx.invoked_subcommand)
|
||||||
|
|
||||||
|
|
||||||
# ── Memory: add ───────────────────────────────────────────────────────────
|
# ── Memory: add ───────────────────────────────────────────────────────────
|
||||||
@@ -163,8 +275,6 @@ def add(
|
|||||||
categories: str | None = typer.Option(
|
categories: str | None = typer.Option(
|
||||||
None, "--categories", help="Categories (JSON array or comma-separated)."
|
None, "--categories", help="Categories (JSON array or comma-separated)."
|
||||||
),
|
),
|
||||||
graph: bool = typer.Option(False, "--graph", help="Enable graph memory extraction."),
|
|
||||||
no_graph: bool = typer.Option(False, "--no-graph", help="Disable graph memory extraction."),
|
|
||||||
output: str = typer.Option(
|
output: str = typer.Option(
|
||||||
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
|
"text", "--output", "-o", help="Output format: text, json, quiet.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -191,13 +301,6 @@ def add(
|
|||||||
backend, config = _get_backend_and_config(api_key, base_url)
|
backend, config = _get_backend_and_config(api_key, base_url)
|
||||||
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||||
|
|
||||||
if no_graph:
|
|
||||||
graph_enabled = False
|
|
||||||
elif graph:
|
|
||||||
graph_enabled = True
|
|
||||||
else:
|
|
||||||
graph_enabled = config.defaults.enable_graph
|
|
||||||
|
|
||||||
cmd_add(
|
cmd_add(
|
||||||
backend,
|
backend,
|
||||||
text,
|
text,
|
||||||
@@ -209,7 +312,6 @@ def add(
|
|||||||
no_infer=no_infer,
|
no_infer=no_infer,
|
||||||
expires=expires,
|
expires=expires,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
enable_graph=graph_enabled,
|
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,12 +355,6 @@ def search(
|
|||||||
help="Specific fields to return (comma-separated).",
|
help="Specific fields to return (comma-separated).",
|
||||||
rich_help_panel="Search",
|
rich_help_panel="Search",
|
||||||
),
|
),
|
||||||
graph: bool = typer.Option(
|
|
||||||
False, "--graph", help="Enable graph in search.", rich_help_panel="Search"
|
|
||||||
),
|
|
||||||
no_graph: bool = typer.Option(
|
|
||||||
False, "--no-graph", help="Disable graph in search.", rich_help_panel="Search"
|
|
||||||
),
|
|
||||||
output: str = typer.Option(
|
output: str = typer.Option(
|
||||||
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
"text", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -273,7 +369,7 @@ def search(
|
|||||||
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Search memories by semantic query.
|
"""Query your memory store — semantic, keyword, or hybrid retrieval.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
mem0 search "preferences" --user-id alice
|
mem0 search "preferences" --user-id alice
|
||||||
@@ -292,13 +388,6 @@ def search(
|
|||||||
backend, config = _get_backend_and_config(api_key, base_url)
|
backend, config = _get_backend_and_config(api_key, base_url)
|
||||||
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||||
|
|
||||||
if no_graph:
|
|
||||||
graph_enabled = False
|
|
||||||
elif graph:
|
|
||||||
graph_enabled = True
|
|
||||||
else:
|
|
||||||
graph_enabled = config.defaults.enable_graph
|
|
||||||
|
|
||||||
cmd_search(
|
cmd_search(
|
||||||
backend,
|
backend,
|
||||||
query,
|
query,
|
||||||
@@ -309,7 +398,6 @@ def search(
|
|||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
filter_json=filter_json,
|
filter_json=filter_json,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
enable_graph=graph_enabled,
|
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,12 +464,6 @@ def list_cmd(
|
|||||||
before: str | None = typer.Option(
|
before: str | None = typer.Option(
|
||||||
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
|
None, "--before", help="Created before (YYYY-MM-DD).", rich_help_panel="Filters"
|
||||||
),
|
),
|
||||||
graph: bool = typer.Option(
|
|
||||||
False, "--graph", help="Enable graph in listing.", rich_help_panel="Filters"
|
|
||||||
),
|
|
||||||
no_graph: bool = typer.Option(
|
|
||||||
False, "--no-graph", help="Disable graph in listing.", rich_help_panel="Filters"
|
|
||||||
),
|
|
||||||
output: str = typer.Option(
|
output: str = typer.Option(
|
||||||
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
"table", "--output", "-o", help="Output: text, json, table.", rich_help_panel="Output"
|
||||||
),
|
),
|
||||||
@@ -407,13 +489,6 @@ def list_cmd(
|
|||||||
backend, config = _get_backend_and_config(api_key, base_url)
|
backend, config = _get_backend_and_config(api_key, base_url)
|
||||||
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
ids = _resolve_ids(config, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||||
|
|
||||||
if no_graph:
|
|
||||||
graph_enabled = False
|
|
||||||
elif graph:
|
|
||||||
graph_enabled = True
|
|
||||||
else:
|
|
||||||
graph_enabled = config.defaults.enable_graph
|
|
||||||
|
|
||||||
cmd_list(
|
cmd_list(
|
||||||
backend,
|
backend,
|
||||||
**ids,
|
**ids,
|
||||||
@@ -422,7 +497,6 @@ def list_cmd(
|
|||||||
category=category,
|
category=category,
|
||||||
after=after,
|
after=after,
|
||||||
before=before,
|
before=before,
|
||||||
enable_graph=graph_enabled,
|
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -538,12 +612,14 @@ def delete(
|
|||||||
|
|
||||||
# ── Dispatch ─────────────────────────────────────────────────────
|
# ── Dispatch ─────────────────────────────────────────────────────
|
||||||
if memory_id is not None:
|
if memory_id is not None:
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "single"})
|
||||||
from mem0_cli.commands.memory import cmd_delete
|
from mem0_cli.commands.memory import cmd_delete
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
backend = _get_backend(api_key, base_url)
|
||||||
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
|
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
|
||||||
|
|
||||||
elif all_:
|
elif all_:
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "all"})
|
||||||
from mem0_cli.commands.memory import cmd_delete_all
|
from mem0_cli.commands.memory import cmd_delete_all
|
||||||
|
|
||||||
backend, config = _get_backend_and_config(api_key, base_url)
|
backend, config = _get_backend_and_config(api_key, base_url)
|
||||||
@@ -551,6 +627,7 @@ def delete(
|
|||||||
cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output)
|
cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output)
|
||||||
|
|
||||||
else: # --entity
|
else: # --entity
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "entity"})
|
||||||
from mem0_cli.commands.entities import cmd_entities_delete
|
from mem0_cli.commands.entities import cmd_entities_delete
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
backend = _get_backend(api_key, base_url)
|
||||||
@@ -702,6 +779,70 @@ def entity_delete(
|
|||||||
app.add_typer(entity_app, name="entity", rich_help_panel="Management")
|
app.add_typer(entity_app, name="entity", rich_help_panel="Management")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event subcommands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@event_app.command("list")
|
||||||
|
def event_list(
|
||||||
|
output: str = typer.Option(
|
||||||
|
"table", "--output", "-o", help="Output: table, json.", rich_help_panel="Output"
|
||||||
|
),
|
||||||
|
api_key: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api-key",
|
||||||
|
help="Override API key.",
|
||||||
|
envvar="MEM0_API_KEY",
|
||||||
|
rich_help_panel="Connection",
|
||||||
|
),
|
||||||
|
base_url: str | None = typer.Option(
|
||||||
|
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""List recent background processing events.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
mem0 event list
|
||||||
|
mem0 event list -o json
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.events_cmd import cmd_event_list
|
||||||
|
|
||||||
|
backend = _get_backend(api_key, base_url)
|
||||||
|
cmd_event_list(backend, output=output)
|
||||||
|
|
||||||
|
|
||||||
|
@event_app.command("status")
|
||||||
|
def event_status(
|
||||||
|
event_id: str = typer.Argument(..., help="Event ID to inspect."),
|
||||||
|
output: str = typer.Option(
|
||||||
|
"text", "--output", "-o", help="Output: text, json.", rich_help_panel="Output"
|
||||||
|
),
|
||||||
|
api_key: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api-key",
|
||||||
|
help="Override API key.",
|
||||||
|
envvar="MEM0_API_KEY",
|
||||||
|
rich_help_panel="Connection",
|
||||||
|
),
|
||||||
|
base_url: str | None = typer.Option(
|
||||||
|
None, "--base-url", help="Override API base URL.", rich_help_panel="Connection"
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Check the status of a specific background event.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
mem0 event status <event-id>
|
||||||
|
mem0 event status <event-id> -o json
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.events_cmd import cmd_event_status
|
||||||
|
|
||||||
|
backend = _get_backend(api_key, base_url)
|
||||||
|
cmd_event_status(backend, event_id, output=output)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event subgroup ──
|
||||||
|
app.add_typer(event_app, name="event", rich_help_panel="Management")
|
||||||
|
|
||||||
|
|
||||||
# ── Management commands ───────────────────────────────────────────────────
|
# ── Management commands ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -715,6 +856,22 @@ def init(
|
|||||||
code: str | None = typer.Option(
|
code: str | None = typer.Option(
|
||||||
None, "--code", help="Verification code (use with --email for non-interactive login)."
|
None, "--code", help="Verification code (use with --email for non-interactive login)."
|
||||||
),
|
),
|
||||||
|
force: bool = typer.Option(
|
||||||
|
False, "--force", help="Overwrite existing config without confirmation."
|
||||||
|
),
|
||||||
|
agent_signal: bool = typer.Option(
|
||||||
|
False, "--agent", help="Bootstrap an unattended Agent Mode account (no email required)."
|
||||||
|
),
|
||||||
|
source: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--source",
|
||||||
|
help="Channel attribution for signup (e.g. github, hn, ph).",
|
||||||
|
),
|
||||||
|
agent_caller: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--agent-caller",
|
||||||
|
help="Self-declared agent identity (e.g. claude-code, cursor). Used with --agent to attribute Agent Mode signups.",
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive setup wizard for mem0 CLI.
|
"""Interactive setup wizard for mem0 CLI.
|
||||||
|
|
||||||
@@ -723,10 +880,97 @@ def init(
|
|||||||
mem0 init --api-key m0-xxx --user-id alice
|
mem0 init --api-key m0-xxx --user-id alice
|
||||||
mem0 init --email alice@company.com
|
mem0 init --email alice@company.com
|
||||||
mem0 init --email alice@company.com --code 482901
|
mem0 init --email alice@company.com --code 482901
|
||||||
|
mem0 init --agent --agent-caller claude-code # AI agent self-identifies on Agent Mode bootstrap
|
||||||
|
mem0 init --email alice@company.com # Claims an existing Agent Mode key when one is present
|
||||||
"""
|
"""
|
||||||
from mem0_cli.commands.init_cmd import run_init
|
from mem0_cli.commands.init_cmd import run_init
|
||||||
|
|
||||||
run_init(api_key=api_key, user_id=user_id, email=email, code=code)
|
run_init(
|
||||||
|
api_key=api_key,
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
code=code,
|
||||||
|
force=force,
|
||||||
|
source=source,
|
||||||
|
agent=agent_signal,
|
||||||
|
agent_caller=agent_caller,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(rich_help_panel="Setup")
|
||||||
|
def identify(
|
||||||
|
name: str = typer.Argument(..., help="Agent identity (e.g. claude-code, cursor, my-bot)."),
|
||||||
|
) -> None:
|
||||||
|
"""Tag your active Agent Mode key with the AI agent that's using it.
|
||||||
|
|
||||||
|
Run this once after `mem0 init --agent` if you didn't pass --agent-caller.
|
||||||
|
Idempotent — re-running just overwrites the value.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mem0 identify claude-code
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.identify_cmd import run_identify
|
||||||
|
|
||||||
|
run_identify(name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="whoami", rich_help_panel="Setup")
|
||||||
|
def whoami_cmd() -> None:
|
||||||
|
"""Print your AGENTRUSH identifier (default_user_id).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mem0 whoami
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.whoami_cmd import run_whoami
|
||||||
|
|
||||||
|
run_whoami()
|
||||||
|
|
||||||
|
|
||||||
|
# ── AGENTRUSH sub-app ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
agent_rush_app = typer.Typer(
|
||||||
|
name="agent-rush",
|
||||||
|
help="AGENTRUSH game commands",
|
||||||
|
no_args_is_help=True,
|
||||||
|
rich_markup_mode="rich",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@agent_rush_app.callback(invoke_without_command=True)
|
||||||
|
def _agent_rush_callback(ctx: typer.Context) -> None:
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
_fire_telemetry(f"agent-rush.{ctx.invoked_subcommand}")
|
||||||
|
|
||||||
|
|
||||||
|
@agent_rush_app.command(name="add")
|
||||||
|
def agent_rush_add(
|
||||||
|
content: str = typer.Argument(..., help="Memory content (50-1000 characters, no URLs)."),
|
||||||
|
) -> None:
|
||||||
|
"""Submit a memory to AGENTRUSH.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mem0 agent-rush add "I enjoy solving constraint-satisfaction problems."
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.agent_rush_cmd import run_agent_rush_add
|
||||||
|
|
||||||
|
run_agent_rush_add(content)
|
||||||
|
|
||||||
|
|
||||||
|
@agent_rush_app.command(name="search")
|
||||||
|
def agent_rush_search(
|
||||||
|
query: str = typer.Argument(..., help="Search query."),
|
||||||
|
) -> None:
|
||||||
|
"""Search AGENTRUSH memories.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
mem0 agent-rush search "constraint satisfaction"
|
||||||
|
"""
|
||||||
|
from mem0_cli.commands.agent_rush_cmd import run_agent_rush_search
|
||||||
|
|
||||||
|
run_agent_rush_search(query)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_typer(agent_rush_app, name="agent-rush", rich_help_panel="Setup")
|
||||||
|
|
||||||
|
|
||||||
# (entity_app registered at module level, below sub-group definitions)
|
# (entity_app registered at module level, below sub-group definitions)
|
||||||
@@ -831,7 +1075,7 @@ def _build_help_json() -> dict:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"description": "Search memories by semantic query.",
|
"description": "Query your memory store — semantic, keyword, or hybrid retrieval.",
|
||||||
"usage": "mem0 search <query> [OPTIONS]",
|
"usage": "mem0 search <query> [OPTIONS]",
|
||||||
"arguments": {"query": {"description": "Search query.", "required": False}},
|
"arguments": {"query": {"description": "Search query.", "required": False}},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -935,6 +1179,24 @@ def _build_help_json() -> dict:
|
|||||||
"value": {"description": "Value to set.", "required": True},
|
"value": {"description": "Value to set.", "required": True},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"event": {
|
||||||
|
"description": "Inspect background processing events.",
|
||||||
|
"subcommands": {
|
||||||
|
"list": {
|
||||||
|
"description": "List recent background processing events.",
|
||||||
|
"usage": "mem0 event list [OPTIONS]",
|
||||||
|
"options": {"--output, -o": "Output format: table, json."},
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Check the status of a specific background event.",
|
||||||
|
"usage": "mem0 event status <event_id> [OPTIONS]",
|
||||||
|
"arguments": {
|
||||||
|
"event_id": {"description": "Event ID to inspect.", "required": True}
|
||||||
|
},
|
||||||
|
"options": {"--output, -o": "Output format: text, json."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"description": "Manage entities.",
|
"description": "Manage entities.",
|
||||||
"subcommands": {
|
"subcommands": {
|
||||||
@@ -986,6 +1248,7 @@ def _build_help_json() -> dict:
|
|||||||
"global_options": {
|
"global_options": {
|
||||||
"--api-key": "Override API key (env: MEM0_API_KEY).",
|
"--api-key": "Override API key (env: MEM0_API_KEY).",
|
||||||
"--base-url": "Override API base URL.",
|
"--base-url": "Override API base URL.",
|
||||||
|
"--json / --agent": "Output as JSON for agent/programmatic use.",
|
||||||
"--help": "Show help for a command.",
|
"--help": "Show help for a command.",
|
||||||
"--version": "Show version and exit.",
|
"--version": "Show version and exit.",
|
||||||
},
|
},
|
||||||
@@ -1015,7 +1278,7 @@ def help(
|
|||||||
console.print("Usage: mem0 <command> [OPTIONS]\n")
|
console.print("Usage: mem0 <command> [OPTIONS]\n")
|
||||||
console.print("[bold]Commands:[/]")
|
console.print("[bold]Commands:[/]")
|
||||||
console.print(" add Add a memory from text, messages, file, or stdin")
|
console.print(" add Add a memory from text, messages, file, or stdin")
|
||||||
console.print(" search Search memories by semantic query")
|
console.print(" search Query your memory store (semantic, keyword, hybrid)")
|
||||||
console.print(" get Get a specific memory by ID")
|
console.print(" get Get a specific memory by ID")
|
||||||
console.print(" list List memories with optional filters")
|
console.print(" list List memories with optional filters")
|
||||||
console.print(" update Update a memory's text or metadata")
|
console.print(" update Update a memory's text or metadata")
|
||||||
@@ -1023,6 +1286,7 @@ def help(
|
|||||||
console.print(" import Import memories from a JSON file")
|
console.print(" import Import memories from a JSON file")
|
||||||
console.print(" config Manage configuration (show, get, set)")
|
console.print(" config Manage configuration (show, get, set)")
|
||||||
console.print(" entity Manage entities (list, delete)")
|
console.print(" entity Manage entities (list, delete)")
|
||||||
|
console.print(" event Inspect background events (list, status)")
|
||||||
console.print(" init Interactive setup wizard")
|
console.print(" init Interactive setup wizard")
|
||||||
console.print(" status Check connectivity and authentication")
|
console.print(" status Check connectivity and authentication")
|
||||||
console.print()
|
console.print()
|
||||||
@@ -1031,14 +1295,6 @@ def help(
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
@app.command(rich_help_panel="Utility")
|
|
||||||
def version() -> None:
|
|
||||||
"""Show version and exit."""
|
|
||||||
from mem0_cli.commands.utils import cmd_version
|
|
||||||
|
|
||||||
cmd_version()
|
|
||||||
|
|
||||||
|
|
||||||
# Register config subgroup here so it appears after help in Management panel
|
# Register config subgroup here so it appears after help in Management panel
|
||||||
app.add_typer(config_app, name="config", rich_help_panel="Management")
|
app.add_typer(config_app, name="config", rich_help_panel="Management")
|
||||||
|
|
||||||
@@ -1047,4 +1303,31 @@ app.add_typer(config_app, name="config", rich_help_panel="Management")
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
app()
|
import sys
|
||||||
|
|
||||||
|
# Allow --json/--agent anywhere in the command line (not just before subcommand).
|
||||||
|
# Special case: `mem0 init --agent` is a subcommand flag (Agent Mode bootstrap)
|
||||||
|
# consumed by init_cmd, not a global JSON-output toggle — leave it in argv.
|
||||||
|
argv_rest = sys.argv[1:]
|
||||||
|
is_init = "init" in argv_rest
|
||||||
|
_global_flags = {"--json"} if is_init else {"--json", "--agent"}
|
||||||
|
if any(a in _global_flags for a in argv_rest):
|
||||||
|
from mem0_cli.state import set_agent_mode
|
||||||
|
|
||||||
|
set_agent_mode(True)
|
||||||
|
sys.argv = [sys.argv[0]] + [a for a in argv_rest if a not in _global_flags]
|
||||||
|
|
||||||
|
try:
|
||||||
|
app()
|
||||||
|
finally:
|
||||||
|
# Surface any unclaimed Agent Mode notice once per command, after the
|
||||||
|
# primary output. In JSON/agent mode the notice is folded into the
|
||||||
|
# envelope by format_json_envelope, so skip the stderr banner there
|
||||||
|
# to avoid duplicate output.
|
||||||
|
from mem0_cli.state import is_agent_mode, take_notice
|
||||||
|
|
||||||
|
notice = take_notice()
|
||||||
|
if notice and not is_agent_mode():
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
Console(stderr=True).print(f"\n[yellow]🔔 {notice}[/yellow]\n")
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class Backend(ABC):
|
|||||||
infer: bool = True,
|
infer: bool = True,
|
||||||
expires: str | None = None,
|
expires: str | None = None,
|
||||||
categories: list[str] | None = None,
|
categories: list[str] | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> dict: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -44,7 +43,6 @@ class Backend(ABC):
|
|||||||
keyword: bool = False,
|
keyword: bool = False,
|
||||||
filters: dict | None = None,
|
filters: dict | None = None,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> list[dict]: ...
|
) -> list[dict]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -63,7 +61,6 @@ class Backend(ABC):
|
|||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
before: str | None = None,
|
before: str | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> list[dict]: ...
|
) -> list[dict]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -104,6 +101,12 @@ class Backend(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def entities(self, entity_type: str) -> list[dict]: ...
|
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:
|
def get_backend(config: Mem0Config) -> Backend:
|
||||||
"""Return the Platform backend."""
|
"""Return the Platform backend."""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from mem0_cli import __version__
|
||||||
from mem0_cli.backend.base import Backend
|
from mem0_cli.backend.base import Backend
|
||||||
from mem0_cli.config import PlatformConfig
|
from mem0_cli.config import PlatformConfig
|
||||||
|
|
||||||
@@ -21,11 +22,17 @@ class PlatformBackend(Backend):
|
|||||||
headers={
|
headers={
|
||||||
"Authorization": f"Token {config.api_key}",
|
"Authorization": f"Token {config.api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "python",
|
||||||
|
"X-Mem0-Client-Version": __version__,
|
||||||
},
|
},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
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)
|
resp = self._client.request(method, path, **kwargs)
|
||||||
if resp.status_code == 401:
|
if resp.status_code == 401:
|
||||||
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
|
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
|
||||||
@@ -41,7 +48,26 @@ class PlatformBackend(Backend):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return {}
|
return {}
|
||||||
return resp.json()
|
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(
|
def add(
|
||||||
self,
|
self,
|
||||||
@@ -57,7 +83,6 @@ class PlatformBackend(Backend):
|
|||||||
infer: bool = True,
|
infer: bool = True,
|
||||||
expires: str | None = None,
|
expires: str | None = None,
|
||||||
categories: list[str] | None = None,
|
categories: list[str] | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -84,10 +109,9 @@ class PlatformBackend(Backend):
|
|||||||
payload["expiration_date"] = expires
|
payload["expiration_date"] = expires
|
||||||
if categories:
|
if categories:
|
||||||
payload["categories"] = categories
|
payload["categories"] = categories
|
||||||
if enable_graph:
|
payload["source"] = "CLI"
|
||||||
payload["enable_graph"] = True
|
|
||||||
|
|
||||||
return self._request("POST", "/v1/memories/", json=payload)
|
return self._request("POST", "/v3/memories/add/", json=payload)
|
||||||
|
|
||||||
def _build_filters(
|
def _build_filters(
|
||||||
self,
|
self,
|
||||||
@@ -98,7 +122,7 @@ class PlatformBackend(Backend):
|
|||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
extra_filters: dict | None = None,
|
extra_filters: dict | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Build a filters dict for v2 API endpoints.
|
"""Build a filters dict for v3 API endpoints.
|
||||||
|
|
||||||
Entity IDs are ANDed (all provided IDs must match).
|
Entity IDs are ANDed (all provided IDs must match).
|
||||||
Extra filters (date ranges, categories) are also ANDed.
|
Extra filters (date ranges, categories) are also ANDed.
|
||||||
@@ -144,7 +168,6 @@ class PlatformBackend(Backend):
|
|||||||
keyword: bool = False,
|
keyword: bool = False,
|
||||||
filters: dict | None = None,
|
filters: dict | None = None,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
|
payload: dict[str, Any] = {"query": query, "top_k": top_k, "threshold": threshold}
|
||||||
|
|
||||||
@@ -163,10 +186,9 @@ class PlatformBackend(Backend):
|
|||||||
payload["keyword_search"] = True
|
payload["keyword_search"] = True
|
||||||
if fields:
|
if fields:
|
||||||
payload["fields"] = fields
|
payload["fields"] = fields
|
||||||
if enable_graph:
|
payload["source"] = "CLI"
|
||||||
payload["enable_graph"] = True
|
|
||||||
|
|
||||||
result = self._request("POST", "/v2/memories/search/", json=payload)
|
result = self._request("POST", "/v3/memories/search/", json=payload)
|
||||||
return (
|
return (
|
||||||
result
|
result
|
||||||
if isinstance(result, list)
|
if isinstance(result, list)
|
||||||
@@ -174,7 +196,7 @@ class PlatformBackend(Backend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get(self, memory_id: str) -> dict:
|
def get(self, memory_id: str) -> dict:
|
||||||
return self._request("GET", f"/v1/memories/{memory_id}/")
|
return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
|
||||||
|
|
||||||
def list_memories(
|
def list_memories(
|
||||||
self,
|
self,
|
||||||
@@ -188,12 +210,11 @@ class PlatformBackend(Backend):
|
|||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
after: str | None = None,
|
after: str | None = None,
|
||||||
before: str | None = None,
|
before: str | None = None,
|
||||||
enable_graph: bool = False,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
params = {"page": str(page), "page_size": str(page_size)}
|
params = {"page": str(page), "page_size": str(page_size)}
|
||||||
|
|
||||||
# Build filters for v2 API — entity IDs and date filters go inside "filters"
|
# Build filters — entity IDs and date filters go inside "filters"
|
||||||
extra: dict[str, Any] = {}
|
extra: dict[str, Any] = {}
|
||||||
if category:
|
if category:
|
||||||
extra["categories"] = {"contains": category}
|
extra["categories"] = {"contains": category}
|
||||||
@@ -211,10 +232,9 @@ class PlatformBackend(Backend):
|
|||||||
)
|
)
|
||||||
if api_filters:
|
if api_filters:
|
||||||
payload["filters"] = api_filters
|
payload["filters"] = api_filters
|
||||||
if enable_graph:
|
payload["source"] = "CLI"
|
||||||
payload["enable_graph"] = True
|
|
||||||
|
|
||||||
result = self._request("POST", "/v2/memories/", json=payload, params=params)
|
result = self._request("POST", "/v3/memories/", json=payload, params=params)
|
||||||
return (
|
return (
|
||||||
result
|
result
|
||||||
if isinstance(result, list)
|
if isinstance(result, list)
|
||||||
@@ -229,6 +249,7 @@ class PlatformBackend(Backend):
|
|||||||
payload["text"] = content
|
payload["text"] = content
|
||||||
if metadata:
|
if metadata:
|
||||||
payload["metadata"] = metadata
|
payload["metadata"] = metadata
|
||||||
|
payload["source"] = "CLI"
|
||||||
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
|
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
|
||||||
|
|
||||||
def delete(
|
def delete(
|
||||||
@@ -242,7 +263,7 @@ class PlatformBackend(Backend):
|
|||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if all:
|
if all:
|
||||||
params: dict[str, str] = {}
|
params: dict[str, str] = {"source": "CLI"}
|
||||||
if user_id:
|
if user_id:
|
||||||
params["user_id"] = user_id
|
params["user_id"] = user_id
|
||||||
if agent_id:
|
if agent_id:
|
||||||
@@ -253,7 +274,7 @@ class PlatformBackend(Backend):
|
|||||||
params["run_id"] = run_id
|
params["run_id"] = run_id
|
||||||
return self._request("DELETE", "/v1/memories/", params=params)
|
return self._request("DELETE", "/v1/memories/", params=params)
|
||||||
elif memory_id:
|
elif memory_id:
|
||||||
return self._request("DELETE", f"/v1/memories/{memory_id}/")
|
return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
|
||||||
else:
|
else:
|
||||||
raise ValueError("Either memory_id or --all is required")
|
raise ValueError("Either memory_id or --all is required")
|
||||||
|
|
||||||
@@ -265,18 +286,37 @@ class PlatformBackend(Backend):
|
|||||||
app_id: str | None = None,
|
app_id: str | None = None,
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
params: dict[str, str] = {}
|
# v2 endpoint: DELETE /v2/entities/{entity_type}/{entity_id}/
|
||||||
if user_id:
|
type_map = {
|
||||||
params["user_id"] = user_id
|
"user": user_id,
|
||||||
if agent_id:
|
"agent": agent_id,
|
||||||
params["agent_id"] = agent_id
|
"app": app_id,
|
||||||
if app_id:
|
"run": run_id,
|
||||||
params["app_id"] = app_id
|
}
|
||||||
if run_id:
|
entities = {t: v for t, v in type_map.items() if v}
|
||||||
params["run_id"] = run_id
|
if not entities:
|
||||||
if not params:
|
|
||||||
raise ValueError("At least one entity ID is required for delete_entities.")
|
raise ValueError("At least one entity ID is required for delete_entities.")
|
||||||
return self._request("DELETE", "/v1/entities/", params=params)
|
# Delete each provided entity via the v2 path-based endpoint
|
||||||
|
result: dict = {}
|
||||||
|
for entity_type, entity_id in entities.items():
|
||||||
|
result = self._request(
|
||||||
|
"DELETE", f"/v2/entities/{entity_type}/{entity_id}/", 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(
|
def status(
|
||||||
self,
|
self,
|
||||||
@@ -284,19 +324,9 @@ class PlatformBackend(Backend):
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Check connectivity by making a lightweight API call."""
|
"""Check connectivity using the ping endpoint."""
|
||||||
try:
|
try:
|
||||||
# If entity IDs are available, validate with a minimal memories list
|
self.ping()
|
||||||
if user_id or agent_id:
|
|
||||||
payload: dict[str, Any] = {}
|
|
||||||
params = {"page": "1", "page_size": "1"}
|
|
||||||
api_filters = self._build_filters(user_id=user_id, agent_id=agent_id)
|
|
||||||
if api_filters:
|
|
||||||
payload["filters"] = api_filters
|
|
||||||
self._request("POST", "/v2/memories/", json=payload, params=params)
|
|
||||||
else:
|
|
||||||
# No entity IDs — use entities endpoint to validate API key
|
|
||||||
self._request("GET", "/v1/entities/")
|
|
||||||
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"connected": False, "backend": "platform", "error": str(e)}
|
return {"connected": False, "backend": "platform", "error": str(e)}
|
||||||
@@ -311,6 +341,13 @@ class PlatformBackend(Backend):
|
|||||||
items = [e for e in items if e.get("type", "").lower() == target_type]
|
items = [e for e in items if e.get("type", "").lower() == target_type]
|
||||||
return items
|
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):
|
class AuthError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ def _sym(fancy: str, plain: str) -> str:
|
|||||||
|
|
||||||
def print_banner(console: Console) -> None:
|
def print_banner(console: Console) -> None:
|
||||||
"""Print the mem0 welcome banner."""
|
"""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}")
|
logo_text = Text(LOGO, style=f"bold {BRAND_COLOR}")
|
||||||
tagline = Text(f" {TAGLINE}\n", style=f"{ACCENT_COLOR}")
|
tagline = Text(f" {TAGLINE}\n", style=f"{ACCENT_COLOR}")
|
||||||
|
|
||||||
@@ -61,23 +65,50 @@ def print_banner(console: Console) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def print_success(console: Console, message: str) -> None:
|
def print_success(console: Console, message: str) -> None:
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
|
if is_agent_mode():
|
||||||
|
return
|
||||||
sym = _sym("✓", "[ok]")
|
sym = _sym("✓", "[ok]")
|
||||||
console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}")
|
console.print(f"[{SUCCESS_COLOR}]{sym}[/] {message}")
|
||||||
|
|
||||||
|
|
||||||
def print_error(console: Console, message: str, hint: str | None = None) -> None:
|
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]")
|
sym = _sym("✗", "[error]")
|
||||||
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {message}")
|
console.print(f"[{ERROR_COLOR}]{sym} Error:[/] {escape(str(message))}")
|
||||||
if hint:
|
if hint:
|
||||||
console.print(f" [{DIM_COLOR}]{hint}[/]")
|
console.print(f" [{DIM_COLOR}]{escape(str(hint))}[/]")
|
||||||
|
|
||||||
|
|
||||||
def print_warning(console: Console, message: str) -> None:
|
def print_warning(console: Console, message: str) -> None:
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
|
if is_agent_mode():
|
||||||
|
return
|
||||||
sym = _sym("⚠", "[warn]")
|
sym = _sym("⚠", "[warn]")
|
||||||
console.print(f"[{WARNING_COLOR}]{sym}[/] {message}")
|
console.print(f"[{WARNING_COLOR}]{sym}[/] {message}")
|
||||||
|
|
||||||
|
|
||||||
def print_info(console: Console, message: str) -> None:
|
def print_info(console: Console, message: str) -> None:
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
|
if is_agent_mode():
|
||||||
|
return
|
||||||
sym = _sym("◆", "*")
|
sym = _sym("◆", "*")
|
||||||
console.print(f"[{BRAND_COLOR}]{sym}[/] {message}")
|
console.print(f"[{BRAND_COLOR}]{sym}[/] {message}")
|
||||||
|
|
||||||
@@ -89,7 +120,9 @@ def timed_status(console: Console, message: str):
|
|||||||
The spinner and timing output are sent to stderr (via ``_err``) so they
|
The spinner and timing output are sent to stderr (via ``_err``) so they
|
||||||
never contaminate machine-readable stdout. The *console* parameter is
|
never contaminate machine-readable stdout. The *console* parameter is
|
||||||
kept for backward compatibility but is not used for spinner output.
|
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:
|
class _Ctx:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -97,6 +130,13 @@ def timed_status(console: Console, message: str):
|
|||||||
self.error_msg = ""
|
self.error_msg = ""
|
||||||
|
|
||||||
ctx = _Ctx()
|
ctx = _Ctx()
|
||||||
|
if is_agent_mode():
|
||||||
|
try:
|
||||||
|
yield ctx
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
with Status(f"[{DIM_COLOR}]{message}[/]", console=_err):
|
with Status(f"[{DIM_COLOR}]{message}[/]", console=_err):
|
||||||
@@ -105,6 +145,11 @@ def timed_status(console: Console, message: str):
|
|||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
if ctx.error_msg:
|
if ctx.error_msg:
|
||||||
print_error(_err, f"{ctx.error_msg} ({elapsed:.2f}s)")
|
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
|
raise
|
||||||
else:
|
else:
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
@@ -114,11 +159,14 @@ def timed_status(console: Console, message: str):
|
|||||||
|
|
||||||
def print_scope(console: Console, **ids: str | None) -> None:
|
def print_scope(console: Console, **ids: str | None) -> None:
|
||||||
"""Show active entity scope if any IDs are set."""
|
"""Show active entity scope if any IDs are set."""
|
||||||
|
from mem0_cli.state import is_agent_mode
|
||||||
|
|
||||||
|
if is_agent_mode():
|
||||||
|
return
|
||||||
parts = []
|
parts = []
|
||||||
for key, val in ids.items():
|
for key, val in ids.items():
|
||||||
if val:
|
if val:
|
||||||
label = key.replace("_", " ").replace("id", "ID").strip()
|
parts.append(f"{key}={val}")
|
||||||
parts.append(f"{label}={val}")
|
|
||||||
if parts:
|
if parts:
|
||||||
scope_str = ", ".join(parts)
|
scope_str = ", ".join(parts)
|
||||||
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
|
console.print(f" [{DIM_COLOR}]Scope: {scope_str}[/]")
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -20,12 +20,17 @@ err_console = Console(stderr=True)
|
|||||||
|
|
||||||
def cmd_config_show(*, output: str = "text") -> None:
|
def cmd_config_show(*, output: str = "text") -> None:
|
||||||
"""Display current configuration (secrets redacted)."""
|
"""Display current configuration (secrets redacted)."""
|
||||||
from mem0_cli.output import format_json_envelope
|
from mem0_cli.output import format_agent_envelope
|
||||||
|
from mem0_cli.state import is_agent_mode, set_current_command
|
||||||
|
|
||||||
|
set_current_command("config show")
|
||||||
|
if is_agent_mode():
|
||||||
|
output = "agent"
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if output == "json":
|
if output in ("json", "agent"):
|
||||||
format_json_envelope(
|
format_agent_envelope(
|
||||||
console,
|
console,
|
||||||
command="config show",
|
command="config show",
|
||||||
data={
|
data={
|
||||||
@@ -34,7 +39,6 @@ def cmd_config_show(*, output: str = "text") -> None:
|
|||||||
"agent_id": config.defaults.agent_id or None,
|
"agent_id": config.defaults.agent_id or None,
|
||||||
"app_id": config.defaults.app_id or None,
|
"app_id": config.defaults.app_id or None,
|
||||||
"run_id": config.defaults.run_id or None,
|
"run_id": config.defaults.run_id or None,
|
||||||
"enable_graph": config.defaults.enable_graph,
|
|
||||||
},
|
},
|
||||||
"platform": {
|
"platform": {
|
||||||
"api_key": redact_key(config.platform.api_key),
|
"api_key": redact_key(config.platform.api_key),
|
||||||
@@ -68,10 +72,6 @@ def cmd_config_show(*, output: str = "text") -> None:
|
|||||||
"defaults.run_id",
|
"defaults.run_id",
|
||||||
config.defaults.run_id or f"[{DIM_COLOR}](not set)[/]",
|
config.defaults.run_id or f"[{DIM_COLOR}](not set)[/]",
|
||||||
)
|
)
|
||||||
table.add_row(
|
|
||||||
"defaults.enable_graph",
|
|
||||||
str(config.defaults.enable_graph).lower(),
|
|
||||||
)
|
|
||||||
table.add_row("", "")
|
table.add_row("", "")
|
||||||
|
|
||||||
# Platform
|
# Platform
|
||||||
@@ -84,25 +84,44 @@ def cmd_config_show(*, output: str = "text") -> None:
|
|||||||
|
|
||||||
def cmd_config_get(key: str) -> None:
|
def cmd_config_get(key: str) -> None:
|
||||||
"""Get a config value."""
|
"""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()
|
config = load_config()
|
||||||
value = get_nested_value(config, key)
|
value = get_nested_value(config, key)
|
||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
print_error(err_console, f"Unknown config key: {key}")
|
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:
|
else:
|
||||||
# Redact secrets
|
console.print(display_value)
|
||||||
if "api_key" in key or "key" in key.split(".")[-1:]:
|
|
||||||
console.print(redact_key(str(value)))
|
|
||||||
else:
|
|
||||||
console.print(str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_config_set(key: str, value: str) -> None:
|
def cmd_config_set(key: str, value: str) -> None:
|
||||||
"""Set a config value."""
|
"""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()
|
config = load_config()
|
||||||
if set_nested_value(config, key, value):
|
if set_nested_value(config, key, value):
|
||||||
save_config(config)
|
save_config(config)
|
||||||
display = redact_key(value) if "key" in key else value
|
display = redact_key(value) if "key" in key else value
|
||||||
print_success(console, f"{key} = {display}")
|
if is_agent_mode():
|
||||||
|
format_agent_envelope(
|
||||||
|
console, command="config set", data={"key": key, "value": display}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print_success(console, f"{key} = {display}")
|
||||||
else:
|
else:
|
||||||
print_error(err_console, f"Unknown config key: {key}")
|
print_error(err_console, f"Unknown config key: {key}")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from mem0_cli.branding import (
|
|||||||
print_success,
|
print_success,
|
||||||
timed_status,
|
timed_status,
|
||||||
)
|
)
|
||||||
from mem0_cli.output import format_json
|
from mem0_cli.output import format_agent_envelope, format_json
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
err_console = Console(stderr=True)
|
err_console = Console(stderr=True)
|
||||||
@@ -26,6 +26,11 @@ err_console = Console(stderr=True)
|
|||||||
|
|
||||||
def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None:
|
def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> None:
|
||||||
"""List entities of a given type."""
|
"""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"}
|
valid_types = {"users", "agents", "apps", "runs"}
|
||||||
if entity_type not in valid_types:
|
if entity_type not in valid_types:
|
||||||
print_error(
|
print_error(
|
||||||
@@ -42,6 +47,16 @@ def cmd_entities_list(backend: Backend, entity_type: str, *, output: str) -> Non
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_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":
|
if output == "json":
|
||||||
format_json(console, results)
|
format_json(console, results)
|
||||||
return
|
return
|
||||||
@@ -77,41 +92,39 @@ def cmd_entities_delete(
|
|||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an entity and all its memories (cascade delete)."""
|
"""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]):
|
if not any([user_id, agent_id, app_id, run_id]):
|
||||||
print_error(
|
print_error(
|
||||||
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
|
err_console, "Provide at least one of --user-id, --agent-id, --app-id, --run-id."
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
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:
|
if dry_run:
|
||||||
scope_parts = []
|
print_info(console, f"Would delete entity {scope_str} and all its memories.")
|
||||||
if user_id:
|
|
||||||
scope_parts.append(f"user={user_id}")
|
|
||||||
if agent_id:
|
|
||||||
scope_parts.append(f"agent={agent_id}")
|
|
||||||
if app_id:
|
|
||||||
scope_parts.append(f"app={app_id}")
|
|
||||||
if run_id:
|
|
||||||
scope_parts.append(f"run={run_id}")
|
|
||||||
scope = ", ".join(scope_parts)
|
|
||||||
print_info(console, f"Would delete entity {scope} and all its memories.")
|
|
||||||
print_info(console, "No changes made (dry run).")
|
print_info(console, "No changes made (dry run).")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not force:
|
if not force:
|
||||||
scope_parts = []
|
|
||||||
if user_id:
|
|
||||||
scope_parts.append(f"user={user_id}")
|
|
||||||
if agent_id:
|
|
||||||
scope_parts.append(f"agent={agent_id}")
|
|
||||||
if app_id:
|
|
||||||
scope_parts.append(f"app={app_id}")
|
|
||||||
if run_id:
|
|
||||||
scope_parts.append(f"run={run_id}")
|
|
||||||
scope = ", ".join(scope_parts)
|
|
||||||
|
|
||||||
confirm = typer.confirm(
|
confirm = typer.confirm(
|
||||||
f"\n \u26a0 Delete entity {scope} AND all its memories? This cannot be undone."
|
f"\n \u26a0 Delete entity {scope_str} AND all its memories? This cannot be undone."
|
||||||
)
|
)
|
||||||
if not confirm:
|
if not confirm:
|
||||||
print_info(console, "Cancelled.")
|
print_info(console, "Cancelled.")
|
||||||
@@ -131,7 +144,25 @@ def cmd_entities_delete(
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
scope = {
|
||||||
|
k: v
|
||||||
|
for k, v in {
|
||||||
|
"user_id": user_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"app_id": app_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
}.items()
|
||||||
|
if v
|
||||||
|
}
|
||||||
|
if output == "agent":
|
||||||
|
format_agent_envelope(
|
||||||
|
console,
|
||||||
|
command="entity delete",
|
||||||
|
data={"deleted": True},
|
||||||
|
scope=scope or None,
|
||||||
|
duration_ms=int(_elapsed * 1000),
|
||||||
|
)
|
||||||
|
elif output == "json":
|
||||||
format_json(console, result)
|
format_json(console, result)
|
||||||
elif output != "quiet":
|
elif output != "quiet":
|
||||||
print_success(console, f"Entity deleted with all memories ({_elapsed:.2f}s)")
|
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}.")
|
||||||
@@ -19,7 +19,13 @@ from mem0_cli.branding import (
|
|||||||
print_info,
|
print_info,
|
||||||
print_success,
|
print_success,
|
||||||
)
|
)
|
||||||
from mem0_cli.config import DEFAULT_BASE_URL, Mem0Config, save_config
|
from mem0_cli.config import (
|
||||||
|
CONFIG_FILE,
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
Mem0Config,
|
||||||
|
load_config,
|
||||||
|
save_config,
|
||||||
|
)
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
err_console = Console(stderr=True)
|
err_console = Console(stderr=True)
|
||||||
@@ -97,6 +103,25 @@ def _validate_email(email: str) -> None:
|
|||||||
raise typer.Exit(1)
|
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(
|
def _email_login(
|
||||||
email: str,
|
email: str,
|
||||||
code: str | None,
|
code: str | None,
|
||||||
@@ -108,6 +133,10 @@ def _email_login(
|
|||||||
The caller expects at minimum an ``api_key`` field.
|
The caller expects at minimum an ``api_key`` field.
|
||||||
"""
|
"""
|
||||||
url = base_url.rstrip("/")
|
url = base_url.rstrip("/")
|
||||||
|
_source_headers = {
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "python",
|
||||||
|
}
|
||||||
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
with httpx.Client(timeout=30.0) as client:
|
||||||
# If code is already provided, skip sending — user already has a code
|
# If code is already provided, skip sending — user already has a code
|
||||||
@@ -116,6 +145,7 @@ def _email_login(
|
|||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{url}/api/v1/auth/email_code/",
|
f"{url}/api/v1/auth/email_code/",
|
||||||
json={"email": email},
|
json={"email": email},
|
||||||
|
headers=_source_headers,
|
||||||
)
|
)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||||
@@ -148,6 +178,7 @@ def _email_login(
|
|||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{url}/api/v1/auth/email_code/verify/",
|
f"{url}/api/v1/auth/email_code/verify/",
|
||||||
json={"email": email, "code": code.strip()},
|
json={"email": email, "code": code.strip()},
|
||||||
|
headers=_source_headers,
|
||||||
)
|
)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||||
@@ -169,21 +200,167 @@ def run_init(
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
code: str | None = None,
|
code: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
source: str | None = None,
|
||||||
|
agent: bool = False,
|
||||||
|
agent_caller: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive setup wizard for mem0 CLI.
|
"""Interactive setup wizard for mem0 CLI.
|
||||||
|
|
||||||
When both *api_key* and *user_id* are supplied, all prompts are skipped
|
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
|
(non-interactive mode). When running in a non-TTY without the required
|
||||||
flags, an error message is printed.
|
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()
|
config = Mem0Config()
|
||||||
|
|
||||||
base_url = os.environ.get("MEM0_BASE_URL", config.platform.base_url or DEFAULT_BASE_URL)
|
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:
|
if code and not email:
|
||||||
print_error(err_console, "--code requires --email.")
|
print_error(err_console, "--code requires --email.")
|
||||||
raise typer.Exit(1)
|
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 ──────────────────────────────────────────────
|
# ── Email login flow ──────────────────────────────────────────────
|
||||||
if email:
|
if email:
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -205,7 +382,11 @@ def run_init(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
config.defaults.user_id = user_id or "mem0-cli"
|
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)
|
save_config(config)
|
||||||
|
|
||||||
@@ -219,25 +400,30 @@ def run_init(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── API key flow (existing) ───────────────────────────────────────
|
# ── 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
|
# Fully non-interactive when both flags provided
|
||||||
if api_key and user_id:
|
if api_key and user_id:
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
|
config.platform.created_via = "api_key"
|
||||||
config.defaults.user_id = user_id
|
config.defaults.user_id = user_id
|
||||||
_validate_platform(config)
|
_validate_platform(config)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
print_success(console, "Configuration saved to ~/.mem0/config.json")
|
print_success(console, "Configuration saved to ~/.mem0/config.json")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Non-TTY without full flags -> error
|
|
||||||
if not sys.stdin.isatty() and (not api_key or not user_id):
|
|
||||||
print_error(
|
|
||||||
err_console,
|
|
||||||
"Non-interactive terminal detected and required flags missing.",
|
|
||||||
hint="Run: mem0 init --api-key <key> --user-id <id>",
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
print_banner(console)
|
print_banner(console)
|
||||||
console.print()
|
console.print()
|
||||||
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
|
print_info(console, "Welcome! Let's set up your mem0 CLI.\n")
|
||||||
@@ -271,7 +457,11 @@ def run_init(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
config.defaults.user_id = user_id or "mem0-cli"
|
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)
|
save_config(config)
|
||||||
|
|
||||||
@@ -287,6 +477,7 @@ def run_init(
|
|||||||
# API key flow
|
# API key flow
|
||||||
if api_key:
|
if api_key:
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
|
config.platform.created_via = "api_key"
|
||||||
else:
|
else:
|
||||||
_setup_platform(config)
|
_setup_platform(config)
|
||||||
|
|
||||||
@@ -314,7 +505,9 @@ def run_init(
|
|||||||
def _setup_platform(config: Mem0Config) -> None:
|
def _setup_platform(config: Mem0Config) -> None:
|
||||||
"""Platform setup flow."""
|
"""Platform setup flow."""
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f" [{DIM_COLOR}]Get your API key at https://app.mem0.ai/dashboard/api-keys[/]")
|
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()
|
||||||
|
|
||||||
console.print(f" [{BRAND_COLOR}]API Key[/]: ", end="")
|
console.print(f" [{BRAND_COLOR}]API Key[/]: ", end="")
|
||||||
@@ -324,6 +517,7 @@ def _setup_platform(config: Mem0Config) -> None:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
config.platform.api_key = api_key
|
config.platform.api_key = api_key
|
||||||
|
config.platform.created_via = "api_key"
|
||||||
|
|
||||||
|
|
||||||
def _setup_defaults(config: Mem0Config) -> None:
|
def _setup_defaults(config: Mem0Config) -> None:
|
||||||
@@ -331,9 +525,10 @@ def _setup_defaults(config: Mem0Config) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
print_info(console, "Set default entity IDs (press Enter to skip).\n")
|
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(
|
user_id = Prompt.ask(
|
||||||
f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]",
|
f" [{BRAND_COLOR}]Default User ID[/] [{DIM_COLOR}](recommended)[/]",
|
||||||
default="mem0-cli",
|
default=_default_user,
|
||||||
)
|
)
|
||||||
if user_id:
|
if user_id:
|
||||||
config.defaults.user_id = user_id
|
config.defaults.user_id = user_id
|
||||||
@@ -353,11 +548,19 @@ def _validate_platform(config: Mem0Config) -> None:
|
|||||||
)
|
)
|
||||||
if status.get("connected"):
|
if status.get("connected"):
|
||||||
print_success(console, "Connected to mem0 Platform!")
|
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:
|
else:
|
||||||
print_error(
|
print_error(
|
||||||
err_console,
|
err_console,
|
||||||
f"Could not connect: {status.get('error', 'Unknown error')}",
|
f"Could not connect: {status.get('error', 'Unknown error')}",
|
||||||
hint="Check your API key and try again.",
|
hint="Visit https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=cli-python to get a new key, then run mem0 init again.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, f"Connection test failed: {e}")
|
print_error(err_console, f"Connection test failed: {e}")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import stat as _stat_mod
|
||||||
import sys
|
import sys
|
||||||
import time as _time
|
import time as _time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,6 +22,7 @@ from mem0_cli.branding import (
|
|||||||
)
|
)
|
||||||
from mem0_cli.output import (
|
from mem0_cli.output import (
|
||||||
format_add_result,
|
format_add_result,
|
||||||
|
format_agent_envelope,
|
||||||
format_json,
|
format_json,
|
||||||
format_memories_table,
|
format_memories_table,
|
||||||
format_memories_text,
|
format_memories_text,
|
||||||
@@ -31,6 +34,19 @@ console = Console()
|
|||||||
err_console = Console(stderr=True)
|
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(
|
def cmd_add(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
text: str | None,
|
text: str | None,
|
||||||
@@ -46,10 +62,14 @@ def cmd_add(
|
|||||||
no_infer: bool,
|
no_infer: bool,
|
||||||
expires: str | None,
|
expires: str | None,
|
||||||
categories: str | None,
|
categories: str | None,
|
||||||
enable_graph: bool = False,
|
|
||||||
output: str = "text",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a memory."""
|
"""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
|
msgs = None
|
||||||
content = text
|
content = text
|
||||||
|
|
||||||
@@ -70,8 +90,8 @@ def cmd_add(
|
|||||||
print_error(err_console, f"Invalid JSON in --messages: {e}")
|
print_error(err_console, f"Invalid JSON in --messages: {e}")
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
# Read from stdin if no text and stdin is piped
|
# Read from stdin only if stdin is an actual pipe or file redirect
|
||||||
elif not content and not sys.stdin.isatty():
|
elif not content and _stdin_is_piped():
|
||||||
content = sys.stdin.read().strip()
|
content = sys.stdin.read().strip()
|
||||||
|
|
||||||
if not content and not msgs:
|
if not content and not msgs:
|
||||||
@@ -124,7 +144,6 @@ def cmd_add(
|
|||||||
infer=not no_infer,
|
infer=not no_infer,
|
||||||
expires=expires,
|
expires=expires,
|
||||||
categories=cats,
|
categories=cats,
|
||||||
enable_graph=enable_graph,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ts.error_msg = str(e)
|
ts.error_msg = str(e)
|
||||||
@@ -133,18 +152,61 @@ def cmd_add(
|
|||||||
if output == "quiet":
|
if output == "quiet":
|
||||||
return
|
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":
|
if output == "json":
|
||||||
format_add_result(console, result, output)
|
format_add_result(console, result, output)
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
print_scope(console, user_id=user_id, agent_id=agent_id, app_id=app_id, run_id=run_id)
|
||||||
# Count results
|
count = len(deduped)
|
||||||
results = result if isinstance(result, list) else result.get("results", [result])
|
all_pending = count > 0 and all(r.get("status") == "PENDING" for r in deduped)
|
||||||
count = len(results) if results else 0
|
if all_pending:
|
||||||
print_success(
|
print_success(
|
||||||
console, f"Memory processed — {count} memor{'y' if count == 1 else 'ies'} extracted"
|
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)
|
format_add_result(console, result, output)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,10 +224,14 @@ def cmd_search(
|
|||||||
keyword: bool,
|
keyword: bool,
|
||||||
filter_json: str | None,
|
filter_json: str | None,
|
||||||
fields: str | None,
|
fields: str | None,
|
||||||
enable_graph: bool = False,
|
|
||||||
output: str = "text",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Search memories."""
|
"""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
|
filters = None
|
||||||
if filter_json:
|
if filter_json:
|
||||||
try:
|
try:
|
||||||
@@ -200,7 +266,6 @@ def cmd_search(
|
|||||||
keyword=keyword,
|
keyword=keyword,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
fields=field_list,
|
fields=field_list,
|
||||||
enable_graph=enable_graph,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
@@ -210,6 +275,27 @@ def cmd_search(
|
|||||||
if output == "quiet":
|
if output == "quiet":
|
||||||
return
|
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":
|
if output == "json":
|
||||||
format_json(console, results)
|
format_json(console, results)
|
||||||
elif output == "table":
|
elif output == "table":
|
||||||
@@ -236,6 +322,11 @@ def cmd_search(
|
|||||||
|
|
||||||
def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
||||||
"""Get a specific memory by ID."""
|
"""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:
|
with timed_status(err_console, "Fetching memory...") as _ts:
|
||||||
try:
|
try:
|
||||||
result = backend.get(memory_id)
|
result = backend.get(memory_id)
|
||||||
@@ -243,7 +334,10 @@ def cmd_get(backend: Backend, memory_id: str, *, output: str) -> None:
|
|||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
format_single_memory(console, result, output)
|
if output == "agent":
|
||||||
|
format_agent_envelope(console, command="get", data=result)
|
||||||
|
else:
|
||||||
|
format_single_memory(console, result, output)
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(
|
def cmd_list(
|
||||||
@@ -258,10 +352,14 @@ def cmd_list(
|
|||||||
category: str | None,
|
category: str | None,
|
||||||
after: str | None,
|
after: str | None,
|
||||||
before: str | None,
|
before: str | None,
|
||||||
enable_graph: bool = False,
|
|
||||||
output: str = "table",
|
output: str = "table",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List memories."""
|
"""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:
|
if page_size < 1:
|
||||||
print_error(err_console, "--page-size must be >= 1.")
|
print_error(err_console, "--page-size must be >= 1.")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -282,7 +380,6 @@ def cmd_list(
|
|||||||
category=category,
|
category=category,
|
||||||
after=after,
|
after=after,
|
||||||
before=before,
|
before=before,
|
||||||
enable_graph=enable_graph,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(err_console, str(e))
|
print_error(err_console, str(e))
|
||||||
@@ -292,15 +389,24 @@ def cmd_list(
|
|||||||
if output == "quiet":
|
if output == "quiet":
|
||||||
return
|
return
|
||||||
|
|
||||||
if output == "json":
|
if output in ("json", "agent"):
|
||||||
from mem0_cli.output import format_json_envelope
|
scope = {
|
||||||
|
k: v
|
||||||
format_json_envelope(
|
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,
|
console,
|
||||||
command="list",
|
command="list",
|
||||||
data=results,
|
data=results,
|
||||||
|
scope=scope or None,
|
||||||
count=len(results),
|
count=len(results),
|
||||||
scope={k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v},
|
duration_ms=int(_elapsed * 1000),
|
||||||
)
|
)
|
||||||
elif output == "table":
|
elif output == "table":
|
||||||
if results:
|
if results:
|
||||||
@@ -343,6 +449,11 @@ def cmd_update(
|
|||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update a memory."""
|
"""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
|
meta = None
|
||||||
if metadata:
|
if metadata:
|
||||||
try:
|
try:
|
||||||
@@ -360,7 +471,14 @@ def cmd_update(
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
if output == "agent":
|
||||||
|
format_agent_envelope(
|
||||||
|
console,
|
||||||
|
command="update",
|
||||||
|
data=result,
|
||||||
|
duration_ms=int(_elapsed * 1000),
|
||||||
|
)
|
||||||
|
elif output == "json":
|
||||||
format_json(console, result)
|
format_json(console, result)
|
||||||
elif output != "quiet":
|
elif output != "quiet":
|
||||||
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
|
print_success(console, f"Memory {memory_id[:8]} updated ({_elapsed:.2f}s)")
|
||||||
@@ -375,6 +493,11 @@ def cmd_delete(
|
|||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a single memory by ID."""
|
"""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:
|
if dry_run:
|
||||||
# Fetch and display what would be deleted
|
# Fetch and display what would be deleted
|
||||||
try:
|
try:
|
||||||
@@ -395,7 +518,14 @@ def cmd_delete(
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
if output == "agent":
|
||||||
|
format_agent_envelope(
|
||||||
|
console,
|
||||||
|
command="delete",
|
||||||
|
data={"id": memory_id, "deleted": True},
|
||||||
|
duration_ms=int(_elapsed * 1000),
|
||||||
|
)
|
||||||
|
elif output == "json":
|
||||||
format_json(console, result)
|
format_json(console, result)
|
||||||
elif output != "quiet":
|
elif output != "quiet":
|
||||||
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
|
print_success(console, f"Memory {memory_id[:8]} deleted ({_elapsed:.2f}s)")
|
||||||
@@ -414,13 +544,17 @@ def cmd_delete_all(
|
|||||||
output: str,
|
output: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete all memories matching a scope."""
|
"""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_:
|
if all_:
|
||||||
# Project-wide wipe using wildcard entity IDs
|
# Project-wide wipe using wildcard entity IDs
|
||||||
if dry_run:
|
# Note: --dry-run is ignored here because the API has no count-before-delete endpoint.
|
||||||
print_info(console, "Would delete ALL memories project-wide.")
|
|
||||||
print_info(console, "Run without --dry-run to see the actual count.")
|
|
||||||
print_info(console, "No changes made (dry run).")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not force:
|
if not force:
|
||||||
confirm = typer.confirm(
|
confirm = typer.confirm(
|
||||||
@@ -445,7 +579,14 @@ def cmd_delete_all(
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
if output == "agent":
|
||||||
|
format_agent_envelope(
|
||||||
|
console,
|
||||||
|
command="delete-all",
|
||||||
|
data={"deleted": True, "scope": "project"},
|
||||||
|
duration_ms=int(_elapsed * 1000),
|
||||||
|
)
|
||||||
|
elif output == "json":
|
||||||
format_json(console, result)
|
format_json(console, result)
|
||||||
elif output != "quiet":
|
elif output != "quiet":
|
||||||
if isinstance(result, dict) and "message" in result:
|
if isinstance(result, dict) and "message" in result:
|
||||||
@@ -503,7 +644,25 @@ def cmd_delete_all(
|
|||||||
raise typer.Exit(1) from None
|
raise typer.Exit(1) from None
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
scope = {
|
||||||
|
k: v
|
||||||
|
for k, v in {
|
||||||
|
"user_id": user_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"app_id": app_id,
|
||||||
|
"run_id": run_id,
|
||||||
|
}.items()
|
||||||
|
if v
|
||||||
|
}
|
||||||
|
if output == "agent":
|
||||||
|
format_agent_envelope(
|
||||||
|
console,
|
||||||
|
command="delete-all",
|
||||||
|
data={"deleted": True},
|
||||||
|
scope=scope or None,
|
||||||
|
duration_ms=int(_elapsed * 1000),
|
||||||
|
)
|
||||||
|
elif output == "json":
|
||||||
format_json(console, result)
|
format_json(console, result)
|
||||||
elif output != "quiet":
|
elif output != "quiet":
|
||||||
if isinstance(result, dict) and "message" in result:
|
if isinstance(result, dict) and "message" in result:
|
||||||
|
|||||||
@@ -35,22 +35,26 @@ def cmd_status(
|
|||||||
output: str = "text",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check connectivity and auth."""
|
"""Check connectivity and auth."""
|
||||||
from mem0_cli.output import format_json_envelope
|
from mem0_cli.output import format_agent_envelope
|
||||||
|
from mem0_cli.state import is_agent_mode, set_current_command
|
||||||
|
|
||||||
|
set_current_command("status")
|
||||||
|
if is_agent_mode():
|
||||||
|
output = "agent"
|
||||||
|
|
||||||
_start = _time.perf_counter()
|
_start = _time.perf_counter()
|
||||||
with timed_status(err_console, "Checking connection...") as _ts:
|
with timed_status(err_console, "Checking connection...") as _ts:
|
||||||
result = backend.status(user_id=user_id, agent_id=agent_id)
|
result = backend.status(user_id=user_id, agent_id=agent_id)
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
if output in ("json", "agent"):
|
||||||
format_json_envelope(
|
format_agent_envelope(
|
||||||
console,
|
console,
|
||||||
command="status",
|
command="status",
|
||||||
data={
|
data={
|
||||||
"connected": result.get("connected", False),
|
"connected": result.get("connected", False),
|
||||||
"backend": result.get("backend", "?"),
|
"backend": result.get("backend", "?"),
|
||||||
"base_url": result.get("base_url", ""),
|
"base_url": result.get("base_url", ""),
|
||||||
"latency_ms": int(_elapsed * 1000),
|
|
||||||
},
|
},
|
||||||
duration_ms=int(_elapsed * 1000),
|
duration_ms=int(_elapsed * 1000),
|
||||||
)
|
)
|
||||||
@@ -67,6 +71,14 @@ def cmd_status(
|
|||||||
lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}")
|
lines.append(f" [{DIM_COLOR}]API URL:[/] {result['base_url']}")
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
lines.append(f" [{ERROR_COLOR}]Error:[/] {result['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")
|
lines.append(f" [{DIM_COLOR}]Latency:[/] {_elapsed:.2f}s")
|
||||||
|
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
@@ -96,7 +108,12 @@ def cmd_import(
|
|||||||
output: str = "text",
|
output: str = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Import memories from a JSON file."""
|
"""Import memories from a JSON file."""
|
||||||
from mem0_cli.output import format_json_envelope
|
from mem0_cli.output import format_agent_envelope
|
||||||
|
from mem0_cli.state import is_agent_mode, set_current_command
|
||||||
|
|
||||||
|
set_current_command("import")
|
||||||
|
if is_agent_mode():
|
||||||
|
output = "agent"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(Path(file_path).read_text())
|
data = json.loads(Path(file_path).read_text())
|
||||||
@@ -129,11 +146,13 @@ def cmd_import(
|
|||||||
failed += 1
|
failed += 1
|
||||||
_elapsed = _time.perf_counter() - _start
|
_elapsed = _time.perf_counter() - _start
|
||||||
|
|
||||||
if output == "json":
|
if output in ("json", "agent"):
|
||||||
format_json_envelope(
|
scope = {k: v for k, v in {"user_id": user_id, "agent_id": agent_id}.items() if v}
|
||||||
|
format_agent_envelope(
|
||||||
console,
|
console,
|
||||||
command="import",
|
command="import",
|
||||||
data={"added": added, "failed": failed, "duration_s": round(_elapsed, 2)},
|
data={"added": added, "failed": failed},
|
||||||
|
scope=scope or None,
|
||||||
duration_ms=int(_elapsed * 1000),
|
duration_ms=int(_elapsed * 1000),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -27,6 +27,15 @@ CONFIG_VERSION = 1
|
|||||||
class PlatformConfig:
|
class PlatformConfig:
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = DEFAULT_BASE_URL
|
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
|
@dataclass
|
||||||
@@ -35,7 +44,18 @@ class DefaultsConfig:
|
|||||||
agent_id: str = ""
|
agent_id: str = ""
|
||||||
app_id: str = ""
|
app_id: str = ""
|
||||||
run_id: str = ""
|
run_id: str = ""
|
||||||
enable_graph: bool = False
|
|
||||||
|
|
||||||
|
@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
|
@dataclass
|
||||||
@@ -43,16 +63,18 @@ class Mem0Config:
|
|||||||
version: int = CONFIG_VERSION
|
version: int = CONFIG_VERSION
|
||||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||||
platform: PlatformConfig = field(default_factory=PlatformConfig)
|
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] = {
|
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||||
"api_key": "platform.api_key",
|
"api_key": "platform.api_key",
|
||||||
"base_url": "platform.base_url",
|
"base_url": "platform.base_url",
|
||||||
|
"user_email": "platform.user_email",
|
||||||
"user_id": "defaults.user_id",
|
"user_id": "defaults.user_id",
|
||||||
"agent_id": "defaults.agent_id",
|
"agent_id": "defaults.agent_id",
|
||||||
"app_id": "defaults.app_id",
|
"app_id": "defaults.app_id",
|
||||||
"run_id": "defaults.run_id",
|
"run_id": "defaults.run_id",
|
||||||
"enable_graph": "defaults.enable_graph",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -76,13 +98,23 @@ def load_config() -> Mem0Config:
|
|||||||
plat = data.get("platform", {})
|
plat = data.get("platform", {})
|
||||||
config.platform.api_key = plat.get("api_key", "")
|
config.platform.api_key = plat.get("api_key", "")
|
||||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
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", {})
|
defaults = data.get("defaults", {})
|
||||||
config.defaults.user_id = defaults.get("user_id", "")
|
config.defaults.user_id = defaults.get("user_id", "")
|
||||||
config.defaults.agent_id = defaults.get("agent_id", "")
|
config.defaults.agent_id = defaults.get("agent_id", "")
|
||||||
config.defaults.app_id = defaults.get("app_id", "")
|
config.defaults.app_id = defaults.get("app_id", "")
|
||||||
config.defaults.run_id = defaults.get("run_id", "")
|
config.defaults.run_id = defaults.get("run_id", "")
|
||||||
config.defaults.enable_graph = defaults.get("enable_graph", False)
|
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
|
# Environment variable overrides
|
||||||
env_key = os.environ.get("MEM0_API_KEY")
|
env_key = os.environ.get("MEM0_API_KEY")
|
||||||
@@ -109,10 +141,6 @@ def load_config() -> Mem0Config:
|
|||||||
if env_run_id:
|
if env_run_id:
|
||||||
config.defaults.run_id = env_run_id
|
config.defaults.run_id = env_run_id
|
||||||
|
|
||||||
env_graph = os.environ.get("MEM0_ENABLE_GRAPH")
|
|
||||||
if env_graph:
|
|
||||||
config.defaults.enable_graph = env_graph.lower() in ("true", "1", "yes")
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -127,11 +155,22 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
"agent_id": config.defaults.agent_id,
|
"agent_id": config.defaults.agent_id,
|
||||||
"app_id": config.defaults.app_id,
|
"app_id": config.defaults.app_id,
|
||||||
"run_id": config.defaults.run_id,
|
"run_id": config.defaults.run_id,
|
||||||
"enable_graph": config.defaults.enable_graph,
|
|
||||||
},
|
},
|
||||||
"platform": {
|
"platform": {
|
||||||
"api_key": config.platform.api_key,
|
"api_key": config.platform.api_key,
|
||||||
"base_url": config.platform.base_url,
|
"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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +179,19 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
|
|
||||||
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
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:
|
def redact_key(key: str) -> str:
|
||||||
"""Redact an API key for display: m0-xxx...xxx"""
|
"""Redact an API key for display: m0-xxx...xxx"""
|
||||||
|
|||||||
@@ -155,16 +155,23 @@ def format_add_result(console: Console, result: dict | list, output: str = "text
|
|||||||
return
|
return
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
|
seen_pending_events: set[str] = set()
|
||||||
for r in results:
|
for r in results:
|
||||||
# Detect async PENDING response from Platform API
|
# Detect async PENDING response from Platform API
|
||||||
if r.get("status") == "PENDING":
|
if r.get("status") == "PENDING":
|
||||||
event_id = r.get("event_id", "")[:8]
|
event_id = r.get("event_id", "")
|
||||||
|
# Deduplicate PENDING entries with the same event_id
|
||||||
|
if event_id and event_id in seen_pending_events:
|
||||||
|
continue
|
||||||
|
if event_id:
|
||||||
|
seen_pending_events.add(event_id)
|
||||||
icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]"
|
icon = f"[{ACCENT_COLOR}]{_sym('⧗', '...')}[/]"
|
||||||
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
|
parts = [f" {icon} [{DIM_COLOR}]{'Queued':<10}[/]"]
|
||||||
parts.append("[white]Processing in background[/]")
|
parts.append("[white]Processing in background[/]")
|
||||||
if event_id:
|
|
||||||
parts.append(f"[{DIM_COLOR}](event {event_id})[/]")
|
|
||||||
console.print(" ".join(parts))
|
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
|
continue
|
||||||
|
|
||||||
event = r.get("event", "ADD")
|
event = r.get("event", "ADD")
|
||||||
@@ -222,6 +229,119 @@ def format_json_envelope(
|
|||||||
if error:
|
if error:
|
||||||
envelope["error"] = error
|
envelope["error"] = error
|
||||||
envelope["data"] = data
|
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))
|
console.print_json(json.dumps(envelope, default=str))
|
||||||
|
|
||||||
|
|
||||||
@@ -237,7 +357,7 @@ def print_result_summary(
|
|||||||
parts = [f"{count} result{'s' if count != 1 else ''}"]
|
parts = [f"{count} result{'s' if count != 1 else ''}"]
|
||||||
if page is not None:
|
if page is not None:
|
||||||
parts.append(f"page {page}")
|
parts.append(f"page {page}")
|
||||||
scope_parts = [f"{k.replace('_', ' ')}={v}" for k, v in scope_ids.items() if v]
|
scope_parts = [f"{k}={v}" for k, v in scope_ids.items() if v]
|
||||||
if scope_parts:
|
if scope_parts:
|
||||||
parts.append(", ".join(scope_parts))
|
parts.append(", ".join(scope_parts))
|
||||||
if duration_secs is not None:
|
if duration_secs is not 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()
|
||||||
@@ -96,6 +96,43 @@ def mock_backend():
|
|||||||
{"name": "alice", "count": 5},
|
{"name": "alice", "count": 5},
|
||||||
{"name": "bob", "count": 3},
|
{"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
|
return backend
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""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)
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
if home_dir:
|
||||||
|
env["HOME"] = home_dir
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "mem0_cli", *args],
|
||||||
|
capture_output=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
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,
|
||||||
|
encoding="utf-8",
|
||||||
|
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",
|
||||||
|
"PYTHONIOENCODING": "utf-8",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
encoding="utf-8",
|
||||||
|
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",
|
||||||
|
"PYTHONIOENCODING": "utf-8",
|
||||||
|
},
|
||||||
|
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
|
||||||
@@ -49,6 +49,7 @@ def _run(
|
|||||||
if key.startswith("MEM0_"):
|
if key.startswith("MEM0_"):
|
||||||
del env[key]
|
del env[key]
|
||||||
env.pop("FORCE_COLOR", None)
|
env.pop("FORCE_COLOR", None)
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
if home_dir:
|
if home_dir:
|
||||||
env["HOME"] = home_dir
|
env["HOME"] = home_dir
|
||||||
if env_override:
|
if env_override:
|
||||||
@@ -56,7 +57,7 @@ def _run(
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-m", "mem0_cli", *args],
|
[sys.executable, "-m", "mem0_cli", *args],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
encoding="utf-8",
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
return subprocess.CompletedProcess(
|
return subprocess.CompletedProcess(
|
||||||
@@ -83,11 +84,6 @@ class TestCLIIntegration:
|
|||||||
assert "add" in result.stdout
|
assert "add" in result.stdout
|
||||||
assert "search" in result.stdout
|
assert "search" in result.stdout
|
||||||
|
|
||||||
def test_version_flag(self):
|
|
||||||
result = _run(["--version"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "0.1.0" in result.stdout
|
|
||||||
|
|
||||||
def test_add_help(self):
|
def test_add_help(self):
|
||||||
result = _run(["add", "--help"])
|
result = _run(["add", "--help"])
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
@@ -229,24 +225,13 @@ class TestCLIIsolated:
|
|||||||
|
|
||||||
|
|
||||||
class TestCLINewFeatures:
|
class TestCLINewFeatures:
|
||||||
"""Tests for MCP parity features: --graph, --limit, entities delete."""
|
"""Tests for MCP parity features: --limit, entities delete."""
|
||||||
|
|
||||||
def test_add_help_has_graph(self):
|
def test_search_help_has_limit(self):
|
||||||
result = _run(["add", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "--graph" in result.stdout
|
|
||||||
|
|
||||||
def test_search_help_has_graph_and_limit(self):
|
|
||||||
result = _run(["search", "--help"])
|
result = _run(["search", "--help"])
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert "--graph" in result.stdout
|
|
||||||
assert "--limit" in result.stdout
|
assert "--limit" in result.stdout
|
||||||
|
|
||||||
def test_list_help_has_graph(self):
|
|
||||||
result = _run(["list", "--help"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "--graph" in result.stdout
|
|
||||||
|
|
||||||
def test_delete_entity_via_delete_flag(self):
|
def test_delete_entity_via_delete_flag(self):
|
||||||
"""delete --entity should appear in help output."""
|
"""delete --entity should appear in help output."""
|
||||||
result = _run(["delete", "--help"])
|
result = _run(["delete", "--help"])
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import typing
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.exceptions import Exit as ClickExit
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
from typer import Exit as TyperExit
|
||||||
|
|
||||||
from mem0_cli.commands.config_cmd import (
|
from mem0_cli.commands.config_cmd import (
|
||||||
cmd_config_get,
|
cmd_config_get,
|
||||||
@@ -16,6 +17,7 @@ from mem0_cli.commands.config_cmd import (
|
|||||||
cmd_config_show,
|
cmd_config_show,
|
||||||
)
|
)
|
||||||
from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list
|
from mem0_cli.commands.entities import cmd_entities_delete, cmd_entities_list
|
||||||
|
from mem0_cli.commands.events_cmd import cmd_event_list, cmd_event_status
|
||||||
from mem0_cli.commands.memory import (
|
from mem0_cli.commands.memory import (
|
||||||
cmd_add,
|
cmd_add,
|
||||||
cmd_delete,
|
cmd_delete,
|
||||||
@@ -28,7 +30,6 @@ from mem0_cli.commands.memory import (
|
|||||||
from mem0_cli.commands.utils import (
|
from mem0_cli.commands.utils import (
|
||||||
cmd_import,
|
cmd_import,
|
||||||
cmd_status,
|
cmd_status,
|
||||||
cmd_version,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,30 +177,28 @@ class TestAddCommand:
|
|||||||
def test_add_no_content_exits(self, mock_backend):
|
def test_add_no_content_exits(self, mock_backend):
|
||||||
console, _buf = _make_console()
|
console, _buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, _err_buf = _make_err_console()
|
||||||
# Patch stdin.isatty to return True so it doesn't try to read stdin
|
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
patch("mem0_cli.commands.memory.sys") as mock_sys,
|
patch("mem0_cli.commands.memory._stdin_is_piped", return_value=False),
|
||||||
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
mock_sys.stdin.isatty.return_value = True
|
cmd_add(
|
||||||
with pytest.raises((SystemExit, ClickExit)):
|
mock_backend,
|
||||||
cmd_add(
|
None,
|
||||||
mock_backend,
|
user_id="alice",
|
||||||
None,
|
agent_id=None,
|
||||||
user_id="alice",
|
app_id=None,
|
||||||
agent_id=None,
|
run_id=None,
|
||||||
app_id=None,
|
messages=None,
|
||||||
run_id=None,
|
file=None,
|
||||||
messages=None,
|
metadata=None,
|
||||||
file=None,
|
immutable=False,
|
||||||
metadata=None,
|
no_infer=False,
|
||||||
immutable=False,
|
expires=None,
|
||||||
no_infer=False,
|
categories=None,
|
||||||
expires=None,
|
output="text",
|
||||||
categories=None,
|
)
|
||||||
output="text",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_add_invalid_metadata_json(self, mock_backend):
|
def test_add_invalid_metadata_json(self, mock_backend):
|
||||||
console, _buf = _make_console()
|
console, _buf = _make_console()
|
||||||
@@ -207,7 +206,7 @@ class TestAddCommand:
|
|||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
pytest.raises((SystemExit, ClickExit)),
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
cmd_add(
|
cmd_add(
|
||||||
mock_backend,
|
mock_backend,
|
||||||
@@ -279,6 +278,66 @@ class TestAddCommand:
|
|||||||
mock_backend.add.assert_called_once()
|
mock_backend.add.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddDeduplicatesPending:
|
||||||
|
"""Ensure duplicate PENDING entries with the same event_id are collapsed."""
|
||||||
|
|
||||||
|
DUPLICATE_PENDING: typing.ClassVar[dict] = {
|
||||||
|
"results": [
|
||||||
|
{"status": "PENDING", "event_id": "evt-dup"},
|
||||||
|
{"status": "PENDING", "event_id": "evt-dup"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_add(self, mock_backend, output):
|
||||||
|
mock_backend.add.return_value = self.DUPLICATE_PENDING
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_add(
|
||||||
|
mock_backend,
|
||||||
|
"test",
|
||||||
|
user_id="alice",
|
||||||
|
agent_id=None,
|
||||||
|
app_id=None,
|
||||||
|
run_id=None,
|
||||||
|
messages=None,
|
||||||
|
file=None,
|
||||||
|
metadata=None,
|
||||||
|
immutable=False,
|
||||||
|
no_infer=False,
|
||||||
|
expires=None,
|
||||||
|
categories=None,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def test_text_shows_one_pending(self, mock_backend):
|
||||||
|
raw = self._run_add(mock_backend, "text")
|
||||||
|
assert raw.count("Queued") == 1
|
||||||
|
|
||||||
|
def test_json_shows_one_pending(self, mock_backend):
|
||||||
|
raw = self._run_add(mock_backend, "json")
|
||||||
|
data = json.loads(raw)
|
||||||
|
results = data.get("results", data)
|
||||||
|
pending = [r for r in results if r.get("status") == "PENDING"]
|
||||||
|
assert len(pending) == 1
|
||||||
|
|
||||||
|
def test_agent_shows_one_pending(self, mock_backend):
|
||||||
|
from mem0_cli.state import set_agent_mode
|
||||||
|
|
||||||
|
set_agent_mode(True)
|
||||||
|
try:
|
||||||
|
raw = self._run_add(mock_backend, "agent")
|
||||||
|
finally:
|
||||||
|
set_agent_mode(False)
|
||||||
|
data = json.loads(raw)
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert len(data["data"]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestSearchCommand:
|
class TestSearchCommand:
|
||||||
def test_search_text(self, mock_backend):
|
def test_search_text(self, mock_backend):
|
||||||
console, buf = _make_console()
|
console, buf = _make_console()
|
||||||
@@ -618,28 +677,6 @@ class TestDeleteAllCommand:
|
|||||||
run_id="*",
|
run_id="*",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_delete_all_project_wide_dry_run(self, mock_backend):
|
|
||||||
console, buf = _make_console()
|
|
||||||
err_console, _err_buf = _make_err_console()
|
|
||||||
with (
|
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
|
||||||
patch("mem0_cli.commands.memory.err_console", err_console),
|
|
||||||
):
|
|
||||||
cmd_delete_all(
|
|
||||||
mock_backend,
|
|
||||||
force=True,
|
|
||||||
all_=True,
|
|
||||||
dry_run=True,
|
|
||||||
user_id=None,
|
|
||||||
agent_id=None,
|
|
||||||
app_id=None,
|
|
||||||
run_id=None,
|
|
||||||
output="text",
|
|
||||||
)
|
|
||||||
output = buf.getvalue()
|
|
||||||
assert "project-wide" in output.lower()
|
|
||||||
mock_backend.delete.assert_not_called()
|
|
||||||
|
|
||||||
def test_delete_all_project_wide_async_response(self, mock_backend):
|
def test_delete_all_project_wide_async_response(self, mock_backend):
|
||||||
mock_backend.delete.return_value = {"message": "Memories deletion started..."}
|
mock_backend.delete.return_value = {"message": "Memories deletion started..."}
|
||||||
console, buf = _make_console()
|
console, buf = _make_console()
|
||||||
@@ -704,15 +741,6 @@ class TestStatusCommand:
|
|||||||
assert '"status"' in output
|
assert '"status"' in output
|
||||||
|
|
||||||
|
|
||||||
class TestVersionCommand:
|
|
||||||
def test_version(self):
|
|
||||||
console, buf = _make_console()
|
|
||||||
with patch("mem0_cli.commands.utils.console", console):
|
|
||||||
cmd_version()
|
|
||||||
output = buf.getvalue()
|
|
||||||
assert "0.1.0" in output
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportCommand:
|
class TestImportCommand:
|
||||||
def test_import_json(self, mock_backend, tmp_path):
|
def test_import_json(self, mock_backend, tmp_path):
|
||||||
file_path = tmp_path / "import.json"
|
file_path = tmp_path / "import.json"
|
||||||
@@ -736,7 +764,7 @@ class TestImportCommand:
|
|||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.utils.console", console),
|
patch("mem0_cli.commands.utils.console", console),
|
||||||
patch("mem0_cli.commands.utils.err_console", err_console),
|
patch("mem0_cli.commands.utils.err_console", err_console),
|
||||||
pytest.raises((SystemExit, ClickExit)),
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
cmd_import(mock_backend, "/nonexistent/file.json", user_id=None, agent_id=None)
|
cmd_import(mock_backend, "/nonexistent/file.json", user_id=None, agent_id=None)
|
||||||
|
|
||||||
@@ -773,7 +801,7 @@ class TestEntitiesListCommand:
|
|||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.entities.console", console),
|
patch("mem0_cli.commands.entities.console", console),
|
||||||
patch("mem0_cli.commands.entities.err_console", err_console),
|
patch("mem0_cli.commands.entities.err_console", err_console),
|
||||||
pytest.raises((SystemExit, ClickExit)),
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
cmd_entities_list(mock_backend, "invalid", output="table")
|
cmd_entities_list(mock_backend, "invalid", output="table")
|
||||||
|
|
||||||
@@ -888,13 +916,35 @@ class TestEntitiesDeleteCommand:
|
|||||||
output = buf.getvalue()
|
output = buf.getvalue()
|
||||||
assert "deleted" in output.lower()
|
assert "deleted" in output.lower()
|
||||||
|
|
||||||
|
def test_delete_entity_agent_id(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.entities.console", console),
|
||||||
|
patch("mem0_cli.commands.entities.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_entities_delete(
|
||||||
|
mock_backend,
|
||||||
|
user_id=None,
|
||||||
|
agent_id="bot1",
|
||||||
|
app_id=None,
|
||||||
|
run_id=None,
|
||||||
|
force=True,
|
||||||
|
output="text",
|
||||||
|
)
|
||||||
|
mock_backend.delete_entities.assert_called_once_with(
|
||||||
|
user_id=None, agent_id="bot1", app_id=None, run_id=None
|
||||||
|
)
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "deleted" in output.lower()
|
||||||
|
|
||||||
def test_delete_entity_no_id_exits(self, mock_backend):
|
def test_delete_entity_no_id_exits(self, mock_backend):
|
||||||
console, _buf = _make_console()
|
console, _buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, _err_buf = _make_err_console()
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.entities.console", console),
|
patch("mem0_cli.commands.entities.console", console),
|
||||||
patch("mem0_cli.commands.entities.err_console", err_console),
|
patch("mem0_cli.commands.entities.err_console", err_console),
|
||||||
pytest.raises((SystemExit, ClickExit)),
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
):
|
):
|
||||||
cmd_entities_delete(
|
cmd_entities_delete(
|
||||||
mock_backend,
|
mock_backend,
|
||||||
@@ -947,9 +997,88 @@ class TestEntitiesDeleteCommand:
|
|||||||
mock_backend.delete_entities.assert_not_called()
|
mock_backend.delete_entities.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestEnableGraph:
|
class TestEventCommands:
|
||||||
def test_add_with_graph(self, mock_backend):
|
def test_event_list_table(self, mock_backend):
|
||||||
console, _buf = _make_console()
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_list(mock_backend, output="table")
|
||||||
|
out = buf.getvalue()
|
||||||
|
assert "evt-abc-" in out
|
||||||
|
assert "ADD" in out
|
||||||
|
assert "SUCCEEDED" in out
|
||||||
|
|
||||||
|
def test_event_list_json(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_list(mock_backend, output="json")
|
||||||
|
out = buf.getvalue()
|
||||||
|
assert "evt-abc-123-def-456" in out
|
||||||
|
assert "evt-def-456-ghi-789" in out
|
||||||
|
|
||||||
|
def test_event_list_empty(self, mock_backend):
|
||||||
|
mock_backend.list_events.return_value = []
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_list(mock_backend, output="table")
|
||||||
|
out = buf.getvalue()
|
||||||
|
assert "No events" in out
|
||||||
|
|
||||||
|
def test_event_status_text(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
|
||||||
|
out = buf.getvalue()
|
||||||
|
assert "evt-abc-123-def-456" in out
|
||||||
|
assert "SUCCEEDED" in out
|
||||||
|
|
||||||
|
def test_event_status_json(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="json")
|
||||||
|
out = buf.getvalue()
|
||||||
|
assert "evt-abc-123-def-456" in out
|
||||||
|
assert "ADD" in out
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentMode:
|
||||||
|
"""Tests for --json/--agent mode: structured JSON envelope output."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Enable agent mode before each test."""
|
||||||
|
from mem0_cli.state import set_agent_mode
|
||||||
|
|
||||||
|
set_agent_mode(True)
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
"""Reset agent mode after each test."""
|
||||||
|
from mem0_cli.state import set_agent_mode
|
||||||
|
|
||||||
|
set_agent_mode(False)
|
||||||
|
|
||||||
|
# ── add ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_add_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, _err_buf = _make_err_console()
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
@@ -957,7 +1086,7 @@ class TestEnableGraph:
|
|||||||
):
|
):
|
||||||
cmd_add(
|
cmd_add(
|
||||||
mock_backend,
|
mock_backend,
|
||||||
"test",
|
"I prefer dark mode",
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
agent_id=None,
|
agent_id=None,
|
||||||
app_id=None,
|
app_id=None,
|
||||||
@@ -969,14 +1098,48 @@ class TestEnableGraph:
|
|||||||
no_infer=False,
|
no_infer=False,
|
||||||
expires=None,
|
expires=None,
|
||||||
categories=None,
|
categories=None,
|
||||||
enable_graph=True,
|
output="text", # will be overridden to "agent"
|
||||||
|
)
|
||||||
|
raw = buf.getvalue()
|
||||||
|
data = json.loads(raw)
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "add"
|
||||||
|
assert "data" in data
|
||||||
|
assert isinstance(data["data"], list)
|
||||||
|
assert data["count"] == 1
|
||||||
|
assert set(data["data"][0].keys()) == {"id", "memory", "event"}
|
||||||
|
|
||||||
|
def test_add_agent_mode_scope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_add(
|
||||||
|
mock_backend,
|
||||||
|
"test",
|
||||||
|
user_id="bob",
|
||||||
|
agent_id="agent1",
|
||||||
|
app_id=None,
|
||||||
|
run_id=None,
|
||||||
|
messages=None,
|
||||||
|
file=None,
|
||||||
|
metadata=None,
|
||||||
|
immutable=False,
|
||||||
|
no_infer=False,
|
||||||
|
expires=None,
|
||||||
|
categories=None,
|
||||||
output="text",
|
output="text",
|
||||||
)
|
)
|
||||||
call_kwargs = mock_backend.add.call_args
|
data = json.loads(buf.getvalue())
|
||||||
assert call_kwargs.kwargs.get("enable_graph") is True
|
assert data["scope"]["user_id"] == "bob"
|
||||||
|
assert data["scope"]["agent_id"] == "agent1"
|
||||||
|
|
||||||
def test_search_with_graph(self, mock_backend):
|
# ── search ───────────────────────────────────────────────────────────────
|
||||||
console, _buf = _make_console()
|
|
||||||
|
def test_search_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, _err_buf = _make_err_console()
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
@@ -984,7 +1147,7 @@ class TestEnableGraph:
|
|||||||
):
|
):
|
||||||
cmd_search(
|
cmd_search(
|
||||||
mock_backend,
|
mock_backend,
|
||||||
"test",
|
"dark mode",
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
agent_id=None,
|
agent_id=None,
|
||||||
app_id=None,
|
app_id=None,
|
||||||
@@ -995,14 +1158,20 @@ class TestEnableGraph:
|
|||||||
keyword=False,
|
keyword=False,
|
||||||
filter_json=None,
|
filter_json=None,
|
||||||
fields=None,
|
fields=None,
|
||||||
enable_graph=True,
|
|
||||||
output="text",
|
output="text",
|
||||||
)
|
)
|
||||||
call_kwargs = mock_backend.search.call_args
|
data = json.loads(buf.getvalue())
|
||||||
assert call_kwargs.kwargs.get("enable_graph") is True
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "search"
|
||||||
|
assert isinstance(data["data"], list)
|
||||||
|
assert data["count"] == 2
|
||||||
|
assert "duration_ms" in data
|
||||||
|
assert set(data["data"][0].keys()) == {"id", "memory", "score", "created_at", "categories"}
|
||||||
|
|
||||||
def test_list_with_graph(self, mock_backend):
|
# ── list ─────────────────────────────────────────────────────────────────
|
||||||
console, _buf = _make_console()
|
|
||||||
|
def test_list_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
err_console, _err_buf = _make_err_console()
|
err_console, _err_buf = _make_err_console()
|
||||||
with (
|
with (
|
||||||
patch("mem0_cli.commands.memory.console", console),
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
@@ -1019,8 +1188,195 @@ class TestEnableGraph:
|
|||||||
category=None,
|
category=None,
|
||||||
after=None,
|
after=None,
|
||||||
before=None,
|
before=None,
|
||||||
enable_graph=True,
|
output="table", # will be overridden to "agent"
|
||||||
output="table",
|
|
||||||
)
|
)
|
||||||
call_kwargs = mock_backend.list_memories.call_args
|
data = json.loads(buf.getvalue())
|
||||||
assert call_kwargs.kwargs.get("enable_graph") is True
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "list"
|
||||||
|
assert isinstance(data["data"], list)
|
||||||
|
assert data["count"] == 2
|
||||||
|
assert data["scope"]["user_id"] == "alice"
|
||||||
|
assert set(data["data"][0].keys()) == {"id", "memory", "created_at", "categories"}
|
||||||
|
|
||||||
|
# ── get ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_get_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_get(mock_backend, "abc-123-def-456", output="text")
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "get"
|
||||||
|
assert isinstance(data["data"], dict)
|
||||||
|
assert data["data"]["id"] == "abc-123-def-456"
|
||||||
|
assert "memory" in data["data"]
|
||||||
|
assert set(data["data"].keys()) >= {"id", "memory"}
|
||||||
|
|
||||||
|
# ── update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_update_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_update(mock_backend, "abc-123", "Updated content", metadata=None, output="text")
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "update"
|
||||||
|
assert isinstance(data["data"], dict)
|
||||||
|
assert "memory" in data["data"]
|
||||||
|
assert "duration_ms" in data
|
||||||
|
|
||||||
|
# ── delete ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delete_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_delete(mock_backend, "abc-123-def-456", output="text")
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "delete"
|
||||||
|
assert data["data"]["id"] == "abc-123-def-456"
|
||||||
|
assert data["data"]["deleted"] is True
|
||||||
|
assert "duration_ms" in data
|
||||||
|
|
||||||
|
# ── event list ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_event_list_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_list(mock_backend, output="table")
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "event list"
|
||||||
|
assert isinstance(data["data"], list)
|
||||||
|
assert data["count"] == 2
|
||||||
|
assert "duration_ms" in data
|
||||||
|
assert set(data["data"][0].keys()) == {
|
||||||
|
"id",
|
||||||
|
"event_type",
|
||||||
|
"status",
|
||||||
|
"latency",
|
||||||
|
"created_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── event status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_event_status_agent_mode_envelope(self, mock_backend):
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.events_cmd.console", console),
|
||||||
|
patch("mem0_cli.commands.events_cmd.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_event_status(mock_backend, "evt-abc-123-def-456", output="text")
|
||||||
|
data = json.loads(buf.getvalue())
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["command"] == "event status"
|
||||||
|
assert isinstance(data["data"], dict)
|
||||||
|
assert data["data"]["id"] == "evt-abc-123-def-456"
|
||||||
|
assert "duration_ms" in data
|
||||||
|
assert set(data["data"]["results"][0].keys()) == {"id", "event", "user_id", "memory"}
|
||||||
|
assert "data" not in data["data"]["results"][0]
|
||||||
|
|
||||||
|
# ── error handling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_error_in_agent_mode_produces_json_to_stdout(self, mock_backend):
|
||||||
|
"""Errors in agent mode must emit a JSON envelope to stdout, not stderr."""
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
mock_backend.get.side_effect = Exception("Memory not found")
|
||||||
|
console, _buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
|
||||||
|
captured_stdout = StringIO()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
patch("sys.stdout", captured_stdout),
|
||||||
|
pytest.raises((SystemExit, TyperExit)),
|
||||||
|
):
|
||||||
|
cmd_get(mock_backend, "bad-id", output="text")
|
||||||
|
|
||||||
|
stdout_output = captured_stdout.getvalue()
|
||||||
|
# The error JSON envelope must be on stdout
|
||||||
|
error_data = json.loads(stdout_output)
|
||||||
|
assert error_data["status"] == "error"
|
||||||
|
assert "error" in error_data
|
||||||
|
assert error_data["data"] is None
|
||||||
|
|
||||||
|
def test_branding_suppressed_in_agent_mode(self, mock_backend):
|
||||||
|
"""Scope line and success message must be absent in agent mode output."""
|
||||||
|
console, buf = _make_console()
|
||||||
|
err_console, _err_buf = _make_err_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console),
|
||||||
|
):
|
||||||
|
cmd_add(
|
||||||
|
mock_backend,
|
||||||
|
"branding test",
|
||||||
|
user_id="alice",
|
||||||
|
agent_id=None,
|
||||||
|
app_id=None,
|
||||||
|
run_id=None,
|
||||||
|
messages=None,
|
||||||
|
file=None,
|
||||||
|
metadata=None,
|
||||||
|
immutable=False,
|
||||||
|
no_infer=False,
|
||||||
|
expires=None,
|
||||||
|
categories=None,
|
||||||
|
output="text",
|
||||||
|
)
|
||||||
|
output = buf.getvalue()
|
||||||
|
# Must be valid JSON only — no human-readable branding
|
||||||
|
data = json.loads(output)
|
||||||
|
assert data["status"] == "success"
|
||||||
|
# "Scope:" and "Memory processed" must NOT appear in the raw output
|
||||||
|
assert "Scope:" not in output
|
||||||
|
assert "Memory processed" not in output
|
||||||
|
assert "spinner" not in output.lower()
|
||||||
|
|
||||||
|
def test_no_spinner_in_agent_mode(self, mock_backend):
|
||||||
|
"""timed_status must not emit spinner output in agent mode."""
|
||||||
|
err_buf = StringIO()
|
||||||
|
err_console_buf = Console(file=err_buf, force_terminal=False, no_color=True, width=120)
|
||||||
|
console, _buf = _make_console()
|
||||||
|
with (
|
||||||
|
patch("mem0_cli.commands.memory.console", console),
|
||||||
|
patch("mem0_cli.commands.memory.err_console", err_console_buf),
|
||||||
|
):
|
||||||
|
cmd_search(
|
||||||
|
mock_backend,
|
||||||
|
"query",
|
||||||
|
user_id="alice",
|
||||||
|
agent_id=None,
|
||||||
|
app_id=None,
|
||||||
|
run_id=None,
|
||||||
|
top_k=5,
|
||||||
|
threshold=0.3,
|
||||||
|
rerank=False,
|
||||||
|
keyword=False,
|
||||||
|
filter_json=None,
|
||||||
|
fields=None,
|
||||||
|
output="text",
|
||||||
|
)
|
||||||
|
# The err_buf captures what would have been spinner/timing noise
|
||||||
|
# In agent mode it should be empty (no status lines printed)
|
||||||
|
err_output = err_buf.getvalue()
|
||||||
|
assert "Searching" not in err_output
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class TestConfig:
|
|||||||
from mem0_cli.config import CONFIG_FILE
|
from mem0_cli.config import CONFIG_FILE
|
||||||
|
|
||||||
mode = os.stat(CONFIG_FILE).st_mode & 0o777
|
mode = os.stat(CONFIG_FILE).st_mode & 0o777
|
||||||
assert mode == 0o600
|
if os.name != "nt":
|
||||||
|
assert mode == 0o600
|
||||||
|
|
||||||
def test_defaults_save_and_load(self, isolate_config):
|
def test_defaults_save_and_load(self, isolate_config):
|
||||||
config = Mem0Config()
|
config = Mem0Config()
|
||||||
@@ -121,46 +122,6 @@ class TestConfig:
|
|||||||
assert config.defaults.agent_id == ""
|
assert config.defaults.agent_id == ""
|
||||||
assert config.defaults.app_id == ""
|
assert config.defaults.app_id == ""
|
||||||
assert config.defaults.run_id == ""
|
assert config.defaults.run_id == ""
|
||||||
assert config.defaults.enable_graph is False
|
|
||||||
|
|
||||||
def test_enable_graph_save_and_load(self, isolate_config):
|
|
||||||
config = Mem0Config()
|
|
||||||
config.defaults.enable_graph = True
|
|
||||||
save_config(config)
|
|
||||||
loaded = load_config()
|
|
||||||
assert loaded.defaults.enable_graph is True
|
|
||||||
|
|
||||||
def test_enable_graph_env_var_true(self, isolate_config, monkeypatch):
|
|
||||||
monkeypatch.setenv("MEM0_ENABLE_GRAPH", "true")
|
|
||||||
loaded = load_config()
|
|
||||||
assert loaded.defaults.enable_graph is True
|
|
||||||
|
|
||||||
def test_enable_graph_env_var_false(self, isolate_config, monkeypatch):
|
|
||||||
config = Mem0Config()
|
|
||||||
config.defaults.enable_graph = True
|
|
||||||
save_config(config)
|
|
||||||
monkeypatch.setenv("MEM0_ENABLE_GRAPH", "false")
|
|
||||||
loaded = load_config()
|
|
||||||
assert loaded.defaults.enable_graph is False
|
|
||||||
|
|
||||||
def test_backward_compat_no_enable_graph_key(self, isolate_config):
|
|
||||||
"""Old config files without 'enable_graph' key should default to False."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mem0_cli.config import CONFIG_FILE, ensure_config_dir
|
|
||||||
|
|
||||||
ensure_config_dir()
|
|
||||||
data = {
|
|
||||||
"version": 1,
|
|
||||||
"defaults": {"user_id": "alice"},
|
|
||||||
"platform": {"api_key": "m0-test", "base_url": "https://api.mem0.ai"},
|
|
||||||
}
|
|
||||||
with open(CONFIG_FILE, "w") as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
loaded = load_config()
|
|
||||||
assert loaded.defaults.enable_graph is False
|
|
||||||
assert loaded.defaults.user_id == "alice"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNestedAccess:
|
class TestNestedAccess:
|
||||||
@@ -192,11 +153,6 @@ class TestNestedAccess:
|
|||||||
assert set_nested_value(config, "defaults.user_id", "bob")
|
assert set_nested_value(config, "defaults.user_id", "bob")
|
||||||
assert config.defaults.user_id == "bob"
|
assert config.defaults.user_id == "bob"
|
||||||
|
|
||||||
def test_set_defaults_enable_graph(self):
|
|
||||||
config = Mem0Config()
|
|
||||||
assert set_nested_value(config, "defaults.enable_graph", "true")
|
|
||||||
assert config.defaults.enable_graph is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveIds:
|
class TestResolveIds:
|
||||||
def test_cli_flag_overrides_default(self):
|
def test_cli_flag_overrides_default(self):
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Unit tests for init internals — decision tree primitives + plugin sync.
|
||||||
|
|
||||||
|
These tests exercise the units that the high-level subprocess parity tests in
|
||||||
|
``test_agent_mode.py`` deliberately can't reach:
|
||||||
|
|
||||||
|
- ``_ping_key`` must NOT treat network errors as "invalid key" (else a VPN
|
||||||
|
flap silently mints a new shadow over a working key).
|
||||||
|
- ``plugin_sync`` must only update entries that already exist, preserve
|
||||||
|
trailing newlines, and never mangle other lines.
|
||||||
|
- The 403→ratelimit translation in ``bootstrap_via_backend`` surfaces the
|
||||||
|
real cause instead of DRF's opaque "You do not have permission" string.
|
||||||
|
|
||||||
|
Mirror surface lives in ``cli/node/tests/agent-mode.test.ts``; if you add a
|
||||||
|
behavioral assertion here, mirror it on the Node side and vice versa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mem0_cli.commands.init_cmd import _ping_key
|
||||||
|
from mem0_cli.plugin_sync import _update_claude_settings, _update_shell_rc
|
||||||
|
|
||||||
|
# ── _ping_key ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, status_code: int) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_200_is_valid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(200))
|
||||||
|
assert _ping_key("k", "http://x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_401_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(401))
|
||||||
|
assert _ping_key("k", "http://x") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_403_is_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(403))
|
||||||
|
assert _ping_key("k", "http://x") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_5xx_is_not_definitively_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Transient upstream failure must NOT cause a shadow to be minted.
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **kw: _Resp(503))
|
||||||
|
assert _ping_key("k", "http://x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_connect_error_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Network blip (DNS, captive portal, etc.) — must NOT trigger a re-mint.
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise httpx.ConnectError("nope")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", boom)
|
||||||
|
assert _ping_key("k", "http://x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_key_timeout_prefers_reuse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise httpx.ReadTimeout("slow")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", boom)
|
||||||
|
assert _ping_key("k", "http://x") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── plugin_sync._update_shell_rc ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_rc_updates_existing_export_preserves_trailing_newline(tmp_path) -> None:
|
||||||
|
rc = tmp_path / ".zshrc"
|
||||||
|
rc.write_text('export MEM0_API_KEY="old"\n', encoding="utf-8")
|
||||||
|
changed = _update_shell_rc(rc, "newkey")
|
||||||
|
assert changed is True
|
||||||
|
assert rc.read_text(encoding="utf-8") == 'export MEM0_API_KEY="newkey"\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_rc_does_not_create_new_export(tmp_path) -> None:
|
||||||
|
rc = tmp_path / ".zshrc"
|
||||||
|
rc.write_text("alias ll='ls -la'\n", encoding="utf-8")
|
||||||
|
changed = _update_shell_rc(rc, "newkey")
|
||||||
|
assert changed is False
|
||||||
|
assert rc.read_text(encoding="utf-8") == "alias ll='ls -la'\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_rc_preserves_surrounding_content(tmp_path) -> None:
|
||||||
|
rc = tmp_path / ".zshrc"
|
||||||
|
original = "# my zshrc\nalias ll='ls -la'\nexport MEM0_API_KEY='old'\nexport OTHER=keepme\n"
|
||||||
|
rc.write_text(original, encoding="utf-8")
|
||||||
|
_update_shell_rc(rc, "newkey")
|
||||||
|
after = rc.read_text(encoding="utf-8")
|
||||||
|
assert "alias ll='ls -la'\n" in after
|
||||||
|
assert "export OTHER=keepme\n" in after
|
||||||
|
assert "# my zshrc\n" in after
|
||||||
|
assert 'export MEM0_API_KEY="newkey"\n' in after
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_rc_idempotent_when_already_matching(tmp_path) -> None:
|
||||||
|
rc = tmp_path / ".zshrc"
|
||||||
|
rc.write_text('export MEM0_API_KEY="same"\n', encoding="utf-8")
|
||||||
|
assert _update_shell_rc(rc, "same") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_rc_missing_file_is_noop(tmp_path) -> None:
|
||||||
|
rc = tmp_path / ".zshrc" # does not exist
|
||||||
|
assert _update_shell_rc(rc, "x") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── plugin_sync._update_claude_settings ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_settings_does_not_create_env_block(tmp_path) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
settings = tmp_path / "settings.json"
|
||||||
|
settings.write_text(json.dumps({"otherKey": 1}), encoding="utf-8")
|
||||||
|
changed = _update_claude_settings(settings, "newkey")
|
||||||
|
assert changed is False
|
||||||
|
# Original content unchanged.
|
||||||
|
assert json.loads(settings.read_text(encoding="utf-8")) == {"otherKey": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_settings_does_not_create_mem0_entry_in_existing_env(tmp_path) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
settings = tmp_path / "settings.json"
|
||||||
|
settings.write_text(json.dumps({"env": {"OTHER_KEY": "x"}}), encoding="utf-8")
|
||||||
|
changed = _update_claude_settings(settings, "newkey")
|
||||||
|
assert changed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_settings_updates_existing_entry(tmp_path) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
settings = tmp_path / "settings.json"
|
||||||
|
settings.write_text(
|
||||||
|
json.dumps({"env": {"MEM0_API_KEY": "old", "OTHER": "y"}}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
changed = _update_claude_settings(settings, "fresh")
|
||||||
|
assert changed is True
|
||||||
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||||
|
assert data["env"]["MEM0_API_KEY"] == "fresh"
|
||||||
|
assert data["env"]["OTHER"] == "y" # other keys preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_settings_idempotent(tmp_path) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
settings = tmp_path / "settings.json"
|
||||||
|
settings.write_text(json.dumps({"env": {"MEM0_API_KEY": "same"}}), encoding="utf-8")
|
||||||
|
assert _update_claude_settings(settings, "same") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_claude_settings_malformed_json_is_noop(tmp_path) -> None:
|
||||||
|
settings = tmp_path / "settings.json"
|
||||||
|
settings.write_text("{ this is not json", encoding="utf-8")
|
||||||
|
assert _update_claude_settings(settings, "x") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── bootstrap rate-limit translation ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_403_permission_surfaces_ratelimit(monkeypatch, capsys) -> None:
|
||||||
|
"""DRF 403 'You do not have permission' must be translated to the daily limit message."""
|
||||||
|
from mem0_cli.commands.agent_mode_cmd import bootstrap_via_backend
|
||||||
|
from mem0_cli.config import Mem0Config
|
||||||
|
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.status_code = 403
|
||||||
|
fake_resp.text = '{"detail": "You do not have permission to perform this action."}'
|
||||||
|
fake_resp.json = MagicMock(
|
||||||
|
return_value={"detail": "You do not have permission to perform this action."}
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, *a, **kw):
|
||||||
|
return fake_resp
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _Client)
|
||||||
|
cfg = Mem0Config()
|
||||||
|
cfg.platform.base_url = "https://api.mem0.ai"
|
||||||
|
import typer
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
bootstrap_via_backend(cfg)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
combined = captured.out + captured.err
|
||||||
|
assert "Daily Agent Mode signup limit reached" in combined
|
||||||
|
assert "permission to perform this action" not in combined
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user